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:
@@ -73,6 +73,9 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-07-11] NE/INK2 amounts display in whole kronor (matches filed SRU values per SFL); momsdeklaration keeps öre (reconciles against ledger and settlement verifikat). Numbered h2 section headers instead of a stepper component on the VAT page: same sequencing legibility, a tenth of the diff.
|
||||
[2026-07-11] Closed the v1/MCP-facing half of the #985 settlement-account gap (PR #985 itself only fixed the dashboard routes): v1 match-supplier-invoice now resolves paymentAccount via resolveSettlementAccount for the pure-SEK accrual path (was always hardcoded 1930, no call site even read cash_account_id); v1 categorize now calls applySettlementAccount after building mappingResult, which it never did before. Left the FX/foreign-currency branch (createSupplierInvoicePaymentEntry) and the cash-method branch (createSupplierInvoiceCashEntry) on their pre-existing internal 1930 default, matching #985's own scope decision on the dashboard route. Follow-ups tracked separately: #1000 (closing the FX/cash-method gap) and #1001 (detecting/remediating historical mis-bookings).
|
||||
[2026-07-12] Compliance-review triage on the payment-link PR: finding 1 (email pay button on kreditfaktura) verified FALSE: invoice-templates.ts derives isCreditNote from credited_invoice_id and hidePayment already gates both HTML and text builders; no change. Finding 2 was the real deferred v1 gap but misfiled against invoice-columns.ts (which already carries deduction_total): the actual hole was the v1 send route's hand-rolled fetch projection, now replaced with the shared INVOICE_FULL_COLUMNS/INVOICE_ITEM_FULL_COLUMNS so PDF/email inputs cannot drift from the GET shape again (closes the [2026-07-10] deferred ROT/RUT send fix; also gives v1 sends the pay button + deduction box). Finding 3 accepted as a robustness fix only: the non-ok path already reflected true server state, but a thrown fetch left the Godkann spinner stuck; approve handler now try/catch/finally with a server refetch on failure.
|
||||
[2026-07-11] Closed the customer-side half of the PR #985 settlement-account gap: match-invoice (POST + preview), the v1 match-invoice route, and the agent/MCP match_transaction_invoice commit path all hardcoded account_number: '1930' for the bank leg unconditionally (never read cash_account_id at all, worse than #985's stale-setting trigger). Added an optional paymentAccount param (default '1930', preserving every other caller) to buildInvoicePaymentClearingLines, createInvoicePaymentJournalEntry, and createInvoiceCashEntry, and threaded resolveSettlementAccount(transaction.cash_account_id) through the three real-transaction-matching call sites above. Left mark-paid (dashboard + v1, no bank transaction in scope), fix-cash-mismatch (narrow historical repair tool, different bug class), and the agent mark_invoice_paid commit path on default 1930 behavior: none of them have a matched bank transaction to resolve an account from.
|
||||
[2026-07-12] resolveSettlementAccount now throws BookkeepingDatabaseError('resolve_settlement_account', ...) instead of warning-and-falling-back-to-1930 when the cash_accounts lookup itself errors (compliance-bot finding, same change applied identically across #985/#986/#987, shared helper file): an explicit cash_account_id almost certainly resolves to a non-1930 account, so a transient DB blip masking it must not silently misbook a real payment. No route/commit.ts code changes needed: match-invoice (POST + preview) run under withRouteContext, whose existing catch-all converts any isBookkeepingError() throw into a structured 500; commitMatchTransactionInvoice's caller (commitPendingOperationInner) already has identical generic bookkeeping-error handling for every other engine failure (marks the op 'rejected', returns status 'failed'). Added regression tests for all three call sites confirming the abort rather than assuming the shared infrastructure handles it silently. Did NOT add v1 match-invoice test coverage for this (or for the settlement-account fix in general): that route has no existing test coverage in the shared app/api/v1/.../[id]/__tests__/route.test.ts file at all -- a pre-existing gap from this PR's own scope, not something this specific fix should expand to cover.
|
||||
[2026-07-12] Closed the two remaining gaps from jakobwennberg's adversarial-review triage on #987 (after rebasing onto main): (1) added the v1 match-invoice route-level test coverage that the prior entry above explicitly deferred -- cash-account threading to createInvoicePaymentJournalEntry, the BOOKKEEPING_DATABASE_ERROR abort on lookup failure, and a new ACCOUNTS_NOT_IN_CHART case -- in app/api/v1/companies/[companyId]/transactions/[id]/__tests__/route.test.ts, mirroring the dashboard route's existing settlement-account-resolution describe block. (2) Added the same findUnresolvableAccounts pre-validation guard against chart_of_accounts that 32c07c4 added to #986's match-supplier-invoice route, to the v1 match-invoice route: gated on !customLines since that is the only branch here that actually consumes the resolved paymentAccount (customLines specify their own accounts directly). The dashboard match-invoice route and the agent/MCP commit path were not given the equivalent guard: jakobwennberg's note named only the v1 surface, and those two paths don't have the same "generic engine error swallows a specific chart violation" failure mode this guard exists to avoid (v1's own catch block already special-cased AccountsNotInChartError; the guard just avoids reaching it via a wasted engine round-trip and gives an explicit pre-check log line).
|
||||
[2026-07-12] resolveSettlementAccount now throws BookkeepingDatabaseError('resolve_settlement_account', ...) instead of warning-and-falling-back-to-1930 when the cash_accounts lookup itself errors (compliance-bot finding, same change applied identically across #985/#986/#987 since it's the shared helper file): an explicit cash_account_id almost certainly resolves to a non-1930 account, so a transient DB blip masking it must not silently misbook a real payment. No route code changes needed here either -- v1 match-supplier-invoice and categorize both already run under withApiV1, whose existing catch-all converts any isBookkeepingError() throw into the correct structured 500 via v1ErrorResponse. Added regression tests for both v1 call sites confirming the abort (status 500, code BOOKKEEPING_DATABASE_ERROR, no JE created) rather than assuming the shared infrastructure handles it silently.
|
||||
[2026-07-12] #986 review follow-up (CodeRabbit + jakobwennberg triage): v1 match-supplier-invoice now pre-validates the resolved settlement account against chart_of_accounts before booking the pure-SEK accrual entry, returning ACCOUNTS_NOT_IN_CHART instead of the generic MATCH_SI_RECORD_PAYMENT_FAILED for a deactivated cash_accounts.ledger_account; same AccountsNotInChartError race-guard added to the catch block, mirroring the categorize routes' existing pattern.
|
||||
[2026-07-11] match-supplier-invoice (POST + preview) misbooked a real bank payment to 2893 (skuld till aktieägare) instead of 1930: both routes defaulted paymentAccount from company_settings.last_supplier_payment_account, a sticky setting only meant to remember the manual mark-paid "betald med privata medel" account choice. Once that setting held 2893 from an unrelated private payment, every subsequent real bank-transaction match reused it. Fixed by resolving the credit account from the matched transaction's own cash_account_id -> cash_accounts.ledger_account (falling back to 1930 when unlinked), mirroring the existing settlement-account lookup in transactions/[id]/categorize/route.ts. last_supplier_payment_account is no longer read by either route; it stays scoped to seeding the manual mark-paid UI's default picker. Did not touch the FX branch (createSupplierInvoicePaymentEntry, still defaults paymentAccount internally to 1930) or the cash-method branch (createSupplierInvoiceCashEntry, called with paymentAccount=undefined): both are pre-existing, separate gaps outside this bug's repro (a pure-SEK accrual match).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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