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:
Mattsson
2026-09-03 19:42:43 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 3159920d7c
commit e2d38b0ab3
21 changed files with 1641 additions and 16 deletions
@@ -0,0 +1,150 @@
/**
* pg-real test for the invoice_payments rows written by settleInvoicePayment
* (issue #2019: "Markera som betald" and the Stripe sync now record the
* payment in the AR sub-ledger).
*
* Those rows carry transaction_id NULL because no bank line drives the flow.
* The service relies on three database facts that only a real Postgres can
* pin:
*
* 1. idx_invoice_payments_tx_inv_unique (transaction_id, invoice_id) treats
* NULL as distinct, so several manual partials on one invoice coexist.
* 2. idx_invoice_payments_je_inv_unique still refuses the same voucher
* linked twice to the same invoice (the last line of defence against a
* double settle).
* 3. The authenticated writer can DELETE its own row: the CAS-failure
* branch in settleInvoicePayment removes the row through the user
* client, and a policy gap there would strand rows silently.
*/
import { describe, it, expect } from 'vitest'
import { randomUUID } from 'node:crypto'
import { getPool, withUserContext } from './setup'
import { insertPostedJournalEntry, seedCompany } from './fixtures'
async function seedCustomerInvoice(params: {
userId: string
companyId: string
total?: number
}): Promise<string> {
const customerId = randomUUID()
await getPool().query(
`INSERT INTO public.customers (id, user_id, company_id, name, customer_type)
VALUES ($1, $2, $3, 'Test Kund AB', 'swedish_business')`,
[customerId, params.userId, params.companyId],
)
const id = randomUUID()
const total = params.total ?? 12500
await getPool().query(
`INSERT INTO public.invoices
(id, user_id, company_id, customer_id, invoice_number, invoice_date, due_date,
currency, subtotal, vat_amount, total, vat_treatment, vat_rate, status,
paid_amount, remaining_amount)
VALUES ($1, $2, $3, $4, $5, '2026-08-01', '2026-08-31', 'SEK',
$6, 0, $6, 'standard_25', 25, 'sent', 0, $6)`,
[id, params.userId, params.companyId, customerId, `F-${id.slice(0, 8)}`, total],
)
return id
}
async function setActiveCompany(userId: string, companyId: string) {
await getPool().query(
`INSERT INTO public.user_preferences (user_id, active_company_id)
VALUES ($1, $2)
ON CONFLICT (user_id) DO UPDATE SET active_company_id = EXCLUDED.active_company_id`,
[userId, companyId],
)
}
const INSERT_MANUAL_PAYMENT = `
INSERT INTO public.invoice_payments
(user_id, company_id, invoice_id, payment_date, amount, currency, exchange_rate,
journal_entry_id, transaction_id, notes)
VALUES ($1, $2, $3, $4, $5, 'SEK', NULL, $6, NULL, NULL)
RETURNING id`
async function seedPaymentVoucher(params: {
userId: string
companyId: string
fiscalPeriodId: string
invoiceId: string
amount: number
voucherNumber: number
}): Promise<string> {
return insertPostedJournalEntry({
userId: params.userId,
companyId: params.companyId,
fiscalPeriodId: params.fiscalPeriodId,
entryDate: '2026-08-28',
description: 'Kontantbetalning kundfaktura',
sourceType: 'invoice_cash_payment',
sourceId: params.invoiceId,
voucherNumber: params.voucherNumber,
lines: [
{ accountNumber: '1930', debitAmount: params.amount, creditAmount: 0 },
{ accountNumber: '3001', debitAmount: 0, creditAmount: params.amount },
],
})
}
describe('invoice_payments rows without a bank transaction (#2019)', () => {
it('lets several transaction-less payments coexist on one invoice', async () => {
const seeded = await seedCompany()
const invoiceId = await seedCustomerInvoice(seeded)
const jeA = await seedPaymentVoucher({ ...seeded, invoiceId, amount: 5000, voucherNumber: 31 })
const jeB = await seedPaymentVoucher({ ...seeded, invoiceId, amount: 7500, voucherNumber: 32 })
const first = await getPool().query(INSERT_MANUAL_PAYMENT, [
seeded.userId, seeded.companyId, invoiceId, '2026-08-20', 5000, jeA,
])
const second = await getPool().query(INSERT_MANUAL_PAYMENT, [
seeded.userId, seeded.companyId, invoiceId, '2026-08-28', 7500, jeB,
])
expect(first.rowCount).toBe(1)
expect(second.rowCount).toBe(1)
const rows = await getPool().query<{ n: string; total: string }>(
`SELECT count(*)::text AS n, sum(amount)::text AS total
FROM public.invoice_payments WHERE invoice_id = $1 AND transaction_id IS NULL`,
[invoiceId],
)
expect(rows.rows[0]).toEqual({ n: '2', total: '12500' })
})
it('still refuses the same voucher linked twice to the same invoice', async () => {
const seeded = await seedCompany()
const invoiceId = await seedCustomerInvoice(seeded)
const je = await seedPaymentVoucher({ ...seeded, invoiceId, amount: 12500, voucherNumber: 41 })
await getPool().query(INSERT_MANUAL_PAYMENT, [
seeded.userId, seeded.companyId, invoiceId, '2026-08-28', 12500, je,
])
await expect(
getPool().query(INSERT_MANUAL_PAYMENT, [
seeded.userId, seeded.companyId, invoiceId, '2026-08-28', 12500, je,
]),
).rejects.toThrow(/idx_invoice_payments_je_inv_unique/)
})
it('lets the authenticated writer insert and delete its own row under RLS', async () => {
const seeded = await seedCompany()
await setActiveCompany(seeded.userId, seeded.companyId)
const invoiceId = await seedCustomerInvoice(seeded)
const je = await seedPaymentVoucher({ ...seeded, invoiceId, amount: 12500, voucherNumber: 51 })
const outcome = await withUserContext(seeded.userId, async (client) => {
const inserted = await client.query<{ id: string }>(INSERT_MANUAL_PAYMENT, [
seeded.userId, seeded.companyId, invoiceId, '2026-08-28', 12500, je,
])
const rowId = inserted.rows[0]?.id
// The CAS-failure branch deletes by (id, company_id) through the user
// client: the delete policy must let the row go.
const deleted = await client.query(
`DELETE FROM public.invoice_payments WHERE id = $1 AND company_id = $2`,
[rowId, seeded.companyId],
)
return { inserted: inserted.rowCount, deleted: deleted.rowCount }
})
expect(outcome).toEqual({ inserted: 1, deleted: 1 })
})
})