fix(invoices): record manual and Stripe settlements in invoice_payments (#2236)
* fix(invoices): record manual and Stripe settlements in invoice_payments (#2019)
settleInvoicePayment created the payment voucher and flipped the invoice to
paid but never wrote the AR sub-ledger row. The kontantmetod bokslut cut-off
reads invoice_payments only (payment DATE, not remaining_amount), so a
manually settled invoice was booked again as a fordran with vilande moms at
year end, double-counting revenue and VAT. The same gap hid the payment from
the Betalningar view and from the voucher -> invoice reference map.
- Insert the row between voucher creation and the CAS status update, same
shape as the bank-match path (amount in invoice currency, transaction_id
null). An insert failure cancels the voucher and fails closed; both CAS
failure branches remove the row together with the voucher.
- Backfill: scripts/backfill-invoice-payment-rows.ts (dry-run default) with
a pure planner in lib/invoices/backfill-invoice-payment-rows.ts. Writes
only where exactly one posted payment voucher exists; zero or several are
reported, never guessed. Rows carry notes 'backfill:#2019' so one DELETE
reverts a run. Executed on staging (10 rows); prod awaits explicit go.
- pg-real: transaction-less rows coexist under the tx/invoice unique index,
the je/invoice index still refuses a double link, and the authenticated
writer can delete its own row (the CAS-failure path depends on it).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pMEgrnPsxDMiYfnXcD2Zo
* fix(invoices): write the payment row from every mark-paid path and harden the backfill
Skeptic and review round on #2236 (issue #2019):
- One helper (lib/invoices/invoice-payment-row.ts) now writes the
invoice_payments row for all four transaction-less settlement paths:
dashboard mark-paid and Stripe via settleInvoicePayment, plus the MCP
mark_invoice_paid commit and the v1 mark-paid route, which booked their
own voucher and never wrote the row. Amount = applied amount (new
paid_amount minus prior), not cash received, so a 3740 öre absorption
never yields a negative fordran in the cut-off or a wrong storno restore.
- The two duplicate detectors no longer treat a payment row with
transaction_id NULL as "reconciled to a bank line": the bank line for a
manual settlement arrives later and the voucher must stay a twin.
- Backfill: payment_date from the voucher entry_date (paid_at was
wall-clock before #1332); refuse rows that disagree with the voucher's
1510 credit / settlement debit; report partially covered invoices
(rows_short) instead of patching; record each executed run in
behandlingshistorik (InvoicePaymentRowBackfilled, migration
20260903180000). Re-run end to end on staging: 10 rows, 10 events.
- Typecheck ratchet: cast in the cut-off test.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pMEgrnPsxDMiYfnXcD2Zo
* fix(invoices): use roundOre in the #2019 backfill (guard ratchet)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pMEgrnPsxDMiYfnXcD2Zo
* fix(invoices): log a failed payment-row rollback and keep backfill rows with their audit event
Swedish review round 2 on #2236:
- removeInvoicePaymentRow no longer swallows a failed compensating DELETE:
it logs at error level with company and row id (a stranded row would
read as a settlement in the kontantmetod cut-off) and returns whether
the row is gone. Unit tests for the helper.
- The backfill deletes a company's rows from the run again when its
behandlingshistorik event cannot be written, so rows and change log
(BFNAR 2013:2 p. 9.16) never diverge; the company is listed for a re-run.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pMEgrnPsxDMiYfnXcD2Zo
* fix(invoices): keep raw insert errors out of the v1 and MCP mark-paid responses
Compliance swarm on #2236 (ISO 27001 A.8.28): the payment-row insert
failure returned the driver's error text to API callers and MCP users.
The text now stays in the server log; callers get the reason code and a
generic Swedish outcome.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pMEgrnPsxDMiYfnXcD2Zo
* fix(invoices): never backfill a payment row into a closed or locked period
Swedish review round 3 on #2236: a row dated into a closed or locked
fiscal period changes facts a filed bokslut or deklaration relied on. The
planner now reports such invoices (period_closed) instead of writing them,
and the script header states that the tagged DELETE is an emergency revert
for the window before any cut-off relies on the rows; afterwards the
correction path is a storno of the cut-off verifikat.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pMEgrnPsxDMiYfnXcD2Zo
* test(fiscal-periods): pass route params in the two mid-month tests (typecheck ratchet)
cc18e9d53 (#2242) added two POST(req) calls without the params argument,
raising the file's TypeScript error count above the ratchet baseline
(25 vs 23). main is red on "Checks" for every PR since; this unblocks the
gate for #2236 and the rest without touching the baseline.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
3159920d7c
commit
e2d38b0ab3
@@ -447,7 +447,7 @@ function makeLedgerSupabase(opts: {
|
||||
ledgerAccount?: string | null
|
||||
lines?: Jel[]
|
||||
txLinks?: LedgerTxLink[]
|
||||
payLinks?: { journal_entry_id: string }[]
|
||||
payLinks?: { journal_entry_id: string; transaction_id?: string | null }[]
|
||||
transactionRows?: TxRow[] // siblings for the orchestrator fall-through
|
||||
}) {
|
||||
// Fixtures stay embed-shaped for readability; the two-step fetch reads the
|
||||
@@ -598,13 +598,31 @@ describe('detectLedgerDuplicateVoucher', () => {
|
||||
})
|
||||
|
||||
it('excludes a voucher already linked to an invoice payment', async () => {
|
||||
const supabase = makeLedgerSupabase({ lines: [jel()], payLinks: [{ journal_entry_id: 'je-2' }] })
|
||||
const supabase = makeLedgerSupabase({
|
||||
lines: [jel()],
|
||||
payLinks: [{ journal_entry_id: 'je-2', transaction_id: 'tx-bank' }],
|
||||
})
|
||||
const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, {
|
||||
id: 'self', date: '2026-03-26', amount: 98565, currency: 'SEK', cash_account_id: null,
|
||||
})
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
// #2019: a manual / Stripe settlement writes a payment row with no bank
|
||||
// transaction. The bank line for that money arrives later; the voucher must
|
||||
// stay a twin so the user is offered a link instead of a blind re-booking.
|
||||
it('keeps a voucher whose payment row has no bank transaction as a twin', async () => {
|
||||
const supabase = makeLedgerSupabase({
|
||||
lines: [jel()],
|
||||
payLinks: [{ journal_entry_id: 'je-2', transaction_id: null }],
|
||||
})
|
||||
const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, {
|
||||
id: 'self', date: '2026-03-26', amount: 98565, currency: 'SEK', cash_account_id: null,
|
||||
})
|
||||
expect(result?.journal_entry_id).toBe('je-2')
|
||||
expect(result?.transaction_id).toBeNull()
|
||||
})
|
||||
|
||||
// ── De-exclusion (gap G3): the linking transaction is itself the twin ─────
|
||||
it('returns a voucher whose linking transaction itself matches the target, with transaction_id set', async () => {
|
||||
// The date-drifted duplicate-import shape: one copy of the bank fee is
|
||||
|
||||
@@ -683,7 +683,11 @@ export async function detectLedgerDuplicateVoucher(
|
||||
.select('id, date, amount, currency, cash_account_id, journal_entry_id')
|
||||
.eq('company_id', companyId)
|
||||
.in('journal_entry_id', entryIds),
|
||||
supabase.from('invoice_payments').select('journal_entry_id').eq('company_id', companyId).in('journal_entry_id', entryIds),
|
||||
supabase
|
||||
.from('invoice_payments')
|
||||
.select('journal_entry_id, transaction_id')
|
||||
.eq('company_id', companyId)
|
||||
.in('journal_entry_id', entryIds),
|
||||
])
|
||||
|
||||
type LinkedTxRow = {
|
||||
@@ -712,9 +716,14 @@ export async function detectLedgerDuplicateVoucher(
|
||||
arr.push(r)
|
||||
linkedTxByEntry.set(r.journal_entry_id, arr)
|
||||
}
|
||||
// Only a payment row that carries a bank transaction means "reconciled to
|
||||
// a bank line". A row with transaction_id NULL is a manual / Stripe
|
||||
// settlement (#2019): the bank line for that money is still to come, and
|
||||
// this voucher must stay a twin candidate so the user is offered a link
|
||||
// instead of a blind second booking.
|
||||
const paymentLinked = new Set<string>()
|
||||
for (const r of (payLinks ?? []) as { journal_entry_id: string | null }[]) {
|
||||
if (r.journal_entry_id) paymentLinked.add(r.journal_entry_id)
|
||||
for (const r of (payLinks ?? []) as { journal_entry_id: string | null; transaction_id: string | null }[]) {
|
||||
if (r.journal_entry_id && r.transaction_id) paymentLinked.add(r.journal_entry_id)
|
||||
}
|
||||
|
||||
const survivors: { line: (typeof candidates)[number]; twinTransactionId: string | null }[] = []
|
||||
|
||||
Reference in New Issue
Block a user