Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions app/Http/Controllers/LoanController.php
Original file line number Diff line number Diff line change
Expand Up @@ -181,11 +181,27 @@ public function autoMatch(Loan $loan)
/**
* Return unmatched transactions that could belong to this loan.
*/
public function unmatchedTransactions(Loan $loan)
public function unmatchedTransactions(Request $request, Loan $loan)
{
$query = Transaction::where('amount', '<', 0)
->whereDoesntHave('loanPayment');

// Optional free-text / amount search so older payments stay reachable.
$search = trim((string) $request->query('search', ''));
if ($search !== '') {
$query->where(function ($q) use ($search) {
$q->where('description', 'like', '%'.$search.'%')
->orWhere('counterparty', 'like', '%'.$search.'%')
->orWhere('reference', 'like', '%'.$search.'%');

$numeric = str_replace([' ', ','], ['', '.'], $search);
if (is_numeric($numeric)) {
// CAST(... AS REAL): SQLite binds PHP floats as text, breaking numeric comparison.
$q->orWhereRaw('ROUND(ABS(amount), 2) = CAST(? AS REAL)', [round((float) $numeric, 2)]);
}
});
}

// Sort matches to the top, then by date descending
if ($loan->match_description) {
$pattern = '%'.$loan->match_description.'%';
Expand All @@ -197,7 +213,7 @@ public function unmatchedTransactions(Loan $loan)

$query->orderByDesc('date');

$transactions = $query->limit(50)->get(['id', 'date', 'description', 'counterparty', 'amount', 'account_id']);
$transactions = $query->limit(100)->get(['id', 'date', 'description', 'counterparty', 'amount', 'account_id']);

// Mark which ones match the text
if ($loan->match_description) {
Expand Down
38 changes: 31 additions & 7 deletions app/Services/AmortizationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
use App\Models\Loan;
use App\Models\LoanPayment;
use App\Models\Transaction;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Carbon;

class AmortizationService
{
Expand Down Expand Up @@ -142,12 +142,18 @@ public function autoMatchPayments(Loan $loan): int
return 0;
}

$monthlyAmount = abs($schedule[0]['payment']);
// Match against the configured monthly rate when set (the actual debit),
// falling back to the computed annuity payment.
$monthlyAmount = $loan->monthly_rate > 0
? abs((float) $loan->monthly_rate)
: abs($schedule[0]['payment']);
$matched = 0;

// Find unmatched transactions near the payment amount
// Find unmatched transactions near the payment amount.
// CAST(... AS REAL) is required: SQLite binds PHP floats as text, and a
// numeric column compared to a text bound value never matches a BETWEEN.
$query = Transaction::where('amount', '<', 0)
->whereBetween(DB::raw('ABS(amount)'), [$monthlyAmount * 0.95, $monthlyAmount * 1.05])
->whereRaw('ABS(amount) BETWEEN CAST(? AS REAL) AND CAST(? AS REAL)', [$monthlyAmount * 0.95, $monthlyAmount * 1.05])
->whereDoesntHave('loanPayment')
->where('date', '>=', $loan->start_date);

Expand All @@ -169,9 +175,8 @@ public function autoMatchPayments(Loan $loan): int
$existingMonths = $loan->payments->map(fn ($p) => $p->date->format('Y-m'))->toArray();

foreach ($transactions as $transaction) {
// Check if payment day is close
$txDay = $transaction->date->day;
if (abs($txDay - $loan->payment_day) <= 3) {
// Check if payment day is close (wrapping across month boundaries)
if ($this->isNearPaymentDay($transaction->date, (int) $loan->payment_day)) {
// Check if we don't already have a payment for this month
$monthKey = $transaction->date->format('Y-m');

Expand All @@ -191,4 +196,23 @@ public function autoMatchPayments(Loan $loan): int

return $matched;
}

/**
* Whether a transaction date falls within tolerance of the loan's payment day.
* Considers the payment day in the previous, current, and next month so a
* debit that shifts across a month boundary (e.g. weekend) still matches.
*/
private function isNearPaymentDay(Carbon $date, int $paymentDay, int $tolerance = 3): bool
{
foreach ([-1, 0, 1] as $offset) {
$month = $date->copy()->startOfMonth()->addMonthsNoOverflow($offset);
$expected = $month->copy()->day(min($paymentDay, $month->daysInMonth));

if (abs($date->diffInDays($expected)) <= $tolerance) {
return true;
}
}

return false;
}
}
31 changes: 27 additions & 4 deletions resources/js/Pages/Loans/Show.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ import StatCard from '@/Components/StatCard.vue';
import { useFormatters } from '@/Composables/useFormatters.js';
import { useTheme } from '@/Composables/useTheme.js';
import { useForm, router } from '@inertiajs/vue3';
import { ref, computed, defineAsyncComponent } from 'vue';
import { ref, computed, defineAsyncComponent, watch } from 'vue';

const VueApexCharts = defineAsyncComponent(() => import('vue3-apexcharts'));
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import Button from 'primevue/button';
import Dialog from 'primevue/dialog';
import InputNumber from 'primevue/inputnumber';
import InputText from 'primevue/inputtext';
import DatePicker from 'primevue/datepicker';
import Select from 'primevue/select';
import Tag from 'primevue/tag';
Expand Down Expand Up @@ -63,19 +64,35 @@ function autoMatch() {
const showMatchDialog = ref(false);
const unmatchedTransactions = ref([]);
const loadingTransactions = ref(false);
const matchSearch = ref('');
let searchTimer = null;

async function openMatchDialog() {
async function fetchUnmatched() {
loadingTransactions.value = true;
showMatchDialog.value = true;
try {
const response = await fetch(`/loans/${props.loan.id}/unmatched-transactions`);
const url = new URL(`/loans/${props.loan.id}/unmatched-transactions`, window.location.origin);
if (matchSearch.value) {
url.searchParams.set('search', matchSearch.value);
}
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to load transactions');
unmatchedTransactions.value = await response.json();
} finally {
loadingTransactions.value = false;
}
}

function openMatchDialog() {
matchSearch.value = '';
showMatchDialog.value = true;
fetchUnmatched();
}

watch(matchSearch, () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(fetchUnmatched, 300);
});

function matchTransaction(transactionId) {
router.post(`/loans/${props.loan.id}/match-transaction`, { transaction_id: transactionId }, {
onSuccess: () => {
Expand Down Expand Up @@ -216,6 +233,12 @@ const chartSeries = computed(() => [

<!-- Match Transaction Dialog -->
<Dialog v-model:visible="showMatchDialog" header="Buchung zuordnen" modal class="w-full max-w-2xl">
<div class="mb-3">
<span class="relative block">
<i class="pi pi-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-sm"></i>
<InputText v-model="matchSearch" placeholder="Nach Beschreibung, Empfänger oder Betrag suchen..." class="w-full pl-9" />
</span>
</div>
<div v-if="loadingTransactions" class="py-8 text-center text-gray-500 text-sm">Buchungen werden geladen...</div>
<div v-else-if="unmatchedTransactions.length === 0" class="py-8 text-center text-gray-400 dark:text-gray-400 text-sm">Keine passenden Buchungen gefunden.</div>
<DataTable v-else :value="unmatchedTransactions" class="text-sm" :rows="10" paginator :rowClass="(data) => data.is_match ? 'bg-green-50 dark:bg-green-900/20' : ''">
Expand Down
91 changes: 91 additions & 0 deletions tests/Feature/LoanPaymentMatchingTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php

namespace Tests\Feature;

use App\Models\Loan;
use App\Models\Transaction;
use App\Services\AmortizationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class LoanPaymentMatchingTest extends TestCase
{
use RefreshDatabase;

private function bankLoan(array $overrides = []): Loan
{
// Computed annuity is 10000 / 100 = 100/month; monthly_rate is the real debit.
return Loan::create(array_merge([
'name' => 'Test',
'type' => 'bank',
'direction' => 'owed',
'principal' => 10000,
'interest_rate' => 0,
'term_months' => 100,
'start_date' => '2026-01-01',
'payment_day' => 15,
'monthly_rate' => 300,
], $overrides));
}

public function test_auto_match_uses_configured_monthly_rate_not_computed_annuity(): void
{
$loan = $this->bankLoan();
$transaction = Transaction::create([
'date' => '2026-02-15',
'amount' => -300, // matches monthly_rate (300), far from annuity (100)
'description' => 'Rate',
]);

$matched = (new AmortizationService)->autoMatchPayments($loan);

$this->assertSame(1, $matched);
$this->assertDatabaseHas('loan_payments', [
'loan_id' => $loan->id,
'transaction_id' => $transaction->id,
]);
}

public function test_auto_match_wraps_month_boundary_for_payment_day(): void
{
$loan = $this->bankLoan(['payment_day' => 30]);
// Due on the 30th but debited on the 1st of the next month.
$transaction = Transaction::create([
'date' => '2026-03-01',
'amount' => -300,
'description' => 'Rate',
]);

$matched = (new AmortizationService)->autoMatchPayments($loan);

$this->assertSame(1, $matched);
$this->assertDatabaseHas('loan_payments', [
'loan_id' => $loan->id,
'transaction_id' => $transaction->id,
]);
}

public function test_unmatched_transactions_can_be_searched(): void
{
$loan = $this->bankLoan();
Transaction::create(['date' => '2026-02-15', 'amount' => -300, 'description' => 'Sparkasse Darlehen']);
Transaction::create(['date' => '2026-02-16', 'amount' => -42, 'description' => 'Amazon']);

$this->getJson("/loans/{$loan->id}/unmatched-transactions?search=Sparkasse")
->assertOk()
->assertJsonCount(1)
->assertJsonFragment(['description' => 'Sparkasse Darlehen']);
}

public function test_unmatched_transactions_can_be_searched_by_amount(): void
{
$loan = $this->bankLoan();
Transaction::create(['date' => '2026-02-15', 'amount' => -300, 'description' => 'Darlehen']);
Transaction::create(['date' => '2026-02-16', 'amount' => -42, 'description' => 'Amazon']);

$this->getJson("/loans/{$loan->id}/unmatched-transactions?search=300")
->assertOk()
->assertJsonCount(1)
->assertJsonFragment(['description' => 'Darlehen']);
}
}
Loading