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
+1
View File
@@ -2,6 +2,7 @@
One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and humans when a non-obvious choice is made (approach picked over an alternative, dependency declined, action stopped by a CLAUDE.md rule). Read before re-litigating a past decision.
[2026-08-30] Link-RPC FX fallback books the residual as its own verifikat, keeps the existing CURRENCY_MISMATCH codes for every fail-closed case, and stays off on kontantmetoden: the linked voucher is posted and immutable so the residual cannot live inside it; reusing the codes avoids new TS error mappings while `details.reason` disambiguates; on cash method no receivable was ever booked, so there is no residual account to true up (migration 20260830140000).
[2026-08-18] Shared-host white-label auth links use an exact NEXT_PUBLIC_WHITELABEL_DOMAINS allowlist and direct per-brand callbacks, with NEXT_PUBLIC_APP_URL as fallback: bouncing recovery through the canonical host would scope the recovery session cookie to that unrelated domain, while exact registered hosts preserve the brand session without trusting arbitrary Host headers or browser origins.
[2026-08-18] Bokio connection validation uses the documented GET /v1/companies/{companyId}/company-information contract and treats only 401/403 as credential rejection: the removed bare company path returned 404 for valid credentials, while a 404 from the documented endpoint identifies the company ID and other failures are not evidence that the token is wrong (#1670).
@@ -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) {
@@ -0,0 +1,891 @@
-- Cross-currency voucher linking: settle a SEK-booked payment voucher against
-- a foreign-currency invoice by booking the FX residual to 7960/3960, instead
-- of refusing with LINK_VOUCHER_CURRENCY_MISMATCH / LINK_SI_VOUCHER_CURRENCY_MISMATCH.
--
-- THE GAP: 20260726140000 taught both link RPCs to resolve the matched amount
-- in the INVOICE's currency. On a foreign invoice the only readable column is
-- `amount_in_currency` on lines labelled with that currency; any matched-side
-- line without such a figure made the voucher UNREADABLE and the RPC failed
-- closed. That is the right call for a line stamped with a THIRD currency,
-- but it also rejected the most common real-world shape: a EUR invoice whose
-- receivable was booked in kronor on 1510 (SIE import, manual voucher, older
-- engine output) and whose payment voucher is plain SEK with no currency
-- metadata at all. Such an invoice could not be settled by any API path.
--
-- THE FIX ports the cross-currency settlement that match_batch_allocate
-- (20260824120000) already performs, with the SAME sign conventions:
--
-- v_fx_diff = booked_sek - settled_sek, both rounded to the öre, where
-- booked_sek = ROUND(remaining * invoices.exchange_rate * 100) / 100 and
-- settled_sek is the voucher's matched-side SEK ledger sum.
--
-- Customer: diff > 0 (received less than booked) -> Dr 7960 Valutakursförlust
-- diff < 0 (received more than booked) -> Cr 3960 Valutakursvinst
-- Supplier: diff > 0 (paid less than booked) -> Cr 3960 Valutakursvinst
-- diff < 0 (paid more than booked) -> Dr 7960 Valutakursförlust
--
-- One structural difference from match_batch_allocate: that RPC CREATES its
-- settlement verifikat, so the residual line lives inside it. The link RPCs
-- link an EXISTING posted voucher, which is immutable (BFL 5 kap 5 §,
-- enforcement triggers from migration 017). The residual is therefore booked
-- as its OWN two-line SEK verifikat (committed atomically through
-- commit_journal_entry, so voucher numbering stays sequential), with the
-- AR/AP counter-leg on the same account the voucher settled. Combined,
-- invoice entry + payment voucher + residual verifikat net the receivable /
-- payable to exactly zero: identical ledger effect to the single verifikat
-- match_batch_allocate books. The residual is öre-rounded on both inputs, so
-- it is either exactly 0.00 (no verifikat needed) or >= 0.01 (a balanced
-- debit/credit pair, both sides > 0).
--
-- The fallback engages ONLY when every part of the case is unambiguous:
-- * the invoice is foreign (resolved currency <> 'SEK');
-- * NO matched-side line is readable in the invoice currency, counted by
-- LINE, not by sum: a line labelled with the invoice's currency that
-- carries amount_in_currency = 0 is still a readable line, and it must
-- disable the fallback (its real SEK ledger movement is excluded from
-- the SEK sum, so engaging anyway would understate the settlement). A
-- mixed voucher stays fail-closed: summing units would be guesswork;
-- * every unreadable matched-side line is genuinely SEK-booked, i.e. its
-- label is 'SEK' or NULL. A line labelled with the invoice's currency but
-- missing amount_in_currency is malformed metadata (buildCurrencyMetadata
-- stamps label and figure together), and a third-currency label is a
-- counterparty discriminator: both keep today's mismatch error;
-- * customer side: accounting method is accrual. On kontantmetoden no
-- receivable was ever booked, so there is no residual to true up and no
-- account to book it against;
-- * invoices/supplier_invoices.exchange_rate is present and sane (same
-- bounds as match_batch_allocate: > 0, < 100000);
-- * the voucher's SEK total is within 10% of booked_sek (same deviation
-- band as match_batch_allocate). The voucher is then read as settling the
-- FULL remaining, exactly as match_batch_allocate treats cross-currency
-- allocations; a partial SEK settlement of a foreign invoice falls
-- outside the band and stays fail-closed.
--
-- Every fail-closed branch keeps the existing CURRENCY_MISMATCH codes (with a
-- `reason` in details for diagnosability), so no TS error mapping changes and
-- callers see the same stable codes as before.
--
-- The payment row records the FULL remaining in the invoice's currency plus
-- payment_exchange_rate = settled_sek / remaining (round-6), mirroring
-- match_batch_allocate's traceability convention. The residual verifikat is
-- created only AFTER the already-linked guard, and the whole RPC body is one
-- transaction: a later failure rolls the verifikat back.
--
-- Everything else in both bodies is verbatim from 20260801204551
-- (UTC-noon paid_at projection + NULL-safe caller_is_company_member guard).
--
-- No schema change, no trigger touched: two CREATE OR REPLACE FUNCTION bodies.
-- pg-test: tests/pg/link-voucher-fx-residual.pg.test.ts
CREATE OR REPLACE FUNCTION public.link_invoice_to_voucher(
p_invoice_id uuid,
p_journal_entry_id uuid,
p_user_id uuid,
p_company_id uuid,
p_notes text DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_invoice RECORD;
v_voucher RECORD;
v_ar_credit_total numeric := 0;
v_line_currency text;
v_remaining numeric;
v_payment_amount numeric;
v_new_paid numeric;
v_new_remaining numeric;
v_new_status text;
v_is_fully_paid boolean;
v_now timestamptz := now();
v_payment_id uuid;
v_jwt_role text := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', '');
v_acting_user uuid := p_user_id;
v_accounting_method text;
-- Unit resolution (20260726140000): the currency the invoice's amounts are
-- quoted in, plus the matched-side lines that cannot be expressed in it.
v_invoice_currency text;
v_account_prefix text;
v_unreadable_count integer := 0;
v_unreadable_currency text;
-- FX residual settlement (new): a foreign invoice whose matched side is
-- booked plain SEK. See the header comment.
v_readable_count integer := 0;
v_sek_side_total numeric := 0;
v_foreign_label_count integer := 0;
v_booked_sek numeric;
v_fx_diff numeric := 0;
v_fx_settled boolean := false;
v_payment_rate numeric; -- round-6 effective rate (traceability)
v_fx_account text;
v_fx_entry_id uuid;
v_fx_voucher_number int;
v_fiscal_period_id uuid;
v_period_is_closed boolean;
v_period_locked_at timestamptz;
v_inv_number_short text;
BEGIN
-- 0. Tenant guard (mirrors 20260611140000): anon/authenticated may only act
-- on their own companies; service_role / direct access bypasses. The
-- NULL-safe caller_is_company_member() form (20260703180000): the raw
-- membership-subquery shape skips the deny branch on UNKNOWN and is
-- banned by the pg-real ratchet (tests/pg/null-safe-tenant-guards
-- .pg.test.ts, which scans prosrc, comments included).
IF v_jwt_role IN ('anon', 'authenticated') THEN
IF NOT public.caller_is_company_member(p_company_id) THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_INVOICE_NOT_FOUND');
END IF;
-- Attribution: the JWT sub is authoritative for user-session callers:
-- p_user_id cannot point the payment row at someone else.
v_acting_user := coalesce(
(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub')::uuid,
p_user_id
);
END IF;
IF p_notes IS NOT NULL AND char_length(p_notes) > 2000 THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_VOUCHER_NOTES_TOO_LONG',
'details', jsonb_build_object('max_length', 2000, 'length', char_length(p_notes))
);
END IF;
-- 1. Lock the invoice for the duration of this transaction. FOR UPDATE so a
-- concurrent linker has to wait until we commit (or roll back).
SELECT * INTO v_invoice
FROM public.invoices
WHERE id = p_invoice_id AND company_id = p_company_id
FOR UPDATE;
IF NOT FOUND THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_INVOICE_NOT_FOUND');
END IF;
IF v_invoice.status NOT IN ('sent', 'overdue', 'partially_paid') THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_VOUCHER_INVOICE_FULLY_PAID',
'details', jsonb_build_object('status', v_invoice.status)
);
END IF;
v_remaining := COALESCE(v_invoice.remaining_amount,
v_invoice.total - COALESCE(v_invoice.paid_amount, 0));
IF v_remaining <= 0.005 THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_INVOICE_FULLY_PAID');
END IF;
-- 2. Resolve the voucher.
SELECT * INTO v_voucher
FROM public.journal_entries
WHERE id = p_journal_entry_id AND company_id = p_company_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_VOUCHER_NOT_FOUND');
END IF;
IF v_voucher.status <> 'posted' THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_VOUCHER_NOT_POSTED',
'details', jsonb_build_object('status', v_voucher.status)
);
END IF;
IF v_voucher.source_type IN ('opening_balance', 'storno') THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_VOUCHER_NO_AR_CREDIT',
'details', jsonb_build_object('source_type', v_voucher.source_type)
);
END IF;
-- 3. Sum the matched amount across the voucher's lines, EXPRESSED IN THE
-- INVOICE'S CURRENCY. Branch on the company's accounting method (defaults
-- to accrual when no settings row).
SELECT cs.accounting_method INTO v_accounting_method
FROM public.company_settings cs
WHERE cs.company_id = p_company_id;
v_accounting_method := COALESCE(v_accounting_method, 'accrual');
-- `invoices.currency` is `text default 'SEK'` and therefore NULLABLE; a
-- missing code has always meant kronor, and must not be read as "not SEK".
v_invoice_currency := COALESCE(v_invoice.currency, 'SEK');
v_account_prefix := CASE WHEN v_accounting_method = 'cash' THEN '19' ELSE '151' END;
IF v_invoice_currency = 'SEK' THEN
-- VERBATIM from 20260620130000. The ledger columns are kronor already, so
-- the document label on the line is irrelevant here.
IF v_accounting_method = 'cash' THEN
-- Kontantmetoden: the payment verifikat debits a liquid-funds account (19xx).
SELECT COALESCE(SUM(debit_amount), 0), MAX(currency)
INTO v_ar_credit_total, v_line_currency
FROM public.journal_entry_lines
WHERE journal_entry_id = p_journal_entry_id
AND account_number LIKE '19%'
AND debit_amount > 0;
ELSE
-- Faktureringsmetoden: the payment verifikat credits the AR account (151x).
SELECT COALESCE(SUM(credit_amount), 0), MAX(currency)
INTO v_ar_credit_total, v_line_currency
FROM public.journal_entry_lines
WHERE journal_entry_id = p_journal_entry_id
AND account_number LIKE '151%'
AND credit_amount > 0;
END IF;
ELSE
-- Foreign invoice: `amount_in_currency` is the only column quoted in the
-- invoice's currency. Magnitude from ABS() because a handful of production
-- rows store the foreign figure negatively while the debit/credit side is
-- authoritative, and that side is already pinned by the `> 0` predicate.
--
-- Three additional aggregates (new) feed the FX residual fallback:
-- * how many matched-side lines are readable at all: the gate below
-- must count LINES, not test the sum, because a readable line with
-- amount_in_currency = 0 sums to 0 while its SEK ledger movement is
-- real and excluded from the SEK sum: engaging the fallback over it
-- would understate the settlement and fabricate an FX residual;
-- * the matched side's raw SEK ledger sum over SEK-booked lines
-- (label 'SEK' or NULL);
-- * how many unreadable lines are NOT SEK-booked (third-currency label,
-- or the invoice's label without a figure): any such line keeps the
-- fallback disabled.
SELECT
COALESCE(SUM(ABS(l.amount_in_currency)) FILTER (
WHERE l.currency = v_invoice_currency AND l.amount_in_currency IS NOT NULL
), 0),
MAX(l.currency) FILTER (
WHERE l.currency = v_invoice_currency AND l.amount_in_currency IS NOT NULL
),
COUNT(*) FILTER (
WHERE l.currency IS DISTINCT FROM v_invoice_currency OR l.amount_in_currency IS NULL
),
MIN(l.currency) FILTER (
WHERE l.currency IS DISTINCT FROM v_invoice_currency OR l.amount_in_currency IS NULL
),
COUNT(*) FILTER (
WHERE l.currency = v_invoice_currency AND l.amount_in_currency IS NOT NULL
),
COALESCE(SUM(CASE WHEN v_accounting_method = 'cash' THEN l.debit_amount ELSE l.credit_amount END) FILTER (
WHERE COALESCE(l.currency, 'SEK') = 'SEK'
), 0),
COUNT(*) FILTER (
WHERE (l.currency IS DISTINCT FROM v_invoice_currency OR l.amount_in_currency IS NULL)
AND COALESCE(l.currency, 'SEK') <> 'SEK'
)
INTO v_ar_credit_total, v_line_currency, v_unreadable_count, v_unreadable_currency,
v_readable_count, v_sek_side_total, v_foreign_label_count
FROM public.journal_entry_lines l
WHERE l.journal_entry_id = p_journal_entry_id
AND l.account_number LIKE v_account_prefix || '%'
AND (CASE WHEN v_accounting_method = 'cash' THEN l.debit_amount ELSE l.credit_amount END) > 0;
IF COALESCE(v_unreadable_count, 0) > 0 THEN
-- FX residual fallback (new): the voucher settles the invoice in plain
-- kronor. Engage only in the unambiguous case; see the header comment.
-- The readable gate counts LINES (see the aggregate comment): with
-- zero readable lines, v_sek_side_total is provably the FULL
-- matched-side ledger sum and v_line_currency is NULL by construction.
IF v_accounting_method = 'accrual'
AND COALESCE(v_readable_count, 0) = 0
AND COALESCE(v_foreign_label_count, 0) = 0
AND v_sek_side_total > 0
AND v_invoice.exchange_rate IS NOT NULL
AND v_invoice.exchange_rate > 0
AND v_invoice.exchange_rate < 100000
THEN
v_sek_side_total := ROUND(v_sek_side_total * 100) / 100;
v_booked_sek := ROUND(v_remaining * v_invoice.exchange_rate * 100) / 100;
-- Same 10% deviation band as match_batch_allocate: outside it the
-- voucher is simply the wrong voucher (e.g. 1 000 kr against a
-- 1 000 EUR remainder), not an FX difference.
IF ABS(v_sek_side_total - v_booked_sek) > v_booked_sek * 0.10 THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_VOUCHER_CURRENCY_MISMATCH',
'details', jsonb_build_object(
'invoice_currency', v_invoice.currency,
'line_currency', v_unreadable_currency,
'reason', 'fx_deviation_too_large',
'expected_sek', v_booked_sek,
'voucher_sek', v_sek_side_total
)
);
END IF;
v_fx_settled := true;
v_fx_diff := ROUND((v_booked_sek - v_sek_side_total) * 100) / 100;
-- The voucher settles the FULL remaining (match_batch_allocate's
-- cross-currency convention); the residual verifikat below trues up
-- the receivable. v_line_currency is NULL here (no readable line),
-- so the label guard further down passes by COALESCE.
v_ar_credit_total := ROUND(v_remaining * 100) / 100;
v_payment_rate := ROUND((v_sek_side_total / v_remaining) * 1000000) / 1000000;
ELSE
-- Fail CLOSED, exactly as before, on everything the fallback cannot
-- read unambiguously: mixed readable/SEK vouchers, third-currency
-- labels, an invoice-labelled line without a figure, kontantmetoden,
-- or a missing/insane exchange rate.
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_VOUCHER_CURRENCY_MISMATCH',
'details', jsonb_build_object(
'invoice_currency', v_invoice.currency,
'line_currency', v_unreadable_currency
)
);
END IF;
END IF;
END IF;
v_ar_credit_total := ROUND(v_ar_credit_total * 100) / 100;
IF v_ar_credit_total <= 0 THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_NO_AR_CREDIT');
END IF;
-- Label guard, still load-bearing, but no longer as a unit check:
-- v_ar_credit_total is already in the invoice's currency. What it catches
-- now is a counterparty discriminator, a matched line stamped with some
-- other document's currency. Always passes on a foreign invoice, because
-- only same-labelled lines could be read at all. Both sides compare the
-- RESOLVED v_invoice_currency, never the raw nullable column: with the raw
-- column, a legacy NULL-currency invoice (which has always meant SEK) hit
-- 'SEK' IS DISTINCT FROM NULL = true and an ordinary domestic payment
-- raised LINK_VOUCHER_CURRENCY_MISMATCH forever.
IF COALESCE(v_line_currency, v_invoice_currency) IS DISTINCT FROM v_invoice_currency THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_VOUCHER_CURRENCY_MISMATCH',
'details', jsonb_build_object(
'invoice_currency', v_invoice.currency,
'line_currency', v_line_currency
)
);
END IF;
-- Both sides are now in the invoice's currency.
IF v_ar_credit_total > v_remaining + 0.005 THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_VOUCHER_AMOUNT_EXCEEDS_REMAINING',
'details', jsonb_build_object(
'ar_credit', v_ar_credit_total,
'remaining', ROUND(v_remaining * 100) / 100
)
);
END IF;
-- 4. Reject re-link of the same voucher to the same invoice. Authoritative
-- under the FOR UPDATE lock; the partial unique index
-- idx_invoice_payments_je_inv_unique stays as the last line of defence
-- for non-RPC writers.
IF EXISTS (
SELECT 1 FROM public.invoice_payments
WHERE company_id = p_company_id
AND invoice_id = p_invoice_id
AND journal_entry_id = p_journal_entry_id
) THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_VOUCHER_ALREADY_LINKED');
END IF;
-- 4b. Book the FX residual as its OWN verifikat: the linked voucher is
-- posted and immutable, so the difference between the receivable's
-- booked kronor and the kronor the voucher settled cannot live inside
-- it. Placed AFTER every guard so a rejected link never creates a
-- verifikat; the RPC body is one transaction, so a later failure rolls
-- it back. v_fx_diff is öre-rounded from öre-rounded inputs: it is
-- either exactly 0.00 (nothing to book) or >= 0.01.
IF v_fx_settled AND ABS(v_fx_diff) > 0.005 THEN
-- Counter-leg account: the AR account the voucher actually settled
-- (largest matched-side line). Non-null: the fallback required
-- v_sek_side_total > 0, so at least one such line exists.
SELECT l.account_number INTO v_fx_account
FROM public.journal_entry_lines l
WHERE l.journal_entry_id = p_journal_entry_id
AND l.account_number LIKE v_account_prefix || '%'
AND l.credit_amount > 0
ORDER BY l.credit_amount DESC, l.account_number ASC
LIMIT 1;
-- Same period resolution + openness check as match_batch_allocate: the
-- residual is dated on the payment voucher's entry_date so the FX result
-- lands in the period the settlement happened in.
SELECT fp.id, fp.is_closed, fp.locked_at
INTO v_fiscal_period_id, v_period_is_closed, v_period_locked_at
FROM public.fiscal_periods fp
WHERE fp.company_id = p_company_id
AND v_voucher.entry_date BETWEEN fp.period_start AND fp.period_end
ORDER BY fp.period_start DESC
LIMIT 1;
IF v_fiscal_period_id IS NULL THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_VOUCHER_CURRENCY_MISMATCH',
'details', jsonb_build_object(
'invoice_currency', v_invoice.currency,
'reason', 'fx_residual_no_fiscal_period',
'entry_date', v_voucher.entry_date
)
);
END IF;
IF v_period_is_closed OR v_period_locked_at IS NOT NULL THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_VOUCHER_CURRENCY_MISMATCH',
'details', jsonb_build_object(
'invoice_currency', v_invoice.currency,
'reason', 'fx_residual_period_locked',
'fiscal_period_id', v_fiscal_period_id
)
);
END IF;
v_inv_number_short := LEFT(COALESCE(v_invoice.invoice_number, ''), 32);
v_fx_entry_id := gen_random_uuid();
INSERT INTO public.journal_entries
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
entry_date, description, source_type, status)
VALUES
(v_fx_entry_id, v_acting_user, p_company_id, v_fiscal_period_id, 0, 'A',
v_voucher.entry_date, 'Valutakursdifferens faktura ' || v_inv_number_short,
'invoice_paid', 'draft');
IF v_fx_diff > 0 THEN
-- Settled below booked value: loss. Dr 7960 / Cr AR, the polarity
-- match_batch_allocate books for a customer allocation under booked_sek.
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_fx_entry_id, '7960', v_fx_diff, 0, 'SEK', 0,
'Valutakursförlust ' || v_inv_number_short),
(v_fx_entry_id, v_fx_account, 0, v_fx_diff, 'SEK', 1,
'Faktura ' || v_inv_number_short || ' (' || v_invoice_currency || ')');
ELSE
-- Settled above booked value: gain. Dr AR / Cr 3960.
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_fx_entry_id, v_fx_account, ABS(v_fx_diff), 0, 'SEK', 0,
'Faktura ' || v_inv_number_short || ' (' || v_invoice_currency || ')'),
(v_fx_entry_id, '3960', 0, ABS(v_fx_diff), 'SEK', 1,
'Valutakursvinst ' || v_inv_number_short);
END IF;
SELECT voucher_number INTO v_fx_voucher_number
FROM public.commit_journal_entry(p_company_id, v_fx_entry_id);
END IF;
-- 5. Compute the advance.
v_payment_amount := LEAST(v_ar_credit_total, ROUND(v_remaining * 100) / 100);
v_new_remaining := GREATEST(0,
ROUND((v_remaining - v_payment_amount) * 100) / 100
);
v_new_paid := ROUND((COALESCE(v_invoice.paid_amount, 0) + v_payment_amount) * 100) / 100;
v_is_fully_paid := v_new_remaining <= 0.005;
v_new_status := CASE WHEN v_is_fully_paid THEN 'paid' ELSE 'partially_paid' END;
-- 6. Apply both writes. The RPC body is one transaction; a failure on the
-- INSERT triggers PG's own rollback of the UPDATE: no manual rollback
-- path needed.
UPDATE public.invoices
SET status = v_new_status,
paid_at = CASE WHEN v_is_fully_paid THEN
((v_voucher.entry_date::timestamp + interval '12 hours') AT TIME ZONE 'UTC')
ELSE paid_at END,
paid_amount = v_new_paid,
remaining_amount = v_new_remaining,
updated_at = v_now
WHERE id = p_invoice_id;
-- The payment row persists the RESOLVED currency: writing the raw column
-- would store NULL for a legacy NULL-currency invoice, and the payment's
-- unit is a fact this row must state, not inherit as "unknown".
-- payment_exchange_rate carries the effective settlement rate on the FX
-- fallback (settled_sek / remaining) and stays NULL on every other path.
INSERT INTO public.invoice_payments (
user_id, company_id, invoice_id, payment_date, amount, currency,
exchange_rate, payment_exchange_rate, journal_entry_id, transaction_id, notes
) VALUES (
v_acting_user, p_company_id, p_invoice_id, v_voucher.entry_date,
v_payment_amount, v_invoice_currency, v_invoice.exchange_rate, v_payment_rate,
p_journal_entry_id, NULL, p_notes
)
RETURNING id INTO v_payment_id;
RETURN jsonb_build_object(
'ok', true,
'payment_id', v_payment_id,
'invoice_status', v_new_status,
'paid_amount', v_new_paid,
'remaining_amount', v_new_remaining,
'payment_amount', v_payment_amount,
'journal_entry_id', p_journal_entry_id,
'currency', v_invoice_currency,
'payment_date', v_voucher.entry_date,
'fx_settled_sek', CASE WHEN v_fx_settled THEN v_sek_side_total END,
'fx_residual_sek', CASE WHEN v_fx_settled THEN v_fx_diff END,
'fx_journal_entry_id', v_fx_entry_id,
'fx_voucher_number', v_fx_voucher_number
);
END;
$$;
CREATE OR REPLACE FUNCTION public.link_supplier_invoice_to_voucher(
p_supplier_invoice_id uuid,
p_journal_entry_id uuid,
p_user_id uuid,
p_company_id uuid,
p_notes text DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_invoice RECORD;
v_voucher RECORD;
v_ap_debit_total numeric := 0;
v_line_currency text;
v_remaining numeric;
v_payment_amount numeric;
v_new_paid numeric;
v_new_remaining numeric;
v_new_status text;
v_is_fully_paid boolean;
v_now timestamptz := now();
v_payment_id uuid;
v_jwt_role text := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', '');
v_acting_user uuid := p_user_id;
-- Unit resolution (20260726140000), as in link_invoice_to_voucher above.
v_invoice_currency text;
v_unreadable_count integer := 0;
v_unreadable_currency text;
-- FX residual settlement (new), mirroring link_invoice_to_voucher. The
-- supplier side has no kontantmetoden branch: the matched side is always
-- the 244x debit.
v_readable_count integer := 0;
v_sek_side_total numeric := 0;
v_foreign_label_count integer := 0;
v_booked_sek numeric;
v_fx_diff numeric := 0;
v_fx_settled boolean := false;
v_payment_rate numeric; -- round-6 effective rate (traceability)
v_fx_account text;
v_fx_entry_id uuid;
v_fx_voucher_number int;
v_fiscal_period_id uuid;
v_period_is_closed boolean;
v_period_locked_at timestamptz;
v_inv_number_short text;
BEGIN
-- Tenant guard (mirrors 20260611140000): anon/authenticated may only act on
-- their own companies; service_role / direct access bypasses. NULL-safe
-- caller_is_company_member() form, as in link_invoice_to_voucher above.
IF v_jwt_role IN ('anon', 'authenticated') THEN
IF NOT public.caller_is_company_member(p_company_id) THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_INVOICE_NOT_FOUND');
END IF;
-- Attribution: the JWT sub is authoritative for user-session callers:
-- p_user_id cannot point the payment row at someone else.
v_acting_user := coalesce(
(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub')::uuid,
p_user_id
);
END IF;
IF p_notes IS NOT NULL AND char_length(p_notes) > 2000 THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'LINK_SI_VOUCHER_NOTES_TOO_LONG',
'details', jsonb_build_object('max_length', 2000, 'length', char_length(p_notes))
);
END IF;
SELECT * INTO v_invoice
FROM public.supplier_invoices
WHERE id = p_supplier_invoice_id AND company_id = p_company_id
FOR UPDATE;
IF NOT FOUND THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_INVOICE_NOT_FOUND');
END IF;
IF v_invoice.status NOT IN ('registered', 'approved', 'overdue', 'partially_paid') THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_INVOICE_FULLY_PAID',
'details', jsonb_build_object('status', v_invoice.status));
END IF;
v_remaining := COALESCE(v_invoice.remaining_amount, v_invoice.total - COALESCE(v_invoice.paid_amount, 0));
IF v_remaining <= 0.005 THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_INVOICE_FULLY_PAID');
END IF;
SELECT * INTO v_voucher
FROM public.journal_entries
WHERE id = p_journal_entry_id AND company_id = p_company_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_VOUCHER_NOT_FOUND');
END IF;
IF v_voucher.status <> 'posted' THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_NOT_POSTED',
'details', jsonb_build_object('status', v_voucher.status));
END IF;
IF v_voucher.source_type IN ('opening_balance', 'storno') THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_NO_AP_DEBIT',
'details', jsonb_build_object('source_type', v_voucher.source_type));
END IF;
-- Sum the AP debit across the full 244x range, EXPRESSED IN THE INVOICE'S
-- CURRENCY. `supplier_invoices.currency` is NOT NULL DEFAULT 'SEK', but the
-- COALESCE keeps this symmetric with the customer side.
v_invoice_currency := COALESCE(v_invoice.currency, 'SEK');
IF v_invoice_currency = 'SEK' THEN
-- VERBATIM from 20260615120000: the ledger column is kronor already.
SELECT COALESCE(SUM(debit_amount), 0), MAX(currency)
INTO v_ap_debit_total, v_line_currency
FROM public.journal_entry_lines
WHERE journal_entry_id = p_journal_entry_id
AND account_number LIKE '244%'
AND debit_amount > 0;
ELSE
-- Foreign supplier invoice. The three extra aggregates feed the FX
-- residual fallback, as in link_invoice_to_voucher above; the readable
-- gate counts LINES for the same reason (a readable line with
-- amount_in_currency = 0 must disable the fallback).
SELECT
COALESCE(SUM(ABS(l.amount_in_currency)) FILTER (
WHERE l.currency = v_invoice_currency AND l.amount_in_currency IS NOT NULL
), 0),
MAX(l.currency) FILTER (
WHERE l.currency = v_invoice_currency AND l.amount_in_currency IS NOT NULL
),
COUNT(*) FILTER (
WHERE l.currency IS DISTINCT FROM v_invoice_currency OR l.amount_in_currency IS NULL
),
MIN(l.currency) FILTER (
WHERE l.currency IS DISTINCT FROM v_invoice_currency OR l.amount_in_currency IS NULL
),
COUNT(*) FILTER (
WHERE l.currency = v_invoice_currency AND l.amount_in_currency IS NOT NULL
),
COALESCE(SUM(l.debit_amount) FILTER (
WHERE COALESCE(l.currency, 'SEK') = 'SEK'
), 0),
COUNT(*) FILTER (
WHERE (l.currency IS DISTINCT FROM v_invoice_currency OR l.amount_in_currency IS NULL)
AND COALESCE(l.currency, 'SEK') <> 'SEK'
)
INTO v_ap_debit_total, v_line_currency, v_unreadable_count, v_unreadable_currency,
v_readable_count, v_sek_side_total, v_foreign_label_count
FROM public.journal_entry_lines l
WHERE l.journal_entry_id = p_journal_entry_id
AND l.account_number LIKE '244%'
AND l.debit_amount > 0;
IF COALESCE(v_unreadable_count, 0) > 0 THEN
-- FX residual fallback (new): see link_invoice_to_voucher above.
IF COALESCE(v_readable_count, 0) = 0
AND COALESCE(v_foreign_label_count, 0) = 0
AND v_sek_side_total > 0
AND v_invoice.exchange_rate IS NOT NULL
AND v_invoice.exchange_rate > 0
AND v_invoice.exchange_rate < 100000
THEN
v_sek_side_total := ROUND(v_sek_side_total * 100) / 100;
v_booked_sek := ROUND(v_remaining * v_invoice.exchange_rate * 100) / 100;
IF ABS(v_sek_side_total - v_booked_sek) > v_booked_sek * 0.10 THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_CURRENCY_MISMATCH',
'details', jsonb_build_object(
'invoice_currency', v_invoice.currency,
'line_currency', v_unreadable_currency,
'reason', 'fx_deviation_too_large',
'expected_sek', v_booked_sek,
'voucher_sek', v_sek_side_total
));
END IF;
v_fx_settled := true;
v_fx_diff := ROUND((v_booked_sek - v_sek_side_total) * 100) / 100;
v_ap_debit_total := ROUND(v_remaining * 100) / 100;
v_payment_rate := ROUND((v_sek_side_total / v_remaining) * 1000000) / 1000000;
ELSE
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_CURRENCY_MISMATCH',
'details', jsonb_build_object(
'invoice_currency', v_invoice.currency,
'line_currency', v_unreadable_currency
));
END IF;
END IF;
END IF;
v_ap_debit_total := ROUND(v_ap_debit_total * 100) / 100;
IF v_ap_debit_total <= 0 THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_NO_AP_DEBIT');
END IF;
-- Label guard: a counterparty discriminator, not a unit check. Compares the
-- RESOLVED currency on both sides, as in link_invoice_to_voucher above.
IF COALESCE(v_line_currency, v_invoice_currency) IS DISTINCT FROM v_invoice_currency THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_CURRENCY_MISMATCH',
'details', jsonb_build_object('invoice_currency', v_invoice.currency, 'line_currency', v_line_currency));
END IF;
-- Both sides are now in the invoice's currency.
IF v_ap_debit_total > v_remaining + 0.005 THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_AMOUNT_EXCEEDS_REMAINING',
'details', jsonb_build_object('ap_debit', v_ap_debit_total, 'remaining', ROUND(v_remaining * 100) / 100));
END IF;
IF EXISTS (
SELECT 1 FROM public.supplier_invoice_payments
WHERE company_id = p_company_id
AND supplier_invoice_id = p_supplier_invoice_id
AND journal_entry_id = p_journal_entry_id
) THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_ALREADY_LINKED');
END IF;
-- Book the FX residual as its OWN verifikat, after every guard: see
-- link_invoice_to_voucher above. Supplier polarity per match_batch_allocate:
-- paid less SEK than booked = gain (Cr 3960), paid more = loss (Dr 7960).
IF v_fx_settled AND ABS(v_fx_diff) > 0.005 THEN
SELECT l.account_number INTO v_fx_account
FROM public.journal_entry_lines l
WHERE l.journal_entry_id = p_journal_entry_id
AND l.account_number LIKE '244%'
AND l.debit_amount > 0
ORDER BY l.debit_amount DESC, l.account_number ASC
LIMIT 1;
SELECT fp.id, fp.is_closed, fp.locked_at
INTO v_fiscal_period_id, v_period_is_closed, v_period_locked_at
FROM public.fiscal_periods fp
WHERE fp.company_id = p_company_id
AND v_voucher.entry_date BETWEEN fp.period_start AND fp.period_end
ORDER BY fp.period_start DESC
LIMIT 1;
IF v_fiscal_period_id IS NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_CURRENCY_MISMATCH',
'details', jsonb_build_object(
'invoice_currency', v_invoice.currency,
'reason', 'fx_residual_no_fiscal_period',
'entry_date', v_voucher.entry_date
));
END IF;
IF v_period_is_closed OR v_period_locked_at IS NOT NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_CURRENCY_MISMATCH',
'details', jsonb_build_object(
'invoice_currency', v_invoice.currency,
'reason', 'fx_residual_period_locked',
'fiscal_period_id', v_fiscal_period_id
));
END IF;
v_inv_number_short := LEFT(COALESCE(v_invoice.supplier_invoice_number, ''), 32);
v_fx_entry_id := gen_random_uuid();
INSERT INTO public.journal_entries
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
entry_date, description, source_type, status)
VALUES
(v_fx_entry_id, v_acting_user, p_company_id, v_fiscal_period_id, 0, 'A',
v_voucher.entry_date, 'Valutakursdifferens leverantörsfaktura ' || v_inv_number_short,
'supplier_invoice_paid', 'draft');
IF v_fx_diff > 0 THEN
-- Paid less than the booked liability: gain. Dr 244x / Cr 3960.
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_fx_entry_id, v_fx_account, v_fx_diff, 0, 'SEK', 0,
'Leverantörsfaktura ' || v_inv_number_short || ' (' || v_invoice_currency || ')'),
(v_fx_entry_id, '3960', 0, v_fx_diff, 'SEK', 1,
'Valutakursvinst ' || v_inv_number_short);
ELSE
-- Paid more than the booked liability: loss. Dr 7960 / Cr 244x.
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_fx_entry_id, '7960', ABS(v_fx_diff), 0, 'SEK', 0,
'Valutakursförlust ' || v_inv_number_short),
(v_fx_entry_id, v_fx_account, 0, ABS(v_fx_diff), 'SEK', 1,
'Leverantörsfaktura ' || v_inv_number_short || ' (' || v_invoice_currency || ')');
END IF;
SELECT voucher_number INTO v_fx_voucher_number
FROM public.commit_journal_entry(p_company_id, v_fx_entry_id);
END IF;
v_payment_amount := LEAST(v_ap_debit_total, ROUND(v_remaining * 100) / 100);
v_new_remaining := GREATEST(0, ROUND((v_remaining - v_payment_amount) * 100) / 100);
v_new_paid := ROUND((COALESCE(v_invoice.paid_amount, 0) + v_payment_amount) * 100) / 100;
v_is_fully_paid := v_new_remaining <= 0.005;
v_new_status := CASE WHEN v_is_fully_paid THEN 'paid' ELSE 'partially_paid' END;
UPDATE public.supplier_invoices
SET status = v_new_status,
paid_at = CASE WHEN v_is_fully_paid THEN
((v_voucher.entry_date::timestamp + interval '12 hours') AT TIME ZONE 'UTC')
ELSE paid_at END,
paid_amount = v_new_paid,
remaining_amount = v_new_remaining,
updated_at = v_now
WHERE id = p_supplier_invoice_id;
-- payment_exchange_rate: effective settlement rate on the FX fallback
-- (settled_sek / remaining), NULL on every other path.
INSERT INTO public.supplier_invoice_payments (
user_id, company_id, supplier_invoice_id, payment_date, amount, currency,
payment_exchange_rate, journal_entry_id, transaction_id, notes
) VALUES (
v_acting_user, p_company_id, p_supplier_invoice_id, v_voucher.entry_date,
v_payment_amount, v_invoice_currency, v_payment_rate, p_journal_entry_id, NULL, p_notes
)
RETURNING id INTO v_payment_id;
RETURN jsonb_build_object(
'ok', true,
'payment_id', v_payment_id,
'invoice_status', v_new_status,
'paid_amount', v_new_paid,
'remaining_amount', v_new_remaining,
'payment_amount', v_payment_amount,
'journal_entry_id', p_journal_entry_id,
'currency', v_invoice_currency,
'fx_settled_sek', CASE WHEN v_fx_settled THEN v_sek_side_total END,
'fx_residual_sek', CASE WHEN v_fx_settled THEN v_fx_diff END,
'fx_journal_entry_id', v_fx_entry_id,
'fx_voucher_number', v_fx_voucher_number
);
END;
$$;
-- Grants are unchanged and restated because CREATE OR REPLACE does not alter
-- them: `authenticated` covers user-session clients, `service_role` the
-- MCP / API-key paths.
REVOKE ALL ON FUNCTION public.link_invoice_to_voucher(uuid, uuid, uuid, uuid, text) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.link_invoice_to_voucher(uuid, uuid, uuid, uuid, text) TO authenticated, service_role;
REVOKE ALL ON FUNCTION public.link_supplier_invoice_to_voucher(uuid, uuid, uuid, uuid, text) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.link_supplier_invoice_to_voucher(uuid, uuid, uuid, uuid, text) TO authenticated, service_role;
COMMENT ON FUNCTION public.link_invoice_to_voucher(uuid, uuid, uuid, uuid, text) IS
'Link a posted verifikat to a customer invoice as its payment. Accounting-method aware (151x credit on faktureringsmetoden, 19xx debit on kontantmetoden). The matched amount is resolved in the INVOICE''S currency: the raw ledger column on a SEK invoice, ABS(amount_in_currency) on a foreign one. A foreign invoice settled by a plain-SEK voucher (accrual only, within 10% of remaining * exchange_rate) is treated as fully settled and the FX residual is booked as its own verifikat to 7960 (loss) / 3960 (gain); every other unreadable case is refused.';
COMMENT ON FUNCTION public.link_supplier_invoice_to_voucher(uuid, uuid, uuid, uuid, text) IS
'Link a posted verifikat to a supplier invoice as its payment, summing the 244x debit in the INVOICE''S currency: the raw ledger column on a SEK invoice, ABS(amount_in_currency) on a foreign one. A foreign invoice paid by a plain-SEK voucher (within 10% of remaining * exchange_rate) is treated as fully settled and the FX residual is booked as its own verifikat to 3960 (gain) / 7960 (loss); every other unreadable case is refused.';
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,935 @@
/**
* pg-real tests for migration
* 20260830140000_link_voucher_rpcs_fx_residual_settlement.sql: a foreign
* invoice whose receivable/payable was booked in plain SEK can be settled by
* a SEK payment voucher, with the FX residual booked to 7960/3960 as its own
* balanced verifikat (the linked voucher is posted and immutable), mirroring
* match_batch_allocate's cross-currency sign conventions.
*
* Harness note: every test runs inside ONE transaction on ONE client. The
* migration file is executed first (CREATE OR REPLACE FUNCTION is
* transactional in Postgres), then the fixtures are seeded, the RPC is
* called, the assertions read from the same client, and everything is rolled
* back. This lets the suite run against a database that has NOT had the
* migration applied yet (the migration ships as a file only), while still
* exercising the real triggers, commit_journal_entry and voucher numbering.
* In CI the migration is already replayed, and re-executing it in the
* transaction is a no-op.
*/
import { describe, it, expect } from 'vitest'
import { randomUUID } from 'node:crypto'
import { readFileSync } from 'node:fs'
import path from 'node:path'
import type { PoolClient } from 'pg'
import { getClient } from '@/tests/pg/setup'
const MIGRATION_SQL = readFileSync(
path.join(
process.cwd(),
'supabase/migrations/20260830140000_link_voucher_rpcs_fx_residual_settlement.sql',
),
'utf8',
)
let seq = 0
function nextSeq(): number {
return (Date.now() % 1_000_000) * 1000 + seq++
}
/** Run `fn` in a transaction that first applies the migration under test and
* always rolls back: nothing persists, in the DB functions included. */
async function withFxMigration(fn: (client: PoolClient) => Promise<void>): Promise<void> {
const client = await getClient()
try {
await client.query('BEGIN')
await client.query(MIGRATION_SQL)
await fn(client)
} finally {
await client.query('ROLLBACK').catch(() => {})
client.release()
}
}
async function seedTenant(client: PoolClient): Promise<{
userId: string
companyId: string
fiscalPeriodId: string
}> {
const userId = randomUUID()
await client.query(
`INSERT INTO auth.users (id, email, instance_id)
VALUES ($1, $2, '00000000-0000-0000-0000-000000000000'::uuid)`,
[userId, `pg-real-${userId}@test.invalid`],
)
const companyId = randomUUID()
await client.query(
`INSERT INTO public.companies (id, name, entity_type, created_by)
VALUES ($1, 'FX Test AB', 'aktiebolag', $2)`,
[companyId, userId],
)
await client.query(
`INSERT INTO public.company_members (company_id, user_id, role)
VALUES ($1, $2, 'owner')`,
[companyId, userId],
)
const fiscalPeriodId = randomUUID()
await client.query(
`INSERT INTO public.fiscal_periods
(id, user_id, company_id, name, period_start, period_end, is_closed)
VALUES ($1, $2, $3, '2026', '2026-01-01', '2026-12-31', false)`,
[fiscalPeriodId, userId, companyId],
)
return { userId, companyId, fiscalPeriodId }
}
async function seedCustomerInvoice(
client: PoolClient,
params: {
userId: string
companyId: string
currency: string
total: number
totalSek?: number | null
exchangeRate?: number | null
},
): Promise<{ invoiceId: string; invoiceNumber: string }> {
const customerId = randomUUID()
await client.query(
`INSERT INTO public.customers (id, user_id, company_id, name, customer_type)
VALUES ($1, $2, $3, 'Utländsk Kund AB', 'swedish_business')`,
[customerId, params.userId, params.companyId],
)
const invoiceId = randomUUID()
const invoiceNumber = `F-${nextSeq()}`
await client.query(
`INSERT INTO public.invoices
(id, user_id, company_id, customer_id, invoice_number, invoice_date, due_date,
currency, exchange_rate, subtotal, vat_amount, total, total_sek,
vat_treatment, vat_rate, status, paid_amount, remaining_amount)
VALUES ($1, $2, $3, $4, $5, '2026-04-01', '2026-05-01',
$6, $7, $8, 0, $8, $9, 'standard_25', 25, 'sent', 0, $8)`,
[
invoiceId,
params.userId,
params.companyId,
customerId,
invoiceNumber,
params.currency,
params.exchangeRate ?? null,
params.total,
params.totalSek ?? null,
],
)
return { invoiceId, invoiceNumber }
}
async function seedSupplierInvoice(
client: PoolClient,
params: {
userId: string
companyId: string
currency: string
total: number
exchangeRate?: number | null
},
): Promise<string> {
const supplierId = randomUUID()
await client.query(
`INSERT INTO public.suppliers
(id, user_id, company_id, name, supplier_type, country, default_payment_terms, default_currency)
VALUES ($1, $2, $3, 'Utlandsleverantör AB', 'swedish_business', 'SE', 30, $4)`,
[supplierId, params.userId, params.companyId, params.currency],
)
const id = randomUUID()
const arrivalNumber = nextSeq()
await client.query(
`INSERT INTO public.supplier_invoices
(id, user_id, company_id, supplier_id, arrival_number, supplier_invoice_number,
invoice_date, due_date, received_date, status, currency, exchange_rate,
subtotal, vat_amount, total, paid_amount, remaining_amount,
vat_treatment, reverse_charge, is_credit_note)
VALUES ($1, $2, $3, $4, $5, $6, '2026-04-01', '2026-05-01', '2026-04-01', 'approved', $7, $8,
$9, 0, $9, 0, $9, 'standard_25', false, false)`,
[
id,
params.userId,
params.companyId,
supplierId,
arrivalNumber,
`LF-${arrivalNumber}`,
params.currency,
params.exchangeRate ?? null,
params.total,
],
)
return id
}
/** A posted, balanced two-line voucher. `sekAmount` goes in the debit/credit
* columns (the ledger is always kronor); `lineCurrency` + `amountInCurrency`
* are the document metadata. Defaults model the plain-SEK voucher this
* migration unblocks. */
async function seedVoucher(
client: PoolClient,
params: {
userId: string
companyId: string
fiscalPeriodId: string
debitAccount: string
creditAccount: string
sekAmount: number
lineCurrency?: string | null
amountInCurrency?: number | null
entryDate?: string
},
): Promise<string> {
const id = randomUUID()
// A prior seed's SET CONSTRAINTS ... IMMEDIATE persists for the rest of the
// transaction; re-defer so the entry-before-lines insert below is legal.
await client.query('SET CONSTRAINTS check_balance_on_posted_insert DEFERRED')
await client.query(
`INSERT INTO public.journal_entries
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
entry_date, description, source_type, status)
VALUES ($1, $2, $3, $4, $5, 'A', $6, 'Betalning', 'manual', 'posted')`,
[
id,
params.userId,
params.companyId,
params.fiscalPeriodId,
nextSeq() % 2_000_000_000,
params.entryDate ?? '2026-05-05',
],
)
await client.query(
`INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency, amount_in_currency)
VALUES ($1, $2, $3, 0, $5, $6),
($1, $4, 0, $3, $5, $6)`,
[
id,
params.debitAccount,
params.sekAmount,
params.creditAccount,
params.lineCurrency ?? 'SEK',
params.amountInCurrency ?? null,
],
)
// Force the deferred posted-insert balance check to run now: the harness
// rolls back instead of committing, so without this the seed would never be
// validated at all.
await client.query('SET CONSTRAINTS check_balance_on_posted_insert IMMEDIATE')
return id
}
/** A posted voucher with fully custom lines, for mixed-line scenarios the
* two-line helper above cannot express. Lines must balance. */
async function seedVoucherWithLines(
client: PoolClient,
params: {
userId: string
companyId: string
fiscalPeriodId: string
lines: {
account: string
debit: number
credit: number
currency?: string | null
amountInCurrency?: number | null
}[]
},
): Promise<string> {
const id = randomUUID()
await client.query('SET CONSTRAINTS check_balance_on_posted_insert DEFERRED')
await client.query(
`INSERT INTO public.journal_entries
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
entry_date, description, source_type, status)
VALUES ($1, $2, $3, $4, $5, 'A', '2026-05-05', 'Betalning', 'manual', 'posted')`,
[id, params.userId, params.companyId, params.fiscalPeriodId, nextSeq() % 2_000_000_000],
)
for (const [i, line] of params.lines.entries()) {
await client.query(
`INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
amount_in_currency, sort_order)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
id,
line.account,
line.debit,
line.credit,
line.currency ?? 'SEK',
line.amountInCurrency ?? null,
i,
],
)
}
await client.query('SET CONSTRAINTS check_balance_on_posted_insert IMMEDIATE')
return id
}
type RpcResult = {
ok: boolean
code?: string
invoice_status?: string
paid_amount?: number
remaining_amount?: number
payment_amount?: number
fx_settled_sek?: number | null
fx_residual_sek?: number | null
fx_journal_entry_id?: string | null
fx_voucher_number?: number | null
details?: Record<string, unknown>
}
async function callLinkInvoice(
client: PoolClient,
args: { invoiceId: string; voucherId: string; userId: string; companyId: string },
): Promise<RpcResult> {
const { rows } = await client.query<{ result: RpcResult }>(
`SELECT public.link_invoice_to_voucher($1, $2, $3, $4, NULL) AS result`,
[args.invoiceId, args.voucherId, args.userId, args.companyId],
)
return rows[0].result
}
async function callLinkSupplierInvoice(
client: PoolClient,
args: { supplierInvoiceId: string; voucherId: string; userId: string; companyId: string },
): Promise<RpcResult> {
const { rows } = await client.query<{ result: RpcResult }>(
`SELECT public.link_supplier_invoice_to_voucher($1, $2, $3, $4, NULL) AS result`,
[args.supplierInvoiceId, args.voucherId, args.userId, args.companyId],
)
return rows[0].result
}
/** Assert the FX residual verifikat exists, is posted with a real voucher
* number, balances exactly, and carries the expected two lines. */
async function assertFxEntry(
client: PoolClient,
fxEntryId: string,
expected: { account: string; debit: number; credit: number }[],
): Promise<void> {
const { rows: entryRows } = await client.query<{
status: string
voucher_number: number
}>(
`SELECT status, voucher_number FROM public.journal_entries WHERE id = $1`,
[fxEntryId],
)
expect(entryRows).toHaveLength(1)
expect(entryRows[0].status).toBe('posted')
expect(Number(entryRows[0].voucher_number)).toBeGreaterThan(0)
const { rows: lines } = await client.query<{
account_number: string
debit_amount: string
credit_amount: string
}>(
`SELECT account_number, debit_amount, credit_amount
FROM public.journal_entry_lines WHERE journal_entry_id = $1
ORDER BY sort_order`,
[fxEntryId],
)
expect(lines).toHaveLength(expected.length)
for (const [i, exp] of expected.entries()) {
expect(lines[i].account_number).toBe(exp.account)
expect(Number(lines[i].debit_amount)).toBe(exp.debit)
expect(Number(lines[i].credit_amount)).toBe(exp.credit)
}
const debitTotal = lines.reduce((s, l) => s + Number(l.debit_amount), 0)
const creditTotal = lines.reduce((s, l) => s + Number(l.credit_amount), 0)
expect(debitTotal).toBe(creditTotal)
expect(debitTotal).toBeGreaterThan(0)
}
// ============================================================
// link_invoice_to_voucher: FX residual settlement
// ============================================================
describe('link_invoice_to_voucher: SEK-booked voucher settles a foreign invoice', () => {
it('books the shortfall to 7960 (loss) and marks the invoice paid', async () => {
await withFxMigration(async (client) => {
const { userId, companyId, fiscalPeriodId } = await seedTenant(client)
// 1000 EUR booked at 11.50: receivable carries 11 500 kr.
const { invoiceId } = await seedCustomerInvoice(client, {
userId,
companyId,
currency: 'EUR',
total: 1000,
totalSek: 11500,
exchangeRate: 11.5,
})
// The bank received 11 200 kr, booked plain SEK with no FX metadata.
const voucherId = await seedVoucher(client, {
userId,
companyId,
fiscalPeriodId,
debitAccount: '1930',
creditAccount: '1510',
sekAmount: 11200,
})
const result = await callLinkInvoice(client, { invoiceId, voucherId, userId, companyId })
// Before the migration this was LINK_VOUCHER_CURRENCY_MISMATCH.
expect(result).toMatchObject({ ok: true, invoice_status: 'paid' })
expect(Number(result.payment_amount)).toBe(1000)
expect(Number(result.remaining_amount)).toBe(0)
expect(Number(result.fx_settled_sek)).toBe(11200)
expect(Number(result.fx_residual_sek)).toBe(300)
expect(result.fx_journal_entry_id).toBeTruthy()
// Residual verifikat: Dr 7960 300 / Cr 1510 300, balanced and posted.
await assertFxEntry(client, result.fx_journal_entry_id as string, [
{ account: '7960', debit: 300, credit: 0 },
{ account: '1510', debit: 0, credit: 300 },
])
// Payment row: full remaining in EUR, effective rate 11.2.
const { rows: payments } = await client.query(
`SELECT amount, currency, payment_exchange_rate FROM public.invoice_payments
WHERE invoice_id = $1 AND journal_entry_id = $2`,
[invoiceId, voucherId],
)
expect(payments).toHaveLength(1)
expect(Number(payments[0].amount)).toBe(1000)
expect(payments[0].currency).toBe('EUR')
expect(Number(payments[0].payment_exchange_rate)).toBe(11.2)
})
})
it('books the excess to 3960 (gain)', async () => {
await withFxMigration(async (client) => {
const { userId, companyId, fiscalPeriodId } = await seedTenant(client)
const { invoiceId } = await seedCustomerInvoice(client, {
userId,
companyId,
currency: 'EUR',
total: 1000,
totalSek: 11500,
exchangeRate: 11.5,
})
// The bank received 11 800 kr: 300 kr above the booked receivable.
const voucherId = await seedVoucher(client, {
userId,
companyId,
fiscalPeriodId,
debitAccount: '1930',
creditAccount: '1510',
sekAmount: 11800,
})
const result = await callLinkInvoice(client, { invoiceId, voucherId, userId, companyId })
expect(result).toMatchObject({ ok: true, invoice_status: 'paid' })
expect(Number(result.fx_residual_sek)).toBe(-300)
await assertFxEntry(client, result.fx_journal_entry_id as string, [
{ account: '1510', debit: 300, credit: 0 },
{ account: '3960', debit: 0, credit: 300 },
])
})
})
it('books no residual verifikat when the SEK amounts agree exactly', async () => {
await withFxMigration(async (client) => {
const { userId, companyId, fiscalPeriodId } = await seedTenant(client)
const { invoiceId } = await seedCustomerInvoice(client, {
userId,
companyId,
currency: 'EUR',
total: 1000,
totalSek: 11500,
exchangeRate: 11.5,
})
const voucherId = await seedVoucher(client, {
userId,
companyId,
fiscalPeriodId,
debitAccount: '1930',
creditAccount: '1510',
sekAmount: 11500,
})
const result = await callLinkInvoice(client, { invoiceId, voucherId, userId, companyId })
expect(result).toMatchObject({ ok: true, invoice_status: 'paid' })
expect(Number(result.payment_amount)).toBe(1000)
expect(Number(result.fx_residual_sek)).toBe(0)
expect(result.fx_journal_entry_id).toBeNull()
const { rows } = await client.query(
`SELECT COUNT(*)::int AS n FROM public.journal_entries
WHERE company_id = $1 AND id <> $2`,
[companyId, voucherId],
)
expect(rows[0].n).toBe(0)
})
})
it('still refuses a SEK voucher far off the booked value (wrong voucher, not FX)', async () => {
await withFxMigration(async (client) => {
const { userId, companyId, fiscalPeriodId } = await seedTenant(client)
const { invoiceId } = await seedCustomerInvoice(client, {
userId,
companyId,
currency: 'EUR',
total: 1000,
totalSek: 11500,
exchangeRate: 11.5,
})
// Same magnitude, wrong unit: 1 000 kr is not 1 000 EUR. 10% band trips.
const voucherId = await seedVoucher(client, {
userId,
companyId,
fiscalPeriodId,
debitAccount: '1930',
creditAccount: '1510',
sekAmount: 1000,
})
const result = await callLinkInvoice(client, { invoiceId, voucherId, userId, companyId })
expect(result.ok).toBe(false)
expect(result.code).toBe('LINK_VOUCHER_CURRENCY_MISMATCH')
expect(result.details?.reason).toBe('fx_deviation_too_large')
const { rows } = await client.query(
`SELECT status, paid_amount, remaining_amount FROM public.invoices WHERE id = $1`,
[invoiceId],
)
expect(rows[0].status).toBe('sent')
expect(Number(rows[0].paid_amount)).toBe(0)
expect(Number(rows[0].remaining_amount)).toBe(1000)
})
})
it('still refuses when the invoice has no usable exchange rate', async () => {
await withFxMigration(async (client) => {
const { userId, companyId, fiscalPeriodId } = await seedTenant(client)
const { invoiceId } = await seedCustomerInvoice(client, {
userId,
companyId,
currency: 'EUR',
total: 1000,
totalSek: null,
exchangeRate: null,
})
const voucherId = await seedVoucher(client, {
userId,
companyId,
fiscalPeriodId,
debitAccount: '1930',
creditAccount: '1510',
sekAmount: 11500,
})
const result = await callLinkInvoice(client, { invoiceId, voucherId, userId, companyId })
expect(result.ok).toBe(false)
expect(result.code).toBe('LINK_VOUCHER_CURRENCY_MISMATCH')
})
})
it('still refuses an invoice-labelled line that carries no foreign figure', async () => {
await withFxMigration(async (client) => {
const { userId, companyId, fiscalPeriodId } = await seedTenant(client)
const { invoiceId } = await seedCustomerInvoice(client, {
userId,
companyId,
currency: 'EUR',
total: 1000,
totalSek: 11500,
exchangeRate: 11.5,
})
// Labelled EUR but amount_in_currency NULL: malformed metadata, not a
// SEK-booked line. The pre-migration refusal must survive.
const voucherId = await seedVoucher(client, {
userId,
companyId,
fiscalPeriodId,
debitAccount: '1930',
creditAccount: '1510',
sekAmount: 11500,
lineCurrency: 'EUR',
amountInCurrency: null,
})
const result = await callLinkInvoice(client, { invoiceId, voucherId, userId, companyId })
expect(result.ok).toBe(false)
expect(result.code).toBe('LINK_VOUCHER_CURRENCY_MISMATCH')
})
})
it('refuses a mixed voucher whose readable line carries amount_in_currency 0', async () => {
await withFxMigration(async (client) => {
// Skeptic counterexample: a 1510 credit labelled EUR with
// amount_in_currency = 0 sums to 0 on the readable side, but its 50 kr
// ledger credit is real and excluded from the SEK sum. A sum-based gate
// engaged the fallback with settled_sek = 950 against a voucher that
// credits 1510 by 1 000 kr, over-crediting AR by 50 kr and booking a
// phantom 7960 loss. The line-count gate must refuse instead.
const { userId, companyId, fiscalPeriodId } = await seedTenant(client)
const { invoiceId } = await seedCustomerInvoice(client, {
userId,
companyId,
currency: 'EUR',
total: 100,
totalSek: 1000,
exchangeRate: 10,
})
const voucherId = await seedVoucherWithLines(client, {
userId,
companyId,
fiscalPeriodId,
lines: [
{ account: '1930', debit: 1000, credit: 0 },
{ account: '1510', debit: 0, credit: 950, currency: null },
{ account: '1510', debit: 0, credit: 50, currency: 'EUR', amountInCurrency: 0 },
],
})
const result = await callLinkInvoice(client, { invoiceId, voucherId, userId, companyId })
expect(result.ok).toBe(false)
expect(result.code).toBe('LINK_VOUCHER_CURRENCY_MISMATCH')
const { rows } = await client.query(
`SELECT status, paid_amount, remaining_amount FROM public.invoices WHERE id = $1`,
[invoiceId],
)
expect(rows[0].status).toBe('sent')
expect(Number(rows[0].paid_amount)).toBe(0)
expect(Number(rows[0].remaining_amount)).toBe(100)
// No residual verifikat may exist: the voucher is the only entry.
const { rows: entries } = await client.query(
`SELECT COUNT(*)::int AS n FROM public.journal_entries
WHERE company_id = $1 AND id <> $2`,
[companyId, voucherId],
)
expect(entries[0].n).toBe(0)
})
})
it('refuses the SEK fallback on kontantmetoden (no receivable to true up)', async () => {
await withFxMigration(async (client) => {
const { userId, companyId, fiscalPeriodId } = await seedTenant(client)
await client.query(
`INSERT INTO public.company_settings (user_id, company_id, accounting_method)
VALUES ($1, $2, 'cash')`,
[userId, companyId],
)
const { invoiceId } = await seedCustomerInvoice(client, {
userId,
companyId,
currency: 'EUR',
total: 1000,
totalSek: 11500,
exchangeRate: 11.5,
})
// Cash method matches the 19xx debit.
const voucherId = await seedVoucher(client, {
userId,
companyId,
fiscalPeriodId,
debitAccount: '1930',
creditAccount: '3001',
sekAmount: 11500,
})
const result = await callLinkInvoice(client, { invoiceId, voucherId, userId, companyId })
expect(result.ok).toBe(false)
expect(result.code).toBe('LINK_VOUCHER_CURRENCY_MISMATCH')
})
})
it('refuses when the residual would land in a locked period, without writing anything', async () => {
await withFxMigration(async (client) => {
const { userId, companyId, fiscalPeriodId } = await seedTenant(client)
const { invoiceId } = await seedCustomerInvoice(client, {
userId,
companyId,
currency: 'EUR',
total: 1000,
totalSek: 11500,
exchangeRate: 11.5,
})
const voucherId = await seedVoucher(client, {
userId,
companyId,
fiscalPeriodId,
debitAccount: '1930',
creditAccount: '1510',
sekAmount: 11200,
})
// Lock the period AFTER the voucher exists: the residual verifikat may
// not be booked there, so the whole link must be refused.
await client.query(`UPDATE public.fiscal_periods SET locked_at = now() WHERE id = $1`, [
fiscalPeriodId,
])
const result = await callLinkInvoice(client, { invoiceId, voucherId, userId, companyId })
expect(result.ok).toBe(false)
expect(result.code).toBe('LINK_VOUCHER_CURRENCY_MISMATCH')
expect(result.details?.reason).toBe('fx_residual_period_locked')
const { rows } = await client.query(
`SELECT status, paid_amount FROM public.invoices WHERE id = $1`,
[invoiceId],
)
expect(rows[0].status).toBe('sent')
expect(Number(rows[0].paid_amount)).toBe(0)
})
})
it('regression: a voucher readable in the invoice currency links exactly as before', async () => {
await withFxMigration(async (client) => {
const { userId, companyId, fiscalPeriodId } = await seedTenant(client)
const { invoiceId } = await seedCustomerInvoice(client, {
userId,
companyId,
currency: 'EUR',
total: 1000,
totalSek: 11500,
exchangeRate: 11.5,
})
const voucherId = await seedVoucher(client, {
userId,
companyId,
fiscalPeriodId,
debitAccount: '1930',
creditAccount: '1510',
sekAmount: 11500,
lineCurrency: 'EUR',
amountInCurrency: 1000,
})
const result = await callLinkInvoice(client, { invoiceId, voucherId, userId, companyId })
expect(result).toMatchObject({ ok: true, invoice_status: 'paid' })
expect(Number(result.payment_amount)).toBe(1000)
// The readable path never engages the fallback: no residual verifikat.
expect(result.fx_journal_entry_id).toBeNull()
})
})
})
// ============================================================
// link_supplier_invoice_to_voucher: FX residual settlement
// ============================================================
describe('link_supplier_invoice_to_voucher: SEK-booked voucher settles a foreign invoice', () => {
it('books the underpayment to 3960 (gain) and marks the invoice paid', async () => {
await withFxMigration(async (client) => {
const { userId, companyId, fiscalPeriodId } = await seedTenant(client)
const supplierInvoiceId = await seedSupplierInvoice(client, {
userId,
companyId,
currency: 'EUR',
total: 1000,
exchangeRate: 11.5,
})
// Paid 11 200 kr against an 11 500 kr booked liability: 300 kr gain.
const voucherId = await seedVoucher(client, {
userId,
companyId,
fiscalPeriodId,
debitAccount: '2440',
creditAccount: '1930',
sekAmount: 11200,
})
const result = await callLinkSupplierInvoice(client, {
supplierInvoiceId,
voucherId,
userId,
companyId,
})
expect(result).toMatchObject({ ok: true, invoice_status: 'paid' })
expect(Number(result.payment_amount)).toBe(1000)
expect(Number(result.fx_residual_sek)).toBe(300)
await assertFxEntry(client, result.fx_journal_entry_id as string, [
{ account: '2440', debit: 300, credit: 0 },
{ account: '3960', debit: 0, credit: 300 },
])
const { rows: payments } = await client.query(
`SELECT amount, currency, payment_exchange_rate FROM public.supplier_invoice_payments
WHERE supplier_invoice_id = $1 AND journal_entry_id = $2`,
[supplierInvoiceId, voucherId],
)
expect(payments).toHaveLength(1)
expect(Number(payments[0].amount)).toBe(1000)
expect(payments[0].currency).toBe('EUR')
expect(Number(payments[0].payment_exchange_rate)).toBe(11.2)
})
})
it('books the overpayment to 7960 (loss)', async () => {
await withFxMigration(async (client) => {
const { userId, companyId, fiscalPeriodId } = await seedTenant(client)
const supplierInvoiceId = await seedSupplierInvoice(client, {
userId,
companyId,
currency: 'EUR',
total: 1000,
exchangeRate: 11.5,
})
// Paid 11 800 kr against the 11 500 kr booked liability: 300 kr loss.
const voucherId = await seedVoucher(client, {
userId,
companyId,
fiscalPeriodId,
debitAccount: '2440',
creditAccount: '1930',
sekAmount: 11800,
})
const result = await callLinkSupplierInvoice(client, {
supplierInvoiceId,
voucherId,
userId,
companyId,
})
expect(result).toMatchObject({ ok: true, invoice_status: 'paid' })
expect(Number(result.fx_residual_sek)).toBe(-300)
await assertFxEntry(client, result.fx_journal_entry_id as string, [
{ account: '7960', debit: 300, credit: 0 },
{ account: '2440', debit: 0, credit: 300 },
])
})
})
it('still refuses a SEK voucher far off the booked value', async () => {
await withFxMigration(async (client) => {
const { userId, companyId, fiscalPeriodId } = await seedTenant(client)
const supplierInvoiceId = await seedSupplierInvoice(client, {
userId,
companyId,
currency: 'EUR',
total: 1000,
exchangeRate: 11.5,
})
const voucherId = await seedVoucher(client, {
userId,
companyId,
fiscalPeriodId,
debitAccount: '2440',
creditAccount: '1930',
sekAmount: 1000,
})
const result = await callLinkSupplierInvoice(client, {
supplierInvoiceId,
voucherId,
userId,
companyId,
})
expect(result.ok).toBe(false)
expect(result.code).toBe('LINK_SI_VOUCHER_CURRENCY_MISMATCH')
expect(result.details?.reason).toBe('fx_deviation_too_large')
const { rows } = await client.query(
`SELECT status, paid_amount, remaining_amount FROM public.supplier_invoices WHERE id = $1`,
[supplierInvoiceId],
)
expect(rows[0].status).toBe('approved')
expect(Number(rows[0].paid_amount)).toBe(0)
expect(Number(rows[0].remaining_amount)).toBe(1000)
})
})
it('still refuses a 244x debit labelled with the invoice currency but no figure', async () => {
await withFxMigration(async (client) => {
const { userId, companyId, fiscalPeriodId } = await seedTenant(client)
const supplierInvoiceId = await seedSupplierInvoice(client, {
userId,
companyId,
currency: 'EUR',
total: 1000,
exchangeRate: 11.5,
})
const voucherId = await seedVoucher(client, {
userId,
companyId,
fiscalPeriodId,
debitAccount: '2440',
creditAccount: '1930',
sekAmount: 11500,
lineCurrency: 'EUR',
amountInCurrency: null,
})
const result = await callLinkSupplierInvoice(client, {
supplierInvoiceId,
voucherId,
userId,
companyId,
})
expect(result.ok).toBe(false)
expect(result.code).toBe('LINK_SI_VOUCHER_CURRENCY_MISMATCH')
})
})
it('refuses a mixed voucher whose readable 244x line carries amount_in_currency 0', async () => {
await withFxMigration(async (client) => {
// Supplier mirror of the skeptic counterexample: the EUR-labelled 244x
// debit with amount_in_currency = 0 is a readable LINE even though it
// sums to 0, so the fallback must stay disabled instead of settling
// with an understated SEK sum and a phantom 3960 gain.
const { userId, companyId, fiscalPeriodId } = await seedTenant(client)
const supplierInvoiceId = await seedSupplierInvoice(client, {
userId,
companyId,
currency: 'EUR',
total: 100,
exchangeRate: 10,
})
const voucherId = await seedVoucherWithLines(client, {
userId,
companyId,
fiscalPeriodId,
lines: [
{ account: '2440', debit: 950, credit: 0, currency: null },
{ account: '2440', debit: 50, credit: 0, currency: 'EUR', amountInCurrency: 0 },
{ account: '1930', debit: 0, credit: 1000 },
],
})
const result = await callLinkSupplierInvoice(client, {
supplierInvoiceId,
voucherId,
userId,
companyId,
})
expect(result.ok).toBe(false)
expect(result.code).toBe('LINK_SI_VOUCHER_CURRENCY_MISMATCH')
const { rows } = await client.query(
`SELECT status, paid_amount, remaining_amount FROM public.supplier_invoices WHERE id = $1`,
[supplierInvoiceId],
)
expect(rows[0].status).toBe('approved')
expect(Number(rows[0].paid_amount)).toBe(0)
expect(Number(rows[0].remaining_amount)).toBe(100)
const { rows: entries } = await client.query(
`SELECT COUNT(*)::int AS n FROM public.journal_entries
WHERE company_id = $1 AND id <> $2`,
[companyId, voucherId],
)
expect(entries[0].n).toBe(0)
})
})
})