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
@@ -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