* feat(invoices): cross-currency settlement + payment-status card Two changes both surfaced by user feedback after PR #614: # 1. Invoice detail page: Betalningsstatus card The customer-invoice detail page now shows paid_amount + remaining_amount + the individual payment events whenever an invoice is partially_paid or paid (was previously only a single "Paid" line on fully-paid invoices, and nothing at all on partially_paid). Mirrors the supplier-invoice page's payment section. Each payment row links to its verifikat. # 2. Cross-currency match-invoice settlement Replaces the PR #614 round-9 block (MATCH_INVOICE_CURRENCY_MISMATCH) with proper FX-aware settlement. Flow: 1. Preview route detects tx.currency !== invoice.currency, fetches the Riksbanken spot rate for invoice.currency on tx.date (ML 8 kap 21–23§), and returns fx_conversion = { rate, rate_date, paid_in_invoice_currency }. When the lookup fails it returns fx_conversion.error = 'rate_unavailable'. 2. InvoiceMatchDialog renders a new Valutaomräkning card showing the rate + invoice-currency-equivalent + projected post-payment state + a one- line kursvinst/kursförlust note. When the lookup failed it swaps in a manual-rate input the user fills from their bank statement; the Confirm button blocks until a positive rate is supplied. 3. POST route does the same lookup (or accepts manual_exchange_rate from the request body), then: - paidInInvoiceCurrency = bankSek / rate (4dp precision) - invoice.paid_amount/remaining_amount accumulate in invoice currency - invoice_payments row records amount + currency = invoice.currency, exchange_rate = the rate actually used (not invoice.exchange_rate) - buildInvoicePaymentClearingLines gets paidInInvoiceCurrency so it credits 1510 by that × invoice.exchange_rate (booking rate) and posts the FX-diff line on 3960 (gain) or 7960 (loss) 4. buildInvoicePaymentClearingLines gains an optional fourth param. When supplied: proportional FX-aware AR-leg + balanced FX-diff. When omitted: pre-existing fallback (full-clear gets FX, partials defer). The change fixes the invoice.paid_amount accumulator bug that PR #614 round-9 worked around by blocking the case entirely. Now SEK→USD settlements actually work, with the verifikat balanced to the öre and the GL+sub-ledger in sync per BFL 5 kap 4–5§. Tests: - 3 new helper tests (paidInInvoiceCurrency happy path + edge cases) - 3 new route tests (Riksbanken happy path, lookup failure, manual rate) - All 4321 tests pass Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(invoices): align cross-currency match preview with commit + review cleanups Addresses PR #615 review feedback. Preview/commit divergence (Greptile P1): preview/route.ts computed paidAmount / isFullyPaid / useCashEntry from the raw SEK transaction.amount before the FX conversion ran. A 1 000 SEK payment against a 140 USD invoice made max(0, 140 − 1000) = 0 → is_fully_paid=true, so a cash-method unbooked invoice previewed a cash entry (Dr 1930 / Cr 30xx) while the POST handler — which converts first — commits the clearing entry (Dr 1930 / Cr 1510). The user approved one verifikat and a different one was booked. Move the FX lookup above the paid/remaining math so paidAmount derives from the invoice-currency conversion, mirroring the POST handler. Rate-unavailable stays non-fully-paid so the cash shape is never previewed on a guess. Add a preview-route regression test (cross-currency → clearing + not fully paid; same-currency cash path still previews the cash entry). Cleanups: - Bound manual_exchange_rate with .max(100000) as a sanity ceiling against pasted/garbage input corrupting the FX-diff posting (swarm V2.3). - Remove the invisible disabled placeholder retry button and its unused fx_manual_rate_retry i18n keys (Greptile P2). - Remove the now-unreachable MATCH_INVOICE_CURRENCY_MISMATCH error code (Greptile P2 dead code; confirmed zero references). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(invoices): record FX rate provenance + cover kursförlust path Follow-up to the PR #615 review (compliance swarm V16 / SOC 2 CC6.1 / GDPR Art.5(1)(f); Swedish accounting review). A manually-supplied cross-currency rate is a user-controlled money-path override of the ML 8 kap 21–23§ obligation and was indistinguishable from an automatic Riksbanken lookup in the audit trail. Tag the resolved rate with source: 'manual' | 'riksbanken' and: - write a "Manuell valutakurs <rate> <ccy>/SEK (betalningsdatum …)" note onto the existing invoice_payments.notes column when manual (BFL 5 kap 6–7§ — the verifikation must reflect the actual affärshändelse); - record rate_source + exchange_rate in payment_match_log.new_state. No schema change — both are existing columns/JSON. Tests: - cover the kursförlust (7960 Dr) branch of the cross-currency paidInInvoiceCurrency path — previously only the 3960 gain was asserted; - assert rate_source provenance ('manual' and 'riksbanken') reaches the match-log new_state on both FX paths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
221 lines
11 KiB
TypeScript
221 lines
11 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
||
import { buildInvoicePaymentClearingLines } from '../invoice-payment-lines'
|
||
|
||
describe('buildInvoicePaymentClearingLines', () => {
|
||
describe('same currency (SEK invoice + SEK tx)', () => {
|
||
it('full payment: 1930 = 1510 = tx amount, no FX line', () => {
|
||
const result = buildInvoicePaymentClearingLines(
|
||
{ amount: 1250, amount_sek: null, currency: 'SEK', exchange_rate: null },
|
||
{ currency: 'SEK', exchange_rate: null, remaining_amount: 1250, total: 1250, paid_amount: 0 },
|
||
'Inbetalning kundfaktura',
|
||
)
|
||
expect(result.bankSek).toBe(1250)
|
||
expect(result.arSek).toBe(1250)
|
||
expect(result.fxDiffSek).toBe(0)
|
||
expect(result.lines).toHaveLength(2)
|
||
expect(result.lines[0]).toMatchObject({ account_number: '1930', debit_amount: 1250, credit_amount: 0 })
|
||
expect(result.lines[1]).toMatchObject({ account_number: '1510', debit_amount: 0, credit_amount: 1250 })
|
||
})
|
||
|
||
it('partial payment: 1930 = 1510 = tx amount (the actual SEK received)', () => {
|
||
// Scenario from the user: invoice 1 250, prior 230 partial, now 1 000 hits.
|
||
// 1930/1510 must equal 1 000 (not 1 250). After this verifikat the invoice
|
||
// remaining is 20 SEK and status stays partially_paid (handled by the
|
||
// caller, not this helper).
|
||
const result = buildInvoicePaymentClearingLines(
|
||
{ amount: 1000, amount_sek: null, currency: 'SEK', exchange_rate: null },
|
||
{ currency: 'SEK', exchange_rate: null, remaining_amount: 1020, total: 1250, paid_amount: 230 },
|
||
'Inbetalning kundfaktura',
|
||
)
|
||
expect(result.bankSek).toBe(1000)
|
||
expect(result.arSek).toBe(1000)
|
||
expect(result.fxDiffSek).toBe(0)
|
||
expect(result.lines).toHaveLength(2)
|
||
})
|
||
|
||
it('expense tx (negative amount) treats absolute SEK value', () => {
|
||
const result = buildInvoicePaymentClearingLines(
|
||
{ amount: -500, amount_sek: null, currency: 'SEK', exchange_rate: null },
|
||
{ currency: 'SEK', exchange_rate: null, remaining_amount: 500, total: 500, paid_amount: 0 },
|
||
'desc',
|
||
)
|
||
expect(result.bankSek).toBe(500)
|
||
expect(result.arSek).toBe(500)
|
||
})
|
||
})
|
||
|
||
describe('cross currency (USD invoice + SEK tx)', () => {
|
||
it('bank received MORE SEK than booked: gain to 3960', () => {
|
||
// Invoice 100 USD booked at 10.00 (1000 SEK on 1510)
|
||
// Bank receives 1100 SEK (rate moved to 11.00 by payment date)
|
||
// FX gain = 100 SEK → 3960 credit
|
||
const result = buildInvoicePaymentClearingLines(
|
||
{ amount: 1100, amount_sek: null, currency: 'SEK', exchange_rate: null },
|
||
{ currency: 'USD', exchange_rate: 10, remaining_amount: 100, total: 100, paid_amount: 0 },
|
||
'Inbetalning kundfaktura',
|
||
)
|
||
expect(result.bankSek).toBe(1100)
|
||
expect(result.arSek).toBe(1000)
|
||
expect(result.fxDiffSek).toBe(-100)
|
||
expect(result.lines).toHaveLength(3)
|
||
expect(result.lines[0]).toMatchObject({ account_number: '1930', debit_amount: 1100 })
|
||
expect(result.lines[1]).toMatchObject({ account_number: '1510', credit_amount: 1000 })
|
||
expect(result.lines[2]).toMatchObject({
|
||
account_number: '3960',
|
||
credit_amount: 100,
|
||
line_description: 'Valutakursvinst',
|
||
})
|
||
// Balanced
|
||
const debit = result.lines.reduce((s, l) => s + l.debit_amount, 0)
|
||
const credit = result.lines.reduce((s, l) => s + l.credit_amount, 0)
|
||
expect(Math.round((debit - credit) * 100)).toBe(0)
|
||
})
|
||
|
||
it('ambiguous loss scenario (bank < SEK booked) is treated as partial — defers FX', () => {
|
||
// Invoice 100 USD booked at 10.50 (1050 SEK on 1510)
|
||
// Bank receives 1000 SEK — could be (a) partial payment that didn't
|
||
// cover the full USD amount, or (b) full payment at a worse FX rate.
|
||
// From a SEK-only bank tx we can't distinguish; defaulting to "partial"
|
||
// is the safer choice (no premature 1510 zeroing). If the user knows
|
||
// it's actually a full-clear-with-loss, they use mark-paid with an
|
||
// explicit exchange_rate_difference instead.
|
||
const result = buildInvoicePaymentClearingLines(
|
||
{ amount: 1000, amount_sek: null, currency: 'SEK', exchange_rate: null },
|
||
{ currency: 'USD', exchange_rate: 10.5, remaining_amount: 100, total: 100, paid_amount: 0 },
|
||
'Inbetalning kundfaktura',
|
||
)
|
||
expect(result.bankSek).toBe(1000)
|
||
expect(result.arSek).toBe(1000)
|
||
expect(result.fxDiffSek).toBe(0)
|
||
expect(result.lines).toHaveLength(2)
|
||
})
|
||
|
||
it('partial cross-currency WITH paidInInvoiceCurrency: posts proportional 1510 credit + FX-diff line', () => {
|
||
// The proper-FX path (round-10): caller supplies the invoice-currency
|
||
// equivalent of the bank payment, computed at today's Riksbanken rate.
|
||
// The helper credits 1510 by that × invoice.exchange_rate (the
|
||
// booking rate) and posts the FX-diff line so the verifikat balances.
|
||
//
|
||
// Scenario: 1000 SEK bank tx, invoice 140 USD @ 9.30 (booked). Today
|
||
// Riksbanken rate: 10.45. paidInInvoiceCurrency = 1000/10.45 = 95.6938.
|
||
// arSek = 95.6938 × 9.30 = 889.95. fxDiff = 889.95 - 1000 = -110.05
|
||
// (negative → gain → 3960 Cr 110.05).
|
||
const result = buildInvoicePaymentClearingLines(
|
||
{ amount: 1000, amount_sek: null, currency: 'SEK', exchange_rate: null },
|
||
{ currency: 'USD', exchange_rate: 9.3, remaining_amount: 140, total: 140, paid_amount: 0 },
|
||
'Inbetalning kundfaktura',
|
||
95.6938, // paidInInvoiceCurrency
|
||
)
|
||
expect(result.bankSek).toBe(1000)
|
||
expect(result.arSek).toBeCloseTo(889.95, 1)
|
||
expect(result.fxDiffSek).toBeCloseTo(-110.05, 1)
|
||
expect(result.lines).toHaveLength(3)
|
||
expect(result.lines[0]).toMatchObject({ account_number: '1930', debit_amount: 1000 })
|
||
expect(result.lines[1]).toMatchObject({ account_number: '1510' })
|
||
expect(result.lines[2]).toMatchObject({
|
||
account_number: '3960',
|
||
line_description: 'Valutakursvinst',
|
||
})
|
||
const debit = result.lines.reduce((s, l) => s + l.debit_amount, 0)
|
||
const credit = result.lines.reduce((s, l) => s + l.credit_amount, 0)
|
||
expect(Math.abs(debit - credit)).toBeLessThanOrEqual(0.005)
|
||
})
|
||
|
||
it('cross-currency WITH paidInInvoiceCurrency, bank < booked: loss to 7960', () => {
|
||
// Mirror of the gain case above with the opposite sign — guards the
|
||
// kursförlust branch the route's gain-only assertion never reaches
|
||
// (Swedish compliance review, PR #615). Invoice 100 USD booked at 9.30
|
||
// (930 SEK on 1510); the 100 USD settlement only fetched 900 SEK at the
|
||
// weaker 9.00 payment-date rate. arSek = 100 × 9.30 = 930.
|
||
// fxDiff = 930 − 900 = +30 (positive → kursförlust → 7960 Dr 30).
|
||
const result = buildInvoicePaymentClearingLines(
|
||
{ amount: 900, amount_sek: null, currency: 'SEK', exchange_rate: null },
|
||
{ currency: 'USD', exchange_rate: 9.3, remaining_amount: 100, total: 100, paid_amount: 0 },
|
||
'Inbetalning kundfaktura',
|
||
100, // paidInInvoiceCurrency (full settlement at today's 9.00 rate)
|
||
)
|
||
expect(result.bankSek).toBe(900)
|
||
expect(result.arSek).toBeCloseTo(930, 2)
|
||
expect(result.fxDiffSek).toBeCloseTo(30, 2)
|
||
expect(result.lines).toHaveLength(3)
|
||
expect(result.lines[0]).toMatchObject({ account_number: '1930', debit_amount: 900 })
|
||
expect(result.lines[1]).toMatchObject({ account_number: '1510', credit_amount: 930 })
|
||
expect(result.lines[2]).toMatchObject({
|
||
account_number: '7960',
|
||
debit_amount: 30,
|
||
line_description: 'Valutakursförlust',
|
||
})
|
||
// Balanced to the öre: Dr 900 + 30 = 930 = Cr 930.
|
||
const debit = result.lines.reduce((s, l) => s + l.debit_amount, 0)
|
||
const credit = result.lines.reduce((s, l) => s + l.credit_amount, 0)
|
||
expect(Math.abs(debit - credit)).toBeLessThanOrEqual(0.005)
|
||
})
|
||
|
||
it('partial cross-currency payment defers FX: bank-leg = AR-leg = bankSek, no 3960/7960 line', () => {
|
||
// Invoice 140 USD @ 15.30 (2142 SEK booked on 1510)
|
||
// Bank receives 230 SEK — way below the 2142 remaining. If we credited
|
||
// the full 2142 to 1510 we'd zero the GL balance while the invoice row
|
||
// stayed partially_paid (BFL 5 kap 4–5§ violation). Defer FX to the
|
||
// final settlement that closes the invoice.
|
||
const result = buildInvoicePaymentClearingLines(
|
||
{ amount: 230, amount_sek: null, currency: 'SEK', exchange_rate: null },
|
||
{ currency: 'USD', exchange_rate: 15.3, remaining_amount: 140, total: 140, paid_amount: 0 },
|
||
'Delbetalning kundfaktura',
|
||
)
|
||
expect(result.bankSek).toBe(230)
|
||
expect(result.arSek).toBe(230)
|
||
expect(result.fxDiffSek).toBe(0)
|
||
expect(result.lines).toHaveLength(2)
|
||
expect(result.lines[0]).toMatchObject({ account_number: '1930', debit_amount: 230 })
|
||
expect(result.lines[1]).toMatchObject({ account_number: '1510', credit_amount: 230 })
|
||
})
|
||
|
||
it('exact match: no FX line', () => {
|
||
// Invoice 100 USD @ 10.00 (1000 SEK booked); bank receives 1000 SEK
|
||
const result = buildInvoicePaymentClearingLines(
|
||
{ amount: 1000, amount_sek: null, currency: 'SEK', exchange_rate: null },
|
||
{ currency: 'USD', exchange_rate: 10, remaining_amount: 100, total: 100, paid_amount: 0 },
|
||
'desc',
|
||
)
|
||
expect(result.bankSek).toBe(1000)
|
||
expect(result.arSek).toBe(1000)
|
||
expect(result.fxDiffSek).toBe(0)
|
||
expect(result.lines).toHaveLength(2)
|
||
})
|
||
|
||
it('sub-öre FX diff is suppressed (within floating-point tolerance)', () => {
|
||
// 100.001 USD × 10 = 1000.01, but bookkeeping rounds at the line level
|
||
const result = buildInvoicePaymentClearingLines(
|
||
{ amount: 1000, amount_sek: null, currency: 'SEK', exchange_rate: null },
|
||
{
|
||
currency: 'USD',
|
||
exchange_rate: 10,
|
||
remaining_amount: 100.0001,
|
||
total: 100.0001,
|
||
paid_amount: 0,
|
||
},
|
||
'desc',
|
||
)
|
||
expect(Math.abs(result.fxDiffSek)).toBeLessThanOrEqual(0.005)
|
||
expect(result.lines).toHaveLength(2)
|
||
})
|
||
})
|
||
|
||
describe('cross currency (USD invoice + USD tx)', () => {
|
||
it('uses tx amount_sek for the bank-leg when populated', () => {
|
||
// USD-denominated bank account paying a USD invoice — ingest converts
|
||
// tx → SEK using the bank-date rate.
|
||
const result = buildInvoicePaymentClearingLines(
|
||
{ amount: 100, amount_sek: 1100, currency: 'USD', exchange_rate: 11 },
|
||
{ currency: 'USD', exchange_rate: 10, remaining_amount: 100, total: 100, paid_amount: 0 },
|
||
'desc',
|
||
)
|
||
// Same-currency path: bank-leg uses resolveSekAmount (which honours
|
||
// amount_sek), AR-leg equals bank-leg, no FX diff line.
|
||
expect(result.bankSek).toBe(1100)
|
||
expect(result.arSek).toBe(1100)
|
||
expect(result.fxDiffSek).toBe(0)
|
||
})
|
||
})
|
||
})
|