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