* 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>
112 lines
3.9 KiB
TypeScript
112 lines
3.9 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { createLogger } from '@/lib/logger'
|
|
import { roundOre } from '@/lib/money'
|
|
|
|
const log = createLogger('invoice-payment-row')
|
|
|
|
/**
|
|
* The AR sub-ledger row for a payment that no bank transaction drives:
|
|
* "Markera som betald" (dashboard, v1, MCP) and the Stripe payment sync.
|
|
*
|
|
* Without the row the payment has no DATE anywhere. The kontantmetod bokslut
|
|
* cut-off (lib/core/bookkeeping/kontantmetod-cutoff.ts) reads invoice_payments
|
|
* only and would book a paid invoice as a fordran with vilande moms at year
|
|
* end, double-counting revenue and VAT (#2019); the Betalningar view and the
|
|
* voucher -> invoice reference map read the same table.
|
|
*
|
|
* Shape mirrors the bank-match path (app/api/transactions/[id]/match-invoice):
|
|
* amount in INVOICE currency, transaction_id null. The
|
|
* (transaction_id, invoice_id) unique index treats nulls as distinct, so
|
|
* several manual partials on one invoice coexist; (journal_entry_id,
|
|
* invoice_id) still refuses the same voucher twice.
|
|
*
|
|
* `amount` is the amount APPLIED to the invoice (new paid_amount minus the
|
|
* prior one), not the cash received: a SEK öresavrundning overshoot absorbed
|
|
* on 3740 is part of the voucher but not of the receivable, and every reader
|
|
* subtracts rows from `total`.
|
|
*/
|
|
export interface RecordInvoicePaymentRowParams {
|
|
userId: string
|
|
companyId: string
|
|
invoice: {
|
|
id: string
|
|
currency?: string | null
|
|
exchange_rate?: number | null
|
|
paid_amount?: number | null
|
|
}
|
|
/** Booking date (YYYY-MM-DD); same value the voucher carries. */
|
|
paymentDate: string
|
|
/** paid_amount after this payment, in invoice currency. */
|
|
newPaidAmount: number
|
|
journalEntryId: string | null
|
|
}
|
|
|
|
export type RecordInvoicePaymentRowResult =
|
|
| { ok: true; id: string }
|
|
| { ok: false; error: string }
|
|
|
|
export async function recordInvoicePaymentRow(
|
|
supabase: SupabaseClient,
|
|
params: RecordInvoicePaymentRowParams,
|
|
): Promise<RecordInvoicePaymentRowResult> {
|
|
const { userId, companyId, invoice, paymentDate, newPaidAmount, journalEntryId } = params
|
|
const amount = roundOre(newPaidAmount - (invoice.paid_amount ?? 0))
|
|
|
|
const { data, error } = await supabase
|
|
.from('invoice_payments')
|
|
.insert({
|
|
user_id: userId,
|
|
company_id: companyId,
|
|
invoice_id: invoice.id,
|
|
payment_date: paymentDate,
|
|
amount,
|
|
currency: invoice.currency ?? 'SEK',
|
|
exchange_rate: invoice.exchange_rate ?? null,
|
|
journal_entry_id: journalEntryId,
|
|
transaction_id: null,
|
|
notes: null,
|
|
})
|
|
.select('id')
|
|
.single()
|
|
|
|
if (error || !data) {
|
|
return { ok: false, error: error?.message ?? 'no_row_returned' }
|
|
}
|
|
return { ok: true, id: (data as { id: string }).id }
|
|
}
|
|
|
|
/**
|
|
* Undo the row on a failed settlement. Best-effort like the voucher storno
|
|
* next to it: the caller is already on a decided error path (race or update
|
|
* failure), and that response must not be replaced by a delete error. Never
|
|
* throws, but never silent either: a row that survives here points at a
|
|
* cancelled voucher for an invoice that never reached paid, and the
|
|
* kontantmetod cut-off would read it as a settlement, so the failure is
|
|
* logged at error level with everything an operator needs to delete it.
|
|
*
|
|
* @returns true when the row is gone, false when it may be stranded.
|
|
*/
|
|
export async function removeInvoicePaymentRow(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
paymentRowId: string | null,
|
|
): Promise<boolean> {
|
|
if (!paymentRowId) return true
|
|
const ctx = { companyId, invoicePaymentId: paymentRowId }
|
|
try {
|
|
const { error } = await supabase
|
|
.from('invoice_payments')
|
|
.delete()
|
|
.eq('id', paymentRowId)
|
|
.eq('company_id', companyId)
|
|
if (error) {
|
|
log.error('invoice_payments rollback failed (row may be stranded)', error, ctx)
|
|
return false
|
|
}
|
|
return true
|
|
} catch (err) {
|
|
log.error('invoice_payments rollback threw (row may be stranded)', err as Error, ctx)
|
|
return false
|
|
}
|
|
}
|