fix(match-batch): cross-currency allocations + widened tolerance (#607)
* fix(match-batch): cross-currency allocations + widened tolerance Reported by jakob testing PR #603's MatchAllocationDialog with a SEK bank tx + a mix of SEK and USD invoices: 1. Tally rendered "1 USD + 1 SEK = 2 kr" — summing different currencies as if they were the same. 2. The 0.005 SEK tolerance blocked confirm on any FX rounding delta. ## What changed **UI (MatchAllocationDialog.tsx)** - Per-row amount input is explicitly in TRANSACTION currency (SEK for a Swedish bank import). Cross-currency rows show an "≈ X.XX (invoice currency)" hint under the input so the user can verify the FX result. - Default amount for a cross-currency allocation is `invoice.remaining × invoice.exchange_rate` (booked SEK), so the user doesn't have to mental-math the FX. - Overshoot tolerance widened from 0.005 SEK to `max(1 SEK, 0.5% × tx)` so bank-side FX rounding doesn't block confirm. A 2 400 kr tx now accepts ~12 kr of tolerance, a 100 kkr transfer accepts 500 kr. **RPC (match_batch_allocate cross_currency migration)** - BATCH_CURRENCY_MISMATCH dropped per-allocation. Mixed currencies now accepted with the convention that the cross-currency row pays the FULL invoice remaining (matches the single-tx match-supplier-invoice behavior). Partial cross-currency is out of scope for v1. - AR/AP line is booked at `invoice.remaining × invoice.exchange_rate` (the SEK that was originally on 1510/2440). FX residual is posted to 7960 (Valutakursförluster) or 3960 (Valutakursvinster) per BAS. - Sign conventions per direction documented inline: Customer: bank > booked → Cr 3960 (gain); bank < booked → Dr 7960 Supplier: bank < booked → Cr 3960 (gain); bank > booked → Dr 7960 - New BATCH_FX_RATE_MISSING when the cross-currency invoice has no exchange_rate on file (would otherwise silently book at 0). - New BATCH_FX_DEVIATION_TOO_LARGE when the user-entered amount deviates more than 10% from booked SEK — catches typos like "140" (USD invoice currency) when they meant "1390" (SEK equivalent) without rejecting genuine rate-day FX movement. RPC patched on remote via Supabase MCP. Same-currency path is byte-identical to the previous behavior. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(match-batch): PR review — strict sum, bank line = tx_abs, FX validation Round-1 review fixes on the cross-currency batch allocation flow: UI (MatchAllocationDialog): - Tighten tolerance to 0.005 SEK so the "balanced ✓" indicator matches what the server will accept. The previous widened tolerance (max 1 SEK or 0.5% × tx) created a reconciliation gap where the JE's bank line could legitimately disagree with the actual bank receipt. - Require balanced before confirm — undershoot is now a blocking state with an explicit warning, not a silent "leave unallocated". - Cross-currency default no longer caps at remainingTxBudget. Capping a USD invoice's default to the leftover SEK budget could silently trigger BATCH_FX_DEVIATION_TOO_LARGE on submit. The user re-balances the other rows to fit. - Add explicit FX-rate validation (bound check 0 < rate < 100000). - When a cross-currency invoice has no usable exchange_rate on file, leave the amount blank and surface a warning instead of guessing. RPC (match_batch_allocate): - New code BATCH_AMOUNT_BELOW_TX. Strict sum check on both sides means the server can't be coaxed by a direct API caller into the same broken state the UI now blocks. - Bank line credit/debit = v_tx_abs (the actual bank movement) instead of sum-of-allocations. Same value within rounding under the strict sum check, but it makes intent legible and lets per-row FX diff lines absorb rounding. - Defense-in-depth company_id filter on all re-queries / UPDATEs in the line-build + payment-row passes. - Drop the v_booked_sek-aliasing-for-invoice.total foot-gun. Use a dedicated v_inv_total var. - Truncate invoice_number to 32 chars in line_description. Tests: - pg-real: cross-currency happy path (USD invoice paid by SEK tx with FX loss to 7960, bank line = tx_abs). - pg-real: BATCH_AMOUNT_BELOW_TX rejection on undershoot. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(match-batch): PR review round 2 - caller user_id verification + FX bound Compliance-swarm + swedish-compliance findings on round 1: - CC6.3 (HIGH): p_user_id was caller-supplied and written into journal_entries.user_id / payment-row user_id without verifying it equals auth.uid(). Membership covered the company; nothing covered the user attribution. Two-layer fix: explicit guard rejects when p_user_id <> auth.uid(), and all writes now resolve v_caller = auth.uid() directly so the guard cant be silently bypassed. - A.8.28 (MED): server-side FX upper-bound (0 < rate < 100000) matches the UI. Previously RPC only checked > 0, allowing the UI guard to diverge. - V1.2.5 (LOW): truncate v_tx.date when concatenated into line_description (defense alongside round 1s invoice_number trunc). - Symmetry: populate supplier_invoice_payments.exchange_rate (column existed, INSERT omitted it). Customer side already populated. Matches swedish-compliances traceability note on AP rorelseskulder. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(match-batch): PR review round 3 - drop p_user_id, CHECK constraints, payment-day rate Genuine round-2 review findings (compliance-swarm + swedish-compliance): - V4.5: p_user_id dropped from RPC signature entirely. Round-2 added a guard; this removes the attack surface at the API boundary. Caller is resolved via auth.uid() inside the function. Route updated. - V2.2: CHECK constraint on invoices.exchange_rate and supplier_invoices.exchange_rate (0 < rate < 100000). Three layers now enforce the bound: schema, RPC, UI. - swedish-compliance traceability gap: payment_exchange_rate column on both invoice_payments and supplier_invoice_payments. Populated as v_alloc_amount / v_inv_remaining for cross-currency rows so FX diffs are reconstructible from the payment record alone (BFL 7 kap behandlingshistorik). NULL for same-currency. The existing exchange_rate column continues to store the invoicing rate. - CC6.1: extract isValidExchangeRate() to lib/utils.ts. UI's three inline bound checks now share one validator. - Dead code: drop unused leftover_note i18n key (sv + en). Tests: - pg-real signature updated (4-arg -> 3-arg) across all 9 call sites. - Added payment_exchange_rate assertion to cross-currency happy path (invoicing rate 10.0 stays, payment-day rate stored as 10.5). Migration applied to remote. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(test): missed 4th arg in BATCH_UNAUTHORIZED pg-real test Round-3 dropped p_user_id from match_batch_allocate. The replace_all caught the userId/companyId pattern but missed the BATCH_UNAUTHORIZED test which uses outsiderId instead of userId. CI failed with "bind message supplies 4 parameters, but prepared statement requires 3". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
4da87e5e4c
commit
fc7a46c3f2
@@ -166,8 +166,8 @@ describe('match_batch_allocate', () => {
|
||||
// it rolls back at the end.
|
||||
await withUserContext(userId, async (client) => {
|
||||
const r = await client.query<{ match_batch_allocate: RpcResult }>(
|
||||
`SELECT match_batch_allocate($1, $2::jsonb, $3, $4)`,
|
||||
[txId, JSON.stringify(allocations), userId, companyId],
|
||||
`SELECT match_batch_allocate($1, $2::jsonb, $3)`,
|
||||
[txId, JSON.stringify(allocations), companyId],
|
||||
)
|
||||
const result = r.rows[0]!.match_batch_allocate
|
||||
|
||||
@@ -258,8 +258,8 @@ describe('match_batch_allocate', () => {
|
||||
|
||||
await withUserContext(userId, async (client) => {
|
||||
const r = await client.query<{ match_batch_allocate: RpcResult }>(
|
||||
`SELECT match_batch_allocate($1, $2::jsonb, $3, $4)`,
|
||||
[txId, JSON.stringify(allocations), userId, companyId],
|
||||
`SELECT match_batch_allocate($1, $2::jsonb, $3)`,
|
||||
[txId, JSON.stringify(allocations), companyId],
|
||||
)
|
||||
const result = r.rows[0]!.match_batch_allocate
|
||||
|
||||
@@ -297,11 +297,10 @@ describe('match_batch_allocate', () => {
|
||||
|
||||
await withUserContext(outsiderId, async (client) => {
|
||||
const r = await client.query<{ match_batch_allocate: RpcResult }>(
|
||||
`SELECT match_batch_allocate($1, $2::jsonb, $3, $4)`,
|
||||
`SELECT match_batch_allocate($1, $2::jsonb, $3)`,
|
||||
[
|
||||
txId,
|
||||
JSON.stringify([{ kind: 'supplier_invoice', supplier_invoice_id: si, amount: 1000 }]),
|
||||
outsiderId,
|
||||
companyId,
|
||||
],
|
||||
)
|
||||
@@ -346,8 +345,8 @@ describe('match_batch_allocate', () => {
|
||||
|
||||
await withUserContext(userId, async (client) => {
|
||||
const r = await client.query<{ match_batch_allocate: RpcResult }>(
|
||||
`SELECT match_batch_allocate($1, $2::jsonb, $3, $4)`,
|
||||
[txId, JSON.stringify(allocations), userId, companyId],
|
||||
`SELECT match_batch_allocate($1, $2::jsonb, $3)`,
|
||||
[txId, JSON.stringify(allocations), companyId],
|
||||
)
|
||||
const result = r.rows[0]!.match_batch_allocate
|
||||
expect(result.ok).toBe(false)
|
||||
@@ -367,11 +366,10 @@ describe('match_batch_allocate', () => {
|
||||
|
||||
await withUserContext(userId, async (client) => {
|
||||
const r = await client.query<{ match_batch_allocate: RpcResult }>(
|
||||
`SELECT match_batch_allocate($1, $2::jsonb, $3, $4)`,
|
||||
`SELECT match_batch_allocate($1, $2::jsonb, $3)`,
|
||||
[
|
||||
txId,
|
||||
JSON.stringify([{ kind: 'supplier_invoice', supplier_invoice_id: si, amount: 1000 }]),
|
||||
userId,
|
||||
companyId,
|
||||
],
|
||||
)
|
||||
@@ -400,8 +398,8 @@ describe('match_batch_allocate', () => {
|
||||
|
||||
await withUserContext(userId, async (client) => {
|
||||
const r = await client.query<{ match_batch_allocate: RpcResult }>(
|
||||
`SELECT match_batch_allocate($1, $2::jsonb, $3, $4)`,
|
||||
[txId, JSON.stringify(allocations), userId, companyId],
|
||||
`SELECT match_batch_allocate($1, $2::jsonb, $3)`,
|
||||
[txId, JSON.stringify(allocations), companyId],
|
||||
)
|
||||
const result = r.rows[0]!.match_batch_allocate
|
||||
expect(result.ok).toBe(false)
|
||||
@@ -443,14 +441,13 @@ describe('match_batch_allocate', () => {
|
||||
|
||||
await withUserContext(userId, async (client) => {
|
||||
const r = await client.query<{ match_batch_allocate: RpcResult }>(
|
||||
`SELECT match_batch_allocate($1, $2::jsonb, $3, $4)`,
|
||||
`SELECT match_batch_allocate($1, $2::jsonb, $3)`,
|
||||
[
|
||||
txId,
|
||||
JSON.stringify([
|
||||
{ kind: 'supplier_invoice', supplier_invoice_id: si, amount: 1000 },
|
||||
{ kind: 'customer_invoice', invoice_id: invoiceId, amount: 1000 },
|
||||
]),
|
||||
userId,
|
||||
companyId,
|
||||
],
|
||||
)
|
||||
@@ -459,4 +456,158 @@ describe('match_batch_allocate', () => {
|
||||
expect(result.code).toBe('BATCH_MIXED_KINDS_UNSUPPORTED')
|
||||
})
|
||||
})
|
||||
|
||||
// PR #607 — cross-currency happy path. One USD supplier invoice paid by
|
||||
// a single SEK bank transaction. The RPC must book the AP line at the
|
||||
// invoice's original SEK value (booked_sek = remaining × exchange_rate)
|
||||
// and post the difference between booked_sek and the actual bank
|
||||
// withdrawal to 7960 (loss) or 3960 (gain). Bank line is the full tx_abs.
|
||||
it('books cross-currency supplier invoice with FX diff line and tx_abs bank line', async () => {
|
||||
const { userId, companyId } = await seedTenant()
|
||||
const supplier = await insertSupplier({ userId, companyId })
|
||||
|
||||
// USD invoice for $100, booked at 10.0 SEK/USD = 1000 SEK on 2440 at
|
||||
// creation time. (We use the standard insertSupplierInvoice and patch
|
||||
// the currency/exchange_rate after so we don't have to thread params
|
||||
// through the helper.)
|
||||
const si = await insertSupplierInvoice({
|
||||
userId, companyId, supplierId: supplier, total: 100,
|
||||
})
|
||||
await getPool().query(
|
||||
`UPDATE public.supplier_invoices
|
||||
SET currency = 'USD', exchange_rate = 10.0, remaining_amount = 100
|
||||
WHERE id = $1`,
|
||||
[si],
|
||||
)
|
||||
|
||||
// Bank actually withdrew 1050 SEK — rate moved to ~10.5 SEK/USD on
|
||||
// payment day. Loss of 50 SEK lands on 7960.
|
||||
const txId = await insertTransaction({
|
||||
userId, companyId, amount: -1050, date: '2026-06-05', currency: 'SEK',
|
||||
})
|
||||
|
||||
await withUserContext(userId, async (client) => {
|
||||
const r = await client.query<{ match_batch_allocate: RpcResult }>(
|
||||
`SELECT match_batch_allocate($1, $2::jsonb, $3)`,
|
||||
[
|
||||
txId,
|
||||
JSON.stringify([
|
||||
{ kind: 'supplier_invoice', supplier_invoice_id: si, amount: 1050 },
|
||||
]),
|
||||
companyId,
|
||||
],
|
||||
)
|
||||
const result = r.rows[0]!.match_batch_allocate
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.allocations).toHaveLength(1)
|
||||
expect(result.allocations![0]!.cross_currency).toBe(true)
|
||||
expect(result.allocations![0]!.status).toBe('paid')
|
||||
|
||||
const 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`,
|
||||
[result.journal_entry_id],
|
||||
)
|
||||
|
||||
// Expected lines:
|
||||
// Dr 2440 1000 (booked SEK at original rate)
|
||||
// Dr 7960 50 (FX loss = bank tx — booked SEK)
|
||||
// Cr 1930 1050 (actual bank withdrawal)
|
||||
expect(lines.rows).toHaveLength(3)
|
||||
|
||||
const ap = lines.rows.find((l) => l.account_number === '2440')!
|
||||
expect(Number(ap.debit_amount)).toBe(1000)
|
||||
expect(Number(ap.credit_amount)).toBe(0)
|
||||
|
||||
const fxLoss = lines.rows.find((l) => l.account_number === '7960')!
|
||||
expect(Number(fxLoss.debit_amount)).toBe(50)
|
||||
expect(Number(fxLoss.credit_amount)).toBe(0)
|
||||
|
||||
const bank = lines.rows.find((l) => l.account_number === '1930')!
|
||||
expect(Number(bank.debit_amount)).toBe(0)
|
||||
expect(Number(bank.credit_amount)).toBe(1050)
|
||||
|
||||
// Round-1 fix: bank line credit must equal tx_abs, not the AR/AP
|
||||
// total. With FX diff lines this distinction matters — verify it.
|
||||
expect(Number(bank.credit_amount)).toBe(1050)
|
||||
|
||||
// Supplier invoice settled in full and stored in invoice currency.
|
||||
const inv = await client.query<{
|
||||
status: string; paid_amount: string; remaining_amount: string
|
||||
}>(
|
||||
`SELECT status, paid_amount, remaining_amount FROM public.supplier_invoices WHERE id = $1`,
|
||||
[si],
|
||||
)
|
||||
expect(inv.rows[0]!.status).toBe('paid')
|
||||
expect(Number(inv.rows[0]!.paid_amount)).toBe(100) // USD value, not SEK
|
||||
expect(Number(inv.rows[0]!.remaining_amount)).toBe(0)
|
||||
|
||||
// Round-3: payment row stores the effective payment-day rate
|
||||
// (v_alloc_amount / v_inv_remaining = 1050/100 = 10.5) alongside
|
||||
// the invoicing rate (10.0). swedish-compliance traceability fix.
|
||||
const pay = await client.query<{
|
||||
exchange_rate: string | null; payment_exchange_rate: string | null
|
||||
}>(
|
||||
`SELECT exchange_rate, payment_exchange_rate
|
||||
FROM public.supplier_invoice_payments
|
||||
WHERE supplier_invoice_id = $1`,
|
||||
[si],
|
||||
)
|
||||
expect(Number(pay.rows[0]!.exchange_rate)).toBe(10) // invoicing rate
|
||||
expect(Number(pay.rows[0]!.payment_exchange_rate)).toBe(10.5) // payment-day rate
|
||||
|
||||
// Sum of debits = sum of credits (balanced verifikat).
|
||||
const balance = await client.query<{ debits: string; credits: string }>(
|
||||
`SELECT
|
||||
COALESCE(SUM(debit_amount), 0) AS debits,
|
||||
COALESCE(SUM(credit_amount), 0) AS credits
|
||||
FROM public.journal_entry_lines
|
||||
WHERE journal_entry_id = $1`,
|
||||
[result.journal_entry_id],
|
||||
)
|
||||
expect(Number(balance.rows[0]!.debits)).toBe(Number(balance.rows[0]!.credits))
|
||||
})
|
||||
})
|
||||
|
||||
// PR #607 round-1 — strict undershoot rejection. The RPC previously
|
||||
// accepted sum(allocations) < tx_abs and silently underbooked the bank
|
||||
// line, breaking reconciliation. Now it must reject with
|
||||
// BATCH_AMOUNT_BELOW_TX.
|
||||
it('rejects BATCH_AMOUNT_BELOW_TX when allocations sum below tx_abs', async () => {
|
||||
const { userId, companyId } = await seedTenant()
|
||||
const supplier = await insertSupplier({ userId, companyId })
|
||||
const si = await insertSupplierInvoice({
|
||||
userId, companyId, supplierId: supplier, total: 1000,
|
||||
})
|
||||
const txId = await insertTransaction({ userId, companyId, amount: -1500 })
|
||||
|
||||
await withUserContext(userId, async (client) => {
|
||||
const r = await client.query<{ match_batch_allocate: RpcResult }>(
|
||||
`SELECT match_batch_allocate($1, $2::jsonb, $3)`,
|
||||
[
|
||||
txId,
|
||||
JSON.stringify([
|
||||
{ kind: 'supplier_invoice', supplier_invoice_id: si, amount: 1000 },
|
||||
]),
|
||||
companyId,
|
||||
],
|
||||
)
|
||||
const result = r.rows[0]!.match_batch_allocate
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.code).toBe('BATCH_AMOUNT_BELOW_TX')
|
||||
expect(result.details).toMatchObject({ allocated: 1000, tx_amount_abs: 1500 })
|
||||
|
||||
const txRow = await client.query<{ journal_entry_id: string | null }>(
|
||||
`SELECT journal_entry_id FROM public.transactions WHERE id = $1`,
|
||||
[txId],
|
||||
)
|
||||
expect(txRow.rows[0]!.journal_entry_id).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user