From 743e3ae7ccdd1407965455b7a2bbce34c835fc19 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Fri, 4 Sep 2026 17:13:29 +0200 Subject: [PATCH] fix(invoices): bank match stores the applied amount, not cash received, in invoice_payments (#2277) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(invoices): bank match stores the applied amount, not cash received, in invoice_payments The dashboard match-invoice route, its v1 twin and the pending-operation match_transaction_invoice executor wrote invoice_payments.amount as the cash received in invoice currency. When a whole-krona bank line settles an öre-carrying remaining (the customer pays the rounded "Att betala"), planInvoicePayment advances paid_amount by the remaining only and books the öre on 3740, so the row exceeded the receivable by the absorbed öre: remaining 999.60, bank 1 000.00 gave a 1 000.00 row against a 999.60 paid_amount. The kontantmetod cut-off then pushed a -0.40 receivable with negative scaled moms, the historical AR ledger showed -0.40 outstanding on a paid invoice, and a storno of the payment voucher restored paid_amount 0.40 off (issue #2250). PR #2236 defined the amount for the manual, MCP and Stripe paths as the amount APPLIED to the invoice (new paid_amount minus the prior one). The three bank-match paths now share that definition through one helper, appliedPaymentAmount() in lib/invoices/invoice-payment-row.ts, which recordInvoicePaymentRow() uses as well. Every other field of the row (payment date, currency, exchange rate, journal entry, bank transaction, notes) is unchanged. Without a residual the applied amount equals the cash received, so ordinary matches post identical rows; cross-currency rows are now öre-rounded like paid_amount instead of the 4-decimal spot conversion, so row and paid_amount agree. Existing rows carrying the overshoot are not repaired here; that is a separate call. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015qgLgdt4mLmha1ZLFMwq1u * refactor(invoices): one writer for invoice_payments rows Rework of the #2250 fix from first principles. The bank-match paths did not just get the amount wrong; the class of bug is that invoice_payments rows were hand-built at five product sites (dashboard bank match, its v1 twin, the pending-operation match, the link-to-existing-voucher flow, and the #2236 paths through the helper), each computing its own fields with no single definition of what the row means. recordInvoicePaymentRow() (lib/invoices/invoice-payment-row.ts) is now the one writer. Its options grew by what the bank paths set, all optional with today's defaults so the #2236 callers are unchanged: transactionId (default null), exchangeRate (the rate actually used; omitted = invoice.exchange_rate, explicit null stored as null) and notes (default null). The failure result carries the Postgres SQLSTATE so the routes keep mapping a unique violation (23505) exactly as before. The applied-amount formula is an internal detail of that file again. Routed through the writer: app/api/transactions/[id]/match-invoice, the v1 match-invoice twin, commitMatchTransactionInvoice in lib/pending-operations/commit.ts, and lib/transactions/link-journal-entry.ts (strict plan, same currency only: its amount is unchanged, it now shares the row semantics). The pending-operation path used to drop the insert error on the floor; it stays non-fatal but is logged with ids. Guard: scripts/checks/no-new-antipatterns.mjs gains direct-invoice-payment-insert, a file-set rule with no baseline (0 today): .from('invoice_payments').insert( or .upsert( anywhere under app/, lib/ or extensions/ outside lib/invoices/invoice-payment-row.ts fails npm run check:guards. Operator scripts under scripts/ are out of its scope on purpose. Tests: the writer's unit tests cover the new options, the explicit-null rate, the SQLSTATE passthrough and the öre-rounded prior-paid subtraction; the per-path 3740 tests from the first commit stand; mock insert slots now return the row id the writer selects back. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015qgLgdt4mLmha1ZLFMwq1u --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 --- .../__tests__/route.test.ts | 4 +- .../match-invoice/__tests__/route.test.ts | 108 +++++++++++++++-- .../transactions/[id]/match-invoice/route.ts | 51 ++++---- .../transactions/[id]/__tests__/route.test.ts | 10 +- .../match-invoice/__tests__/route.test.ts | 97 +++++++++++++++ .../transactions/[id]/match-invoice/route.ts | 50 ++++---- .../__tests__/invoice-payment-row.test.ts | 112 ++++++++++++++++++ lib/invoices/invoice-payment-row.ts | 75 +++++++++--- .../link-transaction-journal-entry.test.ts | 2 +- ...saction-invoice-settlement-account.test.ts | 92 +++++++++++++- lib/pending-operations/commit.ts | 39 ++++-- lib/transactions/link-journal-entry.ts | 37 +++--- scripts/checks/no-new-antipatterns.mjs | 50 +++++++- 13 files changed, 617 insertions(+), 110 deletions(-) diff --git a/app/api/transactions/[id]/link-journal-entry/__tests__/route.test.ts b/app/api/transactions/[id]/link-journal-entry/__tests__/route.test.ts index 1bcb44d5..7bd3786c 100644 --- a/app/api/transactions/[id]/link-journal-entry/__tests__/route.test.ts +++ b/app/api/transactions/[id]/link-journal-entry/__tests__/route.test.ts @@ -238,7 +238,7 @@ describe('POST /api/transactions/[id]/link-journal-entry', () => { // Update invoice (optimistic lock returns updated row) enqueue({ data: [{ id: INV_UUID }], error: null }) // Insert invoice_payments - enqueue({ data: null, error: null }) + enqueue({ data: { id: 'ip-1' }, error: null }) // logMatchEvent enqueue({ data: null, error: null }) @@ -317,7 +317,7 @@ describe('POST /api/transactions/[id]/link-journal-entry', () => { }) enqueue({ data: [{ id: TX_UUID }], error: null }) // update transaction enqueue({ data: [{ id: INV_UUID }], error: null }) // update invoice - enqueue({ data: null, error: null }) // insert invoice_payments + enqueue({ data: { id: 'ip-1' }, error: null }) // insert invoice_payments enqueue({ data: null, error: null }) // logMatchEvent const request = createMockRequest(`/api/transactions/${TX_UUID}/link-journal-entry`, { diff --git a/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts index f25c65b4..1570de21 100644 --- a/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts +++ b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts @@ -304,7 +304,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { mockCreateJournalEntry.mockResolvedValue({ id: 'je-fx' }) enqueue({ data: [{ id: VALID_UUID }], error: null }) // update invoice - enqueue({ data: null, error: null }) // insert invoice_payments + enqueue({ data: { id: 'ip-1' }, error: null }) // insert invoice_payments enqueue({ data: null, error: null }) // update transaction enqueue({ data: null, error: null }) // logMatchEvent @@ -404,7 +404,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { mockCreateJournalEntry.mockResolvedValue({ id: 'je-fx-manual' }) enqueue({ data: [{ id: VALID_UUID }], error: null }) - enqueue({ data: null, error: null }) + enqueue({ data: { id: 'ip-1' }, error: null }) // insert invoice_payments enqueue({ data: null, error: null }) enqueue({ data: null, error: null }) @@ -465,7 +465,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { // Update invoice (optimistic lock returns updated row) enqueue({ data: [{ id: VALID_UUID }], error: null }) // Insert invoice_payments - enqueue({ data: null, error: null }) + enqueue({ data: { id: 'ip-1' }, error: null }) // Update transaction enqueue({ data: null, error: null }) // logMatchEvent insert (fire-and-forget) @@ -494,6 +494,16 @@ describe('POST /api/transactions/[id]/match-invoice', () => { expect(body.paid_at).toBe('2024-06-15T12:00:00Z') const invoiceUpdate = findCalls('invoices', 'update').at(-1)?.[0] expect(invoiceUpdate).toMatchObject({ paid_at: '2024-06-15T12:00:00Z' }) + // No residual: the applied amount IS the cash received, and the row keeps + // the bank transaction, date and currency it always carried (#2250). + expect(findCalls('invoice_payments', 'insert').at(-1)?.[0]).toMatchObject({ + invoice_id: VALID_UUID, + transaction_id: 'tx-1', + payment_date: '2024-06-15', + amount: 12500, + currency: 'SEK', + journal_entry_id: 'je-1', + }) expect(vi.mocked(eventBus.emit)).toHaveBeenCalledWith( expect.objectContaining({ type: 'invoice.match_confirmed', @@ -533,6 +543,78 @@ describe('POST /api/transactions/[id]/match-invoice', () => { ) }) + it('3740 residual: the invoice_payments row carries the applied amount, not the cash received (#2250)', async () => { + // Remaining 999.60 settled by a whole-krona 1 000.00 bank line: 3740 + // absorbs the 0.40 and paid_amount advances by the remaining only. The AR + // sub-ledger row must be 999.60 too, or every reader that subtracts rows + // from total (kontantmetod cut-off, reskontra, the storno sync) lands + // 0.40 off with a negative outstanding on a paid invoice. + const tx = makeTransaction({ id: 'tx-1', amount: 1000, invoice_id: null, date: '2024-06-15' }) + const invoice = makeInvoice({ + id: VALID_UUID, + status: 'sent', + total: 999.6, + remaining_amount: 999.6, + paid_amount: 0, + subtotal: 799.68, + vat_amount: 199.92, + invoice_number: 'F-2024002', + customer: makeCustomer(), + }) + + enqueue({ data: tx, error: null }) // fetch transaction + enqueue({ data: invoice, error: null }) // fetch invoice + enqueue({ data: [], error: null }) // hard-duplicate check + enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 + enqueue({ data: [{ id: VALID_UUID }], error: null }) // update invoice + enqueue({ data: { id: 'ip-1' }, error: null }) // insert invoice_payments + enqueue({ data: null, error: null }) // update transaction + + const request = createMockRequest('/api/transactions/tx-1/match-invoice', { + method: 'POST', + body: { invoice_id: VALID_UUID }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ + invoice_status: string + paid_amount: number + remaining_amount: number + }>(response) + + expect(status).toBe(200) + expect(body.invoice_status).toBe('paid') + expect(body.paid_amount).toBe(999.6) + expect(body.remaining_amount).toBe(0) + // The voucher carries the cash: Dr 1930 1 000 / Cr 1510 999.60 / Cr 3740 0.40. + expect(mockCreateJournalEntry).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'user-1', + expect.objectContaining({ + lines: expect.arrayContaining([ + expect.objectContaining({ account_number: '1930', debit_amount: 1000 }), + expect.objectContaining({ account_number: '1510', credit_amount: 999.6 }), + expect.objectContaining({ account_number: '3740', credit_amount: 0.4 }), + ]), + }), + ) + expect(findCalls('invoices', 'update').at(-1)?.[0]).toMatchObject({ + status: 'paid', + paid_amount: 999.6, + remaining_amount: 0, + }) + // The AR sub-ledger row carries the receivable it cleared, not the cash. + expect(findCalls('invoice_payments', 'insert').at(-1)?.[0]).toMatchObject({ + invoice_id: VALID_UUID, + transaction_id: 'tx-1', + payment_date: '2024-06-15', + amount: 999.6, + currency: 'SEK', + journal_entry_id: 'je-1', + }) + }) + it('stornos conflicting journal entry before matching', async () => { const tx = makeTransaction({ id: 'tx-1', @@ -568,7 +650,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { // Update invoice (optimistic lock) enqueue({ data: [{ id: VALID_UUID }], error: null }) // Insert invoice_payments - enqueue({ data: null, error: null }) + enqueue({ data: { id: 'ip-1' }, error: null }) // Update transaction enqueue({ data: null, error: null }) // logMatchEvent for match @@ -639,7 +721,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { // Update invoice (optimistic lock) enqueue({ data: [{ id: VALID_UUID }], error: null }) // Insert invoice_payments - enqueue({ data: null, error: null }) + enqueue({ data: { id: 'ip-1' }, error: null }) // Update transaction enqueue({ data: null, error: null }) // logMatchEvent @@ -685,7 +767,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 mockCreateJournalEntry.mockResolvedValue({ id: 'je-1' }) enqueue({ data: [{ id: VALID_UUID }], error: null }) // update invoice - enqueue({ data: null, error: null }) // insert invoice_payments + enqueue({ data: { id: 'ip-1' }, error: null }) // insert invoice_payments enqueue({ data: null, error: null }) // update transaction enqueue({ data: null, error: null }) // logMatchEvent @@ -809,7 +891,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { // invoice_payments → update transaction → logMatchEvent. enqueue({ data: null, error: null }) // document_attachments lookup enqueue({ data: [{ id: VALID_UUID }], error: null }) // update invoice - enqueue({ data: null, error: null }) // insert invoice_payments + enqueue({ data: { id: 'ip-1' }, error: null }) // insert invoice_payments enqueue({ data: null, error: null }) // update transaction enqueue({ data: null, error: null }) // logMatchEvent @@ -861,7 +943,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { mockCreateJournalEntry.mockResolvedValue({ id: 'je-1940' }) enqueue({ data: [{ id: VALID_UUID }], error: null }) // update invoice - enqueue({ data: null, error: null }) // insert invoice_payments + enqueue({ data: { id: 'ip-1' }, error: null }) // insert invoice_payments enqueue({ data: null, error: null }) // update transaction enqueue({ data: null, error: null }) // logMatchEvent @@ -915,7 +997,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { mockCreateInvoiceCashEntry.mockResolvedValue({ id: 'je-cash-1940' }) enqueue({ data: [{ id: VALID_UUID }], error: null }) // update invoice - enqueue({ data: null, error: null }) // insert invoice_payments + enqueue({ data: { id: 'ip-1' }, error: null }) // insert invoice_payments enqueue({ data: null, error: null }) // update transaction enqueue({ data: null, error: null }) // logMatchEvent @@ -967,7 +1049,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { mockCreateJournalEntry.mockResolvedValue({ id: 'je-default' }) enqueue({ data: [{ id: VALID_UUID }], error: null }) - enqueue({ data: null, error: null }) + enqueue({ data: { id: 'ip-1' }, error: null }) // insert invoice_payments enqueue({ data: null, error: null }) enqueue({ data: null, error: null }) @@ -1093,7 +1175,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { // Update invoice enqueue({ data: [{ id: VALID_UUID }], error: null }) // Insert invoice_payments - enqueue({ data: null, error: null }) + enqueue({ data: { id: 'ip-1' }, error: null }) // Update transaction enqueue({ data: null, error: null }) // logMatchEvent @@ -1288,7 +1370,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { mockCreateJournalEntry.mockResolvedValue({ id: 'je-partial-extra' }) enqueue({ data: [{ id: VALID_UUID }], error: null }) // update invoice - enqueue({ data: null, error: null }) // insert invoice_payments + enqueue({ data: { id: 'ip-1' }, error: null }) // insert invoice_payments enqueue({ data: null, error: null }) // update tx enqueue({ data: null, error: null }) // logMatchEvent @@ -1368,7 +1450,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { mockCreateJournalEntry.mockResolvedValue({ id: 'je-forced' }) enqueue({ data: [{ id: VALID_UUID }], error: null }) // update invoice - enqueue({ data: null, error: null }) // insert invoice_payments + enqueue({ data: { id: 'ip-1' }, error: null }) // insert invoice_payments enqueue({ data: null, error: null }) // update tx enqueue({ data: null, error: null }) // logMatchEvent diff --git a/app/api/transactions/[id]/match-invoice/route.ts b/app/api/transactions/[id]/match-invoice/route.ts index 4af3a3dd..9361e907 100644 --- a/app/api/transactions/[id]/match-invoice/route.ts +++ b/app/api/transactions/[id]/match-invoice/route.ts @@ -15,6 +15,7 @@ import { validateBody } from '@/lib/api/validate' import { MatchInvoiceSchema } from '@/lib/api/schemas' import { logMatchEvent } from '@/lib/invoices/match-log' import { planInvoicePayment } from '@/lib/invoices/apply-invoice-payment' +import { recordInvoicePaymentRow } from '@/lib/invoices/invoice-payment-row' import { detectDuplicatePaymentVoucher } from '@/lib/invoices/duplicate-payment-detection' import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions' import { paidAtFromDate } from '@/lib/invoices/paid-at' @@ -700,33 +701,37 @@ export const POST = withRouteContext( const paymentNotes = manualRateNote - // Payment row stores amount in INVOICE currency (the column unit). For - // same-currency that's tx.amount; for cross-currency it's the spot-rate - // conversion above. exchange_rate records the rate ACTUALLY USED for - // this payment: Riksbanken (or manual override) on tx.date: per - // ML 8 kap 21-23§. Falling back to invoice.exchange_rate would record - // the invoice-date rate, which is what the round-7/8 bot reviews - // explicitly flagged as wrong. - const { error: paymentInsertError } = await supabase - .from('invoice_payments') - .insert({ - user_id: user.id, - company_id: companyId, - invoice_id, - payment_date: transaction.date, - amount: paidAmountInInvoiceCurrency, + // The AR sub-ledger row goes through the single writer + // (lib/invoices/invoice-payment-row.ts): amount = the amount APPLIED to + // the invoice (new paid_amount minus the prior one), never the cash + // received, so a whole-krona overshoot absorbed on 3740 stays in the + // voucher and out of the receivable (#2250). exchange_rate is the rate + // ACTUALLY USED for this payment: Riksbanken (or manual override) on + // tx.date, per ML 8 kap 21-23§. Falling back to invoice.exchange_rate + // would record the invoice-date rate, which is what the round-7/8 bot + // reviews explicitly flagged as wrong. + const recorded = await recordInvoicePaymentRow(supabase, { + userId: user.id, + companyId, + invoice: { + id: invoice_id, currency: invoice.currency, - exchange_rate: fx.required ? fx.rate : invoice.exchange_rate, - journal_entry_id: journalEntryId, - transaction_id: transactionId, - notes: paymentNotes, - }) + exchange_rate: invoice.exchange_rate, + paid_amount: invoice.paid_amount, + }, + paymentDate: transaction.date, + newPaidAmount, + journalEntryId, + transactionId, + exchangeRate: fx.required ? fx.rate : invoice.exchange_rate, + notes: paymentNotes, + }) - if (paymentInsertError) { - if (paymentInsertError.code === '23505') { + if (!recorded.ok) { + if (recorded.code === '23505') { return errorResponseFromCode('MATCH_INVOICE_DUPLICATE_PAYMENT', txLog, { requestId }) } - txLog.error('failed to record invoice payment', paymentInsertError) + txLog.error('failed to record invoice payment', undefined, { error: recorded.error }) return errorResponseFromCode('MATCH_INVOICE_RECORD_PAYMENT_FAILED', txLog, { requestId }) } diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/transactions/[id]/__tests__/route.test.ts index 0670c615..a1629156 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/__tests__/route.test.ts @@ -557,7 +557,10 @@ describe('POST :id/match-invoice', () => { { data: [{ id: INV_ID }], error: null }, // status update select ], company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }, - invoice_payments: { data: null, error: null }, + invoice_payments: [ + { data: [], error: null }, + { data: { id: 'ip-1' }, error: null }, + ], }), ) const res = await matchInvoicePOST( @@ -716,7 +719,10 @@ describe('POST :id/match-invoice', () => { ], company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }, cash_accounts: { data: { ledger_account: '1940' }, error: null }, - invoice_payments: { data: null, error: null }, + invoice_payments: [ + { data: [], error: null }, + { data: { id: 'ip-1' }, error: null }, + ], }), ) const res = await matchInvoicePOST( diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/__tests__/route.test.ts index d0d831b7..7b8469df 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/__tests__/route.test.ts @@ -154,6 +154,12 @@ describe('POST /api/v1/companies/:companyId/transactions/:id/match-invoice', () data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null, }, + // First read is the hard-duplicate guard (no prior voucher); the + // writer then selects the inserted row's id back. + invoice_payments: [ + { data: [], error: null }, + { data: { id: 'ip-1' }, error: null }, + ], }, calls, ), @@ -176,6 +182,18 @@ describe('POST /api/v1/companies/:companyId/transactions/:id/match-invoice', () (call) => call.table === 'invoices' && call.method === 'update', ) expect(invoiceUpdate?.args[0]).toMatchObject({ paid_at: '2024-06-15T12:00:00Z' }) + // No residual: the applied amount IS the cash received (#2250). + const paymentInsert = calls.find( + (call) => call.table === 'invoice_payments' && call.method === 'insert', + ) + expect(paymentInsert?.args[0]).toMatchObject({ + invoice_id: INVOICE_ID, + transaction_id: TX_ID, + payment_date: '2024-06-15', + amount: 12500, + currency: 'SEK', + journal_entry_id: 'je-1', + }) expect(matchedHandler).toHaveBeenCalledWith( expect.objectContaining({ invoice: expect.objectContaining({ @@ -192,6 +210,85 @@ describe('POST /api/v1/companies/:companyId/transactions/:id/match-invoice', () ) }) + it('3740 residual: the invoice_payments row carries the applied amount, not the cash received (#2250)', async () => { + // Remaining 999.60 settled by a whole-krona 1 000.00 bank line: 3740 + // absorbs the 0.40 and paid_amount advances by the remaining only, so the + // AR sub-ledger row must be 999.60 as well (parity with the dashboard + // route and the pending-operation commit path). + const calls: RecordedCall[] = [] + mockServiceClient.mockReturnValue( + makeFlexibleSupabase( + { + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + transactions: { data: { ...TRANSACTION, amount: 1000 }, error: null }, + invoices: [ + { + data: { ...SENT_INVOICE, total: 999.6, remaining_amount: 999.6, paid_amount: 0 }, + error: null, + }, + { data: [{ id: INVOICE_ID }], error: null }, + ], + company_settings: { + data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, + error: null, + }, + // First read is the hard-duplicate guard (no prior voucher); the + // writer then selects the inserted row's id back. + invoice_payments: [ + { data: [], error: null }, + { data: { id: 'ip-1' }, error: null }, + ], + }, + calls, + ), + ) + + const response = await matchInvoice( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-invoice`, + { invoice_id: INVOICE_ID }, + ), + detailParams(COMPANY_ID, TX_ID), + ) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data.invoice_status).toBe('paid') + expect(body.data.paid_amount).toBe(999.6) + expect(body.data.remaining_amount).toBe(0) + expect(mockCreateJournalEntry).toHaveBeenCalledWith( + expect.anything(), + COMPANY_ID, + USER_ID, + expect.objectContaining({ + lines: expect.arrayContaining([ + expect.objectContaining({ account_number: '1930', debit_amount: 1000 }), + expect.objectContaining({ account_number: '1510', credit_amount: 999.6 }), + expect.objectContaining({ account_number: '3740', credit_amount: 0.4 }), + ]), + }), + ) + const invoiceUpdate = calls.find( + (call) => call.table === 'invoices' && call.method === 'update', + ) + expect(invoiceUpdate?.args[0]).toMatchObject({ + status: 'paid', + paid_amount: 999.6, + remaining_amount: 0, + }) + const paymentInsert = calls.find( + (call) => call.table === 'invoice_payments' && call.method === 'insert', + ) + expect(paymentInsert?.args[0]).toMatchObject({ + invoice_id: INVOICE_ID, + transaction_id: TX_ID, + payment_date: '2024-06-15', + amount: 999.6, + currency: 'SEK', + journal_entry_id: 'je-1', + }) + }) + it('returns 401 when no bearer token is supplied', async () => { mockServiceClient.mockReturnValue(makeFlexibleSupabase({})) const response = await matchInvoice( diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts index a6427c27..095372ba 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts @@ -41,6 +41,7 @@ import { AccountsNotInChartError } from '@/lib/bookkeeping/errors' import { getErrorMessage } from '@/lib/errors/get-error-message' import { logMatchEvent } from '@/lib/invoices/match-log' import { planInvoicePayment } from '@/lib/invoices/apply-invoice-payment' +import { recordInvoicePaymentRow } from '@/lib/invoices/invoice-payment-row' import { detectDuplicatePaymentVoucher } from '@/lib/invoices/duplicate-payment-detection' import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions' import { paidAtFromDate } from '@/lib/invoices/paid-at' @@ -709,33 +710,38 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string const paymentNotes = [cashMethodNote, manualRateNote].filter(Boolean).join(' · ') || null - // amount and currency must agree: the row stores the payment in INVOICE - // currency (the column's unit), never a SEK magnitude wearing the invoice's - // foreign currency code. exchange_rate is the rate ACTUALLY USED for this - // payment (Riksbanken or the caller's override on the payment date, per - // ML 8 kap 21-23§), falling back to the invoice's booking rate only when no - // conversion was needed. - const { error: paymentInsertErr } = await ctx.supabase - .from('invoice_payments') - .insert({ - user_id: ctx.userId, - company_id: ctx.companyId!, - invoice_id, - payment_date: transaction.date, - amount: paidAmount, + // The AR sub-ledger row goes through the single writer + // (lib/invoices/invoice-payment-row.ts): amount = the amount APPLIED to + // the invoice (new paid_amount minus the prior one) in INVOICE currency, + // never a SEK magnitude wearing the invoice's foreign currency code and + // never the cash received (a whole-krona overshoot absorbed on 3740 is + // part of the voucher, not of the receivable, #2250). exchange_rate is + // the rate ACTUALLY USED for this payment (Riksbanken or the caller's + // override on the payment date, per ML 8 kap 21-23§), falling back to the + // invoice's booking rate only when no conversion was needed. + const recorded = await recordInvoicePaymentRow(ctx.supabase, { + userId: ctx.userId, + companyId: ctx.companyId!, + invoice: { + id: invoice_id, currency: invoice.currency, - exchange_rate: fx.required ? fx.rate : invoice.exchange_rate, - journal_entry_id: journalEntryId, - transaction_id: txId, - notes: paymentNotes, - }) - if (paymentInsertErr) { - if (paymentInsertErr.code === '23505') { + exchange_rate: invoice.exchange_rate, + paid_amount: invoice.paid_amount, + }, + paymentDate: transaction.date, + newPaidAmount, + journalEntryId, + transactionId: txId, + exchangeRate: fx.required ? fx.rate : invoice.exchange_rate, + notes: paymentNotes, + }) + if (!recorded.ok) { + if (recorded.code === '23505') { return v1ErrorResponseFromCode('MATCH_INVOICE_DUPLICATE_PAYMENT', txLog, { requestId: ctx.requestId, }) } - txLog.error('failed to record payment', paymentInsertErr) + txLog.error('failed to record payment', undefined, { error: recorded.error }) return v1ErrorResponseFromCode('MATCH_INVOICE_RECORD_PAYMENT_FAILED', txLog, { requestId: ctx.requestId, }) diff --git a/lib/invoices/__tests__/invoice-payment-row.test.ts b/lib/invoices/__tests__/invoice-payment-row.test.ts index cc4512fc..11b99f89 100644 --- a/lib/invoices/__tests__/invoice-payment-row.test.ts +++ b/lib/invoices/__tests__/invoice-payment-row.test.ts @@ -42,6 +42,118 @@ describe('recordInvoicePaymentRow', () => { }) }) + it('subtracts the prior paid_amount on a final partial, rounded to the öre (#2250)', async () => { + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueue({ data: { id: 'ip-2' } }) + + // Remaining 999.60 after a 1 000 partial, settled by a 1 000.00 bank line: + // planInvoicePayment advances paid_amount to 1 999.60 and 3740 carries the + // 0.40. 1999.6 - 1000 is not exact in IEEE 754; roundOre makes it 999.60. + await recordInvoicePaymentRow(supabase as unknown as SupabaseClient, { + userId: 'user-1', + companyId: 'company-1', + invoice: { id: 'inv-1', currency: 'SEK', exchange_rate: null, paid_amount: 1000 }, + paymentDate: '2026-08-28', + newPaidAmount: 1999.6, + journalEntryId: 'je-1', + }) + + expect(findCalls('invoice_payments', 'insert')[0][0]).toMatchObject({ amount: 999.6 }) + }) + + it('treats a missing prior paid_amount as zero and defaults currency to SEK', async () => { + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueue({ data: { id: 'ip-3' } }) + + await recordInvoicePaymentRow(supabase as unknown as SupabaseClient, { + userId: 'user-1', + companyId: 'company-1', + invoice: { id: 'inv-1' }, + paymentDate: '2026-08-28', + newPaidAmount: 12500, + journalEntryId: 'je-1', + }) + + expect(findCalls('invoice_payments', 'insert')[0][0]).toMatchObject({ + amount: 12500, + currency: 'SEK', + exchange_rate: null, + transaction_id: null, + notes: null, + }) + }) + + it('bank match: carries the transaction, the rate actually used and the note', async () => { + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueue({ data: { id: 'ip-4' } }) + + // 1 000 SEK bank line on a 140 USD invoice booked at 9.30, spot 10.45 on + // the payment date: the row records the spot rate (ML 8 kap 21-23 §), not + // the booking rate, and the öre-rounded applied amount. + const result = await recordInvoicePaymentRow(supabase as unknown as SupabaseClient, { + userId: 'user-1', + companyId: 'company-1', + invoice: { id: 'inv-1', currency: 'USD', exchange_rate: 9.3, paid_amount: 0 }, + paymentDate: '2026-05-30', + newPaidAmount: 95.69, + journalEntryId: 'je-fx', + transactionId: 'tx-1', + exchangeRate: 10.45, + notes: 'Manuell kurs 10,45', + }) + + expect(result).toEqual({ ok: true, id: 'ip-4' }) + expect(findCalls('invoice_payments', 'insert')[0][0]).toEqual({ + user_id: 'user-1', + company_id: 'company-1', + invoice_id: 'inv-1', + payment_date: '2026-05-30', + amount: 95.69, + currency: 'USD', + exchange_rate: 10.45, + journal_entry_id: 'je-fx', + transaction_id: 'tx-1', + notes: 'Manuell kurs 10,45', + }) + }) + + it('an explicit null exchangeRate is stored as null, not replaced by the invoice rate', async () => { + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueue({ data: { id: 'ip-5' } }) + + // The link-to-existing-voucher flow records no rate for a SEK bank line + // even when the invoice carries a booking rate. + await recordInvoicePaymentRow(supabase as unknown as SupabaseClient, { + userId: 'user-1', + companyId: 'company-1', + invoice: { id: 'inv-1', currency: 'SEK', exchange_rate: 9.3, paid_amount: 0 }, + paymentDate: '2026-08-28', + newPaidAmount: 100, + journalEntryId: 'je-1', + transactionId: 'tx-1', + exchangeRate: null, + }) + + expect(findCalls('invoice_payments', 'insert')[0][0]).toMatchObject({ + exchange_rate: null, + transaction_id: 'tx-1', + }) + }) + + it('surfaces the Postgres SQLSTATE so a caller can map a unique violation', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: null, error: { message: 'duplicate key value', code: '23505' } }) + const result = await recordInvoicePaymentRow(supabase as unknown as SupabaseClient, { + userId: 'user-1', + companyId: 'company-1', + invoice: { id: 'inv-1' }, + paymentDate: '2026-08-28', + newPaidAmount: 100, + journalEntryId: 'je-1', + }) + expect(result).toEqual({ ok: false, error: 'duplicate key value', code: '23505' }) + }) + it('reports an insert failure instead of throwing', async () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: null, error: { message: 'rls' } }) diff --git a/lib/invoices/invoice-payment-row.ts b/lib/invoices/invoice-payment-row.ts index 1af475a3..96a5cf79 100644 --- a/lib/invoices/invoice-payment-row.ts +++ b/lib/invoices/invoice-payment-row.ts @@ -5,8 +5,16 @@ import { roundOre } from '@/lib/money' const log = createLogger('invoice-payment-row') /** - * The AR sub-ledger row for a payment that no bank transaction drives: - * "Markera som betald" (dashboard, v1, MCP) and the Stripe payment sync. + * The single writer of invoice_payments rows (the customer AR sub-ledger). + * + * Every settlement path records its payment here: "Markera som betald" + * (dashboard, v1, MCP), the Stripe payment sync, the bank match (dashboard, + * v1, pending-operation commit) and the link-to-existing-voucher flow. + * `npm run check:guards` (direct-invoice-payment-insert) refuses a + * `.from('invoice_payments').insert(` anywhere else under app/, lib/ or + * extensions/, so the row's field semantics cannot drift per code path + * again: five hand-built inserts each computed their own `amount`, and the + * bank-match ones stored the cash received (#2250). * * Without the row the payment has no DATE anywhere. The kontantmetod bokslut * cut-off (lib/core/bookkeeping/kontantmetod-cutoff.ts) reads invoice_payments @@ -14,16 +22,13 @@ const log = createLogger('invoice-payment-row') * end, double-counting revenue and VAT (#2019); the Betalningar view and the * voucher -> invoice reference map read the same table. * - * Shape mirrors the bank-match path (app/api/transactions/[id]/match-invoice): - * amount in INVOICE currency, transaction_id null. The + * `amount` is the amount APPLIED to the invoice (new paid_amount minus the + * prior one), in INVOICE currency, never the cash received: a SEK + * öresavrundning overshoot absorbed on 3740 is part of the voucher but not + * of the receivable, and every reader subtracts rows from `total`. The * (transaction_id, invoice_id) unique index treats nulls as distinct, so * several manual partials on one invoice coexist; (journal_entry_id, * invoice_id) still refuses the same voucher twice. - * - * `amount` is the amount APPLIED to the invoice (new paid_amount minus the - * prior one), not the cash received: a SEK öresavrundning overshoot absorbed - * on 3740 is part of the voucher but not of the receivable, and every reader - * subtracts rows from `total`. */ export interface RecordInvoicePaymentRowParams { userId: string @@ -39,18 +44,52 @@ export interface RecordInvoicePaymentRowParams { /** paid_amount after this payment, in invoice currency. */ newPaidAmount: number journalEntryId: string | null + /** The bank transaction that drove the payment. Default null: manual, MCP and Stripe rows have none. */ + transactionId?: string | null + /** + * The rate ACTUALLY USED for this payment when it is not the invoice's + * booking rate: Riksbanken or a manual override on the payment date for a + * cross-currency bank match (ML 8 kap 21-23 §), or null when the caller + * deliberately records none. Omitted: invoice.exchange_rate. + */ + exchangeRate?: number | null + /** Free text on the row (rate provenance, link note). Default null. */ + notes?: string | null } export type RecordInvoicePaymentRowResult = | { ok: true; id: string } - | { ok: false; error: string } + /** `code` is the Postgres SQLSTATE when the driver reported one ('23505' = unique violation). */ + | { ok: false; error: string; code?: string } + +/** + * new paid_amount minus the prior one, to the öre. Internal on purpose: the + * only way to write a row is recordInvoicePaymentRow, so nothing else needs + * the formula. + */ +function appliedPaymentAmount( + invoice: { paid_amount?: number | null }, + newPaidAmount: number, +): number { + return roundOre(newPaidAmount - (invoice.paid_amount ?? 0)) +} export async function recordInvoicePaymentRow( supabase: SupabaseClient, params: RecordInvoicePaymentRowParams, ): Promise { - const { userId, companyId, invoice, paymentDate, newPaidAmount, journalEntryId } = params - const amount = roundOre(newPaidAmount - (invoice.paid_amount ?? 0)) + const { + userId, + companyId, + invoice, + paymentDate, + newPaidAmount, + journalEntryId, + transactionId, + exchangeRate, + notes, + } = params + const amount = appliedPaymentAmount(invoice, newPaidAmount) const { data, error } = await supabase .from('invoice_payments') @@ -61,16 +100,20 @@ export async function recordInvoicePaymentRow( payment_date: paymentDate, amount, currency: invoice.currency ?? 'SEK', - exchange_rate: invoice.exchange_rate ?? null, + exchange_rate: exchangeRate !== undefined ? exchangeRate : (invoice.exchange_rate ?? null), journal_entry_id: journalEntryId, - transaction_id: null, - notes: null, + transaction_id: transactionId ?? null, + notes: notes ?? null, }) .select('id') .single() if (error || !data) { - return { ok: false, error: error?.message ?? 'no_row_returned' } + return { + ok: false, + error: error?.message ?? 'no_row_returned', + ...(error?.code ? { code: error.code } : {}), + } } return { ok: true, id: (data as { id: string }).id } } diff --git a/lib/pending-operations/__tests__/link-transaction-journal-entry.test.ts b/lib/pending-operations/__tests__/link-transaction-journal-entry.test.ts index c7f19d3b..c1d7d5a2 100644 --- a/lib/pending-operations/__tests__/link-transaction-journal-entry.test.ts +++ b/lib/pending-operations/__tests__/link-transaction-journal-entry.test.ts @@ -231,7 +231,7 @@ describe('commitPendingOperation: link_transaction_journal_entry', () => { }) enqueue({ data: [{ id: TX_UUID }], error: null }) // tx UPDATE enqueue({ data: [{ id: INV_UUID }], error: null }) // optimistic-lock invoice UPDATE - enqueue({ data: null, error: null }) // invoice_payments INSERT + enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments INSERT enqueue({ data: null, error: null }) // logMatchEvent insert enqueue({ data: null, error: null }) // dispatcher commit update diff --git a/lib/pending-operations/__tests__/match-transaction-invoice-settlement-account.test.ts b/lib/pending-operations/__tests__/match-transaction-invoice-settlement-account.test.ts index c2c2d80b..dcb34a76 100644 --- a/lib/pending-operations/__tests__/match-transaction-invoice-settlement-account.test.ts +++ b/lib/pending-operations/__tests__/match-transaction-invoice-settlement-account.test.ts @@ -171,7 +171,7 @@ describe('commitPendingOperation: match_transaction_invoice settlement account r enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null }) // settings enqueue({ data: { ledger_account: '1940' }, error: null }) // cash_accounts lookup enqueue({ data: [{ id: 'inv-1' }], error: null }) // invoice CAS update - enqueue({ data: null, error: null }) // invoice_payments insert + enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert enqueue({ data: null, error: null }) // transactions update (link) enqueue({ data: null, error: null }) // dispatcher pending_operations update @@ -196,6 +196,15 @@ describe('commitPendingOperation: match_transaction_invoice settlement account r expect(mockCreateCashEntry).not.toHaveBeenCalled() const invoiceUpdate = findCalls('invoices', 'update').at(-1)?.[0] expect(invoiceUpdate).toMatchObject({ paid_at: '2026-05-12T12:00:00Z' }) + // No residual: the applied amount IS the cash received (#2250). + expect(findCalls('invoice_payments', 'insert').at(-1)?.[0]).toMatchObject({ + invoice_id: 'inv-1', + transaction_id: 'tx-1', + payment_date: '2026-05-12', + amount: 12500, + currency: 'SEK', + journal_entry_id: 'je-1', + }) expect(matchedHandler).toHaveBeenCalledWith( expect.objectContaining({ invoice: expect.objectContaining({ @@ -253,7 +262,7 @@ describe('commitPendingOperation: match_transaction_invoice settlement account r enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null }) // settings enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 enqueue({ data: [{ id: 'inv-1' }], error: null }) // invoice CAS update - enqueue({ data: null, error: null }) // invoice_payments insert + enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert enqueue({ data: null, error: null }) // transactions update (link) enqueue({ data: null, error: null }) // dispatcher pending_operations update @@ -302,7 +311,7 @@ describe('commitPendingOperation: match_transaction_invoice settlement account r // it keeps the 1930 fallback. enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts enqueue({ data: [{ id: 'inv-1' }], error: null }) // invoice CAS update - enqueue({ data: null, error: null }) // invoice_payments insert + enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert enqueue({ data: null, error: null }) // transactions update (link) enqueue({ data: null, error: null }) // dispatcher pending_operations update @@ -407,7 +416,7 @@ describe('commitPendingOperation: match_transaction_invoice settlement account r enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null }) // settings enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 enqueue({ data: [{ id: 'inv-1' }], error: null }) // invoice CAS update - enqueue({ data: null, error: null }) // invoice_payments insert + enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert enqueue({ data: null, error: null }) // transactions update (link) enqueue({ data: null, error: null }) // dispatcher pending_operations update @@ -435,6 +444,79 @@ describe('commitPendingOperation: match_transaction_invoice settlement account r }) }) + it('3740 residual: the invoice_payments row carries the applied amount, not the cash received (#2250)', async () => { + // Remaining 999.60 settled by a whole-krona 1 000.00 bank line: 3740 + // absorbs the 0.40 and paid_amount advances by the remaining only, so the + // AR sub-ledger row must be 999.60 as well (parity with the dashboard and + // v1 routes; the #2236 definition every reader relies on). + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ + data: { + id: 'tx-1', + company_id: 'company-1', + amount: 1000, + currency: 'SEK', + date: '2026-05-12', + invoice_id: null, + journal_entry_id: null, + cash_account_id: null, + }, + error: null, + }) // transaction fetch + enqueue({ + data: { + id: 'inv-1', + invoice_number: 'F-2026002', + status: 'sent', + total: 999.6, + remaining_amount: 999.6, + paid_amount: 0, + currency: 'SEK', + exchange_rate: null, + journal_entry_id: null, + customer: { name: 'Test AB' }, + }, + error: null, + }) // invoice fetch + enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null }) // settings + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 + enqueue({ data: [{ id: 'inv-1' }], error: null }) // invoice CAS update + enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert + enqueue({ data: null, error: null }) // transactions update (link) + enqueue({ data: null, error: null }) // dispatcher pending_operations update + + const op = makePendingOp({ params: { transaction_id: 'tx-1', invoice_id: 'inv-1' } }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('committed') + expect(mockCreateJournalEntry).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'user-1', + expect.objectContaining({ + lines: expect.arrayContaining([ + expect.objectContaining({ account_number: '1930', debit_amount: 1000 }), + expect.objectContaining({ account_number: '1510', credit_amount: 999.6 }), + expect.objectContaining({ account_number: '3740', credit_amount: 0.4 }), + ]), + }), + ) + expect(findCalls('invoices', 'update').at(-1)?.[0]).toMatchObject({ + status: 'paid', + paid_amount: 999.6, + remaining_amount: 0, + }) + expect(findCalls('invoice_payments', 'insert').at(-1)?.[0]).toMatchObject({ + invoice_id: 'inv-1', + transaction_id: 'tx-1', + payment_date: '2026-05-12', + amount: 999.6, + currency: 'SEK', + journal_entry_id: 'je-1', + }) + }) + it('converts a cross-currency payment into the invoice currency before recording it', async () => { // Parity with the dashboard/v1 routes: feeding the raw SEK amount into a // USD invoice corrupts the units of paid_amount / remaining_amount and @@ -474,7 +556,7 @@ describe('commitPendingOperation: match_transaction_invoice settlement account r enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null }) // settings enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 enqueue({ data: [{ id: 'inv-1' }], error: null }) // invoice CAS update - enqueue({ data: null, error: null }) // invoice_payments insert + enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert enqueue({ data: null, error: null }) // transactions update (link) enqueue({ data: null, error: null }) // dispatcher pending_operations update diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index 09e7f8cf..060471d3 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -3504,18 +3504,35 @@ async function commitMatchTransactionInvoice( // No cash-method note here anymore: pure kontantmetoden partials are now // rejected above, and for an invoice booked at send the clearing entry // handles a partial correctly, so the note would be misleading. - await supabase.from('invoice_payments').insert({ - user_id: userId, - company_id: companyId, - invoice_id: invoiceId, - payment_date: transaction.date, - amount: paidAmount, - currency: invoice.currency, - exchange_rate: fx.required ? fx.rate : invoice.exchange_rate, - journal_entry_id: journalEntryId, - transaction_id: transactionId, - notes: null, + // The AR sub-ledger row goes through the single writer + // (lib/invoices/invoice-payment-row.ts), which owns the amount definition: + // applied to the invoice, never the cash received (#2250). Not fatal here, + // exactly like the fire-and-forget insert it replaces: the voucher and the + // invoice update are already committed. The failure is logged now, so the + // row the kontantmetod cut-off would otherwise miss can be added. + const recorded = await recordInvoicePaymentRow(supabase, { + userId, + companyId, + invoice: { + id: invoiceId, + currency: invoice.currency, + exchange_rate: invoice.exchange_rate, + paid_amount: invoice.paid_amount, + }, + paymentDate: transaction.date, + newPaidAmount, + journalEntryId, + transactionId, + exchangeRate: fx.required ? fx.rate : invoice.exchange_rate, }) + if (!recorded.ok) { + log.error('match_transaction_invoice: invoice_payments insert failed', undefined, { + invoiceId, + transactionId, + companyId, + error: recorded.error, + }) + } // The invoice is now settled, so every OTHER transaction still carrying a // suggestion pointer at it is dead: retire them (issue #1259). This diff --git a/lib/transactions/link-journal-entry.ts b/lib/transactions/link-journal-entry.ts index 81eace02..e74d0599 100644 --- a/lib/transactions/link-journal-entry.ts +++ b/lib/transactions/link-journal-entry.ts @@ -17,6 +17,7 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { eventBus } from '@/lib/events/bus' import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions' import { paidAtFromDate } from '@/lib/invoices/paid-at' +import { recordInvoicePaymentRow } from '@/lib/invoices/invoice-payment-row' import { logMatchEvent } from '@/lib/invoices/match-log' import { propagateUnderlagForBookedTransaction } from '@/lib/transactions/inbox-underlag' import { hasBankLineJunctionRow } from '@/lib/transactions/is-booked' @@ -389,22 +390,30 @@ export async function linkTransactionToJournalEntry( // reporting. const paymentExchangeRate = transaction.exchange_rate ?? null - const { error: paymentInsertError } = await supabase - .from('invoice_payments') - .insert({ - user_id: userId, - company_id: companyId, - invoice_id: invoiceId, - payment_date: transaction.date, - amount: transaction.amount, + // The AR sub-ledger row goes through the single writer + // (lib/invoices/invoice-payment-row.ts). This path plans strictly (no öre + // absorption, same currency only), so the applied amount IS the bank line + // to the öre: the writer is used for one set of row semantics, not for a + // different number. A unique violation (23505) means the row already + // exists for this voucher and is not an error here. + const recorded = await recordInvoicePaymentRow(supabase, { + userId, + companyId, + invoice: { + id: invoiceId, currency: invoice.currency, - exchange_rate: paymentExchangeRate, - journal_entry_id: journalEntryId, - transaction_id: transactionId, - notes: 'Kopplad till befintlig verifikation (ingen ny bokföring skapad)', - }) + exchange_rate: invoice.exchange_rate, + paid_amount: invoice.paid_amount, + }, + paymentDate: transaction.date, + newPaidAmount, + journalEntryId, + transactionId, + exchangeRate: paymentExchangeRate, + notes: 'Kopplad till befintlig verifikation (ingen ny bokföring skapad)', + }) - if (paymentInsertError && paymentInsertError.code !== '23505') { + if (!recorded.ok && recorded.code !== '23505') { const { error: invRevertErr } = await supabase .from('invoices') .update({ diff --git a/scripts/checks/no-new-antipatterns.mjs b/scripts/checks/no-new-antipatterns.mjs index 292be09b..1d5c091d 100644 --- a/scripts/checks/no-new-antipatterns.mjs +++ b/scripts/checks/no-new-antipatterns.mjs @@ -37,6 +37,13 @@ * into a compile error; this guard keeps new reports on that path. * Tracked as a file-set. Voucher/line LISTINGS are sanctioned in * LEDGER_SCAN_SANCTIONED: they have no closingEntry decision to make. + * 3c. direct-invoice-payment-insert: a file that inserts into + * `invoice_payments` outside lib/invoices/invoice-payment-row.ts. Five + * hand-built inserts each computed their own `amount`, and the bank-match + * ones stored the cash received instead of the amount applied to the + * invoice, so a whole-krona overshoot absorbed on 3740 made the row + * exceed the receivable (#2250). recordInvoicePaymentRow() is the one + * writer. Tracked as a file-set, no baseline: the count is 0 today. * 4. pinned-dep : a dependency pinned to an exact version (PINNED_DEPS) * whose package.json spec or locked version drifted from the pin. Guards * against a repeat of the @anthropic-ai/bedrock-sdk 0.32.0 prod outage @@ -248,6 +255,30 @@ function findDirectJelInserts() { .sort() } +// The one writer of invoice_payments rows: recordInvoicePaymentRow() owns the +// field semantics (amount = applied to the invoice, never the cash received). +const INVOICE_PAYMENT_INSERT_SANCTIONED = new Set(['lib/invoices/invoice-payment-row.ts']) +const INVOICE_PAYMENT_INSERT_CHAIN_RE = + /\.from\(\s*['"]invoice_payments['"]\s*\)\s*\.\s*(insert|upsert)\(/ + +/** Files that insert into invoice_payments outside the sanctioned writer. */ +function findDirectInvoicePaymentInserts() { + const files = [ + ...walk(path.join(ROOT, 'lib'), ['.ts', '.tsx']), + ...walk(path.join(ROOT, 'app'), ['.ts', '.tsx']), + ...walk(path.join(ROOT, 'extensions'), ['.ts', '.tsx']), + ] + return files + .filter((f) => { + const r = rel(f) + if (INVOICE_PAYMENT_INSERT_SANCTIONED.has(r)) return false + if (r.includes('__tests__/') || r.endsWith('.test.ts')) return false + return INVOICE_PAYMENT_INSERT_CHAIN_RE.test(fs.readFileSync(f, 'utf8')) + }) + .map(rel) + .sort() +} + // The one module allowed to import supabase-js's createClient as a value: it // is the wrapper that applies SERVER_AUTH_OPTIONS. const LEAKY_CLIENT_SANCTIONED = new Set(['lib/supabase/service-client.ts']) @@ -1056,6 +1087,7 @@ const current = { providerHosts: findProviderHostFiles(), ledgerScanningReports: findLedgerScanningReports(), directJelInsert: findDirectJelInserts(), + directInvoicePaymentInsert: findDirectInvoicePaymentInserts(), leakySupabaseClients: findLeakySupabaseClients(), pinnedDepViolations: findPinnedDepViolations(), rawUserErrors: findRawUserErrors(), @@ -1143,6 +1175,22 @@ if (current.directJelInsert.length) { ) } +// 1b1. direct-invoice-payment-insert: allowlist lives in this file +// (INVOICE_PAYMENT_INSERT_SANCTIONED), no baseline: any unsanctioned insert +// site is a hard failure. +if (current.directInvoicePaymentInsert.length) { + failed = true + console.error( + `\n✗ direct-invoice-payment-insert: ${current.directInvoicePaymentInsert.length} file(s) insert into invoice_payments ` + + `outside lib/invoices/invoice-payment-row.ts:`, + ) + current.directInvoicePaymentInsert.forEach((f) => console.error(` ${f}`)) + console.error( + ' → record the payment through recordInvoicePaymentRow() (lib/invoices/invoice-payment-row.ts):\n' + + ' it owns the row semantics (amount = applied to the invoice, never the cash received, #2250).', + ) +} + // 1b3. client-node-builtin: a 'use client' module whose static import closure // reaches a Node builtin ships the browser polyfill chunk (~327 KB) with every // route that renders it. No baseline: 0 today, any reacher is a hard failure. @@ -1500,5 +1548,5 @@ if (failed) { process.exit(1) } console.log( - `\n✓ Antipattern guard passed (raw-route-auth: ${current.rawRouteAuth.length}, naive-ore-round: ${current.naiveOreRound}, hand-rolled-invariant: ${current.handRolledInvariants}, ledger-scanning-report: ${current.ledgerScanningReports.length}, direct-jel-insert: 0, leaky-supabase-client: 0, pinned-dep: 0, raw-user-error: 0, sek-labelled-amount: 0, off-ladder-radius: 0, folded-public-flag: 0, cross-extension-import: 0, ungated-extension-route: ${current.extensionRoutes.ungated.length}/${UNGATED_EXTENSION_ROUTES.size} allowlisted, dialog-overflow-risk: ${dialogOverflowFiles.length} file(s), raw-reference-fetch: ${current.rawReferenceFetch.length} file(s), client-node-builtin: ${current.clientNodeBuiltins.length}, ambiguous-embed: ${current.ambiguousEmbeds.length}, provider-host: ${current.providerHosts.length} file(s), direct-ai-client: ${current.directAiClients.length}/${DIRECT_AI_CLIENT_ALLOWED.size} allowlisted).`, + `\n✓ Antipattern guard passed (raw-route-auth: ${current.rawRouteAuth.length}, naive-ore-round: ${current.naiveOreRound}, hand-rolled-invariant: ${current.handRolledInvariants}, ledger-scanning-report: ${current.ledgerScanningReports.length}, direct-jel-insert: 0, direct-invoice-payment-insert: 0, leaky-supabase-client: 0, pinned-dep: 0, raw-user-error: 0, sek-labelled-amount: 0, off-ladder-radius: 0, folded-public-flag: 0, cross-extension-import: 0, ungated-extension-route: ${current.extensionRoutes.ungated.length}/${UNGATED_EXTENSION_ROUTES.size} allowlisted, dialog-overflow-risk: ${dialogOverflowFiles.length} file(s), raw-reference-fetch: ${current.rawReferenceFetch.length} file(s), client-node-builtin: ${current.clientNodeBuiltins.length}, ambiguous-embed: ${current.ambiguousEmbeds.length}, provider-host: ${current.providerHosts.length} file(s), direct-ai-client: ${current.directAiClients.length}/${DIRECT_AI_CLIENT_ALLOWED.size} allowlisted).`, )