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
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user