Files
accounted/lib/invoices/backfill-invoice-payment-rows.ts
T
MattssonandClaude Fable 5.1 e2d38b0ab3 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>
2026-09-03 19:42:43 +02:00

211 lines
7.6 KiB
TypeScript

/**
* Planner for the #2019 backfill: paid or partially paid customer invoices
* that were settled through "Markera som betald" (or the Stripe sync) before
* settleInvoicePayment wrote the AR sub-ledger row. Pure: the script in
* scripts/backfill-invoice-payment-rows.ts owns the reads and writes.
*
* Deterministic on purpose (project doctrine: never guess). A row is planned
* only when the invoice has exactly ONE posted payment voucher, so the
* journal link is unambiguous. Everything else is reported and skipped:
* zero vouchers (imported / migrated invoices never booked here), several
* vouchers (partials whose split cannot be reconstructed from the header),
* or a row already present (bank-matched, link-to-voucher, or an earlier run).
*/
import { roundOre } from '@/lib/money'
/** Tag written to invoice_payments.notes so one DELETE reverts a whole run. */
export const BACKFILL_NOTES_TAG = 'backfill:#2019'
/** Source types settleInvoicePayment produces (lib/bookkeeping/invoice-entries.ts). */
export const PAYMENT_VOUCHER_SOURCE_TYPES = ['invoice_paid', 'invoice_cash_payment'] as const
export interface BackfillInvoice {
id: string
company_id: string
user_id: string
invoice_number: string | null
status: string
document_type: string | null
currency: string | null
exchange_rate: number | null
paid_amount: number | null
paid_at: string | null
}
export interface BackfillVoucher {
id: string
source_id: string | null
source_type: string
status: string
entry_date: string
/**
* What the voucher actually applied to the receivable, in SEK: the 1510
* credit for a clearing entry (faktureringsmetoden), else the debit on the
* settlement account (19xx / 1686) for a kontantmetoden cash entry. null
* when neither leg exists; undefined when the caller did not load lines.
*/
settlement_sek?: number | null
}
/**
* Derive `settlement_sek` from a voucher's lines. Exported for the script and
* its test; the planner only consumes the result.
*/
export function settlementSekFromLines(
lines: Array<{ account_number: string; debit_amount: number | null; credit_amount: number | null }>,
): number | null {
const credit1510 = lines
.filter((l) => l.account_number === '1510')
.reduce((sum, l) => sum + Number(l.credit_amount ?? 0), 0)
if (credit1510 > 0) return roundOre(credit1510)
const settlementDebit = lines
.filter((l) => l.account_number.startsWith('19') || l.account_number === '1686')
.reduce((sum, l) => sum + Number(l.debit_amount ?? 0), 0)
if (settlementDebit > 0) return roundOre(settlementDebit)
return null
}
export interface BackfillPaymentRow {
user_id: string
company_id: string
invoice_id: string
payment_date: string
amount: number
currency: string
exchange_rate: number | null
journal_entry_id: string
transaction_id: null
notes: string
}
export type BackfillSkipReason =
| 'has_rows'
| 'rows_short'
| 'not_invoice'
| 'not_paid'
| 'no_paid_amount'
| 'no_payment_voucher'
| 'multiple_payment_vouchers'
| 'voucher_amount_mismatch'
| 'voucher_amount_unverifiable'
| 'period_closed'
export type BackfillPlan =
| { kind: 'insert'; row: BackfillPaymentRow }
| { kind: 'skip'; reason: BackfillSkipReason; voucherIds?: string[] }
export interface ExistingPaymentRows {
count: number
/** Sum of invoice_payments.amount, invoice currency. */
sum: number
}
/**
* Decide what to do for one invoice given every voucher whose source_id
* points at it and the invoice_payments rows it already has.
*
* Rows present but summing to less than paid_amount means an earlier manual
* partial has no row while a later bank-matched one does. That invoice is
* `rows_short`: reported for a human, never patched, because the difference
* cannot be attributed to a voucher without guessing.
*/
export interface BackfillPlanOptions {
/**
* Whether the fiscal period covering `date` (YYYY-MM-DD) for this invoice's
* company is closed or locked. A row dated into such a period changes facts
* a filed bokslut or deklaration relied on, so it is reported, not written.
*/
isPeriodClosed?: (date: string) => boolean
}
export function planInvoicePaymentBackfill(
invoice: BackfillInvoice,
vouchers: BackfillVoucher[],
existing: ExistingPaymentRows,
options: BackfillPlanOptions = {},
): BackfillPlan {
const paidAmountRaw = Number(invoice.paid_amount ?? 0)
if (existing.count > 0) {
const short = roundOre(paidAmountRaw - existing.sum)
return short > 0 ? { kind: 'skip', reason: 'rows_short' } : { kind: 'skip', reason: 'has_rows' }
}
if (invoice.document_type && invoice.document_type !== 'invoice') {
return { kind: 'skip', reason: 'not_invoice' }
}
if (invoice.status !== 'paid' && invoice.status !== 'partially_paid') {
return { kind: 'skip', reason: 'not_paid' }
}
const paidAmount = Number(invoice.paid_amount ?? 0)
if (!Number.isFinite(paidAmount) || paidAmount <= 0) {
return { kind: 'skip', reason: 'no_paid_amount' }
}
const paymentVouchers = vouchers.filter(
(v) =>
v.source_id === invoice.id &&
v.status === 'posted' &&
(PAYMENT_VOUCHER_SOURCE_TYPES as readonly string[]).includes(v.source_type),
)
if (paymentVouchers.length === 0) return { kind: 'skip', reason: 'no_payment_voucher' }
if (paymentVouchers.length > 1) {
return {
kind: 'skip',
reason: 'multiple_payment_vouchers',
voucherIds: paymentVouchers.map((v) => v.id),
}
}
const voucher = paymentVouchers[0]
// The row must agree with what the voucher booked, or the cut-off inherits
// a header figure the ledger never carried. SEK invoices must match to the
// öre band; foreign-currency ones are checked through the invoice rate
// within 1 %. An unreadable voucher (no 1510 credit, no settlement debit) or
// a rate-less foreign invoice cannot be verified and is left to a human.
const settlementSek = voucher.settlement_sek
if (settlementSek === undefined || settlementSek === null) {
return { kind: 'skip', reason: 'voucher_amount_unverifiable', voucherIds: [voucher.id] }
}
const currency = invoice.currency ?? 'SEK'
if (currency === 'SEK') {
if (Math.abs(settlementSek - paidAmount) > 0.5) {
return { kind: 'skip', reason: 'voucher_amount_mismatch', voucherIds: [voucher.id] }
}
} else {
const rate = Number(invoice.exchange_rate ?? 0)
if (!(rate > 0)) {
return { kind: 'skip', reason: 'voucher_amount_unverifiable', voucherIds: [voucher.id] }
}
if (Math.abs(settlementSek / rate - paidAmount) > paidAmount * 0.01) {
return { kind: 'skip', reason: 'voucher_amount_mismatch', voucherIds: [voucher.id] }
}
}
// The voucher's entry_date is the affärshändelse date (BFL 5 kap 7 §) and
// is what the settle paths stamp from the user's payment date. paid_at is
// NOT usable: before 2026-08-02 (#1332) it was the wall-clock registration
// time, so a payment booked in January for a December date would land in
// the wrong year. The two agree for every row written since.
const paymentDate = voucher.entry_date
if (options.isPeriodClosed?.(paymentDate)) {
return { kind: 'skip', reason: 'period_closed', voucherIds: [voucher.id] }
}
return {
kind: 'insert',
row: {
user_id: invoice.user_id,
company_id: invoice.company_id,
invoice_id: invoice.id,
payment_date: paymentDate,
amount: roundOre(paidAmount),
currency: invoice.currency ?? 'SEK',
exchange_rate: invoice.exchange_rate ?? null,
journal_entry_id: voucher.id,
transaction_id: null,
notes: `${BACKFILL_NOTES_TAG} Markera som betald utan betalningsrad; verifikat ${voucher.id}`,
},
}
}