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