Files
accounted/lib/bookkeeping/__tests__/invoice-payment-lines.test.ts
T
Jakob WennbergandClaude Opus 4.8 2c59c3633f feat(invoices): cross-currency settlement + payment-status card (#615)
* 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>
2026-06-01 10:45:51 +02:00

221 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
})
})
})