Files
accounted/lib/bookkeeping/__tests__/invoice-payment-lines.test.ts
T
Jonas FlodénandJakob Wennberg 64ea0fef02 fix(transactions): resolve customer-invoice payment account from cash_account_id (#987)
* refactor(transactions): add shared settlement-account resolution helper

Cherry-picked from fork/worktree-starry-waddling-wirth (PR #985) commit
34d5d35 — pulling in just the new lib/bookkeeping/settlement-account.ts
helper and its test, without the match-supplier-invoice route changes
from that PR (those depend on 8bfc31d, not yet on main, and are out of
scope here).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Signed-off-by: Jonas Flodén <jonas@floden.nu>

* fix(transactions): resolve customer-invoice payment account from cash_account_id

Customer-invoice payment matching never resolved the bank leg from the
matched transaction's own cash_account_id: it was unconditionally
hardcoded to 1930 in buildInvoicePaymentClearingLines,
createInvoicePaymentJournalEntry, and createInvoiceCashEntry, with no
override parameter at all. Any bank receipt landing in a non-primary
cash/bank account (a secondary SEK account, or a foreign-currency
account like 1940 for EUR) was silently misbooked to 1930 -- the same
class of bug PR #985 fixed on the supplier-invoice side, except
unconditional there (no stale-setting trigger needed).

Adds an optional paymentAccount parameter (default '1930', preserving
behavior for every caller that doesn't pass one) to the three lib
functions, and threads resolveSettlementAccount(cash_account_id) through
every real bank-transaction-matching call site: the dashboard
match-invoice route (POST + preview), its v1/MCP-facing counterpart, and
the agent/MCP match_transaction_invoice commit path. Deliberately left
on default 1930: mark-paid (dashboard + v1, no bank transaction in
scope), fix-cash-mismatch (narrow historical repair tool for a different
bug), and the agent mark_invoice_paid commit path.

Brings in lib/bookkeeping/settlement-account.ts (cherry-picked from
fork/worktree-starry-waddling-wirth commit 34d5d35) so this PR is
mergeable independently of #985's merge order.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Signed-off-by: Jonas Flodén <jonas@floden.nu>

* test(invoice-entries): cover ROT/RUT 1513 line stays fixed under a non-default paymentAccount

Compliance-bot finding on PR #987: createInvoiceCashEntry's paymentAccount
override was only tested against a plain standard_25 invoice, never
combined with a ROT/RUT deduction_type item. The 1513 receivable line was
already correctly untouched by paymentAccount (it's never the bank leg),
this just closes the test-coverage gap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Signed-off-by: Jonas Flodén <jonas@floden.nu>

* fix(bookkeeping): abort instead of silently defaulting to 1930 when settlement-account lookup errors

Same shared-helper fix as PR #985/#986: resolveSettlementAccount now
throws BookkeepingDatabaseError on a genuine cash_accounts query error
instead of warning and falling back to 1930. An explicit cash_account_id
almost certainly resolves to a non-1930 account, so a transient failure
masking it risked the same class of misbooking this whole PR series
exists to fix, just via infra flakiness instead of a stale setting.

No route/commit.ts changes needed: match-invoice (POST + preview) run
under withRouteContext's existing catch-all, and commitPendingOperation
already has identical generic bookkeeping-error handling for every other
engine failure. Added regression tests for all three call sites
(dashboard POST, preview, and the agent/MCP commit path) confirming the
abort rather than assuming the shared infrastructure handles it silently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Signed-off-by: Jonas Flodén <jonas@floden.nu>

* fix(v1): guard resolved settlement account against chart of accounts

Closes the two remaining gaps from jakobwennberg's triage on #987
(after rebasing onto main and picking up the already-pushed
resolveSettlementAccount abort-on-error fix):

- Added the v1 match-invoice route-level test coverage that was
  missing (cash-account threading, BOOKKEEPING_DATABASE_ERROR abort,
  ACCOUNTS_NOT_IN_CHART), mirroring the dashboard route's existing
  settlement-account-resolution tests.
- Added the same findUnresolvableAccounts pre-validation guard against
  chart_of_accounts that 32c07c4 added to #986's match-supplier-invoice
  route, gated on !customLines since that is the only branch here that
  consumes the resolved paymentAccount.

Signed-off-by: Jonas Flodén

Signed-off-by: Jonas Flodén <jonas@floden.nu>

* test(bookkeeping): align settlement-account error assertion with #985

Use .rejects.toBeInstanceOf(BookkeepingDatabaseError) instead of
toMatchObject({ constructor: ... }), matching #985's edef79d follow-up
(the assertion was correct either way, but this is the more idiomatic
check and now makes the shared helper's test file byte-identical
across #985/#986/#987, removing the add/add merge conflict between
them noted in the merge-order validation.

Signed-off-by: Jonas Flodén

Signed-off-by: Jonas Flodén <jonas@floden.nu>

* test(invoice-payment-lines): add missing 3740 coverage for non-1930 paymentAccount

CodeRabbit nitpick on #987: the test named "...does not affect the
FX-diff or öresavrundning lines" only exercised the 3960 FX-diff
branch, never the pure-SEK 3740 öresavrundning branch it also claimed
to cover. Split into two tests: the existing one renamed to describe
only its FX-diff coverage, plus a new pure-SEK sub-krona-short case
with a resolved non-1930 paymentAccount asserting the 3740 line books
correctly and the bank leg lands on the resolved account, not 1930.

Signed-off-by: Jonas Flodén

Signed-off-by: Jonas Flodén <jonas@floden.nu>

* fix(ci): quote compliance-pr.yml name to fix invalid YAML

The unquoted colon in `name: compliance: review (advisory)` (introduced
by #890's em-dash removal, which swapped an em dash for a colon
in-place) makes YAML read it as a nested mapping key, so GitHub can't
parse the workflow at all - every run fails with 0 jobs scheduled.

Signed-off-by: Jonas Flodén

Signed-off-by: Jonas Flodén <jonas@floden.nu>

* Revert "fix(ci): quote compliance-pr.yml name to fix invalid YAML"

This reverts commit e7c890245d1834cd8f3c9b13a2bc3247fea7eacb.

Signed-off-by: Jonas Flodén <jonas@floden.nu>

---------

Signed-off-by: Jonas Flodén <jonas@floden.nu>
Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
2026-07-12 21:23:19 +02:00

322 lines
16 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('öresavrundning (pure SEK, sub-krona difference → 3740)', () => {
it('customer paid a sub-krona SHORT: clears full 1510, books 3740 debit (förlust)', () => {
const result = buildInvoicePaymentClearingLines(
{ amount: 1000, amount_sek: null, currency: 'SEK', exchange_rate: null },
{ currency: 'SEK', exchange_rate: null, remaining_amount: 1000.25, total: 1000.25, paid_amount: 0 },
'Inbetalning kundfaktura',
)
expect(result.arSek).toBe(1000.25) // full remaining cleared → invoice settles
expect(result.oreRoundingSek).toBe(0.25)
expect(result.lines).toHaveLength(3)
expect(result.lines.find((l) => l.account_number === '1930')?.debit_amount).toBe(1000)
expect(result.lines.find((l) => l.account_number === '1510')?.credit_amount).toBe(1000.25)
expect(result.lines.find((l) => l.account_number === '3740')?.debit_amount).toBe(0.25)
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('customer paid a sub-krona OVER: clears full 1510, books 3740 credit (vinst)', () => {
const result = buildInvoicePaymentClearingLines(
{ amount: 1000.25, amount_sek: null, currency: 'SEK', exchange_rate: null },
{ currency: 'SEK', exchange_rate: null, remaining_amount: 1000, total: 1000, paid_amount: 0 },
'Inbetalning kundfaktura',
)
expect(result.arSek).toBe(1000)
expect(result.oreRoundingSek).toBe(-0.25)
expect(result.lines.find((l) => l.account_number === '3740')?.credit_amount).toBe(0.25)
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('a ≥1 kr shortfall stays a partial (no 3740, AR = bank)', () => {
const result = buildInvoicePaymentClearingLines(
{ amount: 600, amount_sek: null, currency: 'SEK', exchange_rate: null },
{ currency: 'SEK', exchange_rate: null, remaining_amount: 1000, total: 1000, paid_amount: 0 },
'Delbetalning kundfaktura',
)
expect(result.arSek).toBe(600)
expect(result.oreRoundingSek).toBe(0)
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)
})
})
describe('paymentAccount parameter (settlement-account resolution)', () => {
it('defaults the bank leg to 1930 when paymentAccount is not passed (backward compat)', () => {
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.lines[0]).toMatchObject({ account_number: '1930', debit_amount: 1250 })
})
it('books the bank leg to the resolved account when paymentAccount is passed', () => {
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',
undefined,
'1940',
)
expect(result.lines[0]).toMatchObject({ account_number: '1940', debit_amount: 1250 })
// The AR leg (1510) is untouched by the payment-account override.
expect(result.lines[1]).toMatchObject({ account_number: '1510', credit_amount: 1250 })
})
it('a non-1930 paymentAccount does not affect the FX-diff line', () => {
// Cross-currency full clear: bank received more SEK than booked → 3960 gain.
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 },
'desc',
100,
'1940',
)
expect(result.lines[0]).toMatchObject({ account_number: '1940', debit_amount: 1100 })
const fxLine = result.lines.find((l) => l.account_number === '3960')
expect(fxLine).toMatchObject({ credit_amount: 100 })
})
it('a non-1930 paymentAccount does not affect the öresavrundning (3740) line', () => {
// Pure-SEK sub-krona short, same shape as the öresavrundning describe
// block above, but resolved to a non-primary bank account.
const result = buildInvoicePaymentClearingLines(
{ amount: 1000, amount_sek: null, currency: 'SEK', exchange_rate: null },
{ currency: 'SEK', exchange_rate: null, remaining_amount: 1000.25, total: 1000.25, paid_amount: 0 },
'Inbetalning kundfaktura',
undefined,
'1940',
)
expect(result.lines.find((l) => l.account_number === '1940')?.debit_amount).toBe(1000)
expect(result.lines.find((l) => l.account_number === '1930')).toBeUndefined()
expect(result.lines.find((l) => l.account_number === '1510')?.credit_amount).toBe(1000.25)
expect(result.lines.find((l) => l.account_number === '3740')?.debit_amount).toBe(0.25)
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)
})
})
})