fix(invoices): bank match stores the applied amount, not cash received, in invoice_payments (#2277)
* 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 <noreply@anthropic.com> 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 <noreply@anthropic.com> 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
2d927349d3
commit
743e3ae7cc
@@ -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`, {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
+97
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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' } })
|
||||
|
||||
@@ -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<RecordInvoicePaymentRowResult> {
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+87
-5
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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).`,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user