Files
accounted/lib/invoices/duplicate-payment-detection.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

277 lines
12 KiB
TypeScript

/**
* Detect a "soft duplicate" payment voucher for a bank transaction.
*
* Scenario: the user manually booked the receipt as a verifikation
* (Dr 19xx / Cr 1510 or Cr 30xx) *outside* the match-invoice flow. The
* invoice's status stays 'sent', no `invoice_payments` row exists, and the
* matcher would happily propose a second payment voucher: double-booking
* the bank receipt.
*
* Heuristic: a posted journal entry within a tight date window whose lines
* debit a bank/cash account (BAS 19xx) for the same amount, and which is
* not already linked to any transaction or invoice payment, is almost
* certainly the manual booking. We surface it as a candidate; the API
* refuses the match unless the caller passes `force: true`.
*
* Mirrors `findDuplicatePaymentCandidatesForInvoice` (which scans for the
* reverse direction: unlinked transactions that look like a manually-marked
* invoice payment).
*
* Units: the comparison happens in SEK. `resolveTransactionAmountSek` (shared
* with the booking-side twin of this guard, deliberately one definition rather
* than two that can drift) carries the full explanation of why the ledger side
* is always SEK and why `journal_entry_lines.currency` must never be read as
* evidence that a debit/credit figure is foreign.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
import { resolveTransactionAmountSek } from '@/lib/transactions/booking-duplicate-detection'
/** ± days around the transaction date considered "the same payment". */
const DATE_WINDOW_DAYS = 7
/** BAS "kassa och bank" range. 1910-1919 = kassa, 1920-1949 = bank/giro. */
const BANK_ACCOUNT_LOW = 1910
const BANK_ACCOUNT_HIGH = 1949
export interface DuplicateVoucherCandidate {
journal_entry_id: string
voucher_label: string
entry_date: string
description: string | null
/** The voucher leg's SEK debit. Never the bank line's own (possibly foreign) amount. */
amount: number
bank_account_number: string
/**
* How the candidate was selected. The `exact_amount_*` values mean the SEK
* amounts really were compared and matched within 0.01; `date_window_only`
* means the amount test never ran (the bank line has no SEK value, see
* `amount_verified` below) and the candidate rests on date + bank account +
* unlinked-ness alone. Renderers must never phrase a `date_window_only`
* candidate as an amount match.
*/
reason: 'exact_amount_same_date' | 'exact_amount_within_window' | 'date_window_only'
/**
* Whether the bank line's SEK value could be established and actually matched
* the leg. False means the amounts were never compared and the candidate rests
* on date + bank account alone: a "could not verify", not a confirmed
* duplicate. Surfaced rather than swallowed because a silent pass here is what
* mints a second verifikat for one affärshändelse (BFL 5 kap 1-2 §), and a
* hard block would refuse a booking the software cannot actually judge.
*/
amount_verified: boolean
/**
* Why the amounts could not be compared. Null whenever `amount_verified`.
* `transaction_missing_sek_value`: a non-SEK bank line carrying neither
* `amount_sek` nor `exchange_rate`.
*/
unverified_reason: 'transaction_missing_sek_value' | null
}
interface DetectArgs {
companyId: string
transactionId: string
transactionDate: string
/** `transactions.amount`, denominated in `transactionCurrency`: NOT necessarily SEK. */
transactionAmount: number
/**
* `transactions.currency`. Required, not optional, for the reason spelled out
* on `TransactionAmountFields.currency`: an optional field silently reads as
* SEK for any caller that projects a narrow column list, which switches this
* guard off for precisely the FX rows it exists to catch. Null means SEK.
*/
transactionCurrency: string | null
/** `transactions.amount_sek`. */
transactionAmountSek?: number | null
/** `transactions.exchange_rate`. */
transactionExchangeRate?: number | null
}
/**
* Find the single most likely manual verifikation that already books this
* bank transaction. Returns null when no candidate is found.
*
* Filters applied:
* - posted status (drafts cannot be a duplicate by definition)
* - entry date within ±DATE_WINDOW_DAYS of transaction.date
* - has a line that debits a BAS 19xx (kassa/bank) account for the same
* rounded amount (within 0.01 SEK), the bank line's SEK value against the
* leg's debit column, which is always SEK. When the bank line has no SEK
* value (a foreign row with no stored rate) the amount test is skipped and
* the candidate comes back with `amount_verified: false`: skipping is not a
* pass, it is an explicit "could not verify" the caller must surface.
* - not already linked from `transactions.journal_entry_id` (for any row)
* - not already referenced by `invoice_payments.journal_entry_id`
* - not the storno/correction entry for any prior original (source_type
* excluded: those are valid second-line vouchers, not duplicates)
*/
export async function detectDuplicatePaymentVoucher(
supabase: SupabaseClient,
args: DetectArgs,
): Promise<DuplicateVoucherCandidate | null> {
const { companyId, transactionId, transactionDate, transactionAmount } = args
if (Math.round(Math.abs(transactionAmount) * 100) === 0) return null
// The bank line stated in SEK, or null when it cannot be: a non-SEK row with
// neither amount_sek nor exchange_rate. Null disables the amount test below,
// it does not short-circuit to "no duplicate".
const targetSek = resolveTransactionAmountSek({
amount: transactionAmount,
currency: args.transactionCurrency,
amount_sek: args.transactionAmountSek,
exchange_rate: args.transactionExchangeRate,
})
const dateMs = new Date(transactionDate).getTime()
if (Number.isNaN(dateMs)) return null
const lowDate = new Date(dateMs - DATE_WINDOW_DAYS * 24 * 3600 * 1000)
.toISOString()
.split('T')[0]
const highDate = new Date(dateMs + DATE_WINDOW_DAYS * 24 * 3600 * 1000)
.toISOString()
.split('T')[0]
type LineRow = {
account_number: string
debit_amount: number | string
journal_entry: {
id: string
entry_date: string
description: string | null
voucher_series: string | null
voucher_number: number | null
status: string
source_type: string | null
}
}
// Find bank-account debits within the window. The scope filters live on
// journal_entries and the query is driven from there: the old
// `journal_entries!inner` embed made PostgREST compile a correlated LATERAL
// join that walked the ENTIRE journal_entry_lines table across all tenants
// (see lib/bookkeeping/entry-lines.ts). RLS handles isolation; the
// company_id filter is defense-in-depth. The old `.limit(50)` is gone with
// the embed: it capped a ±7-day window of one company's bank legs and could
// hide the real duplicate behind unrelated ones.
let lines: LineRow[]
try {
lines = await fetchEntryLines<LineRow>({
supabase,
entryColumns: 'id, entry_date, description, voucher_series, voucher_number, status, source_type, company_id',
lineColumns: 'account_number, debit_amount',
filterEntries: (q: EntryLinesQuery) =>
q
.eq('company_id', companyId)
.eq('status', 'posted')
.gte('entry_date', lowDate)
.lte('entry_date', highDate),
filterLines: (q: EntryLinesQuery) =>
q
.gte('account_number', String(BANK_ACCOUNT_LOW))
.lte('account_number', String(BANK_ACCOUNT_HIGH))
.gt('debit_amount', 0),
// The old embed was aliased: journal_entry:journal_entries!inner(...).
attachEntriesAs: 'journal_entry',
})
} catch {
// Fail-open, as before: a detection failure must not block the match.
return null
}
if (lines.length === 0) return null
// System-generated payment vouchers (invoice_paid etc.) ARE valid
// duplicates to surface: those are exactly the case where the user
// already booked through a different flow. Only exclude reversals
// and corrections, which are bookkeeping noise rather than payment
// candidates the user would want to link to.
const bankDebits = lines.filter(
(l) => l.journal_entry.source_type !== 'storno' && l.journal_entry.source_type !== 'correction',
)
// Narrow to lines whose SEK debit matches the bank line's SEK value within
// 0.01. With no SEK value on the bank side the test cannot run: keep the
// survivors and flag them unverified rather than returning null, which would
// read as "not a duplicate, go ahead". The existence half of the question is
// unit-free (posted, 19xx, in-window, unlinked), so an empty list here is
// still a genuine "no duplicate".
const candidates =
targetSek === null
? bankDebits
: bankDebits.filter((l) => {
const debitSek = Math.round(Number(l.debit_amount) * 100) / 100
return Math.abs(debitSek - targetSek) < 0.01
})
if (candidates.length === 0) return null
// Exclude entries already linked to a bank transaction, directly or through
// an invoice_payments row that carries one. A payment row with
// transaction_id NULL is a manual / Stripe settlement (#2019): its bank line
// has not been matched yet, so the voucher stays a duplicate candidate.
const entryIds = candidates.map((l) => l.journal_entry.id)
const [{ data: paymentLinks }, { data: txLinks }] = await Promise.all([
supabase
.from('invoice_payments')
.select('journal_entry_id, transaction_id')
.eq('company_id', companyId)
.in('journal_entry_id', entryIds),
supabase
.from('transactions')
.select('id, journal_entry_id')
.eq('company_id', companyId)
.in('journal_entry_id', entryIds),
])
const linkedIds = new Set<string>()
for (const row of (paymentLinks ?? []) as {
journal_entry_id: string | null
transaction_id: string | null
}[]) {
if (row.journal_entry_id && row.transaction_id) linkedIds.add(row.journal_entry_id)
}
for (const row of (txLinks ?? []) as { id: string; journal_entry_id: string | null }[]) {
// A transaction can link to its own JE via the current match flow: but
// we're called *before* that link is created, so the caller's own
// transactionId shouldn't appear. Guard anyway in case of a retry.
if (row.journal_entry_id && row.id !== transactionId) {
linkedIds.add(row.journal_entry_id)
}
}
const unlinked = candidates.filter((l) => !linkedIds.has(l.journal_entry.id))
if (unlinked.length === 0) return null
// Pick the best candidate: same-date beats within-window; otherwise pick
// the closest by date difference.
const targetDateMs = new Date(transactionDate).getTime()
unlinked.sort((a, b) => {
const aDiff = Math.abs(new Date(a.journal_entry.entry_date).getTime() - targetDateMs)
const bDiff = Math.abs(new Date(b.journal_entry.entry_date).getTime() - targetDateMs)
return aDiff - bDiff
})
const best = unlinked[0]
const sameDate = best.journal_entry.entry_date === transactionDate
return {
journal_entry_id: best.journal_entry.id,
voucher_label: `${best.journal_entry.voucher_series ?? 'A'}${best.journal_entry.voucher_number ?? ''}`,
entry_date: best.journal_entry.entry_date,
description: best.journal_entry.description,
amount: Math.round(Number(best.debit_amount) * 100) / 100,
bank_account_number: best.account_number,
// When the amount test never ran, the reason must say so: labelling a
// date-only survivor 'exact_amount_*' made the UI claim an amount match
// that was never made.
reason:
targetSek === null
? 'date_window_only'
: sameDate
? 'exact_amount_same_date'
: 'exact_amount_within_window',
amount_verified: targetSek !== null,
unverified_reason: targetSek === null ? 'transaction_missing_sek_value' : null,
}
}