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>
This commit is contained in:
Mattsson
2026-08-30 16:54:35 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 15df4c741c
commit 7162053e89
6 changed files with 2236 additions and 22 deletions
@@ -822,6 +822,184 @@ describe('validateVoucherForInvoiceLink: foreign-currency invoices', () => {
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.
@@ -1058,6 +1236,108 @@ describe('validateVoucherForSupplierInvoiceLink: foreign-currency invoices', ()
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] })
+63 -10
View File
@@ -460,9 +460,17 @@ export async function validateVoucherForSupplierInvoiceLink(
let apDebitTotal = 0
let lineCurrency: string | null = null
// A 244x debit line that carries no amount in the invoice's currency. Fail
// CLOSED on it: summing only the convertible lines would silently understate
// a voucher that settles more than we can read.
// CLOSED on it, UNLESS the whole matched side is genuinely SEK-booked: then
// the fallback below mirrors the RPC's FX residual settlement gate
// (migration 20260830140000).
let unconvertibleLineCurrency: string | null | undefined
// Fallback classification, counted per LINE exactly as the RPC does: a line
// labelled with the invoice's currency whose amount_in_currency is 0 is
// still a readable LINE and must keep the fallback disabled, because its
// real SEK ledger movement is excluded from sekSideTotal.
let readableCount = 0
let sekSideTotal = 0
let foreignLabelCount = 0
for (const raw of lines) {
const line = raw as {
account_number: string
@@ -472,9 +480,19 @@ export async function validateVoucherForSupplierInvoiceLink(
amount_in_currency: number | string | null
}
if (!line.account_number?.startsWith(AP_ACCOUNT_PREFIX)) continue
const rawDebit = Number(line.debit_amount) || 0
if (invoiceCurrency !== 'SEK' && rawDebit > 0) {
if (line.currency === invoiceCurrency && line.amount_in_currency != null) {
readableCount += 1
} else if ((line.currency ?? 'SEK') === 'SEK') {
sekSideTotal += rawDebit
} else {
foreignLabelCount += 1
}
}
const debit = ledgerLineSideAmountIn(line, invoiceCurrency, 'debit')
if (debit === null) {
if ((Number(line.debit_amount) || 0) > 0 && unconvertibleLineCurrency === undefined) {
if (rawDebit > 0 && unconvertibleLineCurrency === undefined) {
unconvertibleLineCurrency = line.currency
}
continue
@@ -486,14 +504,49 @@ export async function validateVoucherForSupplierInvoiceLink(
apDebitTotal = round2(apDebitTotal)
if (unconvertibleLineCurrency !== undefined) {
return {
ok: false,
code: 'LINK_SI_VOUCHER_CURRENCY_MISMATCH',
details: {
invoice_currency: invoice.currency,
line_currency: unconvertibleLineCurrency,
},
// SEK-booked settlement fallback, mirroring the RPC gate byte-for-byte
// (the supplier side has no kontantmetoden branch): zero readable lines,
// every unreadable line SEK-booked, a sane exchange_rate, and the
// voucher's SEK total within 10% of remaining * rate. The RPC settles the
// FULL remaining and books the FX residual to 3960/7960 as its own
// verifikat; the validation outcome only has to agree.
const exchangeRate = Number(invoice.exchange_rate)
const fallbackEligible =
readableCount === 0 &&
foreignLabelCount === 0 &&
sekSideTotal > 0 &&
Number.isFinite(exchangeRate) &&
exchangeRate > 0 &&
exchangeRate < 100000
if (!fallbackEligible) {
return {
ok: false,
code: 'LINK_SI_VOUCHER_CURRENCY_MISMATCH',
details: {
invoice_currency: invoice.currency,
line_currency: unconvertibleLineCurrency,
},
}
}
const sekTotal = round2(sekSideTotal)
const bookedSek = round2(remainingAmount * exchangeRate)
if (Math.abs(sekTotal - bookedSek) > bookedSek * 0.1) {
return {
ok: false,
code: 'LINK_SI_VOUCHER_CURRENCY_MISMATCH',
details: {
invoice_currency: invoice.currency,
line_currency: unconvertibleLineCurrency,
reason: 'fx_deviation_too_large',
expected_sek: bookedSek,
voucher_sek: sekTotal,
},
}
}
// Full-remaining settlement, exactly as the RPC computes it. lineCurrency
// is null here (no readable line), so the label guard below passes and the
// exceeds-remaining guard sees an equal amount.
apDebitTotal = round2(remainingAmount)
}
if (apDebitTotal <= 0) {
+66 -12
View File
@@ -566,9 +566,17 @@ export async function validateVoucherForInvoiceLink(
let arCreditTotal = 0
let lineCurrency: string | null = null
// A matched-side line on the right account that carries no amount in the
// invoice's currency. Fail CLOSED on it: summing only the convertible lines
// would silently understate a voucher that settles more than we can see.
// invoice's currency. Fail CLOSED on it, UNLESS the whole matched side is
// genuinely SEK-booked: then the fallback below mirrors the RPC's FX
// residual settlement gate (migration 20260830140000).
let unconvertibleLineCurrency: string | null | undefined
// Fallback classification, counted per LINE exactly as the RPC does: a line
// labelled with the invoice's currency whose amount_in_currency is 0 is
// still a readable LINE and must keep the fallback disabled, because its
// real SEK ledger movement is excluded from sekSideTotal.
let readableCount = 0
let sekSideTotal = 0
let foreignLabelCount = 0
for (const raw of lines) {
const line = raw as {
account_number: string
@@ -578,11 +586,20 @@ export async function validateVoucherForInvoiceLink(
amount_in_currency: number | string | null
}
if (!line.account_number?.startsWith(accountPrefix)) continue
// Only a line that actually moves on the matched side counts as evidence
// of a settlement; the opposite leg is irrelevant.
const rawSide = Number(isCash ? line.debit_amount : line.credit_amount) || 0
if (invoiceCurrency !== 'SEK' && rawSide > 0) {
if (line.currency === invoiceCurrency && line.amount_in_currency != null) {
readableCount += 1
} else if ((line.currency ?? 'SEK') === 'SEK') {
sekSideTotal += rawSide
} else {
foreignLabelCount += 1
}
}
const matched = ledgerLineSideAmountIn(line, invoiceCurrency, matchedSide)
if (matched === null) {
// Only a line that actually moves on the matched side counts as evidence
// of a settlement we cannot read; the opposite leg is irrelevant.
const rawSide = Number(isCash ? line.debit_amount : line.credit_amount) || 0
if (rawSide > 0 && unconvertibleLineCurrency === undefined) {
unconvertibleLineCurrency = line.currency
}
@@ -595,14 +612,51 @@ export async function validateVoucherForInvoiceLink(
arCreditTotal = round2(arCreditTotal)
if (unconvertibleLineCurrency !== undefined) {
return {
ok: false,
code: 'LINK_VOUCHER_CURRENCY_MISMATCH',
details: {
invoice_currency: invoice.currency,
line_currency: unconvertibleLineCurrency,
},
// SEK-booked settlement fallback, mirroring the RPC gate byte-for-byte:
// accrual only, zero readable lines, every unreadable line SEK-booked, a
// sane invoice exchange_rate, and the voucher's SEK total within 10% of
// remaining * rate. The RPC then settles the FULL remaining and books the
// FX residual to 7960/3960 as its own verifikat; here the validation
// outcome only has to agree, so the staging path stops refusing what the
// commit RPC accepts.
const exchangeRate = Number(invoice.exchange_rate)
const fallbackEligible =
!isCash &&
readableCount === 0 &&
foreignLabelCount === 0 &&
sekSideTotal > 0 &&
Number.isFinite(exchangeRate) &&
exchangeRate > 0 &&
exchangeRate < 100000
if (!fallbackEligible) {
return {
ok: false,
code: 'LINK_VOUCHER_CURRENCY_MISMATCH',
details: {
invoice_currency: invoice.currency,
line_currency: unconvertibleLineCurrency,
},
}
}
const sekTotal = round2(sekSideTotal)
const bookedSek = round2(remainingAmount * exchangeRate)
if (Math.abs(sekTotal - bookedSek) > bookedSek * 0.1) {
return {
ok: false,
code: 'LINK_VOUCHER_CURRENCY_MISMATCH',
details: {
invoice_currency: invoice.currency,
line_currency: unconvertibleLineCurrency,
reason: 'fx_deviation_too_large',
expected_sek: bookedSek,
voucher_sek: sekTotal,
},
}
}
// Full-remaining settlement, exactly as the RPC computes it. lineCurrency
// is null here (no readable line), so the label guard below passes and the
// exceeds-remaining guard sees an equal amount.
arCreditTotal = round2(remainingAmount)
}
if (arCreditTotal <= 0) {