Files
accounted/tests/pg/link-voucher-fx-residual.pg.test.ts
Mattsson 7162053e89 fix(bookkeeping): link RPCs settle cross-currency invoices with 7960/3960 FX residual (#2037)
* fix(bookkeeping): link RPCs settle cross-currency invoices with 7960/3960 FX residual

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

936 lines
32 KiB
TypeScript

/**
* 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)
})
})
})