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:
@@ -635,6 +635,205 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
expect(mockCreateInvoiceCashEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Settlement-account resolution (customer-invoice counterpart of the
|
||||
// supplier-side fix in match-supplier-invoice/route.ts): the bank leg must
|
||||
// be resolved from THIS transaction's own cash_account_id, never hardcoded
|
||||
// to 1930, so a receipt into a secondary/foreign-currency account books to
|
||||
// that account.
|
||||
describe('settlement account resolution', () => {
|
||||
it('clearing entry: credits the transaction\'s own linked cash account, not 1930', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
amount: 12500,
|
||||
invoice_id: null,
|
||||
date: '2024-06-15',
|
||||
cash_account_id: 'ca-1940',
|
||||
})
|
||||
const invoice = makeInvoice({
|
||||
id: VALID_UUID,
|
||||
status: 'sent',
|
||||
total: 12500,
|
||||
remaining_amount: 12500,
|
||||
subtotal: 10000,
|
||||
vat_amount: 2500,
|
||||
invoice_number: 'F-2024001',
|
||||
})
|
||||
|
||||
enqueue({ data: tx, error: null }) // transactions
|
||||
enqueue({ data: invoice, error: null }) // invoices
|
||||
enqueue({ data: [], error: null }) // hard-duplicate check
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) // company_settings
|
||||
enqueue({ data: { ledger_account: '1940' }, error: null }) // cash_accounts lookup
|
||||
|
||||
mockCreateJournalEntry.mockResolvedValue({ id: 'je-1940' })
|
||||
|
||||
enqueue({ data: [{ id: VALID_UUID }], error: null }) // update invoice
|
||||
enqueue({ data: null, error: null }) // insert invoice_payments
|
||||
enqueue({ data: null, error: null }) // update transaction
|
||||
enqueue({ data: null, error: null }) // logMatchEvent
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ journal_entry_id: string }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.journal_entry_id).toBe('je-1940')
|
||||
expect(mockCreateJournalEntry).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'user-1',
|
||||
expect.objectContaining({
|
||||
lines: expect.arrayContaining([
|
||||
expect.objectContaining({ account_number: '1940', debit_amount: 12500 }),
|
||||
expect.objectContaining({ account_number: '1510', credit_amount: 12500 }),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
// The primary bank account must NOT appear on this verifikat.
|
||||
const call = mockCreateJournalEntry.mock.calls[0][3] as { lines: Array<{ account_number: string }> }
|
||||
expect(call.lines.some((l) => l.account_number === '1930')).toBe(false)
|
||||
})
|
||||
|
||||
it('cash entry: passes the transaction\'s own linked cash account through to createInvoiceCashEntry', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
amount: 12500,
|
||||
invoice_id: null,
|
||||
date: '2024-06-15',
|
||||
cash_account_id: 'ca-1940',
|
||||
})
|
||||
const invoice = makeInvoice({
|
||||
id: VALID_UUID,
|
||||
status: 'sent',
|
||||
total: 12500,
|
||||
remaining_amount: 12500,
|
||||
paid_amount: 0,
|
||||
})
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: [], error: null }) // hard-duplicate check
|
||||
enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null })
|
||||
enqueue({ data: { ledger_account: '1940' }, error: null }) // cash_accounts lookup
|
||||
|
||||
mockCreateInvoiceCashEntry.mockResolvedValue({ id: 'je-cash-1940' })
|
||||
|
||||
enqueue({ data: [{ id: VALID_UUID }], error: null }) // update invoice
|
||||
enqueue({ data: null, error: null }) // insert invoice_payments
|
||||
enqueue({ data: null, error: null }) // update transaction
|
||||
enqueue({ data: null, error: null }) // logMatchEvent
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ journal_entry_id: string }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.journal_entry_id).toBe('je-cash-1940')
|
||||
expect(mockCreateInvoiceCashEntry).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'user-1',
|
||||
expect.anything(),
|
||||
'2024-06-15',
|
||||
'enskild_firma',
|
||||
undefined,
|
||||
'1940',
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to 1930 when the transaction has no linked cash account', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
amount: 12500,
|
||||
invoice_id: null,
|
||||
date: '2024-06-15',
|
||||
cash_account_id: null,
|
||||
})
|
||||
const invoice = makeInvoice({
|
||||
id: VALID_UUID,
|
||||
status: 'sent',
|
||||
total: 12500,
|
||||
remaining_amount: 12500,
|
||||
})
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: [], error: null }) // hard-duplicate check
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
// No cash_accounts enqueue: resolveSettlementAccount short-circuits to
|
||||
// '1930' when cash_account_id is null, with no DB call.
|
||||
|
||||
mockCreateJournalEntry.mockResolvedValue({ id: 'je-default' })
|
||||
|
||||
enqueue({ data: [{ id: VALID_UUID }], error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(mockCreateJournalEntry).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'user-1',
|
||||
expect.objectContaining({
|
||||
lines: expect.arrayContaining([
|
||||
expect.objectContaining({ account_number: '1930', debit_amount: 12500 }),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('aborts with 500 BOOKKEEPING_DATABASE_ERROR (mutates nothing) when the cash_accounts lookup errors', async () => {
|
||||
// Regression: an explicit cash_account_id almost certainly resolves to
|
||||
// a non-1930 account, so a transient lookup failure must not silently
|
||||
// degrade to 1930 -- the same misbooking risk this fix exists to close,
|
||||
// just triggered by infra flakiness instead of a stale setting.
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
amount: 12500,
|
||||
invoice_id: null,
|
||||
date: '2024-06-15',
|
||||
cash_account_id: 'ca-broken',
|
||||
})
|
||||
const invoice = makeInvoice({
|
||||
id: VALID_UUID,
|
||||
status: 'sent',
|
||||
total: 12500,
|
||||
remaining_amount: 12500,
|
||||
})
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: [], error: null }) // hard-duplicate check
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
enqueue({ data: null, error: { message: 'connection reset' } }) // cash_accounts lookup errors
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(body.error.code).toBe('BOOKKEEPING_DATABASE_ERROR')
|
||||
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 400 MATCH_AMOUNT_EXCEEDS_REMAINING when tx amount exceeds invoice remaining', async () => {
|
||||
// Tx is +12 000 SEK, invoice has 5 000 SEK remaining. Legacy code path
|
||||
// would push paid_amount past invoice.total; the new guard rejects so
|
||||
|
||||
@@ -202,4 +202,158 @@ describe('GET /api/transactions/[id]/match-invoice/preview', () => {
|
||||
expect(vat?.credit_amount).toBe(1042.5)
|
||||
expect(bank?.debit_amount).toBe(5212.5)
|
||||
})
|
||||
|
||||
// Settlement-account resolution (customer-invoice counterpart of the
|
||||
// supplier-side fix): the bank leg must reflect THIS transaction's own
|
||||
// linked cash account, not a hardcoded 1930, so a receipt into a secondary
|
||||
// account previews the exact verifikat the POST handler will commit.
|
||||
describe('settlement account resolution', () => {
|
||||
it('clearing entry: previews the bank leg on the transaction\'s own linked cash account, not 1930', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-4',
|
||||
amount: 1250,
|
||||
currency: 'SEK',
|
||||
date: '2026-05-30',
|
||||
invoice_id: null,
|
||||
cash_account_id: 'ca-1940',
|
||||
})
|
||||
const invoice = makeInvoice({
|
||||
id: VALID_UUID,
|
||||
status: 'sent',
|
||||
currency: 'SEK',
|
||||
total: 1250,
|
||||
remaining_amount: 1250,
|
||||
paid_amount: 0,
|
||||
journal_entry_id: 'je-original', // already booked → clearing path
|
||||
})
|
||||
enqueue({ data: tx, error: null }) // transactions
|
||||
enqueue({ data: invoice, error: null }) // invoices
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) // company_settings
|
||||
enqueue({ data: { ledger_account: '1940' }, error: null }) // cash_accounts lookup
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-4/match-invoice/preview', {
|
||||
searchParams: { invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await GET(request, createMockRouteParams({ id: 'tx-4' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
entry_type: string
|
||||
lines: Array<{ account_number: string; debit_amount: number }>
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.entry_type).toBe('clearing')
|
||||
const accounts = body.lines.map((l) => l.account_number)
|
||||
expect(accounts).toContain('1940')
|
||||
expect(accounts).not.toContain('1930')
|
||||
expect(body.lines.find((l) => l.account_number === '1940')?.debit_amount).toBe(1250)
|
||||
})
|
||||
|
||||
it('cash entry: previews the bank leg on the transaction\'s own linked cash account, not 1930', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-5',
|
||||
amount: 12500,
|
||||
currency: 'SEK',
|
||||
date: '2026-05-30',
|
||||
invoice_id: null,
|
||||
cash_account_id: 'ca-1940',
|
||||
})
|
||||
const invoice = makeInvoice({
|
||||
id: VALID_UUID,
|
||||
status: 'sent',
|
||||
currency: 'SEK',
|
||||
total: 12500,
|
||||
remaining_amount: 12500,
|
||||
paid_amount: 0,
|
||||
})
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null })
|
||||
enqueue({ data: { ledger_account: '1940' }, error: null }) // cash_accounts lookup
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-5/match-invoice/preview', {
|
||||
searchParams: { invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await GET(request, createMockRouteParams({ id: 'tx-5' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
entry_type: string
|
||||
lines: Array<{ account_number: string; debit_amount: number }>
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.entry_type).toBe('cash')
|
||||
const accounts = body.lines.map((l) => l.account_number)
|
||||
expect(accounts).toContain('1940')
|
||||
expect(accounts).not.toContain('1930')
|
||||
expect(body.lines.find((l) => l.account_number === '1940')?.debit_amount).toBe(12500)
|
||||
})
|
||||
|
||||
it('defaults to 1930 when the transaction has no linked cash account', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-6',
|
||||
amount: 1250,
|
||||
currency: 'SEK',
|
||||
date: '2026-05-30',
|
||||
invoice_id: null,
|
||||
cash_account_id: null,
|
||||
})
|
||||
const invoice = makeInvoice({
|
||||
id: VALID_UUID,
|
||||
status: 'sent',
|
||||
currency: 'SEK',
|
||||
total: 1250,
|
||||
remaining_amount: 1250,
|
||||
paid_amount: 0,
|
||||
journal_entry_id: 'je-original',
|
||||
})
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
// No cash_accounts enqueue: resolveSettlementAccount short-circuits to
|
||||
// '1930' when cash_account_id is null, with no DB call.
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-6/match-invoice/preview', {
|
||||
searchParams: { invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await GET(request, createMockRouteParams({ id: 'tx-6' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
lines: Array<{ account_number: string; debit_amount: number }>
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.lines.find((l) => l.account_number === '1930')?.debit_amount).toBe(1250)
|
||||
})
|
||||
|
||||
it('aborts with 500 BOOKKEEPING_DATABASE_ERROR when the cash_accounts lookup errors', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-7',
|
||||
amount: 1250,
|
||||
currency: 'SEK',
|
||||
date: '2026-05-30',
|
||||
invoice_id: null,
|
||||
cash_account_id: 'ca-broken',
|
||||
})
|
||||
const invoice = makeInvoice({
|
||||
id: VALID_UUID,
|
||||
status: 'sent',
|
||||
currency: 'SEK',
|
||||
total: 1250,
|
||||
remaining_amount: 1250,
|
||||
paid_amount: 0,
|
||||
journal_entry_id: 'je-original',
|
||||
})
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
enqueue({ data: null, error: { message: 'connection reset' } }) // cash_accounts lookup errors
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-7/match-invoice/preview', {
|
||||
searchParams: { invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await GET(request, createMockRouteParams({ id: 'tx-7' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(body.error.code).toBe('BOOKKEEPING_DATABASE_ERROR')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,10 +6,15 @@
|
||||
*
|
||||
* The shape mirrors the routing decision in the POST handler: if the invoice
|
||||
* was already booked (invoice.journal_entry_id is set, i.e. 1510 is on the
|
||||
* books), we preview the clearing entry (Dr 1930 / Cr 1510). Only when the
|
||||
* invoice was never booked AND the company is on kontantmetoden AND the
|
||||
* receipt fully pays the invoice do we preview the cash entry (Dr 1930 /
|
||||
* Cr 30xx / Cr 26xx).
|
||||
* books), we preview the clearing entry (Dr <resolved account> / Cr 1510).
|
||||
* Only when the invoice was never booked AND the company is on kontantmetoden
|
||||
* AND the receipt fully pays the invoice do we preview the cash entry
|
||||
* (Dr <resolved account> / Cr 30xx / Cr 26xx).
|
||||
*
|
||||
* The bank leg is resolved from THIS transaction's own cash_account_id via
|
||||
* resolveSettlementAccount, never hardcoded to 1930, so the preview stays
|
||||
* byte-identical to what the POST handler commits (mirrors the fix already
|
||||
* applied on the supplier-invoice side).
|
||||
*
|
||||
* The UI uses this to show the user the exact lines before they confirm:
|
||||
* the lack of any preview was part of the reported bug.
|
||||
@@ -21,6 +26,7 @@ import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
|
||||
import { roundOre, ORE_ROUNDING_SETTLEMENT_MAX } from '@/lib/money'
|
||||
import { getRevenueAccount, getOutputVatAccount } from '@/lib/bookkeeping/invoice-entries'
|
||||
import { buildInvoicePaymentClearingLines } from '@/lib/bookkeeping/invoice-payment-lines'
|
||||
import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
|
||||
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
|
||||
import type { Currency, EntityType, Invoice, InvoiceItem } from '@/types'
|
||||
import { z } from 'zod'
|
||||
@@ -54,11 +60,13 @@ export const GET = withRouteContext(
|
||||
|
||||
// Data minimization (GDPR Art.5(1)(c)): amount_sek + exchange_rate are
|
||||
// pulled because buildInvoicePaymentClearingLines needs them for the
|
||||
// cross-currency bank-leg math (round-7 FX fix). All other columns
|
||||
// would broaden the projection without serving the preview's purpose.
|
||||
// cross-currency bank-leg math (round-7 FX fix). cash_account_id resolves
|
||||
// which BAS account this bank line actually settles into, mirroring the
|
||||
// POST handler's settlement-account lookup. All other columns would
|
||||
// broaden the projection without serving the preview's purpose.
|
||||
const { data: transaction, error: txErr } = await supabase
|
||||
.from('transactions')
|
||||
.select('id, date, amount, amount_sek, currency, exchange_rate')
|
||||
.select('id, date, amount, amount_sek, currency, exchange_rate, cash_account_id')
|
||||
.eq('id', transactionId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
@@ -85,6 +93,16 @@ export const GET = withRouteContext(
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
|
||||
// Same resolution as the POST handler: debit the cash account this
|
||||
// transaction is actually linked to, never a hardcoded 1930, so the
|
||||
// preview stays byte-identical to what gets committed.
|
||||
const paymentAccount = await resolveSettlementAccount(
|
||||
supabase,
|
||||
companyId!,
|
||||
transaction.cash_account_id,
|
||||
log,
|
||||
)
|
||||
|
||||
// Cross-currency FX preview. When tx.currency !== invoice.currency we fetch
|
||||
// the Riksbanken spot rate for invoice.currency on the tx date and surface
|
||||
// the conversion to the dialog (the user sees the rate + invoice-currency-
|
||||
@@ -244,7 +262,7 @@ export const GET = withRouteContext(
|
||||
: resolveSekAmount(inv.total, inv.total_sek, inv.currency, inv.exchange_rate)
|
||||
|
||||
lines.push({
|
||||
account_number: '1930',
|
||||
account_number: paymentAccount,
|
||||
debit_amount: Math.round(cashDebit * 100) / 100,
|
||||
credit_amount: 0,
|
||||
description: 'Inbetalning från bank',
|
||||
@@ -277,6 +295,7 @@ export const GET = withRouteContext(
|
||||
fxConversion.required && !('error' in fxConversion)
|
||||
? fxConversion.paid_in_invoice_currency
|
||||
: undefined,
|
||||
paymentAccount,
|
||||
)
|
||||
for (const line of clearingLines) {
|
||||
lines.push({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createInvoiceCashEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
import { buildInvoicePaymentClearingLines } from '@/lib/bookkeeping/invoice-payment-lines'
|
||||
import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
|
||||
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
|
||||
import { reverseEntry, createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
|
||||
import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
@@ -27,8 +28,17 @@ ensureInitialized()
|
||||
* 3. Updates invoice status to 'paid' or 'partially_paid'
|
||||
* 4. Records payment in invoice_payments table
|
||||
* 5. Creates journal entry for payment receipt
|
||||
* - Debit 1930 Företagskonto (Bank)
|
||||
* - Debit <resolved bank account> Företagskonto (Bank)
|
||||
* - Credit 1510 Kundfordringar (Accounts Receivable)
|
||||
*
|
||||
* The bank leg is resolved from THIS transaction's own cash_account_id via
|
||||
* resolveSettlementAccount (cash_account_id -> cash_accounts.ledger_account),
|
||||
* never hardcoded to 1930: a receipt landing in a secondary SEK account or a
|
||||
* foreign-currency account (e.g. 1940 for EUR) must book to that account, not
|
||||
* silently to the primary bank account. Mirrors the fix already applied on
|
||||
* the supplier-invoice side (match-supplier-invoice/route.ts), which resolves
|
||||
* the credited account the same way instead of falling back to a stale
|
||||
* company-wide setting.
|
||||
*/
|
||||
export const POST = withRouteContext(
|
||||
'transaction.match_invoice',
|
||||
@@ -323,6 +333,20 @@ export const POST = withRouteContext(
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
const 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. A receipt into a secondary SEK account or a
|
||||
// foreign-currency account (e.g. 1940 for EUR) must book there, not to
|
||||
// the primary account, or the GL silently diverges from the actual bank
|
||||
// statement it's meant to represent (BFL 5 kap 1-2§).
|
||||
const paymentAccount = await resolveSettlementAccount(
|
||||
supabase,
|
||||
companyId!,
|
||||
transaction.cash_account_id,
|
||||
txLog,
|
||||
)
|
||||
|
||||
// Drive the JE shape from the INVOICE'S booking state, not from the
|
||||
// company's current accounting_method setting. If the invoice was already
|
||||
// booked at send (Dr 1510 / Cr 30xx + VAT) we MUST clear 1510 here:
|
||||
@@ -374,7 +398,7 @@ export const POST = withRouteContext(
|
||||
} else if (useCashEntry) {
|
||||
const journalEntry = await createInvoiceCashEntry(
|
||||
supabase, companyId, user.id, invoice as Invoice, transaction.date,
|
||||
entityType, invoice.customer?.name,
|
||||
entityType, invoice.customer?.name, paymentAccount,
|
||||
)
|
||||
journalEntryId = journalEntry?.id ?? null
|
||||
} else {
|
||||
@@ -420,6 +444,7 @@ export const POST = withRouteContext(
|
||||
// amount so the helper credits 1510 proportionally and posts the
|
||||
// FX-diff line. Same-currency: undefined, helper just uses bankSek.
|
||||
fx.required ? fx.paidInInvoiceCurrency : undefined,
|
||||
paymentAccount,
|
||||
)
|
||||
const journalEntry = await createJournalEntry(supabase, companyId!, user.id, {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user