fix(v1): thread resolved settlement account through FX and cash-method supplier-payment branches (#1033)

* fix(v1): thread resolved settlement account through FX and cash-method supplier-payment branches

The v1 (MCP-facing) match-supplier-invoice route resolved paymentAccount
via resolveSettlementAccount but only passed it to
createSupplierInvoicePaymentEntry for pure-SEK matches (gated on
isPureSek) and never to createSupplierInvoiceCashEntry at all. A
foreign-currency match, or a kontantmetoden match, settling from a
bank/cash account other than the primary 1930 (e.g. a EUR account on
1940) was still misbooked to 1930: the same class of bug PR #985/#986
fixed for the pure-SEK accrual path.

Pass the resolved account through both branches unconditionally (the
generators' internal 1930 default remains the documented no-link
fallback, reached via resolveSettlementAccount's own fallback for
transactions without a cash_account_id), and widen the
findUnresolvableAccounts chart pre-validation from the pure-SEK accrual
path to every non-customLines branch, since all of them now consume the
resolved account.

The dashboard route needed no code change: its FX/cash-method branches
were already threaded inside PR #985 itself. Added branch-level
regression tests on both routes (linked non-1930 account books to that
account; no cash_account_id falls back to 1930; deactivated resolved
account rejects with ACCOUNTS_NOT_IN_CHART before booking).

Fixes #1000

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

* fix(v1): validate settlement account against the chart before the conflicting-JE storno

The widened findUnresolvableAccounts pre-validation ran after the
conflicting-categorization storno, so a request rejected with
ACCOUNTS_NOT_IN_CHART could first reverse the transaction's posted
categorization entry: an irreversible side effect on a failed request.
Move the paymentAccount resolution and the chart validation ahead of
the storno block (same !customLines guard, same error shape) and add a
regression test asserting reverseEntry is never called when the chart
validation fails. The dashboard route has no storno block and no chart
pre-validation on this path, so it is unaffected.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-16 18:13:09 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 1dc85736d8
commit f0907da7e9
4 changed files with 315 additions and 50 deletions
@@ -85,7 +85,12 @@ function makeReq() {
}
function enqueueHappyPath(opts: {
transaction: { amount: number; currency: string; amount_sek?: number | null }
transaction: {
amount: number
currency: string
amount_sek?: number | null
cash_account_id?: string | null
}
invoice: {
currency: string
exchange_rate?: number | null
@@ -94,6 +99,9 @@ function enqueueHappyPath(opts: {
status?: string
}
accountingMethod?: string
// ledger_account returned by the cash_accounts lookup; only enqueued when
// the transaction carries a cash_account_id.
cashAccountLedger?: string
}) {
// 1. transactions fetch
enqueue({
@@ -104,6 +112,7 @@ function enqueueHappyPath(opts: {
currency: opts.transaction.currency,
amount_sek: opts.transaction.amount_sek ?? null,
supplier_invoice_id: null,
cash_account_id: opts.transaction.cash_account_id ?? null,
date: '2026-05-12',
},
error: null,
@@ -124,11 +133,15 @@ function enqueueHappyPath(opts: {
})
// 3. company_settings fetch
enqueue({ data: { accounting_method: opts.accountingMethod ?? 'accrual' }, error: null })
// 4. supplier_invoices update (CAS)
// 4. cash_accounts lookup (only when the transaction is linked to one)
if (opts.transaction.cash_account_id) {
enqueue({ data: { ledger_account: opts.cashAccountLedger ?? '1930' }, error: null })
}
// 5. supplier_invoices update (CAS)
enqueue({ data: [{ id: SI_UUID }], error: null })
// 5. supplier_invoice_payments insert
// 6. supplier_invoice_payments insert
enqueue({ data: null, error: null })
// 6. transactions update (link)
// 7. transactions update (link)
enqueue({ data: null, error: null })
}
@@ -362,6 +375,65 @@ describe('POST /api/transactions/[id]/match-supplier-invoice: settlement account
})
})
describe('POST /api/transactions/[id]/match-supplier-invoice: settlement account on FX and cash-method branches', () => {
// Regression locks for #1000: the FX branch (createSupplierInvoicePaymentEntry)
// and cash-method branch (createSupplierInvoiceCashEntry) must receive the
// resolved settlement account, not fall back to their internal 1930 default
// whenever the transaction is linked to a different cash account.
it('FX branch: passes the linked non-1930 account to createSupplierInvoicePaymentEntry', async () => {
enqueueHappyPath({
transaction: { amount: -2400, currency: 'SEK', cash_account_id: 'ca-1940' },
invoice: { currency: 'EUR', exchange_rate: 10.6254, remaining_amount: 225 },
cashAccountLedger: '1940',
})
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
expect(res.status).toBe(200)
expect(mockCreatePaymentEntry).toHaveBeenCalledTimes(1)
const args = mockCreatePaymentEntry.mock.calls[0]
// Confirm the FX branch actually ran: booked 2390.72, paid 2400 -> loss 9.28.
expect(args[6]).toBeCloseTo(-9.28, 2)
expect(args[8]).toBe('1940')
})
it('FX branch: falls back to 1930 when the transaction has no linked cash account', async () => {
enqueueHappyPath({
transaction: { amount: -2400, currency: 'SEK' },
invoice: { currency: 'EUR', exchange_rate: 10.6254, remaining_amount: 225 },
})
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
expect(res.status).toBe(200)
expect(mockCreatePaymentEntry.mock.calls[0][8]).toBe('1930')
})
it('cash-method branch: passes the linked non-1930 account to createSupplierInvoiceCashEntry', async () => {
enqueueHappyPath({
transaction: { amount: -500, currency: 'SEK', cash_account_id: 'ca-1940' },
invoice: { currency: 'SEK', remaining_amount: 500 },
accountingMethod: 'cash',
cashAccountLedger: '1940',
})
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
expect(res.status).toBe(200)
expect(mockCreateCashEntry).toHaveBeenCalledTimes(1)
expect(mockCreatePaymentEntry).not.toHaveBeenCalled()
const args = mockCreateCashEntry.mock.calls[0]
expect(args[8]).toBe('1940')
// Pure SEK settlement: no settledBankSek override.
expect(args[9]).toBeUndefined()
})
it('cash-method branch: falls back to 1930 when the transaction has no linked cash account', async () => {
enqueueHappyPath({
transaction: { amount: -500, currency: 'SEK' },
invoice: { currency: 'SEK', remaining_amount: 500 },
accountingMethod: 'cash',
})
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
expect(res.status).toBe(200)
expect(mockCreateCashEntry.mock.calls[0][8]).toBe('1930')
})
})
describe('POST /api/transactions/[id]/match-supplier-invoice: non-FX paths', () => {
it('returns 200 with the expected body shape on the happy path', async () => {
enqueueHappyPath({