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>
This commit is contained in:
Jonas Flodén
2026-07-12 21:23:19 +02:00
committed by GitHub
co-authored by Jakob Wennberg
parent 8a41b5dbf2
commit 64ea0fef02
12 changed files with 1053 additions and 15 deletions
@@ -570,6 +570,183 @@ describe('POST :id/match-invoice', () => {
expect(res.status).toBe(400)
expect((await res.json()).error.code).toBe('MATCH_INVOICE_TX_ALREADY_LINKED')
})
// The v1 route threads resolveSettlementAccount(transaction.cash_account_id)
// exactly like the dashboard route and the agent/MCP commit path; these
// regression tests were missing here (flagged in triage on #987) even
// though the lib-level resolveSettlementAccount tests and the dashboard
// route tests already cover the same behavior.
describe('settlement account resolution', () => {
it('credits the transaction\'s own linked cash account, not 1930', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
transactions: {
data: {
id: TX_ID,
amount: 12500,
date: '2026-05-12',
currency: 'SEK',
invoice_id: null,
journal_entry_id: null,
cash_account_id: 'ca-1940',
},
error: null,
},
invoices: [
{
data: {
id: INV_ID,
status: 'sent',
document_type: 'invoice',
total: 12500,
paid_amount: 0,
remaining_amount: 12500,
currency: 'SEK',
exchange_rate: null,
customer: { name: 'Acme' },
items: [],
journal_entry_id: null,
},
error: null,
},
{ data: [{ id: INV_ID }], error: null },
],
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
cash_accounts: { data: { ledger_account: '1940' }, error: null },
invoice_payments: { data: null, error: null },
}),
)
const res = await matchInvoicePOST(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-invoice`,
{ invoice_id: INV_ID },
),
txParams(TX_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.invoice_status).toBe('paid')
expect(createInvPmtJE).toHaveBeenCalledWith(
expect.anything(),
COMPANY_ID,
'user-1',
expect.objectContaining({ id: INV_ID }),
'2026-05-12',
undefined,
'Acme',
12500,
'1940',
)
})
it('aborts with 500 BOOKKEEPING_DATABASE_ERROR (mutates nothing) when the cash_accounts lookup errors', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
transactions: {
data: {
id: TX_ID,
amount: 12500,
date: '2026-05-12',
currency: 'SEK',
invoice_id: null,
journal_entry_id: null,
cash_account_id: 'ca-1940',
},
error: null,
},
invoices: {
data: {
id: INV_ID,
status: 'sent',
document_type: 'invoice',
total: 12500,
paid_amount: 0,
remaining_amount: 12500,
currency: 'SEK',
exchange_rate: null,
customer: { name: 'Acme' },
items: [],
journal_entry_id: null,
},
error: null,
},
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
cash_accounts: { data: null, error: { message: 'boom' } },
}),
)
const res = await matchInvoicePOST(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-invoice`,
{ invoice_id: INV_ID },
),
txParams(TX_ID),
)
expect(res.status).toBe(500)
const body = await res.json()
expect(body.error.code).toBe('BOOKKEEPING_DATABASE_ERROR')
expect(createInvPmtJE).not.toHaveBeenCalled()
expect(createInvCashJE).not.toHaveBeenCalled()
})
it('returns 400 ACCOUNTS_NOT_IN_CHART when the linked cash account is deactivated in the kontoplan', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
transactions: {
data: {
id: TX_ID,
amount: 12500,
date: '2026-05-12',
currency: 'SEK',
invoice_id: null,
journal_entry_id: null,
cash_account_id: 'ca-1940',
},
error: null,
},
invoices: {
data: {
id: INV_ID,
status: 'sent',
document_type: 'invoice',
total: 12500,
paid_amount: 0,
remaining_amount: 12500,
currency: 'SEK',
exchange_rate: null,
customer: { name: 'Acme' },
items: [],
journal_entry_id: null,
},
error: null,
},
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
cash_accounts: { data: { ledger_account: '1940' }, error: null },
}),
)
// Simulate the 1940 account existing in cash_accounts but having been
// deactivated in chart_of_accounts since.
findMissingAccountsMock.mockResolvedValueOnce(['1940'])
const res = await matchInvoicePOST(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-invoice`,
{ invoice_id: INV_ID },
),
txParams(TX_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('ACCOUNTS_NOT_IN_CHART')
expect(body.error.details.account_numbers).toEqual(['1940'])
// Engine and invoice/transaction updates must NOT run: the match stays
// retryable rather than posting a payment against a dead account.
expect(createInvPmtJE).not.toHaveBeenCalled()
expect(createInvCashJE).not.toHaveBeenCalled()
})
})
})
describe('POST :id/match-supplier-invoice', () => {
@@ -4,8 +4,11 @@
* Match a positive (income) transaction to an open customer invoice. The
* full flow:
* 1. Storno any conflicting auto-categorization JE.
* 2. Create the payment journal entry (1930 debit / 1510 credit under
* accrual; cash-method path delegates to createInvoiceCashEntry).
* 2. Create the payment journal entry (resolved bank account debit / 1510
* credit under accrual; cash-method path delegates to
* createInvoiceCashEntry). The debited account is resolved from this
* transaction's own cash_account_id via resolveSettlementAccount, never
* hardcoded to 1930 (mirrors the fix on the supplier-invoice side).
* 3. Re-attach the invoice PDF to the new payment JE (BFL 7 kap underlag).
* 4. Update invoice status (paid / partially_paid) with optimistic lock.
* 5. Insert invoice_payments row; link transaction to invoice.
@@ -26,6 +29,8 @@ import {
createInvoicePaymentJournalEntry,
createInvoiceCashEntry,
} from '@/lib/bookkeeping/invoice-entries'
import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
import { findUnresolvableAccounts } from '@/lib/bookkeeping/account-validation'
import { reverseEntry, createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/errors'
import { getErrorMessage } from '@/lib/errors/get-error-message'
@@ -315,6 +320,17 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
const entityType: EntityType =
(settings?.entity_type as EntityType) || 'enskild_firma'
// Debit the cash account THIS transaction actually belongs to, never a
// hardcoded 1930: cash_account_id -> cash_accounts.ledger_account is the
// only source of truth for which bank/cash account a real, matched
// transaction settled into.
const paymentAccount = await resolveSettlementAccount(
ctx.supabase,
ctx.companyId!,
transaction.cash_account_id,
txLog,
)
// The JE shape is driven by the INVOICE'S booking state, not the
// company's current setting. If the invoice already has a JE (Dr 1510
// posted at send), the match must clear 1510: otherwise the receivable
@@ -346,6 +362,24 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
})
}
// Guard the resolved account against the chart (mirrors the categorize
// routes and the same fix on match-supplier-invoice): an inactive
// cash_accounts.ledger_account would otherwise reach the engine as a
// generic INVOICE_PAID_BOOK_FAILED instead of ACCOUNTS_NOT_IN_CHART.
// Only reachable where the account is actually used: customLines specify
// their own accounts directly and never consume paymentAccount.
if (!customLines) {
const missingAccounts = await findUnresolvableAccounts(ctx.supabase, ctx.companyId!, [
paymentAccount,
])
if (missingAccounts.length > 0) {
txLog.warn('resolved settlement account is inactive/unknown', { missingAccounts })
return v1ErrorResponse(new AccountsNotInChartError(missingAccounts), txLog, {
requestId: ctx.requestId,
})
}
}
// Strict-mode for the public API: if the payment JE can't be created we
// ABORT before touching invoice / payment / transaction state. The
// dashboard's internal route soft-fails here and surfaces a banner so
@@ -392,6 +426,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
transaction.date,
entityType,
invoice.customer?.name,
paymentAccount,
)
journalEntryId = je?.id ?? null
} else {
@@ -404,6 +439,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
undefined,
invoice.customer?.name,
paidAmount,
paymentAccount,
)
journalEntryId = je?.id ?? null
}