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:
@@ -766,6 +766,85 @@ describe('createInvoicePaymentJournalEntry: exchange rate difference', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('paymentAccount parameter (settlement-account resolution)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('createInvoicePaymentJournalEntry defaults the bank leg to 1930 when paymentAccount is omitted', async () => {
|
||||
const invoice = makeInvoice({ total: 1250 })
|
||||
|
||||
await createInvoicePaymentJournalEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-15')
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
expect(input.lines.find((l) => l.account_number === '1930')?.debit_amount).toBe(1250)
|
||||
})
|
||||
|
||||
it('createInvoicePaymentJournalEntry books the bank leg to the resolved account (SEK, no FX)', async () => {
|
||||
const invoice = makeInvoice({ total: 1250 })
|
||||
|
||||
await createInvoicePaymentJournalEntry(
|
||||
null as never, 'company-1', 'user-1', invoice, '2024-07-15',
|
||||
undefined, undefined, undefined, '1940',
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
expect(input.lines.find((l) => l.account_number === '1940')?.debit_amount).toBe(1250)
|
||||
expect(input.lines.find((l) => l.account_number === '1930')).toBeUndefined()
|
||||
// 1510 credit is untouched by the payment-account override.
|
||||
expect(input.lines.find((l) => l.account_number === '1510')?.credit_amount).toBe(1250)
|
||||
})
|
||||
|
||||
it('createInvoicePaymentJournalEntry books the FX-branch bank leg to the resolved account, leaving 3960/1510 untouched', async () => {
|
||||
const invoice = makeInvoice({
|
||||
currency: 'EUR',
|
||||
exchange_rate: 11.5,
|
||||
total: 1000,
|
||||
total_sek: 11500,
|
||||
})
|
||||
|
||||
await createInvoicePaymentJournalEntry(
|
||||
null as never, 'company-1', 'user-1', invoice, '2024-07-15',
|
||||
200, undefined, undefined, '1940',
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
expect(input.lines.find((l) => l.account_number === '1940')?.debit_amount).toBe(11700)
|
||||
expect(input.lines.find((l) => l.account_number === '1930')).toBeUndefined()
|
||||
expect(input.lines.find((l) => l.account_number === '1510')?.credit_amount).toBe(11500)
|
||||
expect(input.lines.find((l) => l.account_number === '3960')?.credit_amount).toBe(200)
|
||||
})
|
||||
|
||||
it('createInvoiceCashEntry defaults the bank leg to 1930 when paymentAccount is omitted', async () => {
|
||||
const invoice = makeInvoice({ total: 1198 })
|
||||
|
||||
await createInvoiceCashEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-01')
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
expect(input.lines.find((l) => l.account_number === '1930')?.debit_amount).toBe(1198)
|
||||
})
|
||||
|
||||
it('createInvoiceCashEntry books the bank leg to the resolved account, leaving revenue/VAT credits untouched', async () => {
|
||||
const invoice = makeInvoice({
|
||||
subtotal: 1000,
|
||||
vat_amount: 198,
|
||||
total: 1198,
|
||||
vat_treatment: 'standard_25',
|
||||
})
|
||||
|
||||
await createInvoiceCashEntry(
|
||||
null as never, 'company-1', 'user-1', invoice, '2024-07-01',
|
||||
'enskild_firma', undefined, '1940',
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
expect(input.lines.find((l) => l.account_number === '1940')?.debit_amount).toBe(1198)
|
||||
expect(input.lines.find((l) => l.account_number === '1930')).toBeUndefined()
|
||||
expect(input.lines.find((l) => l.account_number === '3001')?.credit_amount).toBe(1000)
|
||||
expect(input.lines.find((l) => l.account_number === '2611')?.credit_amount).toBe(198)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createInvoiceJournalEntry: ROT/RUT-avdrag', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -1326,4 +1405,46 @@ describe('createInvoiceCashEntry: ROT/RUT-avdrag', () => {
|
||||
const totalCredit = input.lines.reduce((sum, l) => sum + l.credit_amount, 0)
|
||||
expect(totalDebit).toBe(totalCredit)
|
||||
})
|
||||
|
||||
it('cash method ROT with a non-default paymentAccount: bank leg moves, 1513 stays fixed', async () => {
|
||||
const invoice = makeInvoice({
|
||||
subtotal: 10000,
|
||||
vat_amount: 2500,
|
||||
total: 12500,
|
||||
vat_treatment: 'standard_25',
|
||||
items: [
|
||||
makeItem({
|
||||
quantity: 1,
|
||||
unit_price: 10000,
|
||||
line_total: 10000,
|
||||
vat_rate: 25,
|
||||
vat_amount: 2500,
|
||||
deduction_type: 'rot',
|
||||
deduction_amount: 3000,
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
await createInvoiceCashEntry(
|
||||
null as never, 'company-1', 'user-1', invoice, '2024-07-01',
|
||||
'enskild_firma', undefined, '1940',
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
|
||||
// The bank leg follows the resolved paymentAccount, still reduced by the deduction.
|
||||
const debit1940 = input.lines.find((l) => l.account_number === '1940')
|
||||
expect(debit1940?.debit_amount).toBe(9500)
|
||||
expect(input.lines.find((l) => l.account_number === '1930')).toBeUndefined()
|
||||
|
||||
// The ROT/RUT receivable from Skatteverket is never the bank leg, so it
|
||||
// must stay on 1513 regardless of paymentAccount.
|
||||
const debit1513NonDefault = input.lines.find((l) => l.account_number === '1513')
|
||||
expect(debit1513NonDefault?.debit_amount).toBe(3000)
|
||||
|
||||
// Balance
|
||||
const totalDebit = input.lines.reduce((sum, l) => sum + l.debit_amount, 0)
|
||||
const totalCredit = input.lines.reduce((sum, l) => sum + l.credit_amount, 0)
|
||||
expect(totalDebit).toBe(totalCredit)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -261,4 +261,61 @@ describe('buildInvoicePaymentClearingLines', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -129,12 +129,30 @@ export interface PaymentClearingLines {
|
||||
* otherwise defer (book 1930 = 1510 = bankSek with no FX line). The
|
||||
* deferred path leaves the GL slightly understated until the final
|
||||
* settlement closes the invoice.
|
||||
*
|
||||
* # paymentAccount
|
||||
* The bank-leg account (the debit line below). Defaults to '1930': callers
|
||||
* that haven't resolved the transaction's actual cash account keep booking
|
||||
* there unchanged. Callers matching a real bank transaction should resolve
|
||||
* it via resolveSettlementAccount (cash_account_id -> cash_accounts.ledger_
|
||||
* account) and pass it here so a receipt into a non-primary bank/cash
|
||||
* account (e.g. a secondary SEK account, or a EUR account on 1940) doesn't
|
||||
* silently get misbooked to the primary account.
|
||||
*/
|
||||
export function buildInvoicePaymentClearingLines(
|
||||
tx: PaymentClearingTx,
|
||||
invoice: PaymentClearingInvoice,
|
||||
description: string,
|
||||
paidInInvoiceCurrency?: number,
|
||||
/**
|
||||
* BAS account for the bank leg (the 1930 debit below). Defaults to '1930'
|
||||
* to preserve existing behaviour for every caller that doesn't pass one.
|
||||
* Callers that know which cash account the underlying bank transaction
|
||||
* actually belongs to (via cash_account_id -> cash_accounts.ledger_account,
|
||||
* see lib/bookkeeping/settlement-account.ts) should resolve it and pass it
|
||||
* here instead of always booking to the primary bank account.
|
||||
*/
|
||||
paymentAccount = '1930',
|
||||
): PaymentClearingLines {
|
||||
// Bank-leg: actual SEK that hit the bank. resolveSekAmount returns the
|
||||
// raw amount for SEK txs and amount * exchange_rate for foreign txs
|
||||
@@ -201,7 +219,7 @@ export function buildInvoicePaymentClearingLines(
|
||||
|
||||
const lines: CreateJournalEntryLineInput[] = [
|
||||
{
|
||||
account_number: '1930',
|
||||
account_number: paymentAccount,
|
||||
debit_amount: bankSek,
|
||||
credit_amount: 0,
|
||||
line_description: description,
|
||||
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Settlement-account resolution coverage for the agent/MCP match-transaction-
|
||||
* to-invoice commit path (`commitMatchTransactionInvoice` in
|
||||
* lib/pending-operations/commit.ts).
|
||||
*
|
||||
* This path books the customer-payment verifikat exactly like the dashboard's
|
||||
* POST /api/transactions/[id]/match-invoice route, and previously shared the
|
||||
* same gap: the bank leg was unconditionally hardcoded to 1930 instead of
|
||||
* being resolved from the matched transaction's own cash_account_id. Mirrors
|
||||
* the fix and the regression tests added to that route's test suite.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import type { PendingOperation } from '@/types'
|
||||
|
||||
const mockCreatePaymentEntry = vi.fn()
|
||||
const mockCreateCashEntry = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/invoice-entries', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/bookkeeping/invoice-entries')>(
|
||||
'@/lib/bookkeeping/invoice-entries',
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
createInvoicePaymentJournalEntry: (...args: unknown[]) => mockCreatePaymentEntry(...args),
|
||||
createInvoiceCashEntry: (...args: unknown[]) => mockCreateCashEntry(...args),
|
||||
}
|
||||
})
|
||||
|
||||
import { commitPendingOperation } from '../commit'
|
||||
|
||||
function makePendingOp(overrides: Partial<PendingOperation>): PendingOperation {
|
||||
return {
|
||||
id: 'op-1',
|
||||
user_id: 'user-1',
|
||||
company_id: 'company-1',
|
||||
operation_type: 'match_transaction_invoice',
|
||||
status: 'pending',
|
||||
title: 'test',
|
||||
params: {},
|
||||
preview_data: {},
|
||||
result_data: null,
|
||||
actor_type: 'user',
|
||||
actor_id: null,
|
||||
actor_label: null,
|
||||
risk_level: 'medium',
|
||||
created_at: '2026-05-03T00:00:00Z',
|
||||
resolved_at: null,
|
||||
updated_at: '2026-05-03T00:00:00Z',
|
||||
...overrides,
|
||||
} as PendingOperation
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
mockCreatePaymentEntry.mockResolvedValue({ id: 'je-1' })
|
||||
mockCreateCashEntry.mockResolvedValue({ id: 'je-1' })
|
||||
})
|
||||
|
||||
describe('commitPendingOperation: match_transaction_invoice settlement account resolution', () => {
|
||||
it('credits the payment JE to the transaction\'s own linked cash account, not a hardcoded 1930', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'tx-1',
|
||||
company_id: 'company-1',
|
||||
amount: 12500,
|
||||
currency: 'SEK',
|
||||
date: '2026-05-12',
|
||||
invoice_id: null,
|
||||
journal_entry_id: null,
|
||||
cash_account_id: 'ca-1940',
|
||||
},
|
||||
error: null,
|
||||
}) // transaction fetch
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'inv-1',
|
||||
invoice_number: 'F-2026001',
|
||||
status: 'sent',
|
||||
total: 12500,
|
||||
remaining_amount: 12500,
|
||||
paid_amount: 0,
|
||||
currency: 'SEK',
|
||||
exchange_rate: null,
|
||||
journal_entry_id: null,
|
||||
customer: { name: 'Test AB' },
|
||||
},
|
||||
error: null,
|
||||
}) // invoice fetch
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null }) // settings
|
||||
enqueue({ data: { ledger_account: '1940' }, error: null }) // cash_accounts lookup
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null }) // invoice CAS update
|
||||
enqueue({ data: null, error: null }) // invoice_payments insert
|
||||
enqueue({ data: null, error: null }) // transactions update (link)
|
||||
enqueue({ data: null, error: null }) // dispatcher pending_operations update
|
||||
|
||||
const op = makePendingOp({ params: { transaction_id: 'tx-1', invoice_id: 'inv-1' } })
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('committed')
|
||||
expect(mockCreatePaymentEntry).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'user-1',
|
||||
expect.objectContaining({ id: 'inv-1' }),
|
||||
'2026-05-12',
|
||||
undefined,
|
||||
'Test AB',
|
||||
12500,
|
||||
'1940',
|
||||
)
|
||||
expect(mockCreateCashEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('defaults to 1930 when the transaction has no linked cash account', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'tx-1',
|
||||
company_id: 'company-1',
|
||||
amount: 12500,
|
||||
currency: 'SEK',
|
||||
date: '2026-05-12',
|
||||
invoice_id: null,
|
||||
journal_entry_id: null,
|
||||
cash_account_id: null,
|
||||
},
|
||||
error: null,
|
||||
}) // transaction fetch
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'inv-1',
|
||||
invoice_number: 'F-2026001',
|
||||
status: 'sent',
|
||||
total: 12500,
|
||||
remaining_amount: 12500,
|
||||
paid_amount: 0,
|
||||
currency: 'SEK',
|
||||
exchange_rate: null,
|
||||
journal_entry_id: null,
|
||||
customer: { name: 'Test AB' },
|
||||
},
|
||||
error: null,
|
||||
}) // invoice fetch
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null }) // settings
|
||||
// No cash_accounts enqueue: resolveSettlementAccount short-circuits to
|
||||
// '1930' when cash_account_id is null, with no DB call.
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null }) // invoice CAS update
|
||||
enqueue({ data: null, error: null }) // invoice_payments insert
|
||||
enqueue({ data: null, error: null }) // transactions update (link)
|
||||
enqueue({ data: null, error: null }) // dispatcher pending_operations update
|
||||
|
||||
const op = makePendingOp({ params: { transaction_id: 'tx-1', invoice_id: 'inv-1' } })
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('committed')
|
||||
expect(mockCreatePaymentEntry).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'user-1',
|
||||
expect.objectContaining({ id: 'inv-1' }),
|
||||
'2026-05-12',
|
||||
undefined,
|
||||
'Test AB',
|
||||
12500,
|
||||
'1930',
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects the operation (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 { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'tx-1',
|
||||
company_id: 'company-1',
|
||||
amount: 12500,
|
||||
currency: 'SEK',
|
||||
date: '2026-05-12',
|
||||
invoice_id: null,
|
||||
journal_entry_id: null,
|
||||
cash_account_id: 'ca-broken',
|
||||
},
|
||||
error: null,
|
||||
}) // transaction fetch
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'inv-1',
|
||||
invoice_number: 'F-2026001',
|
||||
status: 'sent',
|
||||
total: 12500,
|
||||
remaining_amount: 12500,
|
||||
paid_amount: 0,
|
||||
currency: 'SEK',
|
||||
exchange_rate: null,
|
||||
journal_entry_id: null,
|
||||
customer: { name: 'Test AB' },
|
||||
},
|
||||
error: null,
|
||||
}) // invoice fetch
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null }) // settings
|
||||
enqueue({ data: null, error: { message: 'connection reset' } }) // cash_accounts lookup errors
|
||||
enqueue({ data: null, error: null }) // dispatcher marks the op 'rejected'
|
||||
|
||||
const op = makePendingOp({ params: { transaction_id: 'tx-1', invoice_id: 'inv-1' } })
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('failed')
|
||||
expect(mockCreatePaymentEntry).not.toHaveBeenCalled()
|
||||
expect(mockCreateCashEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
createInvoiceJournalEntry,
|
||||
createCreditNoteJournalEntry,
|
||||
} from '@/lib/bookkeeping/invoice-entries'
|
||||
import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
|
||||
import { createJournalEntry, findFiscalPeriod, reverseEntry, validateBalance } from '@/lib/bookkeeping/engine'
|
||||
import { coerceDimensionsBag } from '@/lib/bookkeeping/dimension-resolver'
|
||||
import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
|
||||
@@ -1412,16 +1413,24 @@ async function commitMatchTransactionInvoice(
|
||||
const invoiceAlreadyBooked = !!(invoice as { journal_entry_id?: string | null }).journal_entry_id
|
||||
const useCashEntry = !invoiceAlreadyBooked && accountingMethod === 'cash' && isFullyPaid
|
||||
|
||||
// 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. Mirrors the match-invoice route fix.
|
||||
const paymentAccount = await resolveSettlementAccount(supabase, companyId, transaction.cash_account_id, log)
|
||||
|
||||
let journalEntryId: string | null = null
|
||||
try {
|
||||
if (useCashEntry) {
|
||||
const je = await createInvoiceCashEntry(
|
||||
supabase, companyId, userId, invoice as Invoice, transaction.date, entityType, invoice.customer?.name
|
||||
supabase, companyId, userId, invoice as Invoice, transaction.date, entityType, invoice.customer?.name,
|
||||
paymentAccount,
|
||||
)
|
||||
journalEntryId = je?.id ?? null
|
||||
} else {
|
||||
const je = await createInvoicePaymentJournalEntry(
|
||||
supabase, companyId, userId, invoice as Invoice, transaction.date, undefined, invoice.customer?.name, paidAmount
|
||||
supabase, companyId, userId, invoice as Invoice, transaction.date, undefined, invoice.customer?.name, paidAmount,
|
||||
paymentAccount,
|
||||
)
|
||||
journalEntryId = je?.id ?? null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user