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
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user