Files
accounted/lib/invoices/__tests__/voucher-matching-fx.test.ts
T
MattssonandClaude Fable 5 7162053e89 fix(bookkeeping): link RPCs settle cross-currency invoices with 7960/3960 FX residual (#2037)
* fix(bookkeeping): link RPCs settle cross-currency invoices with 7960/3960 FX residual

A foreign-currency invoice whose receivable (1510) or payable (2440) was
booked in plain SEK could not be settled by any API path:
link_invoice_to_voucher and link_supplier_invoice_to_voucher failed closed
with LINK_VOUCHER_CURRENCY_MISMATCH on every SEK-booked matched-side line.

Port match_batch_allocate's cross-currency settlement into both RPCs, with
identical sign conventions: when every matched-side line is genuinely
SEK-booked, the invoice has a sane exchange_rate, and the voucher's SEK sum
is within 10 percent of remaining * rate, the voucher settles the full
remaining and the FX residual (booked_sek - settled_sek) is booked to 7960
(loss) / 3960 (gain). Because the linked voucher is posted and immutable,
the residual lives in its own balanced two-line verifikat committed through
commit_journal_entry, dated on the voucher's entry_date with an explicit
open-period check. Every ambiguous case (mixed readable/SEK lines, third
currency label, missing rate, kontantmetoden, deviation outside the band,
locked period) keeps the existing mismatch codes, now with details.reason.

Verified with a 14-scenario transactional probe against staging Postgres
(rolled back; catalog untouched) plus tests/pg/link-voucher-fx-residual
.pg.test.ts, which applies the migration inside each test's transaction so
it runs against a database that has not applied it yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy

* fix(bookkeeping): move FX residual migration past 20260830101500 from main

Main gained 20260830101500_seed_agent_atom_bodies.sql, a later version than
this branch's 20260830100000; renamed to 20260830120000 so the migration
chain stays ordered. Test and decision-log references updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy

* fix(bookkeeping): gate the FX fallback on readable line count, not sum

Skeptic counterexample: a matched-side line labelled with the invoice's
currency but carrying amount_in_currency = 0 is a readable LINE that sums
to 0. The sum-based gate engaged the fallback while the line's real SEK
ledger movement was excluded from the settled sum, over-crediting the
receivable (mirrored on AP) and booking a phantom FX result. The gate now
counts readable lines: any readable line disables the fallback, so the SEK
sum is provably the full matched-side ledger amount whenever it engages.

Verified against staging Postgres in a rolled-back transaction: both
counterexample vouchers now refuse with LINK_*_CURRENCY_MISMATCH and no
writes, while the plain-SEK settlement paths still book balanced 7960/3960
residuals. Regression tests added for both sides.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy

* fix(bookkeeping): move FX residual migration past colliding 20260830120000 from main

Main gained 20260830120000_reminder_text_overrides.sql, colliding with this
branch's version timestamp; renamed to 20260830140000 (references updated).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy

* fix(invoices): stage-time validators mirror the RPC's SEK settlement gate

The MCP staging path pre-validates links with validateVoucherForInvoiceLink
and validateVoucherForSupplierInvoiceLink before the RPC ever runs, so the
new FX residual fallback was unreachable through MCP: the exact case this
change exists for. Both validators now mirror the RPC's gate byte-for-byte
(migration 20260830140000): accrual only on the customer side, zero
readable lines counted per LINE (a zero-amount readable line disables the
fallback), every unreadable matched-side line SEK-booked, sane
exchange_rate bounds, and the 10 percent deviation band; eligible vouchers
validate as a full-remaining settlement, everything else keeps the
CURRENCY_MISMATCH refusal with details.reason.

Unit tests: fallback settlement, deviation refusal, zero-amount readable
line refusal, kontantmetoden refusal, missing-rate refusal, and supplier
mirrors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:54:35 +02:00

1393 lines
46 KiB
TypeScript

/**
* Foreign-currency voucher matching, both sides of the sub-ledger.
*
* THE BUG THESE COVER: `journal_entry_lines.currency` labels the DOCUMENT, not
* the AMOUNT. `lib/bookkeeping/currency-utils.ts#buildCurrencyMetadata` stamps
* `currency: 'EUR'` + `amount_in_currency` onto a line whose debit/credit
* columns hold SEK. So every "the currencies match, so these amounts are
* comparable" guard passed on exactly the FX rows it existed to catch, and then
* compared a SEK ledger figure against a remainder quoted in the invoice's
* currency.
*
* The customer-invoice matcher made it worse than a bad score: the amount band
* is pushed into SQL, so a SEK `credit_amount` was bounded by a EUR band and
* the one correct voucher was dropped BEFORE scoring ever ran, leaving only
* same-magnitude coincidences behind. That is why the candidate query here runs
* against a mock that actually APPLIES the filters (see
* {@link createFilteringSupabase}) instead of a queued mock that ignores them:
* a queued mock cannot tell a correct pre-filter from a broken one.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import {
findMatchingVouchersForInvoice,
validateVoucherForInvoiceLink,
} from '../voucher-matching'
import {
findMatchingVouchersForSupplierInvoice,
validateVoucherForSupplierInvoiceLink,
} from '../supplier-voucher-matching'
import { makeInvoice, makeSupplierInvoice, createQueuedMockSupabase } from '@/tests/helpers'
// ============================================================
// A mock that actually executes the filters
// ============================================================
type FilterOp = 'eq' | 'gt' | 'gte' | 'lte' | 'like'
interface RecordedFilter {
op: FilterOp
column: string
value: unknown
}
/**
* A `.or('col.op.val,col.op.val', { referencedTable })` call: the conditions
* are ORed with each other and the whole group ANDs with the other filters,
* exactly as PostgREST composes it. The FX prefilter's two-sided floor band
* (floor <= |amount_in_currency| <= ceil) rides on this shape, so the mock has
* to execute it rather than swallow it.
*/
interface RecordedOrGroup {
op: 'or'
conditions: RecordedFilter[]
}
type AnyRecordedFilter = RecordedFilter | RecordedOrGroup
interface FixtureLine {
id: string
account_number: string
debit_amount: number
credit_amount: number
currency: string | null
/** Omitted entirely on rows that predate the FX columns. */
amount_in_currency?: number | string | null
}
interface FixtureEntry {
id: string
voucher_series: string
voucher_number: number
entry_date: string
description: string
status: string
source_type: string
fiscal_period_id: string
company_id: string
journal_entry_lines: FixtureLine[]
}
/**
* SQL comparison semantics, including the part that carries this whole test
* file: a comparison against NULL is UNKNOWN, so the row does NOT survive a
* WHERE clause. A line with no `amount_in_currency` therefore drops out of the
* foreign band by itself, which is correct: it carries no rate, so it cannot be
* expressed in the invoice's currency at all.
*/
function valueMatches(value: unknown, filter: RecordedFilter): boolean {
if (value === null || value === undefined) return false
if (filter.op === 'like') {
const pattern = String(filter.value)
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
.replace(/%/g, '.*')
return new RegExp(`^${pattern}$`).test(String(value))
}
if (filter.op === 'eq') return value === filter.value
// Ordered comparisons run numerically on numerics and lexically otherwise,
// which is what Postgres does per column type. `entry_date` ranges arrive as
// ISO date strings, where lexical order IS chronological order.
const numA = Number(value)
const numB = Number(filter.value)
const numeric = !Number.isNaN(numA) && !Number.isNaN(numB)
const a: number | string = numeric ? numA : String(value)
const b: number | string = numeric ? numB : String(filter.value)
if (filter.op === 'gt') return a > b
if (filter.op === 'gte') return a >= b
return a <= b
}
const EMBED_PREFIX = 'journal_entry_lines.'
/** Whether a recorded filter (or every branch of an or-group) targets the embed. */
function targetsEmbeddedLines(f: AnyRecordedFilter): boolean {
if (f.op === 'or') return f.conditions.every((c) => c.column.startsWith(EMBED_PREFIX))
return f.column.startsWith(EMBED_PREFIX)
}
function stripEmbedPrefix(f: RecordedFilter): RecordedFilter {
return f.column.startsWith(EMBED_PREFIX)
? { ...f, column: f.column.slice(EMBED_PREFIX.length) }
: f
}
/** One filter (or one or-group: any branch suffices) against one row. */
function rowSatisfies(row: Record<string, unknown>, f: AnyRecordedFilter): boolean {
if (f.op === 'or') return f.conditions.some((c) => rowSatisfies(row, c))
return valueMatches(row[f.column], f)
}
/**
* Apply the recorded filters the way PostgREST does for
* `journal_entries?select=...,journal_entry_lines!inner(...)`:
* plain columns filter the entries, `journal_entry_lines.*` columns filter the
* EMBEDDED rows, and because the embed is `!inner` an entry whose lines are all
* filtered away disappears from the result entirely. That last part is the
* mechanism the bug rode in on.
*/
function applyEntryFilters(entries: FixtureEntry[], filters: AnyRecordedFilter[]): FixtureEntry[] {
const entryFilters = filters.filter((f) => !targetsEmbeddedLines(f))
const lineFilters = filters.filter(targetsEmbeddedLines).map((f): AnyRecordedFilter =>
f.op === 'or'
? { ...f, conditions: f.conditions.map(stripEmbedPrefix) }
: stripEmbedPrefix(f)
)
const result: FixtureEntry[] = []
for (const entry of entries) {
const entryOk = entryFilters.every((f) =>
rowSatisfies(entry as unknown as Record<string, unknown>, f)
)
if (!entryOk) continue
const lines = entry.journal_entry_lines.filter((line) =>
lineFilters.every((f) => rowSatisfies(line as unknown as Record<string, unknown>, f))
)
if (lines.length === 0) continue
result.push({ ...entry, journal_entry_lines: lines })
}
return result
}
interface FilteringMockConfig {
accountingMethod?: 'accrual' | 'cash'
entries: FixtureEntry[]
/** Rows returned by the invoice_payments / supplier_invoice_payments dedup. */
existingLinks?: { journal_entry_id: string }[]
periods?: { id: string; is_closed: boolean; locked_at: string | null }[]
}
/**
* Minimal PostgREST stand-in for the two matcher call graphs. Records the
* filters per query and resolves them against `config.entries` at await time,
* so the assertions below are about which vouchers SURVIVE, not about which
* filter strings were typed.
*/
function createFilteringSupabase(config: FilteringMockConfig) {
const queries: { table: string; columns: string; filters: AnyRecordedFilter[] }[] = []
function chainFor(table: string) {
const filters: AnyRecordedFilter[] = []
const record = { table, columns: '', filters }
queries.push(record)
const findEntry = () => {
const idFilter = filters.find(
(f): f is RecordedFilter => f.op === 'eq' && f.column === 'id'
)
const entry = config.entries.find((e) => e.id === idFilter?.value)
if (!entry) return null
const { journal_entry_lines: _lines, ...rest } = entry
return rest
}
const resolveList = (): { data: unknown; error: null } => {
switch (table) {
case 'journal_entries': {
// The two-step fetch (lib/bookkeeping/entry-lines.ts) asks for bare
// entries; the embed path asks for entries WITH lines. Both are
// filtered identically; only the shape differs.
const matched = applyEntryFilters(config.entries, filters)
if (record.columns.includes('journal_entry_lines')) {
return { data: matched, error: null }
}
return {
data: matched.map(({ journal_entry_lines: _l, ...rest }) => rest),
error: null,
}
}
case 'journal_entry_lines': {
// Second leg of the two-step fetch, plus the validators' direct read.
const idFilter = filters.find(
(f): f is RecordedFilter =>
f.op !== 'or' && f.column === 'journal_entry_id' && (f.op === 'eq' || f.op === 'like')
)
const inIds = (record as { inIds?: string[] }).inIds
const scoped = config.entries.filter((e) =>
idFilter ? e.id === idFilter.value : (inIds ?? [e.id]).includes(e.id)
)
const lineFilters = filters.filter(
(f): f is RecordedFilter =>
f.op !== 'or' && f.column !== 'journal_entry_id' && !f.column.startsWith(EMBED_PREFIX)
)
const lines = scoped.flatMap((e) =>
e.journal_entry_lines
.filter((line) =>
lineFilters.every((f) =>
valueMatches((line as unknown as Record<string, unknown>)[f.column], f)
)
)
.map((line) => ({ ...line, journal_entry_id: e.id }))
)
return { data: lines, error: null }
}
case 'invoice_payments':
case 'supplier_invoice_payments':
return { data: config.existingLinks ?? [], error: null }
case 'fiscal_periods':
return {
data:
config.periods ??
[...new Set(config.entries.map((e) => e.fiscal_period_id))].map((id) => ({
id,
is_closed: false,
locked_at: null,
})),
error: null,
}
default:
return { data: [], error: null }
}
}
const resolveSingle = (): { data: unknown; error: null } => {
if (table === 'company_settings') {
return { data: { accounting_method: config.accountingMethod ?? 'accrual' }, error: null }
}
if (table === 'journal_entries') return { data: findEntry(), error: null }
return { data: null, error: null }
}
const chain: Record<string, unknown> = {}
for (const op of ['eq', 'gt', 'gte', 'lte', 'like'] as FilterOp[]) {
chain[op] = (column: string, value: unknown) => {
filters.push({ op, column, value })
return chain
}
}
chain.select = (columns?: string) => {
record.columns = String(columns ?? '')
return chain
}
chain.in = (column: string, values: string[]) => {
if (column === 'journal_entry_id') (record as { inIds?: string[] }).inIds = values
return chain
}
// `.or()` is parsed and EXECUTED, not swallowed: the FX prefilter's floor
// band lives in an or-group, and a mock that ignores it cannot tell a
// correct two-sided band from no band at all.
chain.or = (filterString: string, opts?: { referencedTable?: string; foreignTable?: string }) => {
const prefix = opts?.referencedTable ?? opts?.foreignTable
const conditions: RecordedFilter[] = String(filterString)
.split(',')
.map((part) => {
const [column, op, ...valueParts] = part.split('.')
return {
op: op as FilterOp,
column: prefix ? `${prefix}.${column}` : column,
value: valueParts.join('.'),
}
})
filters.push({ op: 'or', conditions })
return chain
}
for (const op of ['order', 'range', 'limit', 'not', 'is']) {
chain[op] = () => chain
}
chain.maybeSingle = () => Promise.resolve(resolveSingle())
chain.single = () => Promise.resolve(resolveSingle())
chain.then = (onOk: (v: unknown) => unknown, onErr?: (e: unknown) => unknown) =>
Promise.resolve(resolveList()).then(onOk, onErr)
return chain
}
return {
supabase: { from: vi.fn((table: string) => chainFor(table)), rpc: vi.fn() },
queries,
}
}
// ============================================================
// Fixtures: one EUR invoice, three vouchers competing for it
// ============================================================
/** 1000 EUR outstanding, booked at 11.50 SEK/EUR = 11 500 kr. */
function eurInvoice() {
return makeInvoice({
id: 'inv-eur',
invoice_number: 'F-9001',
currency: 'EUR',
total: 1000,
total_sek: 11500,
exchange_rate: 11.5,
remaining_amount: 1000,
paid_amount: null,
status: 'sent',
invoice_date: '2026-06-01',
due_date: '2026-06-30',
})
}
function entry(
id: string,
voucherNumber: number,
description: string,
lines: FixtureLine[]
): FixtureEntry {
return {
id,
voucher_series: 'A',
voucher_number: voucherNumber,
entry_date: '2026-06-28',
description,
status: 'posted',
source_type: 'manual',
fiscal_period_id: 'fp-1',
company_id: 'company-1',
journal_entry_lines: lines,
}
}
/**
* The RIGHT voucher: the 1000 EUR payment. `credit_amount` is the SEK figure
* (11 500 kr), `amount_in_currency` is the 1000 EUR the invoice is quoted in.
*/
const correctEurVoucher = entry('je-correct', 11, 'Inbetalning utländsk kund', [
{
id: 'l-correct-ar',
account_number: '1510',
debit_amount: 0,
credit_amount: 11500,
currency: 'EUR',
amount_in_currency: 1000,
},
{
id: 'l-correct-bank',
account_number: '1930',
debit_amount: 11500,
credit_amount: 0,
currency: 'EUR',
amount_in_currency: 1000,
},
])
/**
* The DECOY: an unrelated 87 EUR payment, which happens to be ~1000 kr. Its SEK
* `credit_amount` sits inside a band built from a EUR remainder, so the broken
* pre-filter kept precisely this one and threw away the voucher above.
*/
const decoySameMagnitudeVoucher = entry('je-decoy', 12, 'Inbetalning smabelopp', [
{
id: 'l-decoy-ar',
account_number: '1510',
debit_amount: 0,
credit_amount: 1000.5,
currency: 'EUR',
amount_in_currency: 87,
},
])
/** A plain 1000 kr domestic payment: same magnitude, wrong unit, no rate. */
const domesticSekVoucher = entry('je-sek', 13, 'Inbetalning inrikes', [
{
id: 'l-sek-ar',
account_number: '1510',
debit_amount: 0,
credit_amount: 1000,
currency: 'SEK',
},
])
// ============================================================
// findMatchingVouchersForInvoice: the SQL pre-filter
// ============================================================
describe('findMatchingVouchersForInvoice: foreign-currency invoices', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('does not exclude the correct EUR voucher in the SQL pre-filter', async () => {
const { supabase } = createFilteringSupabase({
entries: [correctEurVoucher, decoySameMagnitudeVoucher, domesticSekVoucher],
})
const result = await findMatchingVouchersForInvoice(
supabase as never,
'company-1',
eurInvoice() as never
)
expect(result.map((c) => c.journal_entry_id)).toContain('je-correct')
})
it('quotes the matched amount in the invoice currency, not the SEK column', async () => {
const { supabase } = createFilteringSupabase({ entries: [correctEurVoucher] })
const [candidate] = await findMatchingVouchersForInvoice(
supabase as never,
'company-1',
eurInvoice() as never
)
// 1000 EUR, the figure the invoice's 1000 EUR remainder can be compared
// with. 11 500 here would be the SEK column leaking through.
expect(candidate.ar_credit_amount).toBe(1000)
expect(candidate.currency).toBe('EUR')
})
it('prefers the correct voucher over a same-magnitude decoy', async () => {
const { supabase } = createFilteringSupabase({
entries: [decoySameMagnitudeVoucher, correctEurVoucher],
})
const result = await findMatchingVouchersForInvoice(
supabase as never,
'company-1',
eurInvoice() as never
)
expect(result[0].journal_entry_id).toBe('je-correct')
// 87 EUR is not a plausible settlement of a 1000 EUR invoice at all: it
// must not merely rank lower, it must not be offered.
expect(result.map((c) => c.journal_entry_id)).not.toContain('je-decoy')
})
it('never offers a rate-less domestic voucher for a EUR invoice', async () => {
const { supabase } = createFilteringSupabase({ entries: [domesticSekVoucher] })
const result = await findMatchingVouchersForInvoice(
supabase as never,
'company-1',
eurInvoice() as never
)
expect(result).toEqual([])
})
it('bounds amount_in_currency and matches the currency label, not the SEK column', async () => {
const { supabase, queries } = createFilteringSupabase({ entries: [correctEurVoucher] })
await findMatchingVouchersForInvoice(supabase as never, 'company-1', eurInvoice() as never)
const candidateQuery = queries.find(
(q) => q.table === 'journal_entries' && q.columns.includes('journal_entry_lines')
)
const banded = candidateQuery!.filters
.filter((f): f is RecordedFilter => f.op === 'gte' || f.op === 'lte')
.map((f) => f.column)
expect(banded).toContain('journal_entry_lines.amount_in_currency')
expect(banded).not.toContain('journal_entry_lines.credit_amount')
expect(candidateQuery!.filters).toEqual(
expect.arrayContaining([
{ op: 'eq', column: 'journal_entry_lines.currency', value: 'EUR' },
])
)
// Both FX columns must be projected or every line is unconvertible.
expect(candidateQuery!.columns).toContain('amount_in_currency')
expect(candidateQuery!.columns).toContain('currency')
})
it('applies the fuzzy floor to the FX band on both signs', async () => {
// The SEK path has always banded [floor, ceil]; the FX path only kept the
// ceiling, so every small same-currency line survived the prefilter and
// could crowd the exact-amount voucher out of the .limit(limit * 10) cap
// before scoring. The floor arrives as an or-group so negatively-stored
// foreign figures stay in: floor <= |amount_in_currency| <= ceil.
const { supabase, queries } = createFilteringSupabase({ entries: [correctEurVoucher] })
await findMatchingVouchersForInvoice(supabase as never, 'company-1', eurInvoice() as never)
const candidateQuery = queries.find(
(q) => q.table === 'journal_entries' && q.columns.includes('journal_entry_lines')
)
const orGroups = candidateQuery!.filters.filter(
(f): f is RecordedOrGroup => f.op === 'or'
)
expect(orGroups).toHaveLength(1)
// hi = lo = 1000 EUR, pad = min(1000 * 0.01, 500) + 0.02 → floor 989.98.
expect(orGroups[0].conditions).toEqual(
expect.arrayContaining([
{ op: 'gte', column: 'journal_entry_lines.amount_in_currency', value: '989.98' },
{ op: 'lte', column: 'journal_entry_lines.amount_in_currency', value: '-989.98' },
])
)
})
it('keeps a negatively-stored foreign figure inside the floored band', async () => {
// A few production rows store amount_in_currency with a negative sign; the
// direction comes off the debit/credit side. The floor must not evict them.
const negativeStored = entry('je-negative', 18, 'Inbetalning utländsk kund', [
{
id: 'l-negative',
account_number: '1510',
debit_amount: 0,
credit_amount: 11500,
currency: 'EUR',
amount_in_currency: -1000,
},
])
const { supabase } = createFilteringSupabase({ entries: [negativeStored] })
const result = await findMatchingVouchersForInvoice(
supabase as never,
'company-1',
eurInvoice() as never
)
expect(result.map((c) => c.journal_entry_id)).toContain('je-negative')
})
it('the floor keeps a small sibling line from polluting the matched sum', async () => {
// One voucher whose 151x legs are the real 1000 EUR settlement plus an
// unrelated small 87 EUR credit. Without the floor both lines passed the
// prefilter, the matched sum became 1087 EUR and the one correct voucher
// scored as a no-match. With the floor the small line drops in SQL and the
// voucher matches on its 1000 EUR leg.
const withSmallSibling = entry('je-sibling', 19, 'Inbetalning utländsk kund', [
{
id: 'l-sib-main',
account_number: '1510',
debit_amount: 0,
credit_amount: 11500,
currency: 'EUR',
amount_in_currency: 1000,
},
{
id: 'l-sib-small',
account_number: '1510',
debit_amount: 0,
credit_amount: 1000.5,
currency: 'EUR',
amount_in_currency: 87,
},
])
const { supabase } = createFilteringSupabase({ entries: [withSmallSibling] })
const result = await findMatchingVouchersForInvoice(
supabase as never,
'company-1',
eurInvoice() as never
)
expect(result.map((c) => c.journal_entry_id)).toContain('je-sibling')
expect(result[0].ar_credit_amount).toBe(1000)
})
})
// ============================================================
// SEK-only companies: the 95% path must not move at all
// ============================================================
describe('findMatchingVouchersForInvoice: SEK is untouched', () => {
beforeEach(() => {
vi.clearAllMocks()
})
function sekInvoice() {
return makeInvoice({
id: 'inv-sek',
invoice_number: 'F-9002',
currency: 'SEK',
total: 1000,
remaining_amount: 1000,
paid_amount: null,
status: 'sent',
invoice_date: '2026-06-01',
due_date: '2026-06-30',
})
}
it('still matches a plain SEK voucher on the raw ledger column', async () => {
const { supabase } = createFilteringSupabase({ entries: [domesticSekVoucher] })
const [candidate] = await findMatchingVouchersForInvoice(
supabase as never,
'company-1',
sekInvoice() as never
)
expect(candidate.journal_entry_id).toBe('je-sek')
expect(candidate.ar_credit_amount).toBe(1000)
})
it('keeps the amount band on the SEK column and adds no currency filter', async () => {
const { supabase, queries } = createFilteringSupabase({ entries: [domesticSekVoucher] })
await findMatchingVouchersForInvoice(supabase as never, 'company-1', sekInvoice() as never)
const candidateQuery = queries.find(
(q) => q.table === 'journal_entries' && q.columns.includes('journal_entry_lines')
)
const banded = candidateQuery!.filters
.filter((f): f is RecordedFilter => f.op === 'gte' || f.op === 'lte')
.map((f) => f.column)
expect(banded).toContain('journal_entry_lines.credit_amount')
expect(banded).not.toContain('journal_entry_lines.amount_in_currency')
expect(
candidateQuery!.filters.some(
(f) => f.op !== 'or' && f.column === 'journal_entry_lines.currency'
)
).toBe(false)
// The SEK band is expressed as plain gte/lte on the ledger column: no
// or-group is added (the floor already sits in the gte).
expect(candidateQuery!.filters.some((f) => f.op === 'or')).toBe(false)
})
it('reads the SEK column even when the line is labelled with a foreign document', async () => {
// A SEK invoice settled by a voucher that also carries a EUR document
// label: the ledger column is kronor either way, so the label is irrelevant
// and the match must still happen.
const mixed = entry('je-mixed', 14, 'Inbetalning', [
{
id: 'l-mixed',
account_number: '1510',
debit_amount: 0,
credit_amount: 1000,
currency: 'SEK',
amount_in_currency: null,
},
])
const { supabase } = createFilteringSupabase({ entries: [mixed] })
const [candidate] = await findMatchingVouchersForInvoice(
supabase as never,
'company-1',
sekInvoice() as never
)
expect(candidate.ar_credit_amount).toBe(1000)
})
it('treats a legacy NULL invoice currency as SEK, not as foreign', async () => {
// `invoices.currency` is `text default 'SEK'` and therefore nullable, while
// the TS type says otherwise. A NULL must not test `!== 'SEK'` and send a
// domestic invoice down the amount_in_currency path.
const { supabase, queries } = createFilteringSupabase({ entries: [domesticSekVoucher] })
await findMatchingVouchersForInvoice(
supabase as never,
'company-1',
makeInvoice({
currency: null as unknown as 'SEK',
total: 1000,
remaining_amount: 1000,
paid_amount: null,
due_date: '2026-06-30',
invoice_number: 'F-9003',
}) as never
)
const candidateQuery = queries.find(
(q) => q.table === 'journal_entries' && q.columns.includes('journal_entry_lines')
)
expect(
candidateQuery!.filters.some(
(f) => f.op !== 'or' && f.column === 'journal_entry_lines.currency'
)
).toBe(false)
expect(
candidateQuery!.filters.some(
(f) =>
(f.op === 'or' &&
f.conditions.some((c) => c.column === 'journal_entry_lines.amount_in_currency')) ||
(f.op !== 'or' && f.column === 'journal_entry_lines.amount_in_currency')
)
).toBe(false)
})
})
// ============================================================
// validateVoucherForInvoiceLink: EXCEEDS_REMAINING + payment amount
// ============================================================
describe('validateVoucherForInvoiceLink: foreign-currency invoices', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('accepts the correct EUR voucher instead of EXCEEDS_REMAINING', async () => {
const { supabase } = createFilteringSupabase({ entries: [correctEurVoucher] })
const result = await validateVoucherForInvoiceLink(
supabase as never,
'company-1',
eurInvoice() as never,
'je-correct'
)
// Comparing the 11 500 kr credit against the 1000 EUR remainder rejected
// the one voucher that was actually right.
expect(result.ok).toBe(true)
})
it('writes the payment amount in the invoice currency', async () => {
const { supabase } = createFilteringSupabase({ entries: [correctEurVoucher] })
const result = await validateVoucherForInvoiceLink(
supabase as never,
'company-1',
eurInvoice() as never,
'je-correct'
)
expect(result.ok).toBe(true)
if (!result.ok) return
// Math.min(11500, 1000) = 1000 by accident; Math.min(1000, 1000) = 1000 on
// purpose. The distinguishing figure is arCreditAmount.
expect(result.arCreditAmount).toBe(1000)
expect(result.paymentAmount).toBe(1000)
expect(result.remainingAfter).toBe(0)
expect(result.isFullyPaid).toBe(true)
})
it('reports a partial settlement in the invoice currency', async () => {
// 400 EUR of a 1000 EUR invoice, booked at 11.50 = 4600 kr.
const partial = entry('je-partial', 15, 'Delbetalning', [
{
id: 'l-partial',
account_number: '1510',
debit_amount: 0,
credit_amount: 4600,
currency: 'EUR',
amount_in_currency: 400,
},
])
const { supabase } = createFilteringSupabase({ entries: [partial] })
const result = await validateVoucherForInvoiceLink(
supabase as never,
'company-1',
eurInvoice() as never,
'je-partial'
)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.paymentAmount).toBe(400)
expect(result.remainingAfter).toBe(600)
expect(result.isFullyPaid).toBe(false)
})
it('still rejects a voucher that genuinely exceeds the remainder', async () => {
// 1500 EUR against a 1000 EUR remainder: the guard must keep working in the
// invoice's own unit.
const tooBig = entry('je-toobig', 16, 'Inbetalning', [
{
id: 'l-toobig',
account_number: '1510',
debit_amount: 0,
credit_amount: 17250,
currency: 'EUR',
amount_in_currency: 1500,
},
])
const { supabase } = createFilteringSupabase({ entries: [tooBig] })
const result = await validateVoucherForInvoiceLink(
supabase as never,
'company-1',
eurInvoice() as never,
'je-toobig'
)
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.code).toBe('LINK_VOUCHER_AMOUNT_EXCEEDS_REMAINING')
expect(result.details).toMatchObject({ ar_credit: 1500, remaining: 1000 })
})
it('refuses a rate-less AR credit rather than reading it as EUR', async () => {
// A 151x credit with no amount_in_currency carries no figure in EUR. Summing
// only the readable lines would understate the voucher, so this fails closed.
const rateless = entry('je-rateless', 17, 'Inbetalning', [
{
id: 'l-rateless',
account_number: '1510',
debit_amount: 0,
credit_amount: 11500,
currency: 'EUR',
amount_in_currency: null,
},
])
const { supabase } = createFilteringSupabase({ entries: [rateless] })
const result = await validateVoucherForInvoiceLink(
supabase as never,
'company-1',
eurInvoice() as never,
'je-rateless'
)
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.code).toBe('LINK_VOUCHER_CURRENCY_MISMATCH')
})
it('settles a plain SEK-booked voucher via the FX fallback (RPC gate mirror)', async () => {
// The receivable was booked in kronor (SIE import / manual voucher): the
// RPC (migration 20260830140000) now settles this and books the residual
// to 7960/3960, so the validator must stop refusing it at stage time.
const sekBooked = entry('je-sekbooked', 18, 'Inbetalning', [
{
id: 'l-sekbooked-ar',
account_number: '1510',
debit_amount: 0,
credit_amount: 11200,
currency: 'SEK',
},
{
id: 'l-sekbooked-bank',
account_number: '1930',
debit_amount: 11200,
credit_amount: 0,
currency: 'SEK',
},
])
const { supabase } = createFilteringSupabase({ entries: [sekBooked] })
const result = await validateVoucherForInvoiceLink(
supabase as never,
'company-1',
eurInvoice() as never,
'je-sekbooked'
)
expect(result.ok).toBe(true)
if (!result.ok) return
// Full-remaining settlement in the invoice currency, like the RPC.
expect(result.arCreditAmount).toBe(1000)
expect(result.paymentAmount).toBe(1000)
expect(result.remainingAfter).toBe(0)
expect(result.isFullyPaid).toBe(true)
expect(result.arLineCurrency).toBeNull()
})
it('keeps the refusal when the SEK total deviates more than 10% from booked', async () => {
// 1000 kr against an 11 500 kr booked receivable: wrong voucher, not FX.
const { supabase } = createFilteringSupabase({ entries: [domesticSekVoucher] })
const result = await validateVoucherForInvoiceLink(
supabase as never,
'company-1',
eurInvoice() as never,
'je-sek'
)
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.code).toBe('LINK_VOUCHER_CURRENCY_MISMATCH')
expect(result.details).toMatchObject({ reason: 'fx_deviation_too_large' })
})
it('keeps the refusal when a readable line carries amount_in_currency 0', async () => {
// Skeptic counterexample: the EUR-labelled zero line is a readable LINE
// whose 50 kr ledger credit is excluded from the SEK sum; engaging the
// fallback would understate the settlement. The gate counts lines.
const mixedZero = entry('je-mixed-zero', 19, 'Inbetalning', [
{
id: 'l-mz-sek',
account_number: '1510',
debit_amount: 0,
credit_amount: 950,
currency: 'SEK',
},
{
id: 'l-mz-zero',
account_number: '1510',
debit_amount: 0,
credit_amount: 50,
currency: 'EUR',
amount_in_currency: 0,
},
{
id: 'l-mz-bank',
account_number: '1930',
debit_amount: 1000,
credit_amount: 0,
currency: 'SEK',
},
])
const { supabase } = createFilteringSupabase({ entries: [mixedZero] })
const result = await validateVoucherForInvoiceLink(
supabase as never,
'company-1',
makeInvoice({
id: 'inv-eur-small',
invoice_number: 'F-9010',
currency: 'EUR',
total: 100,
total_sek: 1000,
exchange_rate: 10,
remaining_amount: 100,
paid_amount: null,
status: 'sent',
due_date: '2026-06-30',
}) as never,
'je-mixed-zero'
)
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.code).toBe('LINK_VOUCHER_CURRENCY_MISMATCH')
})
it('keeps the refusal on kontantmetoden (no receivable to true up)', async () => {
const cashVoucher = entry('je-cash-sek', 20, 'Inbetalning', [
{
id: 'l-cash-bank',
account_number: '1930',
debit_amount: 11500,
credit_amount: 0,
currency: 'SEK',
},
{
id: 'l-cash-rev',
account_number: '3001',
debit_amount: 0,
credit_amount: 11500,
currency: 'SEK',
},
])
const { supabase } = createFilteringSupabase({
accountingMethod: 'cash',
entries: [cashVoucher],
})
const result = await validateVoucherForInvoiceLink(
supabase as never,
'company-1',
eurInvoice() as never,
'je-cash-sek'
)
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.code).toBe('LINK_VOUCHER_CURRENCY_MISMATCH')
})
it('keeps the refusal when the invoice has no exchange rate', async () => {
const sekBooked = entry('je-sekbooked-norate', 25, 'Inbetalning', [
{
id: 'l-norate-ar',
account_number: '1510',
debit_amount: 0,
credit_amount: 11500,
currency: 'SEK',
},
])
const { supabase } = createFilteringSupabase({ entries: [sekBooked] })
const result = await validateVoucherForInvoiceLink(
supabase as never,
'company-1',
makeInvoice({
id: 'inv-eur-norate',
invoice_number: 'F-9011',
currency: 'EUR',
total: 1000,
total_sek: null,
exchange_rate: null,
remaining_amount: 1000,
paid_amount: null,
status: 'sent',
due_date: '2026-06-30',
}) as never,
'je-sekbooked-norate'
)
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.code).toBe('LINK_VOUCHER_CURRENCY_MISMATCH')
})
it('selects currency + amount_in_currency off the lines', async () => {
// The fix is inert if the column list omits them: the sweep has already hit
// that once, via RPCs that projected neither.
const { supabase, queries } = createFilteringSupabase({ entries: [correctEurVoucher] })
await validateVoucherForInvoiceLink(
supabase as never,
'company-1',
eurInvoice() as never,
'je-correct'
)
const lineQuery = queries.find((q) => q.table === 'journal_entry_lines')
expect(lineQuery!.columns).toContain('currency')
expect(lineQuery!.columns).toContain('amount_in_currency')
})
it('SEK: the happy path is unchanged', async () => {
const { supabase } = createFilteringSupabase({ entries: [domesticSekVoucher] })
const result = await validateVoucherForInvoiceLink(
supabase as never,
'company-1',
makeInvoice({
currency: 'SEK',
total: 1000,
remaining_amount: 1000,
paid_amount: null,
status: 'sent',
due_date: '2026-06-30',
invoice_number: 'F-9004',
}) as never,
'je-sek'
)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.arCreditAmount).toBe(1000)
expect(result.paymentAmount).toBe(1000)
})
})
// ============================================================
// Supplier side: 244x debits
// ============================================================
/** 1000 EUR supplier invoice, booked at 11.50 = 11 500 kr. */
function eurSupplierInvoice() {
return makeSupplierInvoice({
id: 'sinv-eur',
supplier_invoice_number: 'L-9001',
currency: 'EUR',
total: 1000,
total_sek: 11500,
exchange_rate: 11.5,
remaining_amount: 1000,
paid_amount: 0,
status: 'registered',
invoice_date: '2026-06-01',
due_date: '2026-06-30',
// A number that appears in none of the voucher descriptions below: the OCR
// pass matches on arrival_number and would short-circuit the amount logic
// these tests are about. `arrival_number` is NOT NULL, hence a sentinel.
arrival_number: 999999,
})
}
const correctEurApVoucher = entry('je-ap-correct', 21, 'Betalning utlandsleverantor', [
{
id: 'l-ap-correct',
account_number: '2440',
debit_amount: 11500,
credit_amount: 0,
currency: 'EUR',
amount_in_currency: 1000,
},
])
const domesticApVoucher = entry('je-ap-sek', 22, 'Betalning leverantor', [
{
id: 'l-ap-sek',
account_number: '2440',
debit_amount: 1000,
credit_amount: 0,
currency: 'SEK',
},
])
describe('findMatchingVouchersForSupplierInvoice: foreign-currency invoices', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('quotes the AP debit in the invoice currency', async () => {
const { supabase } = createFilteringSupabase({ entries: [correctEurApVoucher] })
const [candidate] = await findMatchingVouchersForSupplierInvoice(
supabase as never,
'company-1',
eurSupplierInvoice() as never
)
expect(candidate.journal_entry_id).toBe('je-ap-correct')
expect(candidate.ap_debit_amount).toBe(1000)
expect(candidate.currency).toBe('EUR')
})
it('never offers a rate-less domestic AP debit for a EUR invoice', async () => {
const { supabase } = createFilteringSupabase({ entries: [domesticApVoucher] })
const result = await findMatchingVouchersForSupplierInvoice(
supabase as never,
'company-1',
eurSupplierInvoice() as never
)
expect(result).toEqual([])
})
it('narrows the line query to the invoice currency and projects both FX columns', async () => {
const { supabase, queries } = createFilteringSupabase({ entries: [correctEurApVoucher] })
await findMatchingVouchersForSupplierInvoice(
supabase as never,
'company-1',
eurSupplierInvoice() as never
)
const lineQuery = queries.find((q) => q.table === 'journal_entry_lines')
expect(lineQuery!.columns).toContain('amount_in_currency')
expect(lineQuery!.filters).toEqual(
expect.arrayContaining([{ op: 'eq', column: 'currency', value: 'EUR' }])
)
})
it('SEK: matches on the raw column and adds no currency filter', async () => {
const { supabase, queries } = createFilteringSupabase({ entries: [domesticApVoucher] })
const [candidate] = await findMatchingVouchersForSupplierInvoice(
supabase as never,
'company-1',
makeSupplierInvoice({
currency: 'SEK',
total: 1000,
remaining_amount: 1000,
paid_amount: 0,
status: 'registered',
due_date: '2026-06-30',
supplier_invoice_number: 'L-9002',
// A number that appears in none of the voucher descriptions below: the OCR
// pass matches on arrival_number and would short-circuit the amount logic
// these tests are about. `arrival_number` is NOT NULL, hence a sentinel.
arrival_number: 999999,
}) as never
)
expect(candidate.ap_debit_amount).toBe(1000)
const lineQuery = queries.find((q) => q.table === 'journal_entry_lines')
expect(
lineQuery!.filters.some((f) => f.op !== 'or' && f.column === 'currency')
).toBe(false)
})
})
describe('validateVoucherForSupplierInvoiceLink: foreign-currency invoices', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('accepts the correct EUR voucher instead of EXCEEDS_REMAINING', async () => {
const { supabase } = createFilteringSupabase({ entries: [correctEurApVoucher] })
const result = await validateVoucherForSupplierInvoiceLink(
supabase as never,
'company-1',
eurSupplierInvoice() as never,
'je-ap-correct'
)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.apDebitAmount).toBe(1000)
expect(result.paymentAmount).toBe(1000)
})
it('still rejects an AP debit that genuinely exceeds the remainder', async () => {
const tooBig = entry('je-ap-toobig', 23, 'Betalning', [
{
id: 'l-ap-toobig',
account_number: '2440',
debit_amount: 17250,
credit_amount: 0,
currency: 'EUR',
amount_in_currency: 1500,
},
])
const { supabase } = createFilteringSupabase({ entries: [tooBig] })
const result = await validateVoucherForSupplierInvoiceLink(
supabase as never,
'company-1',
eurSupplierInvoice() as never,
'je-ap-toobig'
)
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.code).toBe('LINK_SI_VOUCHER_AMOUNT_EXCEEDS_REMAINING')
expect(result.details).toMatchObject({ ap_debit: 1500, remaining: 1000 })
})
it('refuses a rate-less AP debit rather than reading it as EUR', async () => {
const rateless = entry('je-ap-rateless', 24, 'Betalning', [
{
id: 'l-ap-rateless',
account_number: '2440',
debit_amount: 11500,
credit_amount: 0,
currency: 'EUR',
amount_in_currency: null,
},
])
const { supabase } = createFilteringSupabase({ entries: [rateless] })
const result = await validateVoucherForSupplierInvoiceLink(
supabase as never,
'company-1',
eurSupplierInvoice() as never,
'je-ap-rateless'
)
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.code).toBe('LINK_SI_VOUCHER_CURRENCY_MISMATCH')
})
it('settles a plain SEK-booked 244x voucher via the FX fallback (RPC gate mirror)', async () => {
const sekBooked = entry('je-ap-sekbooked', 26, 'Betalning', [
{
id: 'l-ap-sekbooked',
account_number: '2440',
debit_amount: 11200,
credit_amount: 0,
currency: 'SEK',
},
{
id: 'l-ap-sekbooked-bank',
account_number: '1930',
debit_amount: 0,
credit_amount: 11200,
currency: 'SEK',
},
])
const { supabase } = createFilteringSupabase({ entries: [sekBooked] })
const result = await validateVoucherForSupplierInvoiceLink(
supabase as never,
'company-1',
eurSupplierInvoice() as never,
'je-ap-sekbooked'
)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.apDebitAmount).toBe(1000)
expect(result.paymentAmount).toBe(1000)
expect(result.remainingAfter).toBe(0)
expect(result.isFullyPaid).toBe(true)
expect(result.apLineCurrency).toBeNull()
})
it('keeps the refusal when a readable 244x line carries amount_in_currency 0', async () => {
const mixedZero = entry('je-ap-mixed-zero', 27, 'Betalning', [
{
id: 'l-apmz-sek',
account_number: '2440',
debit_amount: 950,
credit_amount: 0,
currency: 'SEK',
},
{
id: 'l-apmz-zero',
account_number: '2440',
debit_amount: 50,
credit_amount: 0,
currency: 'EUR',
amount_in_currency: 0,
},
{
id: 'l-apmz-bank',
account_number: '1930',
debit_amount: 0,
credit_amount: 1000,
currency: 'SEK',
},
])
const { supabase } = createFilteringSupabase({ entries: [mixedZero] })
const result = await validateVoucherForSupplierInvoiceLink(
supabase as never,
'company-1',
makeSupplierInvoice({
id: 'sinv-eur-small',
supplier_invoice_number: 'L-9010',
currency: 'EUR',
total: 100,
total_sek: 1000,
exchange_rate: 10,
remaining_amount: 100,
paid_amount: 0,
status: 'registered',
due_date: '2026-06-30',
arrival_number: 999999,
}) as never,
'je-ap-mixed-zero'
)
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.code).toBe('LINK_SI_VOUCHER_CURRENCY_MISMATCH')
})
it('keeps the refusal when the SEK total deviates more than 10% from booked', async () => {
const { supabase } = createFilteringSupabase({ entries: [domesticApVoucher] })
const result = await validateVoucherForSupplierInvoiceLink(
supabase as never,
'company-1',
eurSupplierInvoice() as never,
'je-ap-sek'
)
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.code).toBe('LINK_SI_VOUCHER_CURRENCY_MISMATCH')
expect(result.details).toMatchObject({ reason: 'fx_deviation_too_large' })
})
it('SEK: the happy path is unchanged', async () => {
const { supabase } = createFilteringSupabase({ entries: [domesticApVoucher] })
const result = await validateVoucherForSupplierInvoiceLink(
supabase as never,
'company-1',
makeSupplierInvoice({
currency: 'SEK',
total: 1000,
remaining_amount: 1000,
paid_amount: 0,
status: 'registered',
due_date: '2026-06-30',
supplier_invoice_number: 'L-9003',
// A number that appears in none of the voucher descriptions below: the OCR
// pass matches on arrival_number and would short-circuit the amount logic
// these tests are about. `arrival_number` is NOT NULL, hence a sentinel.
arrival_number: 999999,
}) as never,
'je-ap-sek'
)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.apDebitAmount).toBe(1000)
})
})
// ============================================================
// The shared rule itself
// ============================================================
describe('ledgerLineSideAmountIn contract as used here', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('is the same helper bank reconciliation uses', async () => {
// The promotion to lib/bookkeeping/ledger-line-amount.ts must not have left
// bank-reconciliation.ts without its export: a second implementation of
// this rule is how the two sides drift apart again.
const promoted = await import('@/lib/bookkeeping/ledger-line-amount')
const reexported = await import('@/lib/reconciliation/bank-reconciliation')
expect(reexported.ledgerLineAmountIn).toBe(promoted.ledgerLineAmountIn)
})
it('unused queued mock helper stays importable', () => {
// Guards against the shared fixture factory drifting out from under the
// sibling suites in this directory.
expect(typeof createQueuedMockSupabase).toBe('function')
})
})