* 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>
211 lines
7.6 KiB
TypeScript
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}`,
|
|
},
|
|
}
|
|
}
|