Files
accounted/lib/transactions/__tests__/booking-duplicate-detection.test.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

898 lines
39 KiB
TypeScript

/**
* Tests for the booking-time duplicate guard.
*
* Detection queries `transactions` for same-date already-booked siblings, then
* filters by öre + cash-account compatibility in JS, then resolves the voucher
* label from `journal_entries`. The mock returns the rows each query yields.
*/
import { describe, it, expect, beforeEach } from 'vitest'
import {
detectBookedDuplicateTransaction,
detectLedgerDuplicateVoucher,
detectBookingDuplicate,
} from '../booking-duplicate-detection'
type TxRow = {
id: string
date: string
amount: number | string
/** Absent in most fixtures: the detector normalises a missing/null label to SEK. */
currency?: string | null
/** The sibling's own FX fields: the source of its reported SEK figure. */
amount_sek?: number | string | null
exchange_rate?: number | string | null
description: string | null
cash_account_id: string | null
/** NULL = no 1:1 anchor; booked-ness may still come from links/payments. */
journal_entry_id: string | null
}
type JeRow = { voucher_series: string | null; voucher_number: number | null; entry_date: string | null }
/** Anchor rows for the is_transaction_booked resolution (batch-fetched). */
type SiblingAnchors = {
voucherLinks?: { transaction_id: string; journal_entry_id: string }[]
invoicePayments?: { transaction_id: string | null; journal_entry_id: string | null }[]
supplierPayments?: { transaction_id: string | null; journal_entry_id: string | null }[]
}
function txChain(data: TxRow[]) {
const c: Record<string, unknown> = {}
c.select = () => c
c.eq = () => c
c.not = () => c
c.neq = () => c
c.gte = () => c
c.lte = () => c
c.order = () => c
c.limit = () => Promise.resolve({ data, error: null })
return c
}
function jeChain(data: JeRow | null) {
const c: Record<string, unknown> = {}
c.select = () => c
c.eq = () => c
c.maybeSingle = () => Promise.resolve({ data, error: null })
return c
}
/** Chain for the anchor lookups, terminal on `.in()`. */
function anchorChain(data: unknown[]) {
const c: Record<string, unknown> = {}
c.select = () => c
c.eq = () => c
c.in = () => Promise.resolve({ data, error: null })
return c
}
function makeSupabase(
txData: TxRow[],
jeData: JeRow | null = { voucher_series: 'A', voucher_number: 142, entry_date: '2025-12-19' },
anchors: SiblingAnchors = {},
) {
return {
from: (table: string) => {
switch (table) {
case 'transactions':
return txChain(txData)
case 'transaction_voucher_links':
return anchorChain(anchors.voucherLinks ?? [])
case 'invoice_payments':
return anchorChain(anchors.invoicePayments ?? [])
case 'supplier_invoice_payments':
return anchorChain(anchors.supplierPayments ?? [])
default:
return jeChain(jeData)
}
},
} as never
}
const COMPANY = 'co-1'
const sibling = (over: Partial<TxRow> = {}): TxRow => ({
id: 'sib-1',
date: '2025-12-19',
amount: -1616,
description: 'TELENOR SVERIGE AB',
cash_account_id: null,
journal_entry_id: 'je-1',
...over,
})
describe('detectBookedDuplicateTransaction', () => {
it('returns null when no same-date booked sibling exists', async () => {
const supabase = makeSupabase([])
const result = await detectBookedDuplicateTransaction(supabase, COMPANY, {
id: 'self', date: '2025-12-19', amount: -1616, currency: 'SEK', cash_account_id: null,
})
expect(result).toBeNull()
})
it('flags a same date+amount+account booked sibling with its voucher label', async () => {
const supabase = makeSupabase([sibling()])
const result = await detectBookedDuplicateTransaction(supabase, COMPANY, {
id: 'self', date: '2025-12-19', amount: -1616, currency: 'SEK', cash_account_id: null,
})
expect(result).toEqual({
transaction_id: 'sib-1',
currency: null,
amount_in_currency: null,
journal_entry_id: 'je-1',
voucher_label: 'A142',
entry_date: '2025-12-19',
description: 'TELENOR SVERIGE AB',
amount: -1616,
account_number: null,
amount_verified: true,
unverified_reason: null,
})
})
it('does NOT flag a sibling on a different known cash account', async () => {
const supabase = makeSupabase([sibling({ cash_account_id: 'acct-A' })])
const result = await detectBookedDuplicateTransaction(supabase, COMPANY, {
id: 'self', date: '2025-12-19', amount: -1616, currency: 'SEK', cash_account_id: 'acct-B',
})
expect(result).toBeNull()
})
it('flags when accounts are compatible via a null on either side', async () => {
const supabase = makeSupabase([sibling({ cash_account_id: 'acct-A' })])
const result = await detectBookedDuplicateTransaction(supabase, COMPANY, {
id: 'self', date: '2025-12-19', amount: -1616, currency: 'SEK', cash_account_id: null,
})
expect(result?.transaction_id).toBe('sib-1')
})
it('does NOT flag a sibling with a different amount', async () => {
const supabase = makeSupabase([sibling({ amount: -1000 })])
const result = await detectBookedDuplicateTransaction(supabase, COMPANY, {
id: 'self', date: '2025-12-19', amount: -1616, currency: 'SEK', cash_account_id: null,
})
expect(result).toBeNull()
})
it('matches a numeric-string amount from PostgREST against a JS number (öre)', async () => {
const supabase = makeSupabase([sibling({ amount: '-1616.00' })])
const result = await detectBookedDuplicateTransaction(supabase, COMPANY, {
id: 'self', date: '2025-12-19', amount: -1616, currency: 'SEK', cash_account_id: null,
})
expect(result?.transaction_id).toBe('sib-1')
})
it('returns null for a zero-amount target without querying', async () => {
const supabase = makeSupabase([sibling({ amount: 0 })])
const result = await detectBookedDuplicateTransaction(supabase, COMPANY, {
id: 'self', date: '2025-12-19', amount: 0, currency: 'SEK', cash_account_id: null,
})
expect(result).toBeNull()
})
it('picks the lowest-id sibling deterministically (stable under force re-detect)', async () => {
const supabase = makeSupabase([
sibling({ id: 'sib-9' }),
sibling({ id: 'sib-2' }),
])
const result = await detectBookedDuplicateTransaction(supabase, COMPANY, {
id: 'self', date: '2025-12-19', amount: -1616, currency: 'SEK', cash_account_id: null,
})
expect(result?.transaction_id).toBe('sib-2')
})
// ── Date window (a duplicate import often drifts: bokföringsdag vs valutadag) ──
it('flags a booked sibling one day earlier (window recall, gap G1)', async () => {
const supabase = makeSupabase([sibling({ date: '2025-12-18' })])
const result = await detectBookedDuplicateTransaction(supabase, COMPANY, {
id: 'self', date: '2025-12-19', amount: -1616, currency: 'SEK', cash_account_id: null,
})
expect(result?.transaction_id).toBe('sib-1')
})
it('does NOT flag a booked sibling outside the ±3 day window', async () => {
// 5 days out: repeated monthly/weekly charges must not warn.
const supabase = makeSupabase([sibling({ date: '2025-12-14' })])
const result = await detectBookedDuplicateTransaction(supabase, COMPANY, {
id: 'self', date: '2025-12-19', amount: -1616, currency: 'SEK', cash_account_id: null,
})
expect(result).toBeNull()
})
it('prefers the exact-date sibling over a drifted one regardless of id order', async () => {
// The drifted sibling carries the LOWER id: a bare lowest-id pick would
// choose it, so this pins the date-distance-first ranking (force=true is
// bound to the reviewed candidate and re-detection must return the same
// one).
const supabase = makeSupabase([
sibling({ id: 'sib-0-drifted', date: '2025-12-17' }),
sibling({ id: 'sib-9-exact', date: '2025-12-19' }),
])
const result = await detectBookedDuplicateTransaction(supabase, COMPANY, {
id: 'self', date: '2025-12-19', amount: -1616, currency: 'SEK', cash_account_id: null,
})
expect(result?.transaction_id).toBe('sib-9-exact')
})
// ── Booked-ness via is_transaction_booked semantics (gap G2) ────────────
it('detects a bulk-booked sibling anchored only via transaction_voucher_links', async () => {
// journal_entry_id NULL is the documented shape for N-tx-to-1-JE bulk
// bookings (lib/transactions/is-booked.ts); the old bare-column filter
// made these twins invisible.
const supabase = makeSupabase(
[sibling({ journal_entry_id: null })],
{ voucher_series: 'A', voucher_number: 142, entry_date: '2025-12-19' },
{ voucherLinks: [{ transaction_id: 'sib-1', journal_entry_id: 'je-bulk' }] },
)
const result = await detectBookedDuplicateTransaction(supabase, COMPANY, {
id: 'self', date: '2025-12-19', amount: -1616, currency: 'SEK', cash_account_id: null,
})
expect(result?.transaction_id).toBe('sib-1')
expect(result?.journal_entry_id).toBe('je-bulk')
})
it('detects a multi-allocated sibling anchored only via invoice_payments', async () => {
const supabase = makeSupabase(
[sibling({ journal_entry_id: null })],
{ voucher_series: 'A', voucher_number: 142, entry_date: '2025-12-19' },
{ invoicePayments: [{ transaction_id: 'sib-1', journal_entry_id: 'je-alloc' }] },
)
const result = await detectBookedDuplicateTransaction(supabase, COMPANY, {
id: 'self', date: '2025-12-19', amount: -1616, currency: 'SEK', cash_account_id: null,
})
expect(result?.transaction_id).toBe('sib-1')
expect(result?.journal_entry_id).toBe('je-alloc')
})
it('does NOT flag an unbooked sibling (no anchor in any of the three sources)', async () => {
const supabase = makeSupabase([sibling({ journal_entry_id: null })])
const result = await detectBookedDuplicateTransaction(supabase, COMPANY, {
id: 'self', date: '2025-12-19', amount: -1616, currency: 'SEK', cash_account_id: null,
})
expect(result).toBeNull()
})
it('excludes a voucher-link-anchored sibling whose entry is in excludeJournalEntryIds', async () => {
// A sibling anchored to a JE minted THIS batch is the batch's own fresh
// booking, mirroring the ledger half's entry-id exclusion.
const supabase = makeSupabase(
[sibling({ journal_entry_id: null })],
{ voucher_series: 'A', voucher_number: 142, entry_date: '2025-12-19' },
{ voucherLinks: [{ transaction_id: 'sib-1', journal_entry_id: 'je-batch' }] },
)
const result = await detectBookedDuplicateTransaction(
supabase,
COMPANY,
{ id: 'self', date: '2025-12-19', amount: -1616, currency: 'SEK', cash_account_id: null },
{ excludeJournalEntryIds: ['je-batch'] },
)
expect(result).toBeNull()
})
// ── Intra-batch exclusion (bulk-book false-positive fix) ────────────────
it('excludes a same-batch sibling whose id is in excludeTransactionIds', async () => {
const supabase = makeSupabase([sibling({ id: 'sib-batch' })])
const result = await detectBookedDuplicateTransaction(
supabase,
COMPANY,
{ id: 'self', date: '2025-12-19', amount: -1616, currency: 'SEK', cash_account_id: null },
{ excludeTransactionIds: ['sib-batch'] },
)
expect(result).toBeNull()
})
it('STILL flags a pre-existing sibling not in excludeTransactionIds (invariant preserved)', async () => {
// 'sib-old' existed before the batch; only 'sib-batch' was booked this run.
const supabase = makeSupabase([sibling({ id: 'sib-old' })])
const result = await detectBookedDuplicateTransaction(
supabase,
COMPANY,
{ id: 'self', date: '2025-12-19', amount: -1616, currency: 'SEK', cash_account_id: null },
{ excludeTransactionIds: ['sib-batch'] },
)
expect(result?.transaction_id).toBe('sib-old')
})
// ── Currency (transactions.amount is denominated in transactions.currency) ──
it('flags a foreign sibling and reports ITS OWN SEK figure, never the raw EUR number', async () => {
// The candidate's `amount` field is kr-labelled by every consumer
// (formatCurrency's SEK default in DuplicateBookingDialog, "${amount} kr"
// in the agent messages), so it must hold the sibling's SEK value, exactly
// as the ledger branch reports the 19xx leg's SEK figure.
const supabase = makeSupabase([
sibling({ amount: -1000, currency: 'EUR', amount_sek: -11500, exchange_rate: 11.5 }),
])
const result = await detectBookedDuplicateTransaction(supabase, COMPANY, {
id: 'self', date: '2025-12-19', amount: -1000, currency: 'EUR',
amount_sek: -11450, exchange_rate: 11.45, cash_account_id: null,
})
expect(result?.transaction_id).toBe('sib-1')
// The SIBLING's stored SEK, not the raw -1000 and not the target's -11450.
expect(result?.amount).toBe(-11500)
expect(result?.amount_verified).toBe(true)
expect(result?.unverified_reason).toBeNull()
// Foreign context rides along so the UI can show the recognisable original.
expect(result?.currency).toBe('EUR')
expect(result?.amount_in_currency).toBe(-1000)
})
it('converts via the sibling exchange_rate when its amount_sek was never stored', async () => {
const supabase = makeSupabase([
sibling({ amount: -1000, currency: 'EUR', amount_sek: null, exchange_rate: 11.5 }),
])
const result = await detectBookedDuplicateTransaction(supabase, COMPANY, {
id: 'self', date: '2025-12-19', amount: -1000, currency: 'EUR',
amount_sek: -11500, exchange_rate: 11.5, cash_account_id: null,
})
expect(result?.amount).toBe(-11500)
expect(result?.amount_verified).toBe(true)
})
it('surfaces a rateless foreign sibling as unverified with NO kr figure', async () => {
// THE dialog bug this module's contract exists to prevent: a 1 000 EUR
// sibling without amount_sek/exchange_rate used to reach the dialog as
// amount: -1000, which formatCurrency() prints as "-1 000,00 kr" for an
// ~11 500 kr movement, on the exact screen whose only question is "is this
// the same event?". The match itself is exact (same currency, same öre),
// so the candidate is still surfaced: but with the honest foreign figure
// and no fabricated kronor. Note the TARGET's own rate is present here and
// must NOT be borrowed for the sibling.
const supabase = makeSupabase([sibling({ amount: -1000, currency: 'EUR' })])
const result = await detectBookedDuplicateTransaction(supabase, COMPANY, {
id: 'self', date: '2025-12-19', amount: -1000, currency: 'EUR',
amount_sek: -11500, exchange_rate: 11.5, cash_account_id: null,
})
expect(result?.transaction_id).toBe('sib-1')
expect(result?.amount).toBeNull()
expect(result?.amount_verified).toBe(false)
expect(result?.unverified_reason).toBe('transaction_missing_sek_value')
expect(result?.currency).toBe('EUR')
expect(result?.amount_in_currency).toBe(-1000)
})
it('does NOT flag a EUR line against a same-magnitude SEK sibling', async () => {
// 100 EUR and 100 SEK on one day are two different affärshändelser. Before
// the currency guard the raw öre keys collided and this false-positived.
const supabase = makeSupabase([sibling({ amount: -100, currency: 'SEK' })])
const result = await detectBookedDuplicateTransaction(supabase, COMPANY, {
id: 'self', date: '2025-12-19', amount: -100, currency: 'EUR',
amount_sek: -1150, exchange_rate: 11.5, cash_account_id: null,
})
expect(result).toBeNull()
})
it('does NOT flag a SEK line against a same-magnitude EUR sibling (mirror)', async () => {
const supabase = makeSupabase([sibling({ amount: -100, currency: 'EUR' })])
const result = await detectBookedDuplicateTransaction(supabase, COMPANY, {
id: 'self', date: '2025-12-19', amount: -100, currency: 'SEK', cash_account_id: null,
})
expect(result).toBeNull()
})
it('treats a null sibling currency as SEK (column default), so SEK companies are unaffected', async () => {
const supabase = makeSupabase([sibling({ amount: -1616, currency: null })])
const result = await detectBookedDuplicateTransaction(supabase, COMPANY, {
id: 'self', date: '2025-12-19', amount: -1616, currency: null, cash_account_id: null,
})
expect(result?.transaction_id).toBe('sib-1')
})
})
// ── Ledger-only voucher guard (the orphan with no sibling transaction) ───────
type Jel = {
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
}
}
/** A chain whose terminals all resolve to the SAME canned result for a table. */
function ledgerChain(result: { data: unknown; error: unknown }) {
const c: Record<string, unknown> = {}
c.select = () => c
c.eq = () => c
c.neq = () => c
c.not = () => c
c.gt = () => c
c.gte = () => c
c.lte = () => c
c.order = () => c
c.limit = () => Promise.resolve(result)
c.maybeSingle = () => Promise.resolve(result)
c.single = () => Promise.resolve(result)
c.in = () => Promise.resolve(result) // terminal for the link-exclusion lookups
return c
}
/**
* Chain for the two-step entry-lines fetch (lib/bookkeeping/entry-lines.ts):
* the helper pages both journal_entries and journal_entry_lines with
* `.order('id').range(from, to)`, so `.range()` is the terminal. One short
* page ends the paging loop.
*/
function entryLinesChain(rows: unknown[], singleRow: unknown = null) {
const c: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'neq', 'not', 'gt', 'gte', 'lte', 'in', 'contains', 'ilike', 'filter', 'order', 'limit']) {
c[m] = () => c
}
c.range = () => Promise.resolve({ data: rows, error: null })
c.maybeSingle = () => Promise.resolve({ data: singleRow, error: null })
c.single = () => Promise.resolve({ data: singleRow, error: null })
return c
}
/** Tables the ledger scan touches, in call order. */
const tablesTouched: string[] = []
/**
* A row from the ledger scan's link-exclusion lookup on `transactions`. The
* bare `{ journal_entry_id }` shape (legacy fixtures) reads as a linking
* transaction with no amount, which never matches the target: exactly the
* "reconciled to something else" exclusion behaviour the old tests pinned.
*/
type LedgerTxLink = {
journal_entry_id: string
id?: string
date?: string
amount?: number | string
currency?: string | null
cash_account_id?: string | null
}
function makeLedgerSupabase(opts: {
ledgerAccount?: string | null
lines?: Jel[]
txLinks?: LedgerTxLink[]
payLinks?: { journal_entry_id: string; transaction_id?: string | null }[]
transactionRows?: TxRow[] // siblings for the orchestrator fall-through
}) {
// Fixtures stay embed-shaped for readability; the two-step fetch reads the
// parent entries and the bare lines separately, so split them here. The
// helper reattaches the parent under `journal_entry`, reproducing exactly
// what the old aliased embed returned.
const jels = opts.lines ?? []
const entries = [...new Map(jels.map((l) => [l.journal_entry.id, l.journal_entry])).values()]
const bareLines = jels.map((l, i) => ({
id: `line-${i}`,
journal_entry_id: l.journal_entry.id,
account_number: l.account_number,
debit_amount: l.debit_amount,
credit_amount: l.credit_amount,
}))
return {
from: (table: string) => {
tablesTouched.push(table)
switch (table) {
case 'cash_accounts':
return ledgerChain({
data: opts.ledgerAccount != null ? { ledger_account: opts.ledgerAccount } : null,
error: null,
})
case 'journal_entries':
// Two roles: the entry-side page of the two-step fetch (.range) and
// the sibling scan's voucher-label lookup (.maybeSingle).
return entryLinesChain(entries, null)
case 'journal_entry_lines':
return entryLinesChain(bareLines)
case 'invoice_payments':
return ledgerChain({ data: opts.payLinks ?? [], error: null })
case 'transactions':
// Same table backs the sibling scan (.limit) and the link-exclusion
// lookup (.in). The sibling scan returns transactionRows; the link
// lookup returns txLinks. With a shape-only mock both share one canned
// result, so tests that need a sibling set transactionRows and leave
// txLinks empty (and vice versa).
return ledgerChain({ data: opts.transactionRows ?? opts.txLinks ?? [], error: null })
default:
return ledgerChain({ data: null, error: null })
}
},
} as never
}
const jel = (over: Partial<Jel> = {}): Jel => ({
account_number: over.account_number ?? '1930',
debit_amount: over.debit_amount ?? 98565,
credit_amount: over.credit_amount ?? 0,
journal_entry: {
id: 'je-2',
entry_date: '2026-03-30',
description: 'Inbetalning kundfaktura 2026001',
voucher_series: 'A',
voucher_number: 2,
status: 'posted',
source_type: 'invoice_paid',
...over.journal_entry,
},
})
describe('detectLedgerDuplicateVoucher', () => {
beforeEach(() => {
tablesTouched.length = 0
})
it('drives the scan from journal_entries, never from a cross-tenant line embed', async () => {
// Shape guard for the entry-lines conversion: the parent entries are
// fetched first and the lines are fetched by journal_entry_id, so no
// query starts on journal_entry_lines with the scope on an embed.
const supabase = makeLedgerSupabase({ lines: [jel()] })
await detectLedgerDuplicateVoucher(supabase, COMPANY, {
id: 'self', date: '2026-03-26', amount: 98565, currency: 'SEK', cash_account_id: null,
})
expect(tablesTouched.indexOf('journal_entries')).toBeGreaterThanOrEqual(0)
expect(tablesTouched.indexOf('journal_entries')).toBeLessThan(
tablesTouched.indexOf('journal_entry_lines'),
)
})
it('flags an inbound receipt already booked as a 19xx debit voucher (no sibling tx)', async () => {
const supabase = makeLedgerSupabase({ lines: [jel()] })
const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, {
id: 'self', date: '2026-03-26', amount: 98565, currency: 'SEK', cash_account_id: null,
})
expect(result).toEqual({
transaction_id: null,
journal_entry_id: 'je-2',
voucher_label: 'A2',
entry_date: '2026-03-30',
description: 'Inbetalning kundfaktura 2026001',
amount: 98565,
// The matched leg's account rides along so the dialog's match action
// links on the exact 19xx the voucher was booked to (issue #919).
account_number: '1930',
// The leg is SEK by construction: no foreign context to carry.
currency: null,
amount_in_currency: null,
amount_verified: true,
unverified_reason: null,
})
})
it('flags an outbound payout already booked as a 19xx credit voucher (salary case)', async () => {
const salaryLine = jel({
debit_amount: 0,
credit_amount: 16609,
journal_entry: {
id: 'je-3', entry_date: '2026-05-04', description: 'Lön 2026-05: Nettolön',
voucher_series: 'A', voucher_number: 3, status: 'posted', source_type: 'salary',
},
})
const supabase = makeLedgerSupabase({ lines: [salaryLine] })
const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, {
id: 'self', date: '2026-05-04', amount: -16609, currency: 'SEK', cash_account_id: null,
})
expect(result?.journal_entry_id).toBe('je-3')
expect(result?.transaction_id).toBeNull()
expect(result?.amount).toBe(16609)
expect(result?.account_number).toBe('1930')
})
it('does NOT flag an inbound receipt against a credit-only voucher (wrong direction)', async () => {
// A 19xx CREDIT is a payout, not the receipt the inbound line is looking for.
const supabase = makeLedgerSupabase({ lines: [jel({ debit_amount: 0, credit_amount: 98565 })] })
const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, {
id: 'self', date: '2026-03-26', amount: 98565, currency: 'SEK', cash_account_id: null,
})
expect(result).toBeNull()
})
it('does NOT flag when the amount differs', async () => {
const supabase = makeLedgerSupabase({ lines: [jel({ debit_amount: 90000 })] })
const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, {
id: 'self', date: '2026-03-26', amount: 98565, currency: 'SEK', cash_account_id: null,
})
expect(result).toBeNull()
})
it('excludes a voucher already linked to a transaction', async () => {
const supabase = makeLedgerSupabase({ lines: [jel()], txLinks: [{ journal_entry_id: 'je-2' }] })
const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, {
id: 'self', date: '2026-03-26', amount: 98565, currency: 'SEK', cash_account_id: null,
})
expect(result).toBeNull()
})
it('excludes a voucher already linked to an invoice payment', async () => {
const supabase = makeLedgerSupabase({
lines: [jel()],
payLinks: [{ journal_entry_id: 'je-2', transaction_id: 'tx-bank' }],
})
const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, {
id: 'self', date: '2026-03-26', amount: 98565, currency: 'SEK', cash_account_id: null,
})
expect(result).toBeNull()
})
// #2019: a manual / Stripe settlement writes a payment row with no bank
// transaction. The bank line for that money arrives later; the voucher must
// stay a twin so the user is offered a link instead of a blind re-booking.
it('keeps a voucher whose payment row has no bank transaction as a twin', async () => {
const supabase = makeLedgerSupabase({
lines: [jel()],
payLinks: [{ journal_entry_id: 'je-2', transaction_id: null }],
})
const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, {
id: 'self', date: '2026-03-26', amount: 98565, currency: 'SEK', cash_account_id: null,
})
expect(result?.journal_entry_id).toBe('je-2')
expect(result?.transaction_id).toBeNull()
})
// ── De-exclusion (gap G3): the linking transaction is itself the twin ─────
it('returns a voucher whose linking transaction itself matches the target, with transaction_id set', async () => {
// The date-drifted duplicate-import shape: one copy of the bank fee is
// booked (tx 'twin-1' → je-2), the target is a re-imported copy with a
// drifted date. The old exclusion dropped je-2 as "reconciled" and BOTH
// guard halves stayed silent.
const supabase = makeLedgerSupabase({
lines: [jel()],
txLinks: [{
id: 'twin-1', date: '2026-03-30', amount: 98565, currency: null,
cash_account_id: null, journal_entry_id: 'je-2',
}],
})
const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, {
id: 'self', date: '2026-03-26', amount: 98565, currency: 'SEK', cash_account_id: null,
})
expect(result?.journal_entry_id).toBe('je-2')
expect(result?.transaction_id).toBe('twin-1')
expect(result?.account_number).toBe('1930')
})
it('still excludes a voucher whose linking transaction differs in amount', async () => {
// Regression for the exclusion invariant: a voucher reconciled to a
// NON-matching transaction is genuinely settled by something else.
const supabase = makeLedgerSupabase({
lines: [jel()],
txLinks: [{
id: 'other-1', date: '2026-03-30', amount: 50000, currency: null,
cash_account_id: null, journal_entry_id: 'je-2',
}],
})
const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, {
id: 'self', date: '2026-03-26', amount: 98565, currency: 'SEK', cash_account_id: null,
})
expect(result).toBeNull()
})
it('still excludes a voucher whose matching-amount linking transaction is far outside the window', async () => {
// Same öre but weeks away: a recurring same-amount fee, not this movement.
const supabase = makeLedgerSupabase({
lines: [jel()],
txLinks: [{
id: 'far-1', date: '2026-04-20', amount: 98565, currency: null,
cash_account_id: null, journal_entry_id: 'je-2',
}],
})
const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, {
id: 'self', date: '2026-03-26', amount: 98565, currency: 'SEK', cash_account_id: null,
})
expect(result).toBeNull()
})
it('still excludes a voucher whose linking transaction sits on a different known cash account', async () => {
const supabase = makeLedgerSupabase({
ledgerAccount: '1930',
lines: [jel()],
txLinks: [{
id: 'acct-1', date: '2026-03-30', amount: 98565, currency: null,
cash_account_id: 'ca-other', journal_entry_id: 'je-2',
}],
})
const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, {
id: 'self', date: '2026-03-26', amount: 98565, currency: 'SEK', cash_account_id: 'ca-self',
})
expect(result).toBeNull()
})
it('ignores storno/correction vouchers (valid second vouchers, not duplicates)', async () => {
const stornoLine = jel({ journal_entry: { ...jel().journal_entry, source_type: 'storno' } })
const supabase = makeLedgerSupabase({ lines: [stornoLine] })
const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, {
id: 'self', date: '2026-03-26', amount: 98565, currency: 'SEK', cash_account_id: null,
})
expect(result).toBeNull()
})
it('matches a numeric-string leg amount from PostgREST (öre)', async () => {
const supabase = makeLedgerSupabase({ lines: [jel({ debit_amount: '98565.00' })] })
const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, {
id: 'self', date: '2026-03-26', amount: 98565, currency: 'SEK', cash_account_id: null,
})
expect(result?.journal_entry_id).toBe('je-2')
})
it('returns null for a zero-amount target without querying', async () => {
const supabase = makeLedgerSupabase({ lines: [jel()] })
const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, {
id: 'self', date: '2026-03-26', amount: 0, currency: 'SEK', cash_account_id: null,
})
expect(result).toBeNull()
})
// ── Intra-batch exclusion (bulk-book false-positive fix) ────────────────
it('excludes a same-batch voucher whose journal_entry.id is in excludeJournalEntryIds', async () => {
const supabase = makeLedgerSupabase({ lines: [jel()] }) // jel() → journal_entry.id 'je-2'
const result = await detectLedgerDuplicateVoucher(
supabase,
COMPANY,
{ id: 'self', date: '2026-03-26', amount: 98565, currency: 'SEK', cash_account_id: null },
{ excludeJournalEntryIds: ['je-2'] },
)
expect(result).toBeNull()
})
it('STILL flags a pre-existing voucher not in excludeJournalEntryIds (invariant preserved)', async () => {
const supabase = makeLedgerSupabase({ lines: [jel()] })
const result = await detectLedgerDuplicateVoucher(
supabase,
COMPANY,
{ id: 'self', date: '2026-03-26', amount: 98565, currency: 'SEK', cash_account_id: null },
{ excludeJournalEntryIds: ['je-booked-this-batch'] },
)
expect(result?.journal_entry_id).toBe('je-2')
})
// ── FX: the bank line is foreign, the 19xx leg is always SEK ───────────────
//
// Fixture: a 8570.87 EUR receipt booked at 11.5 SEK/EUR lands on the ledger as
// a 98565 SEK debit (that is what jel() holds). The bank line's own `amount`
// is 8570.87 and must never be the number compared against the leg.
it('flags a foreign receipt against the SEK leg its own booking produced', async () => {
const supabase = makeLedgerSupabase({ lines: [jel()] }) // 98565 SEK debit
const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, {
id: 'self', date: '2026-03-26', amount: 8570.87, currency: 'EUR',
amount_sek: 98565, cash_account_id: null,
})
expect(result?.journal_entry_id).toBe('je-2')
expect(result?.amount).toBe(98565)
expect(result?.amount_verified).toBe(true)
expect(result?.unverified_reason).toBeNull()
})
it('converts via exchange_rate when amount_sek was never stored', async () => {
const supabase = makeLedgerSupabase({ lines: [jel()] })
const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, {
id: 'self', date: '2026-03-26', amount: 8570.8695652, currency: 'EUR',
amount_sek: null, exchange_rate: 11.5, cash_account_id: null,
})
expect(result?.journal_entry_id).toBe('je-2')
expect(result?.amount_verified).toBe(true)
})
it('does NOT flag a foreign line against an unrelated same-magnitude SEK voucher', async () => {
// 98565 EUR is ~1.13 MSEK, nothing to do with the 98565 SEK leg. The old
// guard compared the raw numbers and matched them.
const supabase = makeLedgerSupabase({ lines: [jel()] })
const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, {
id: 'self', date: '2026-03-26', amount: 98565, currency: 'EUR',
amount_sek: 1133497.5, exchange_rate: 11.5, cash_account_id: null,
})
expect(result).toBeNull()
})
it('WARNS rather than passing when a foreign line has no rate at all', async () => {
// Neither amount_sek nor exchange_rate: nothing can be compared. Returning
// null here would wave the booking through and mint a second verifikat for
// one affärshändelse, so the candidate is surfaced as unverified instead.
// The bank line sits ONE day from the voucher (2026-03-29 vs 2026-03-30):
// without an amount comparison, only date-adjacent vouchers may be named.
const supabase = makeLedgerSupabase({ lines: [jel()] })
const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, {
id: 'self', date: '2026-03-29', amount: 8570.87, currency: 'EUR',
amount_sek: null, exchange_rate: null, cash_account_id: null,
})
expect(result).not.toBeNull()
expect(result?.journal_entry_id).toBe('je-2')
expect(result?.amount_verified).toBe(false)
expect(result?.unverified_reason).toBe('transaction_missing_sek_value')
// Still the leg's SEK figure, so no caller can print a foreign number as kr.
expect(result?.amount).toBe(98565)
})
it('does NOT name a voucher days away when the amounts cannot be compared', async () => {
// Rateless foreign line 4 days from the only 19xx leg in the window. With
// the amount test skipped, date + account + direction are the only
// evidence, and a ±7 day pick would attribute an arbitrary unrelated
// voucher to this bank line in the user-facing warning. Beyond ±1 day the
// guard stays silent rather than pointing at the wrong verifikat.
const supabase = makeLedgerSupabase({ lines: [jel()] }) // entry_date 2026-03-30
const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, {
id: 'self', date: '2026-03-26', amount: 8570.87, currency: 'EUR',
amount_sek: null, exchange_rate: null, cash_account_id: null,
})
expect(result).toBeNull()
})
it('still verifies a SEK amount match across the full ±7 day window (verified path unchanged)', async () => {
// The 1-day gate applies ONLY to the unverified (rateless) path: a SEK
// line whose amount matched exactly still dedupes 4 days out.
const supabase = makeLedgerSupabase({ lines: [jel()] }) // 98565 SEK debit, 2026-03-30
const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, {
id: 'self', date: '2026-03-26', amount: 98565, currency: 'SEK', cash_account_id: null,
})
expect(result?.journal_entry_id).toBe('je-2')
expect(result?.amount_verified).toBe(true)
})
it('returns null (a verified pass) for a rateless foreign line with no 19xx leg in the window', async () => {
// The existence half of the question needs no currency: no candidate leg at
// all means there provably is no ledger twin, so this is not a silent pass.
const supabase = makeLedgerSupabase({ lines: [] })
const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, {
id: 'self', date: '2026-03-26', amount: 8570.87, currency: 'EUR',
amount_sek: null, exchange_rate: null, cash_account_id: null,
})
expect(result).toBeNull()
})
it('still respects direction and storno filters when the amount cannot be verified', async () => {
// Same-day target so the storno filter, not the 1-day naming gate, is
// what removes the candidate.
const stornoLine = jel({ journal_entry: { ...jel().journal_entry, source_type: 'storno' } })
const supabase = makeLedgerSupabase({ lines: [stornoLine] })
const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, {
id: 'self', date: '2026-03-30', amount: 8570.87, currency: 'EUR',
amount_sek: null, exchange_rate: null, cash_account_id: null,
})
expect(result).toBeNull()
})
it('leaves a SEK company on exactly the old path (no FX fields, exact match required)', async () => {
const supabase = makeLedgerSupabase({ lines: [jel({ debit_amount: 90000 })] })
const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, {
id: 'self', date: '2026-03-26', amount: 98565, currency: null, cash_account_id: null,
})
expect(result).toBeNull()
})
})
describe('detectBookingDuplicate (orchestrator)', () => {
it('returns the sibling transaction when one exists (voucher scan not needed)', async () => {
const supabase = makeLedgerSupabase({ transactionRows: [sibling()] })
const result = await detectBookingDuplicate(supabase, COMPANY, {
id: 'self', date: '2025-12-19', amount: -1616, currency: 'SEK', cash_account_id: null,
})
expect(result?.transaction_id).toBe('sib-1')
})
it('falls through to the ledger voucher when there is no sibling transaction', async () => {
const supabase = makeLedgerSupabase({ transactionRows: [], lines: [jel()] })
const result = await detectBookingDuplicate(supabase, COMPANY, {
id: 'self', date: '2026-03-26', amount: 98565, currency: 'SEK', cash_account_id: null,
})
expect(result?.transaction_id).toBeNull()
expect(result?.journal_entry_id).toBe('je-2')
})
it('returns null when neither a sibling nor a voucher matches', async () => {
const supabase = makeLedgerSupabase({ transactionRows: [], lines: [] })
const result = await detectBookingDuplicate(supabase, COMPANY, {
id: 'self', date: '2026-03-26', amount: 98565, currency: 'SEK', cash_account_id: null,
})
expect(result).toBeNull()
})
it('propagates exclusions to BOTH the sibling scan and the ledger scan', async () => {
// A matching sibling AND a matching ledger voucher exist, but both belong to
// this same batch (excluded) → the orchestrator must report no duplicate.
const supabase = makeLedgerSupabase({
transactionRows: [sibling({ id: 'sib-batch', amount: 98565, journal_entry_id: 'je-sib' })],
lines: [jel()], // journal_entry.id 'je-2'
})
const result = await detectBookingDuplicate(
supabase,
COMPANY,
{ id: 'self', date: '2026-03-26', amount: 98565, currency: 'SEK', cash_account_id: null },
{ excludeTransactionIds: ['sib-batch'], excludeJournalEntryIds: ['je-2'] },
)
expect(result).toBeNull()
})
})