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
parent 8a41b5dbf2
commit 64ea0fef02
12 changed files with 1053 additions and 15 deletions
@@ -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)
})
})
})
+19 -1
View File
@@ -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,
@@ -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()
})
})
+11 -2
View File
@@ -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
}