From 6336786d3f0cdb66cafd83707fdccfaa8bf3df1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Sch=C3=A4fer?= Date: Fri, 3 Jul 2026 19:59:43 +0200 Subject: [PATCH] Fix loan auto-match and add search to the matching modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: the amount band used whereBetween with float bounds. SQLite binds PHP floats as text, so 'ABS(amount) BETWEEN 285.0 AND 315.0' compared a numeric column against text bounds and never matched — auto-match silently found nothing on every loan. Fixed with CAST(? AS REAL). Also: - Auto-match now uses the configured monthly_rate (the real debit) for the amount band instead of only the computed annuity payment. - The payment-day proximity check now wraps month boundaries, so a debit that shifts into the next month (e.g. a 30th due date posting on the 1st) matches. - The 'Buchung zuordnen' modal gains a search box (description/counterparty/ reference/amount) and raises the cap from 50 to 100, so older payments are reachable instead of being stuck behind 5 pages. --- app/Http/Controllers/LoanController.php | 20 ++++- app/Services/AmortizationService.php | 38 ++++++++-- resources/js/Pages/Loans/Show.vue | 31 +++++++- tests/Feature/LoanPaymentMatchingTest.php | 91 +++++++++++++++++++++++ 4 files changed, 167 insertions(+), 13 deletions(-) create mode 100644 tests/Feature/LoanPaymentMatchingTest.php diff --git a/app/Http/Controllers/LoanController.php b/app/Http/Controllers/LoanController.php index 8b1c4c5..be4556e 100644 --- a/app/Http/Controllers/LoanController.php +++ b/app/Http/Controllers/LoanController.php @@ -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.'%'; @@ -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) { diff --git a/app/Services/AmortizationService.php b/app/Services/AmortizationService.php index 0dccb24..aa808c7 100644 --- a/app/Services/AmortizationService.php +++ b/app/Services/AmortizationService.php @@ -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 { @@ -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); @@ -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'); @@ -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; + } } diff --git a/resources/js/Pages/Loans/Show.vue b/resources/js/Pages/Loans/Show.vue index 99cb659..96cd146 100644 --- a/resources/js/Pages/Loans/Show.vue +++ b/resources/js/Pages/Loans/Show.vue @@ -5,7 +5,7 @@ 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'; @@ -13,6 +13,7 @@ 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'; @@ -63,12 +64,17 @@ 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 { @@ -76,6 +82,17 @@ async function openMatchDialog() { } } +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: () => { @@ -216,6 +233,12 @@ const chartSeries = computed(() => [ +
+ + + + +
Buchungen werden geladen...
Keine passenden Buchungen gefunden.
diff --git a/tests/Feature/LoanPaymentMatchingTest.php b/tests/Feature/LoanPaymentMatchingTest.php new file mode 100644 index 0000000..8c7a360 --- /dev/null +++ b/tests/Feature/LoanPaymentMatchingTest.php @@ -0,0 +1,91 @@ + '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']); + } +}