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:
co-authored by
Claude Fable 5
parent
15df4c741c
commit
7162053e89
@@ -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] })
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user