Files
accounted/lib/transactions/booking-duplicate-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

801 lines
39 KiB
TypeScript

/**
* Booking-time duplicate guard for bank transactions.
*
* Why this exists
* ---------------
* A bank account's transactions can land in the `transactions` table twice: a
* CSV import on top of a PSD2 sync, or a re-sync whose external_id drifted (see
* the import dedup in lib/transactions/ingest.ts). Import-time dedup is
* best-effort and can miss. The cosmetic cost of a missed duplicate is a second
* row in the "Att bokföra" list. The REAL cost is booking BOTH copies: that
* creates two verifikationer for one affärshändelse, double-counts the
* cost/income, and is felaktig bokföring under BFL (the second verifikat has no
* underlying event). Rättelse would then require storno, not deletion.
*
* This guard runs at booking time. Before a transaction becomes a verifikat it
* looks for ANOTHER transaction in the same company that is already booked and
* shares this one's (date, amount, cash account). If found, the caller surfaces
* it as a WARNING: never a hard block, because genuinely repeated
* same-(date,amount) payments do occur (e.g. several identical Swish transfers
* in one day). The user confirms with force=true after reviewing the candidate.
*
* Mirrors the invoice-side `detectDuplicatePaymentVoucher`
* (lib/invoices/duplicate-payment-detection.ts), but keyed on an already-booked
* sibling TRANSACTION rather than a manually-posted journal entry.
*
* Units: every amount comparison in this file happens in SEK. See
* {@link resolveTransactionAmountSek} for why, and for what happens when a bank
* line cannot be expressed in SEK at all.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { roundOre } from '@/lib/money'
import { resolveSekAmountOrNull } from '@/lib/bookkeeping/currency-utils'
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
import { getPrimaryJournalEntryId } from '@/lib/transactions/is-booked'
/** Integer öre: representation-agnostic amount key (mirrors the ingest dedup). */
function toOre(amount: number | string): number {
return Math.round(Number(amount) * 100)
}
/** The `transactions` columns needed to state a bank line's amount in SEK. */
export interface TransactionAmountFields {
/** `transactions.amount`, denominated in `currency`: NOT necessarily SEK. */
amount: number | string
/**
* `transactions.currency`. REQUIRED, not optional, on purpose: an optional
* field reads as `undefined` for any caller that projects a narrow column
* list instead of `select('*')`, `undefined` would have to be defaulted to
* SEK, and that default silently switches off the FX half of this guard for
* exactly the rows it exists to catch. Required turns that mistake into a
* compile error at every call site. Null is fine and means SEK: it is
* PostgREST's shape for the column's 'SEK' default.
*/
currency: string | null
/** `transactions.amount_sek`: `amount` converted at ingest. Null = no rate stored. */
amount_sek?: number | string | null
/** `transactions.exchange_rate`: SEK per 1 unit of `currency`. */
exchange_rate?: number | string | null
}
/**
* A bank line's magnitude in SEK, or null when it cannot be established.
*
* WHY THIS EXISTS. `transactions.amount` is denominated in
* `transactions.currency`, while `journal_entry_lines.debit_amount` /
* `credit_amount` are ALWAYS SEK: lib/bookkeeping/currency-utils.ts converts the
* foreign figure to SEK for those columns and then stamps `currency` +
* `amount_in_currency` onto the SAME line as metadata describing the source
* DOCUMENT. So `journal_entry_lines.currency` is a label, never evidence that
* the debit/credit figure is in that currency, and any guard shaped like "the
* currencies match, so the amounts are comparable" passes on precisely the FX
* rows it exists to catch. Comparing a raw EUR `transactions.amount` against a
* leg got it wrong twice over: it never matched the real EUR twin (so a foreign
* affärshändelse could be booked a second time with nothing objecting, BFL 5 kap
* 1-2 §: one affärshändelse, one verifikation), and it did match unrelated SEK
* vouchers of the same magnitude.
*
* BASIS: SEK, for both halves of this file, because it is the only unit both
* sides can always reach. Note the deliberate asymmetry with
* `ledgerLineAmountIn()` in lib/reconciliation/bank-reconciliation.ts, which
* resolves ledger lines in the ACCOUNT's currency: there the whole statement
* being reconciled is foreign, whereas here the twin being hunted is usually an
* ordinary SEK verifikat (an invoice marked paid, a salary payout, a
* hand-posted entry) that carries no `amount_in_currency` at all. Comparing in
* EUR would resolve every one of those to null and reopen the double-booking
* hole this guard exists to close.
*
* Returning null rather than falling back to the raw foreign number (which is
* what `resolveSekAmount()` in currency-utils does, tolerably, for a line
* amount that only has to balance against itself) is the whole point: guessing
* the unit IS the bug. Mirrors `resolveSekAmountOrNull()` in
* lib/bookkeeping/mapping-engine.ts, which draws the same line for the same
* reason. Callers must treat null as "could not verify", never as "no match".
*/
export function resolveTransactionAmountSek(tx: TransactionAmountFields): number | null {
const amount = Number(tx.amount)
if (!Number.isFinite(amount)) return null
// SEK rows short-circuit before any FX field is read, so a SEK-only company
// behaves exactly as it did before this guard learned about currencies.
const currency = (tx.currency || 'SEK').toUpperCase()
if (currency === 'SEK') return roundOre(Math.abs(amount))
if (tx.amount_sek != null) {
const sek = Number(tx.amount_sek)
if (Number.isFinite(sek)) return roundOre(Math.abs(sek))
}
if (tx.exchange_rate != null) {
const rate = Number(tx.exchange_rate)
if (Number.isFinite(rate) && rate > 0) return roundOre(Math.abs(amount) * rate)
}
// Non-SEK row carrying neither a converted amount nor a rate: the shape a row
// gets when the Riksbanken lookup failed at ingest (lib/currency/riksbanken.ts
// deliberately inserts without amount_sek rather than inventing a rate).
return null
}
/** An already-booked transaction OR voucher that looks like the same real movement. */
export interface BookedDuplicateCandidate {
/**
* The already-booked twin TRANSACTION, or `null` when the duplicate is a
* ledger-only voucher (a payment/payout booked straight to the cash account
* with no transaction row behind it: see detectLedgerDuplicateVoucher). Set
* by the sibling detector, and ALSO by the ledger detector when the matched
* voucher's linking transaction is itself the target's twin (the
* date-drifted duplicate-import shape): consumers must branch on this field,
* not on which detector produced the candidate.
*/
transaction_id: string | null
/** Its verifikat. */
journal_entry_id: string
/** Human label, e.g. "A142" (voucher_series + voucher_number). */
voucher_label: string
entry_date: string
description: string | null
/**
* ALWAYS a SEK figure or null, NEVER a foreign number: every consumer labels
* this field "kr" (DuplicateBookingDialog renders it through the SEK-default
* `formatCurrency()`, and the agent-path messages append "kr" verbatim).
*
* Ledger-voucher candidate: the matched 19xx leg's debit/credit column,
* which is SEK by construction, so always present.
*
* Sibling-transaction candidate: the sibling row's OWN SEK value via the
* strict `resolveSekAmountOrNull()` ladder (amount as-is when SEK, else
* amount_sek, else amount * exchange_rate), signed like the row. NULL when
* the sibling is foreign and carries neither: a foreign amount with no
* stored rate has no SEK value, and refusing beats fabricating one on the
* exact screen whose only question is "is this the same event?". Renderers
* must fall back to `amount_in_currency` + `currency` and say the kr value
* is unavailable, never print a foreign number as kronor.
*/
amount: number | null
/**
* The 19xx settlement account of the voucher leg that matched, set for
* every ledger-detected candidate (twin-linked or not) so the match action
* can link on the exact account the voucher was booked to (a legacy
* transaction without cash_account_id would otherwise resolve by currency
* and can pick the wrong 19xx). Null for sibling-transaction candidates,
* whose legs are not fetched.
*/
account_number: string | null
/**
* The sibling row's own denomination when it is not SEK: uppercased ISO code
* plus `transactions.amount` as stored (öre-rounded, signed). Both null for
* SEK siblings and for ledger-voucher candidates (whose matched leg is SEK
* and carries no foreign context of its own). Naming mirrors the
* journal-entry-line currency metadata (`currency` / `amount_in_currency`,
* lib/bookkeeping/currency-utils.ts).
*/
currency: string | null
amount_in_currency: number | null
/**
* Whether the candidate's kr figure is fully established: the two amounts
* were brought to a common unit AND `amount` above holds a real SEK figure.
* True for every SEK candidate and for a foreign sibling whose own stored
* conversion states it in kronor.
*
* False in two shapes, both surfaced rather than silently passed: staying
* silent would let a second verifikat be minted for one affärshändelse (BFL
* 5 kap 1-2 §: one affärshändelse, one verifikation), while hard-blocking
* would refuse a booking the software cannot judge, and an unbooked
* affärshändelse breaks löpande bokföring just as surely. So the guard warns
* and leaves the call to the user.
*
* - Ledger-voucher candidate: the TARGET bank line could not be stated in
* SEK, so no amount comparison was possible at all; the candidate matches
* on date + bank account + direction alone (`amount` still holds the
* leg's SEK figure).
* - Sibling-transaction candidate: the öre comparison DID hold, exactly, in
* the shared foreign currency, but the sibling row cannot state that
* figure in kronor, so `amount` is null and only `amount_in_currency` +
* `currency` may be shown.
*/
amount_verified: boolean
/**
* Why the kr figure could not be fully established. Null whenever
* `amount_verified`. `transaction_missing_sek_value`: a non-SEK
* `transactions` row (the target bank line for a ledger-voucher candidate,
* the already-booked sibling for a sibling-transaction candidate) carrying
* neither `amount_sek` nor `exchange_rate` (see
* {@link resolveTransactionAmountSek}).
*/
unverified_reason: 'transaction_missing_sek_value' | null
}
/** Minimal shape of the transaction about to be booked. */
export interface BookingTarget extends TransactionAmountFields {
id: string
date: string
cash_account_id?: string | null
}
/**
* Same-batch siblings to exclude from booking-time duplicate detection.
*
* When a bulk run books several DISTINCT bank movements that happen to share a
* (date, amount, cash account): several identical Swish transfers the user
* explicitly selected: the second booking must NOT dedupe against the first
* booking's freshly-created verifikat: they are separate affärshändelser. The
* bulk driver accumulates the ids it has booked so far in THIS batch and passes
* them here so intra-batch siblings never flag one another.
*
* CRITICAL: only ids created within the current batch belong here. A duplicate
* that existed BEFORE the batch has neither its transaction id nor its voucher
* id in these lists, so it is STILL detected and skipped. Both fields are
* optional; the default (no exclusion) keeps single-booking callers unaffected.
*/
export interface BookingDuplicateExclusions {
/** Sibling transaction ids booked earlier in the same bulk run. */
excludeTransactionIds?: string[]
/** Journal-entry ids minted earlier in the same bulk run. */
excludeJournalEntryIds?: string[]
}
/**
* ± days around the target's date an already-booked SIBLING TRANSACTION may
* sit and still be "the same" movement. A duplicate import of one real
* movement often carries a drifted date (CSV bokföringsdag vs PSD2 valutadag,
* a weekend in between), so an exact-date match missed exactly the twin this
* guard exists to catch. Deliberately tighter than the voucher window below:
* a sibling match additionally requires the exact öre amount in the same
* currency, but adjacent-day repeated payments (Swish, weekly SaaS charges)
* are common enough that a wide window would over-warn.
*/
const SIBLING_DUPLICATE_DATE_WINDOW_DAYS = 3
/**
* Find an already-booked sibling transaction sharing (date-window, amount,
* account). Returns the single best candidate, or null.
*
* Booked-ness uses the full is_transaction_booked semantics
* (lib/transactions/is-booked.ts): a bare `transactions.journal_entry_id`
* check misses bulk-booked rows (anchored via transaction_voucher_links) and
* multi-allocated rows (anchored via invoice_payments /
* supplier_invoice_payments), which read as "unbooked" and made the guard
* blind to their twins. The anchor rows are batch-fetched for the windowed
* candidates and the candidate's verifikat resolves via
* getPrimaryJournalEntryId.
*
* Account guard mirrors the import dedup bridge: when BOTH sides know their
* cash_account_id they must match; a null on either side is treated as
* compatible (single-account companies and un-backfilled rows behave as before).
*
* Currency guard: both sides are `transactions.amount`, each denominated in its
* own row's `transactions.currency` (which, unlike journal_entry_lines.currency,
* really does label the adjacent amount). The öre comparison is only
* like-with-like once those labels agree, so a mismatch is skipped outright: a
* 100 EUR line and a 100 SEK line on the same day are not the same
* affärshändelse. No SEK conversion is needed or wanted for the COMPARISON;
* comparing in the shared currency is exact, whereas routing both through
* amount_sek would make a 100 EUR row collide with an unrelated 1150 SEK row.
* The REPORTED `amount` is a different matter: consumers label it "kr", so it
* is the sibling's SEK figure (or null when that cannot be established), never
* the raw foreign number: see the resolution at the bottom.
*
* Fail-open: a query error returns null rather than throwing: a detection
* failure must never block a legitimate booking. The pick is deterministic:
* smallest date distance first (an exact-date sibling always outranks a
* drifted one), then earliest date, then lowest id, so a re-detection under
* force=true returns the same candidate the user reviewed.
*/
export async function detectBookedDuplicateTransaction(
supabase: SupabaseClient,
companyId: string,
target: BookingTarget,
opts?: BookingDuplicateExclusions,
): Promise<BookedDuplicateCandidate | null> {
const targetOre = toOre(target.amount)
if (targetOre === 0 || Number.isNaN(targetOre)) return null
// Siblings booked earlier in this same bulk run are distinct events the user
// selected, not duplicates: never flag one against another. Same for
// vouchers minted earlier in the run: a bulk-booked sibling resolves its
// verifikat via transaction_voucher_links below, so the entry-id exclusion
// applies here too.
const excludeTransactionIds = new Set(opts?.excludeTransactionIds ?? [])
const excludeJournalEntryIds = new Set(opts?.excludeJournalEntryIds ?? [])
const targetCurrency = (target.currency || 'SEK').toUpperCase()
const targetDateMs = new Date(target.date).getTime()
if (Number.isNaN(targetDateMs)) return null
const siblingWindowMs = SIBLING_DUPLICATE_DATE_WINDOW_DAYS * 24 * 3600 * 1000
const siblingLowDate = new Date(targetDateMs - siblingWindowMs).toISOString().split('T')[0]
const siblingHighDate = new Date(targetDateMs + siblingWindowMs).toISOString().split('T')[0]
// Same company, date inside the sibling window, not the target row itself.
// The amount, currency and account match is applied in JS so a
// numeric-string amount from PostgREST ("-1616.00") collapses to the same
// öre as the number (-1616). Booked-ness is resolved AFTER the fetch (see
// below): filtering on journal_entry_id here would drop bulk-booked and
// multi-allocated siblings whose column is NULL. Ordered so the row set is
// deterministic even at the limit.
const { data, error } = await supabase
.from('transactions')
.select('id, date, amount, currency, amount_sek, exchange_rate, description, cash_account_id, journal_entry_id')
.eq('company_id', companyId)
.gte('date', siblingLowDate)
.lte('date', siblingHighDate)
.neq('id', target.id)
.order('id', { ascending: true })
.limit(500)
if (error || !data || data.length === 0) return null
type Row = {
id: string
date: string
amount: number | string
currency: string | null
amount_sek: number | string | null
exchange_rate: number | string | null
description: string | null
cash_account_id: string | null
journal_entry_id: string | null
}
const targetAccount = target.cash_account_id ?? null
const matches = (data as unknown as Row[]).filter((r) => {
if (excludeTransactionIds.has(r.id)) return false
// Currency guard first: `amount` is only comparable to `amount` when both
// rows are denominated in the same currency. Null normalises to SEK (the
// column default), so SEK-only companies see no behaviour change.
if ((r.currency || 'SEK').toUpperCase() !== targetCurrency) return false
if (toOre(r.amount) !== targetOre) return false
// Account guard: both-known must match; a null on either side is compatible.
if (targetAccount !== null && r.cash_account_id !== null && r.cash_account_id !== targetAccount) {
return false
}
// Window re-check in JS (the query already ranged on date): keeps the
// window authoritative in one place and drops rows with unparseable dates.
const rowMs = new Date(r.date).getTime()
return Number.isFinite(rowMs) && Math.abs(rowMs - targetDateMs) <= siblingWindowMs
})
if (matches.length === 0) return null
// Resolve booked-ness with the full is_transaction_booked semantics: the
// anchor may live on the row itself (journal_entry_id), in
// transaction_voucher_links (bulk-book), or in invoice_payments /
// supplier_invoice_payments (multi-allocation). Batch-fetched only for the
// rows whose own column is NULL. All lookups filter by company_id (defense
// in depth alongside RLS).
const unanchoredIds = matches.filter((r) => r.journal_entry_id == null).map((r) => r.id)
let paymentRows: { transaction_id: string | null; journal_entry_id: string | null }[] = []
let voucherLinkRows: { transaction_id: string; journal_entry_id: string }[] = []
if (unanchoredIds.length > 0) {
const [vl, ip, sp] = await Promise.all([
supabase
.from('transaction_voucher_links')
.select('transaction_id, journal_entry_id')
.eq('company_id', companyId)
.in('transaction_id', unanchoredIds),
supabase
.from('invoice_payments')
.select('transaction_id, journal_entry_id')
.eq('company_id', companyId)
.in('transaction_id', unanchoredIds),
supabase
.from('supplier_invoice_payments')
.select('transaction_id, journal_entry_id')
.eq('company_id', companyId)
.in('transaction_id', unanchoredIds),
])
voucherLinkRows = (vl.data ?? []) as { transaction_id: string; journal_entry_id: string }[]
paymentRows = [...(ip.data ?? []), ...(sp.data ?? [])] as {
transaction_id: string | null
journal_entry_id: string | null
}[]
}
const booked = matches
.map((r) => ({
row: r,
journalEntryId: getPrimaryJournalEntryId(r, paymentRows, voucherLinkRows),
}))
.filter(
(x): x is { row: Row; journalEntryId: string } =>
x.journalEntryId != null && !excludeJournalEntryIds.has(x.journalEntryId),
)
if (booked.length === 0) return null
// Deterministic pick, and exact-date candidates MUST outrank drifted ones:
// force=true re-detection is bound to the candidate the user reviewed
// (TRANSACTION_BOOK_FORCE_CANDIDATE_MISMATCH otherwise), so the ranking has
// explicit total-order tiebreakers.
booked.sort((a, b) => {
const ad = Math.abs(new Date(a.row.date).getTime() - targetDateMs)
const bd = Math.abs(new Date(b.row.date).getTime() - targetDateMs)
if (ad !== bd) return ad - bd
if (a.row.date !== b.row.date) return a.row.date < b.row.date ? -1 : 1
return a.row.id.localeCompare(b.row.id)
})
const best = booked[0].row
const bestJournalEntryId = booked[0].journalEntryId
// Resolve the voucher label for the warning (best-effort: a missing label
// still yields a usable candidate the UI can render by date/amount).
let voucherLabel = ''
let entryDate = best.date
const { data: je } = await supabase
.from('journal_entries')
.select('voucher_series, voucher_number, entry_date')
.eq('id', bestJournalEntryId)
.maybeSingle()
if (je) {
const j = je as { voucher_series: string | null; voucher_number: number | null; entry_date: string | null }
voucherLabel = `${j.voucher_series ?? 'A'}${j.voucher_number ?? ''}`
entryDate = j.entry_date ?? best.date
}
// The reported amount must be SEK: every consumer labels it "kr" (the ledger
// branch below returns the 19xx leg's SEK figure for exactly this reason;
// the two branches must agree). The öre comparison above was exact in the
// shared currency, so the MATCH stands either way; what can be missing is
// only the sibling row's statement of that figure in kronor.
// resolveSekAmountOrNull() is the strict ladder (SEK as-is, else amount_sek,
// else amount * exchange_rate) and refuses with null rather than inventing a
// rate. The TARGET's rate is deliberately not borrowed for the sibling: the
// sibling's verifikat was booked at the sibling's own rate, and a kr figure
// the user cannot find on that verifikat would mislead on the exact screen
// whose only question is "is this the same event?".
const rowCurrency = (best.currency || 'SEK').toUpperCase()
// PostgREST numerics may arrive as strings; a non-numeric value collapses to
// null here so the strict ladder refuses instead of propagating NaN (same
// Number.isFinite discipline as resolveTransactionAmountSek above).
const bestAmountSek = best.amount_sek != null && Number.isFinite(Number(best.amount_sek))
? Number(best.amount_sek)
: null
const bestRate = best.exchange_rate != null && Number.isFinite(Number(best.exchange_rate))
? Number(best.exchange_rate)
: null
const bestSek = resolveSekAmountOrNull(Number(best.amount), bestAmountSek, rowCurrency, bestRate)
return {
transaction_id: best.id,
journal_entry_id: bestJournalEntryId,
voucher_label: voucherLabel,
entry_date: entryDate,
description: best.description,
// Always SEK or null, never the raw foreign number: a foreign amount in
// this field gets printed with "kr" after it (the original bug).
amount: bestSek != null ? roundOre(bestSek) : null,
account_number: null,
currency: rowCurrency === 'SEK' ? null : rowCurrency,
amount_in_currency: rowCurrency === 'SEK' ? null : roundOre(Number(best.amount)),
// Both sides passed the currency guard above, so the öre comparison that
// selected this row was made in one unit. amount_verified additionally
// requires the kr figure itself: a rateless foreign sibling matched
// exactly but cannot be stated in kronor, and saying so beats fabricating.
amount_verified: bestSek != null,
unverified_reason: bestSek != null ? null : 'transaction_missing_sek_value',
}
}
/** ± days around the bank-tx date a voucher may be dated and still be "the same" movement. */
const VOUCHER_DUPLICATE_DATE_WINDOW_DAYS = 7
/**
* ± days a voucher may be dated from a bank line whose amount CANNOT be
* verified (rateless foreign target) and still be NAMED as a candidate. When
* the amount test is skipped, date + account + direction are the only
* remaining evidence, and the full ±7 day window is far too wide for that:
* the closest-date pick would attribute an arbitrary unrelated voucher to the
* bank line in the user-facing "Möjlig dubblettbokföring: verifikat ..."
* message. One day keeps the honest "beloppen kunde inte jämföras" warning
* for a genuinely adjacent booking without pointing at the wrong verifikat.
*/
const UNVERIFIED_VOUCHER_DATE_WINDOW_DAYS = 1
/** BAS "kassa och bank" range. 1910-1919 = kassa, 1920-1949 = bank/giro. */
const BANK_ACCOUNT_LOW = 1910
const BANK_ACCOUNT_HIGH = 1949
/**
* Find an unlinked posted voucher whose bank/cash (19xx) leg already books this
* exact bank movement: the ledger-only twin of the bank line.
*
* This is the second half of the booking-time duplicate guard. The first half
* (detectBookedDuplicateTransaction) only finds an already-booked SIBLING
* TRANSACTION. But the most damaging orphan has NO sibling transaction at all:
* the affärshändelse was booked through a flow that posts straight to the ledger
* and never creates or links a bank-transaction row: invoice "markera som
* betald" (Dr 19xx / Cr 1510), the salary run's net-wage payout (Cr 19xx), a
* hand-posted verifikat. Booking the bank line on top of that double-counts the
* movement on the cash account: two verifikationer for one affärshändelse,
* felaktig bokföring per BFL. Because the import dedup and the sibling guard
* both only see the `transactions` table, neither catches this: only matching
* the bank line against the ledger does.
*
* Direction-aware so it works both ways:
* - inbound (target.amount > 0, money in) → a 19xx DEBIT of the same amount
* - outbound (target.amount < 0, money out) → a 19xx CREDIT of the same amount
* Direction reads off the sign of `target.amount`, which is unit-independent.
*
* Amounts are compared in SEK (see {@link resolveTransactionAmountSek}): the
* 19xx leg's debit/credit column already IS the SEK figure, so it is used
* as-is, and `line.currency` is deliberately never consulted (it labels the
* source document, not the leg). A bank line whose SEK value cannot be
* established does not silently pass: see `amount_verified` on the result,
* and note the tighter ±1 day naming window that applies to exactly that
* case ({@link UNVERIFIED_VOUCHER_DATE_WINDOW_DAYS}).
*
* Account-aware: when the bank line knows its cash account, the matching leg
* must be on that account's ledger account; otherwise any 19xx leg matches
* (single-account companies, legacy rows with no cash_account_id).
*
* Excludes vouchers already linked to a transaction or an invoice_payment
* (those are reconciled, not orphans) and storno/correction entries (valid
* second vouchers, not duplicates): EXCEPT a voucher whose linking transaction
* itself matches the target (same öre in the same currency, compatible
* account, date in the window), which is returned as the twin with
* `transaction_id` set. Fail-open: a query error returns null so a detection
* failure never blocks a legitimate booking. The pick is deterministic
* (closest date, then lowest journal_entry id, then account) so a force
* re-detect is stable.
*/
export async function detectLedgerDuplicateVoucher(
supabase: SupabaseClient,
companyId: string,
target: BookingTarget,
opts?: BookingDuplicateExclusions,
): Promise<BookedDuplicateCandidate | null> {
const targetOre = toOre(target.amount)
if (targetOre === 0 || Number.isNaN(targetOre)) return null
// Vouchers minted earlier in this same bulk run are this batch's own fresh
// bookings: a subsequent sibling must not dedupe against them.
const excludeJournalEntryIds = new Set(opts?.excludeJournalEntryIds ?? [])
// Null when this is a non-SEK bank line with no rate: the amounts then cannot
// be compared at all, which is handled below rather than swept under a pass.
const targetSek = resolveTransactionAmountSek(target)
const inbound = targetOre > 0
// For the linked-transaction twin test in the exclusion block below: the
// linking transaction's `amount` is denominated in ITS OWN currency, so the
// öre comparison is only like-with-like when the labels agree (same guard
// as the sibling detector).
const targetCurrency = (target.currency || 'SEK').toUpperCase()
const targetAccount = target.cash_account_id ?? null
const dateMs = new Date(target.date).getTime()
if (Number.isNaN(dateMs)) return null
const windowMs = VOUCHER_DUPLICATE_DATE_WINDOW_DAYS * 24 * 3600 * 1000
const lowDate = new Date(dateMs - windowMs).toISOString().split('T')[0]
const highDate = new Date(dateMs + windowMs).toISOString().split('T')[0]
// Resolve the bank line's settlement ledger account, when known, so a movement
// on one bank account never deduplicates a voucher on a different account of
// the same company (the 19xx leg below is matched against it).
let settlementAccount: string | null = null
if (target.cash_account_id) {
const { data: ca } = await supabase
.from('cash_accounts')
.select('ledger_account')
.eq('company_id', companyId)
.eq('id', target.cash_account_id)
.maybeSingle()
settlementAccount = ((ca as { ledger_account?: string } | null)?.ledger_account) ?? null
}
const amountColumn = inbound ? 'debit_amount' : 'credit_amount'
type LineRow = {
account_number: string
debit_amount: number | string
credit_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
}
}
// Two-step fetch instead of a `journal_entries!inner` embed: PostgREST
// compiles that embed into a correlated LATERAL join that walks the ENTIRE
// journal_entry_lines table across all tenants (see
// lib/bookkeeping/entry-lines.ts). The scope is the same as before: this
// company's posted entries inside the date window, with the 19xx leg picked
// on the line side. The old `.limit(50)` is gone with the embed: the window
// is ±7 days of one company's vouchers, and an arbitrary 50-row cap could
// hide the real twin behind unrelated bank legs.
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, credit_amount',
filterEntries: (q: EntryLinesQuery) =>
q
.eq('company_id', companyId)
.eq('status', 'posted')
.gte('entry_date', lowDate)
.lte('entry_date', highDate),
filterLines: (q: EntryLinesQuery) => {
const scoped = q.gt(amountColumn, 0)
return settlementAccount
? scoped.eq('account_number', settlementAccount)
: scoped
.gte('account_number', String(BANK_ACCOUNT_LOW))
.lte('account_number', String(BANK_ACCOUNT_HIGH))
},
// The old embed was aliased: journal_entry:journal_entries!inner(...).
attachEntriesAs: 'journal_entry',
})
} catch {
// Fail-open, as before: a detection failure must never block a booking.
return null
}
if (lines.length === 0) return null
const sameMovement = lines
// Same-batch vouchers are this run's own fresh bookings, never duplicates.
.filter((l) => !excludeJournalEntryIds.has(l.journal_entry.id))
// Reversals/corrections are valid second vouchers, not duplicate bookings.
.filter((l) => l.journal_entry.source_type !== 'storno' && l.journal_entry.source_type !== 'correction')
// The amount test compares the bank line's SEK value against the leg's
// debit/credit column, which is already SEK. When the bank line has no SEK
// value there is nothing to test with, so the test is SKIPPED rather than
// failed: failing it would drop every survivor and return null, and a null
// here reads as "go ahead", which is how one affärshändelse ends up with two
// verifikationer. The survivors are reported as unverified instead: BUT only
// those within ±1 day of the bank line. Without an amount comparison, date
// proximity is the only evidence left, and naming the closest voucher in a
// ±7 day window would attribute an unrelated verifikat to this bank line in
// the user-facing warning (the lowest-id pick is deterministic, not right).
//
// The EXISTENCE half of the question still holds without any currency: the
// direction, settlement-account, date-window and posted filters above are all
// unit-free, so an empty `sameMovement` really does mean there is no ledger
// twin, and returning null there is a verified pass, not a silent one.
const unverifiedWindowMs = UNVERIFIED_VOUCHER_DATE_WINDOW_DAYS * 24 * 3600 * 1000
const candidates =
targetSek === null
? sameMovement.filter((l) => {
const legMs = new Date(l.journal_entry.entry_date).getTime()
return Number.isFinite(legMs) && Math.abs(legMs - dateMs) <= unverifiedWindowMs
})
: sameMovement.filter((l) => {
const legSek = roundOre(Number(inbound ? l.debit_amount : l.credit_amount))
return Math.abs(legSek - targetSek) < 0.01
})
if (candidates.length === 0) return null
// Drop vouchers already reconciled to a transaction or an invoice payment:
// those aren't orphans. Both lookups are filtered by company_id (defense in
// depth alongside RLS).
//
// EXCEPTION: a voucher whose linking transaction ITSELF looks like the
// target's twin (same öre in the same currency, compatible cash account,
// date inside the window) is NOT "reconciled to something else": that shape
// is a date-drifted duplicate import row whose copy is already booked.
// Excluding it made the guard blind to exactly that double-booking (the
// sibling scan missed on date, this scan dropped the voucher as linked).
// Such a voucher is returned as the twin WITH transaction_id set so the UI
// can offer match/ignore instead of a blind second booking.
const entryIds = candidates.map((l) => l.journal_entry.id)
const [{ data: txLinks }, { data: payLinks }] = await Promise.all([
supabase
.from('transactions')
.select('id, date, amount, currency, cash_account_id, journal_entry_id')
.eq('company_id', companyId)
.in('journal_entry_id', entryIds),
supabase
.from('invoice_payments')
.select('journal_entry_id, transaction_id')
.eq('company_id', companyId)
.in('journal_entry_id', entryIds),
])
type LinkedTxRow = {
id: string
date: string
amount: number | string
currency: string | null
cash_account_id: string | null
journal_entry_id: string | null
}
const linkedTxMatchesTarget = (r: LinkedTxRow): boolean => {
if (r.id === target.id) return false
if ((r.currency || 'SEK').toUpperCase() !== targetCurrency) return false
if (toOre(r.amount) !== targetOre) return false
if (targetAccount !== null && r.cash_account_id !== null && r.cash_account_id !== targetAccount) {
return false
}
const linkedMs = new Date(r.date).getTime()
return Number.isFinite(linkedMs) && Math.abs(linkedMs - dateMs) <= windowMs
}
const linkedTxByEntry = new Map<string, LinkedTxRow[]>()
for (const r of (txLinks ?? []) as LinkedTxRow[]) {
if (!r.journal_entry_id) continue
const arr = linkedTxByEntry.get(r.journal_entry_id) ?? []
arr.push(r)
linkedTxByEntry.set(r.journal_entry_id, arr)
}
// Only a payment row that carries a bank transaction means "reconciled to
// a bank line". A row with transaction_id NULL is a manual / Stripe
// settlement (#2019): the bank line for that money is still to come, and
// this voucher must stay a twin candidate so the user is offered a link
// instead of a blind second booking.
const paymentLinked = new Set<string>()
for (const r of (payLinks ?? []) as { journal_entry_id: string | null; transaction_id: string | null }[]) {
if (r.journal_entry_id && r.transaction_id) paymentLinked.add(r.journal_entry_id)
}
const survivors: { line: (typeof candidates)[number]; twinTransactionId: string | null }[] = []
for (const l of candidates) {
const entryId = l.journal_entry.id
const links = linkedTxByEntry.get(entryId) ?? []
// Deterministic twin pick within one voucher: lowest matching tx id.
const matchingTwinIds = links
.filter(linkedTxMatchesTarget)
.map((r) => r.id)
.sort((a, b) => a.localeCompare(b))
if (matchingTwinIds.length > 0) {
survivors.push({ line: l, twinTransactionId: matchingTwinIds[0] })
continue
}
// Linked to a non-matching transaction or to an invoice payment: genuinely
// reconciled to something else, not an orphan. Excluded as before.
if (links.length > 0 || paymentLinked.has(entryId)) continue
survivors.push({ line: l, twinTransactionId: null })
}
if (survivors.length === 0) return null
survivors.sort((a, b) => {
const ad = Math.abs(new Date(a.line.journal_entry.entry_date).getTime() - dateMs)
const bd = Math.abs(new Date(b.line.journal_entry.entry_date).getTime() - dateMs)
if (ad !== bd) return ad - bd
if (a.line.journal_entry.id !== b.line.journal_entry.id) {
return a.line.journal_entry.id.localeCompare(b.line.journal_entry.id)
}
// Same entry can expose two 19xx legs (own-account transfer): total order
// keeps the force re-detect stable.
return a.line.account_number.localeCompare(b.line.account_number)
})
const best = survivors[0].line
return {
// Set when the voucher's linking transaction is itself the target's twin
// (the de-excluded shape above); null for a true ledger-only orphan.
transaction_id: survivors[0].twinTransactionId,
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,
// Always the leg's SEK figure, matched or not, so the UI never prints a
// foreign number with "kr" after it.
amount: roundOre(Number(inbound ? best.debit_amount : best.credit_amount)),
account_number: best.account_number,
// The matched leg is SEK by construction, so there is no foreign context
// to carry (line.currency labels the source document, not the leg).
currency: null,
amount_in_currency: null,
amount_verified: targetSek !== null,
unverified_reason: targetSek === null ? 'transaction_missing_sek_value' : null,
}
}
/**
* Unified booking-time duplicate guard. Returns the single best already-booked
* candidate for this bank line: a sibling transaction first (the cheaper,
* higher-confidence signal), then a ledger-only voucher. Null when neither
* fires. This is the function every booking chokepoint should call (web /book +
* /categorize routes and the agent commit executors) so all paths reject the
* same double-bookings.
*/
export async function detectBookingDuplicate(
supabase: SupabaseClient,
companyId: string,
target: BookingTarget,
opts?: BookingDuplicateExclusions,
): Promise<BookedDuplicateCandidate | null> {
const sibling = await detectBookedDuplicateTransaction(supabase, companyId, target, opts)
if (sibling) return sibling
return detectLedgerDuplicateVoucher(supabase, companyId, target, opts)
}