fix(v1): thread resolved settlement account through FX and cash-method supplier-payment branches (#1033)
* fix(v1): thread resolved settlement account through FX and cash-method supplier-payment branches The v1 (MCP-facing) match-supplier-invoice route resolved paymentAccount via resolveSettlementAccount but only passed it to createSupplierInvoicePaymentEntry for pure-SEK matches (gated on isPureSek) and never to createSupplierInvoiceCashEntry at all. A foreign-currency match, or a kontantmetoden match, settling from a bank/cash account other than the primary 1930 (e.g. a EUR account on 1940) was still misbooked to 1930: the same class of bug PR #985/#986 fixed for the pure-SEK accrual path. Pass the resolved account through both branches unconditionally (the generators' internal 1930 default remains the documented no-link fallback, reached via resolveSettlementAccount's own fallback for transactions without a cash_account_id), and widen the findUnresolvableAccounts chart pre-validation from the pure-SEK accrual path to every non-customLines branch, since all of them now consume the resolved account. The dashboard route needed no code change: its FX/cash-method branches were already threaded inside PR #985 itself. Added branch-level regression tests on both routes (linked non-1930 account books to that account; no cash_account_id falls back to 1930; deactivated resolved account rejects with ACCOUNTS_NOT_IN_CHART before booking). Fixes #1000 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(v1): validate settlement account against the chart before the conflicting-JE storno The widened findUnresolvableAccounts pre-validation ran after the conflicting-categorization storno, so a request rejected with ACCOUNTS_NOT_IN_CHART could first reverse the transaction's posted categorization entry: an irreversible side effect on a failed request. Move the paymentAccount resolution and the chart validation ahead of the storno block (same !customLines guard, same error shape) and add a regression test asserting reverseEntry is never called when the chart validation fails. The dashboard route has no storno block and no chart pre-validation on this path, so it is unaffected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -164,6 +164,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-07-15] Superseded the hard deletion part of the 2026-07-14 credit note draft decision: numbered credit note drafts are now retained as cancelled rows and reopened on retry so the KR series remains complete.
|
||||
[2026-07-15] Customer personnummer uses application field encryption with masked API and UI output rather than a database-only cipher: the existing key custody and AES-256-GCM implementation can protect values before they reach Postgres, while ordinary reads never expose the full identifier.
|
||||
[2026-07-15] Credit note creation uses a completion marker plus unique company guards instead of a large creation RPC: incomplete parents are never returned, concurrent requests converge, and all journal writes remain in the bookkeeping engine.
|
||||
[2026-07-16] Closed #1000 (v1 match-supplier-invoice FX + cash-method settlement account): threaded the already-resolved paymentAccount into createSupplierInvoicePaymentEntry (was gated on isPureSek) and createSupplierInvoiceCashEntry (was passed undefined), and widened the findUnresolvableAccounts chart pre-validation from the pure-SEK accrual path to every !customLines branch, since every non-customLines branch now consumes the resolved account. The dashboard route needed no code change (its FX/cash branches were already threaded inside PR #985 itself); added branch-level regression tests on both routes instead. The entry generators keep their internal 1930 default: it is the documented no-link fallback, reached via resolveSettlementAccount returning 1930 for transactions without a cash_account_id.
|
||||
|
||||
[2026-07-16] Two bugs from one customer report (an AB). Bug 1 (acct 2893 showed 2393's "langfristig del" memo after an andringsverifikation): root cause = CorrectionEntryDialog never re-derived line_description on account change (JournalEntryForm does). Fixed forward via a pure helper (correction-line-description.ts) that refreshes the memo only when it is empty or still equals the prev account's name (preserves hand-typed memos). Chose NO prod data repair: the wrong memo sits on a POSTED verifikat (immutable per migration-017 trigger); it is cosmetic (account number + amounts correct, all reports key off the number); ~26 posted lines across 11 cos share this stale-echo pattern, all fix-forward only. Deferred the twin entry-level header fix (#1031). Bug 2 (auto tax-deadlines never appeared): root cause = generation only fired on a settings save where a TAX field CHANGED value (didTaxFieldsChange); settings are filled once at onboarding so re-saving generated nothing -> only 5/776 real cos had system deadlines. Chose count-based self-heal (regenerate when the company has 0 system deadlines) over always-regenerate, because generateTaxDeadlinesForUser deletes+reinserts and would reset is_completed/status on every unrelated save. Also wired the /deadlines empty-state to the existing (dead) /api/tax-deadlines/generate route, and fixed a 1000-row PostgREST cap in the annual cron. Backfilled 771 real cos with zero system deadlines via scripts/backfill-tax-deadlines.ts. Deferred moms_period=yearly config (#1030, 295 filers, largest VAT cohort): helarsmoms deadline (SFL 26 kap. 33-33b) depends on EU-trade status (no flag in CompanySettingsForDeadlines) and, for AB, the income-tax-return date.
|
||||
[2026-07-15] Repaired the single legacy paid credit note blocking invoices_credit_note_not_paid validation by normalizing its invoice metadata to sent, clearing payment fields, setting zero payable remainder, and linking its existing balanced posted V44 reversal: the immutable voucher already exactly reversed V42 and was not edited or duplicated.
|
||||
|
||||
@@ -85,7 +85,12 @@ function makeReq() {
|
||||
}
|
||||
|
||||
function enqueueHappyPath(opts: {
|
||||
transaction: { amount: number; currency: string; amount_sek?: number | null }
|
||||
transaction: {
|
||||
amount: number
|
||||
currency: string
|
||||
amount_sek?: number | null
|
||||
cash_account_id?: string | null
|
||||
}
|
||||
invoice: {
|
||||
currency: string
|
||||
exchange_rate?: number | null
|
||||
@@ -94,6 +99,9 @@ function enqueueHappyPath(opts: {
|
||||
status?: string
|
||||
}
|
||||
accountingMethod?: string
|
||||
// ledger_account returned by the cash_accounts lookup; only enqueued when
|
||||
// the transaction carries a cash_account_id.
|
||||
cashAccountLedger?: string
|
||||
}) {
|
||||
// 1. transactions fetch
|
||||
enqueue({
|
||||
@@ -104,6 +112,7 @@ function enqueueHappyPath(opts: {
|
||||
currency: opts.transaction.currency,
|
||||
amount_sek: opts.transaction.amount_sek ?? null,
|
||||
supplier_invoice_id: null,
|
||||
cash_account_id: opts.transaction.cash_account_id ?? null,
|
||||
date: '2026-05-12',
|
||||
},
|
||||
error: null,
|
||||
@@ -124,11 +133,15 @@ function enqueueHappyPath(opts: {
|
||||
})
|
||||
// 3. company_settings fetch
|
||||
enqueue({ data: { accounting_method: opts.accountingMethod ?? 'accrual' }, error: null })
|
||||
// 4. supplier_invoices update (CAS)
|
||||
// 4. cash_accounts lookup (only when the transaction is linked to one)
|
||||
if (opts.transaction.cash_account_id) {
|
||||
enqueue({ data: { ledger_account: opts.cashAccountLedger ?? '1930' }, error: null })
|
||||
}
|
||||
// 5. supplier_invoices update (CAS)
|
||||
enqueue({ data: [{ id: SI_UUID }], error: null })
|
||||
// 5. supplier_invoice_payments insert
|
||||
// 6. supplier_invoice_payments insert
|
||||
enqueue({ data: null, error: null })
|
||||
// 6. transactions update (link)
|
||||
// 7. transactions update (link)
|
||||
enqueue({ data: null, error: null })
|
||||
}
|
||||
|
||||
@@ -362,6 +375,65 @@ describe('POST /api/transactions/[id]/match-supplier-invoice: settlement account
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/transactions/[id]/match-supplier-invoice: settlement account on FX and cash-method branches', () => {
|
||||
// Regression locks for #1000: the FX branch (createSupplierInvoicePaymentEntry)
|
||||
// and cash-method branch (createSupplierInvoiceCashEntry) must receive the
|
||||
// resolved settlement account, not fall back to their internal 1930 default
|
||||
// whenever the transaction is linked to a different cash account.
|
||||
it('FX branch: passes the linked non-1930 account to createSupplierInvoicePaymentEntry', async () => {
|
||||
enqueueHappyPath({
|
||||
transaction: { amount: -2400, currency: 'SEK', cash_account_id: 'ca-1940' },
|
||||
invoice: { currency: 'EUR', exchange_rate: 10.6254, remaining_amount: 225 },
|
||||
cashAccountLedger: '1940',
|
||||
})
|
||||
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockCreatePaymentEntry).toHaveBeenCalledTimes(1)
|
||||
const args = mockCreatePaymentEntry.mock.calls[0]
|
||||
// Confirm the FX branch actually ran: booked 2390.72, paid 2400 -> loss 9.28.
|
||||
expect(args[6]).toBeCloseTo(-9.28, 2)
|
||||
expect(args[8]).toBe('1940')
|
||||
})
|
||||
|
||||
it('FX branch: falls back to 1930 when the transaction has no linked cash account', async () => {
|
||||
enqueueHappyPath({
|
||||
transaction: { amount: -2400, currency: 'SEK' },
|
||||
invoice: { currency: 'EUR', exchange_rate: 10.6254, remaining_amount: 225 },
|
||||
})
|
||||
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockCreatePaymentEntry.mock.calls[0][8]).toBe('1930')
|
||||
})
|
||||
|
||||
it('cash-method branch: passes the linked non-1930 account to createSupplierInvoiceCashEntry', async () => {
|
||||
enqueueHappyPath({
|
||||
transaction: { amount: -500, currency: 'SEK', cash_account_id: 'ca-1940' },
|
||||
invoice: { currency: 'SEK', remaining_amount: 500 },
|
||||
accountingMethod: 'cash',
|
||||
cashAccountLedger: '1940',
|
||||
})
|
||||
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockCreateCashEntry).toHaveBeenCalledTimes(1)
|
||||
expect(mockCreatePaymentEntry).not.toHaveBeenCalled()
|
||||
const args = mockCreateCashEntry.mock.calls[0]
|
||||
expect(args[8]).toBe('1940')
|
||||
// Pure SEK settlement: no settledBankSek override.
|
||||
expect(args[9]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('cash-method branch: falls back to 1930 when the transaction has no linked cash account', async () => {
|
||||
enqueueHappyPath({
|
||||
transaction: { amount: -500, currency: 'SEK' },
|
||||
invoice: { currency: 'SEK', remaining_amount: 500 },
|
||||
accountingMethod: 'cash',
|
||||
})
|
||||
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockCreateCashEntry.mock.calls[0][8]).toBe('1930')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/transactions/[id]/match-supplier-invoice: non-FX paths', () => {
|
||||
it('returns 200 with the expected body shape on the happy path', async () => {
|
||||
enqueueHappyPath({
|
||||
|
||||
@@ -28,12 +28,13 @@ vi.mock('@supabase/supabase-js', async () => {
|
||||
})
|
||||
|
||||
// Engine stubs: happy-path returns reusable across cases.
|
||||
const { createTxJE, reverseEntryMock, createInvPmtJE, createInvCashJE, createSupplierInvPmtJE, findMissingAccountsMock } = vi.hoisted(() => ({
|
||||
const { createTxJE, reverseEntryMock, createInvPmtJE, createInvCashJE, createSupplierInvPmtJE, createSupplierInvCashJE, findMissingAccountsMock } = vi.hoisted(() => ({
|
||||
createTxJE: vi.fn().mockResolvedValue({ id: 'je-fresh' }),
|
||||
reverseEntryMock: vi.fn().mockResolvedValue(undefined),
|
||||
createInvPmtJE: vi.fn().mockResolvedValue({ id: 'je-invpmt' }),
|
||||
createInvCashJE: vi.fn().mockResolvedValue({ id: 'je-invcash' }),
|
||||
createSupplierInvPmtJE: vi.fn().mockResolvedValue({ id: 'je-sipmt' }),
|
||||
createSupplierInvCashJE: vi.fn().mockResolvedValue({ id: 'je-sicash' }),
|
||||
// Default: no missing accounts. Per-case overrides simulate the
|
||||
// template-references-inactive-account bug or a race where deactivation
|
||||
// happened between our validation and the engine's resolveAccountIds.
|
||||
@@ -52,7 +53,7 @@ vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
|
||||
}))
|
||||
vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({
|
||||
createSupplierInvoicePaymentEntry: createSupplierInvPmtJE,
|
||||
createSupplierInvoiceCashEntry: vi.fn().mockResolvedValue({ id: 'je-sicash' }),
|
||||
createSupplierInvoiceCashEntry: createSupplierInvCashJE,
|
||||
}))
|
||||
vi.mock('@/lib/invoices/match-log', () => ({
|
||||
logMatchEvent: vi.fn(),
|
||||
@@ -1120,4 +1121,192 @@ describe('POST :id/match-supplier-invoice', () => {
|
||||
expect(createSupplierInvPmtJE).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Regression for #1000: the FX and cash-method branches were left on the
|
||||
// entry generators' internal 1930 default when the pure-SEK path was fixed
|
||||
// (#986). A foreign-currency match, or a kontantmetoden match, settling
|
||||
// from a non-primary account (e.g. a EUR account on 1940) was still
|
||||
// misbooked to 1930.
|
||||
describe('settlement account resolution (FX and cash-method branches)', () => {
|
||||
function fxTables(cashAccountId: string | null) {
|
||||
return {
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
transactions: {
|
||||
data: {
|
||||
id: TX_ID,
|
||||
amount: -2400,
|
||||
date: '2026-05-12',
|
||||
currency: 'SEK',
|
||||
amount_sek: null,
|
||||
supplier_invoice_id: null,
|
||||
journal_entry_id: null,
|
||||
cash_account_id: cashAccountId,
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
supplier_invoices: [
|
||||
{
|
||||
data: {
|
||||
id: SI_ID,
|
||||
status: 'approved',
|
||||
total: 225,
|
||||
paid_amount: 0,
|
||||
remaining_amount: 225,
|
||||
currency: 'EUR',
|
||||
exchange_rate: 10.6254,
|
||||
supplier: { name: 'Acme GmbH', supplier_type: 'eu_business' },
|
||||
items: [],
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
{ data: [{ id: SI_ID }], error: null },
|
||||
],
|
||||
company_settings: { data: { accounting_method: 'accrual' }, error: null },
|
||||
cash_accounts: { data: { ledger_account: '1940' }, error: null },
|
||||
supplier_invoice_payments: { data: null, error: null },
|
||||
}
|
||||
}
|
||||
|
||||
function cashMethodTables(cashAccountId: string | null) {
|
||||
return {
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
transactions: {
|
||||
data: {
|
||||
id: TX_ID,
|
||||
amount: -5000,
|
||||
date: '2026-05-12',
|
||||
currency: 'SEK',
|
||||
amount_sek: null,
|
||||
supplier_invoice_id: null,
|
||||
journal_entry_id: null,
|
||||
cash_account_id: cashAccountId,
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
supplier_invoices: [
|
||||
{
|
||||
data: {
|
||||
id: SI_ID,
|
||||
status: 'approved',
|
||||
total: 5000,
|
||||
paid_amount: 0,
|
||||
remaining_amount: 5000,
|
||||
currency: 'SEK',
|
||||
exchange_rate: null,
|
||||
supplier: { name: 'Acme', supplier_type: 'swedish_business' },
|
||||
items: [],
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
{ data: [{ id: SI_ID }], error: null },
|
||||
],
|
||||
company_settings: { data: { accounting_method: 'cash' }, error: null },
|
||||
cash_accounts: { data: { ledger_account: '1940' }, error: null },
|
||||
supplier_invoice_payments: { data: null, error: null },
|
||||
}
|
||||
}
|
||||
|
||||
it('FX branch: credits the transaction\'s own linked non-1930 account', async () => {
|
||||
mockServiceClient.mockReturnValue(makeFlexibleSupabase(fxTables('ca-1940')))
|
||||
const res = await matchSIPOST(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-supplier-invoice`,
|
||||
{ supplier_invoice_id: SI_ID },
|
||||
),
|
||||
txParams(TX_ID),
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(createSupplierInvPmtJE).toHaveBeenCalledTimes(1)
|
||||
const args = createSupplierInvPmtJE.mock.calls[0]
|
||||
// Confirm we exercised the FX branch: 225 EUR @ 10.6254 booked as
|
||||
// 2390.72 SEK, bank paid 2400 SEK -> loss of 9.28.
|
||||
expect(args[4]).toBeCloseTo(2390.72, 2)
|
||||
expect(args[6]).toBeCloseTo(-9.28, 2)
|
||||
expect(args[8]).toBe('1940')
|
||||
})
|
||||
|
||||
it('FX branch: falls back to 1930 when the transaction has no linked cash account', async () => {
|
||||
mockServiceClient.mockReturnValue(makeFlexibleSupabase(fxTables(null)))
|
||||
const res = await matchSIPOST(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-supplier-invoice`,
|
||||
{ supplier_invoice_id: SI_ID },
|
||||
),
|
||||
txParams(TX_ID),
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(createSupplierInvPmtJE.mock.calls[0][8]).toBe('1930')
|
||||
})
|
||||
|
||||
it('cash-method branch: credits the transaction\'s own linked non-1930 account', async () => {
|
||||
mockServiceClient.mockReturnValue(makeFlexibleSupabase(cashMethodTables('ca-1940')))
|
||||
const res = await matchSIPOST(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-supplier-invoice`,
|
||||
{ supplier_invoice_id: SI_ID },
|
||||
),
|
||||
txParams(TX_ID),
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(createSupplierInvCashJE).toHaveBeenCalledTimes(1)
|
||||
expect(createSupplierInvPmtJE).not.toHaveBeenCalled()
|
||||
const args = createSupplierInvCashJE.mock.calls[0]
|
||||
expect(args[8]).toBe('1940')
|
||||
// Pure SEK settlement: no settledBankSek override.
|
||||
expect(args[9]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('cash-method branch: falls back to 1930 when the transaction has no linked cash account', async () => {
|
||||
mockServiceClient.mockReturnValue(makeFlexibleSupabase(cashMethodTables(null)))
|
||||
const res = await matchSIPOST(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-supplier-invoice`,
|
||||
{ supplier_invoice_id: SI_ID },
|
||||
),
|
||||
txParams(TX_ID),
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(createSupplierInvCashJE.mock.calls[0][8]).toBe('1930')
|
||||
})
|
||||
|
||||
it('cash-method branch: rejects with ACCOUNTS_NOT_IN_CHART when the resolved account is deactivated', async () => {
|
||||
// The chart pre-validation guard must cover the branches that now
|
||||
// consume paymentAccount, not only the pure-SEK accrual path.
|
||||
mockServiceClient.mockReturnValue(makeFlexibleSupabase(cashMethodTables('ca-1940')))
|
||||
findMissingAccountsMock.mockResolvedValueOnce(['1940'])
|
||||
const res = await matchSIPOST(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-supplier-invoice`,
|
||||
{ supplier_invoice_id: SI_ID },
|
||||
),
|
||||
txParams(TX_ID),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
expect((await res.json()).error.code).toBe('ACCOUNTS_NOT_IN_CHART')
|
||||
expect(createSupplierInvCashJE).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does NOT storno an existing conflicting JE when chart validation rejects the request', async () => {
|
||||
// Regression: the chart pre-validation must run BEFORE the
|
||||
// conflicting-categorization storno. Otherwise a request that is
|
||||
// ultimately rejected with ACCOUNTS_NOT_IN_CHART would first reverse
|
||||
// the transaction's posted categorization entry: an irreversible side
|
||||
// effect on a failed request.
|
||||
const tables = cashMethodTables('ca-1940')
|
||||
;(tables.transactions.data as { journal_entry_id: string | null }).journal_entry_id = JE_ID
|
||||
mockServiceClient.mockReturnValue(makeFlexibleSupabase(tables))
|
||||
findMissingAccountsMock.mockResolvedValueOnce(['1940'])
|
||||
const res = await matchSIPOST(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-supplier-invoice`,
|
||||
{ supplier_invoice_id: SI_ID },
|
||||
),
|
||||
txParams(TX_ID),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
expect((await res.json()).error.code).toBe('ACCOUNTS_NOT_IN_CHART')
|
||||
expect(reverseEntryMock).not.toHaveBeenCalled()
|
||||
expect(createSupplierInvCashJE).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+47
-44
@@ -39,7 +39,7 @@ registerEndpoint({
|
||||
path: '/api/v1/companies/:companyId/transactions/:id/match-supplier-invoice',
|
||||
summary: 'Match a negative bank transaction to a supplier invoice.',
|
||||
description:
|
||||
'Confirms a supplier invoice payment match. Creates the payment journal entry (accrual: 2440 debit / 1930 credit; cash-method: collapsed registration+payment), updates supplier_invoices, inserts a supplier_invoice_payments row, and links the transaction. Handles FX differences for cross-currency payments (7960 gain / 3960 loss).',
|
||||
'Confirms a supplier invoice payment match. Creates the payment journal entry (accrual: 2440 debit, credit on the transaction\'s own settlement account, 1930 when unlinked; cash-method: collapsed registration+payment), updates supplier_invoices, inserts a supplier_invoice_payments row, and links the transaction. Handles FX differences for cross-currency payments (7960 gain / 3960 loss).',
|
||||
useWhen:
|
||||
'You have a bank payment and a known open supplier invoice. The transaction must be negative (expense) and unlinked.',
|
||||
doNotUseFor:
|
||||
@@ -151,6 +151,41 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
})
|
||||
}
|
||||
|
||||
// Credit 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 account a matched transaction
|
||||
// settled from (mirrors the dashboard route's #985 fix). Threaded through
|
||||
// every booking branch below (pure-SEK accrual, FX, and cash-method);
|
||||
// customLines specify their own accounts directly (#1000).
|
||||
const paymentAccount = await resolveSettlementAccount(
|
||||
ctx.supabase,
|
||||
ctx.companyId!,
|
||||
transaction.cash_account_id,
|
||||
txLog,
|
||||
)
|
||||
|
||||
// Guard the resolved account against the chart (mirrors the categorize
|
||||
// routes): an inactive cash_accounts.ledger_account would otherwise reach
|
||||
// the engine as a generic MATCH_SI_RECORD_PAYMENT_FAILED instead of
|
||||
// ACCOUNTS_NOT_IN_CHART. Gated on !customLines: custom lines specify their
|
||||
// own accounts directly; every other branch consumes paymentAccount.
|
||||
// This MUST run before the conflicting-JE storno below: a request rejected
|
||||
// here must leave no trace, and reversing the existing categorization JE
|
||||
// is an irreversible side effect (posted vouchers are immutable).
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Storno any conflicting auto-categorization JE before booking the
|
||||
// payment. Mirrors the match-invoice path. Without this, an earlier
|
||||
// :categorize of the same transaction (e.g. as expense_office with a
|
||||
@@ -221,48 +256,12 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
.single()
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
|
||||
// Credit 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 account a matched transaction
|
||||
// settled from (mirrors the dashboard route's #985 fix). Only applied to
|
||||
// the pure-SEK accrual path below; the FX path keeps its pre-existing
|
||||
// internal 1930 default, matching that fix's scope.
|
||||
const paymentAccount = await resolveSettlementAccount(
|
||||
ctx.supabase,
|
||||
ctx.companyId!,
|
||||
transaction.cash_account_id,
|
||||
txLog,
|
||||
)
|
||||
|
||||
// Route on the supplier invoice's actual booking state. An invoice
|
||||
// booked at receipt (registration_journal_entry_id set) must clear
|
||||
// 2440 regardless of the company's current setting.
|
||||
const siAlreadyBooked = !!(invoice as { registration_journal_entry_id?: string | null }).registration_journal_entry_id
|
||||
const useCashEntry = !siAlreadyBooked && accountingMethod === 'cash'
|
||||
|
||||
// Pure SEK: both legs of the match are SEK, so the payment account
|
||||
// resolved above can safely replace the 1930 default. Kept out of scope
|
||||
// for foreign-currency matches, same as the dashboard route.
|
||||
const isPureSek = transaction.currency === 'SEK' && invoice.currency === 'SEK'
|
||||
|
||||
// Guard the resolved account against the chart (mirrors the categorize
|
||||
// routes): an inactive cash_accounts.ledger_account would otherwise reach
|
||||
// the engine as a generic MATCH_SI_RECORD_PAYMENT_FAILED instead of
|
||||
// ACCOUNTS_NOT_IN_CHART. Only reachable where the account is actually used.
|
||||
if (isPureSek && !useCashEntry && !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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Full settlement = the bank amount pays off the whole remaining balance.
|
||||
// Cross-currency always settles the remaining (paymentAmountInvoiceCurrency
|
||||
// is clamped to invoice.remaining_amount above).
|
||||
@@ -330,9 +329,13 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
transaction.date,
|
||||
invoice.supplier?.supplier_type || 'swedish_business',
|
||||
undefined, // supplierName (unchanged default)
|
||||
undefined, // paymentAccount (unchanged default 1930)
|
||||
// Pin a foreign-currency settlement to the payment-date rate so 1930
|
||||
// equals the bank movement. No-op for SEK / same-rate settlements.
|
||||
// Settle from the transaction's own resolved cash account; the
|
||||
// internal 1930 default only stands for unlinked transactions, via
|
||||
// resolveSettlementAccount's own fallback (#1000).
|
||||
paymentAccount,
|
||||
// Pin a foreign-currency settlement to the payment-date rate so the
|
||||
// settlement account equals the bank movement. No-op for SEK /
|
||||
// same-rate settlements.
|
||||
exchangeRateDifference !== 0 && fullSettlement ? actualBankSek : undefined,
|
||||
)
|
||||
if (je) journalEntryId = je.id
|
||||
@@ -346,10 +349,10 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
transaction.date,
|
||||
exchangeRateDifference !== 0 ? exchangeRateDifference : undefined,
|
||||
undefined, // supplierName (unchanged default)
|
||||
// Resolved settlement account, pure-SEK matches only: the FX
|
||||
// branch keeps defaulting internally to 1930, out of scope here
|
||||
// just as it was for the dashboard route's #985 fix.
|
||||
isPureSek ? paymentAccount : undefined,
|
||||
// Resolved settlement account for pure-SEK and FX matches alike:
|
||||
// the internal 1930 default only stands for unlinked transactions,
|
||||
// via resolveSettlementAccount's own fallback (#1000).
|
||||
paymentAccount,
|
||||
)
|
||||
if (je) journalEntryId = je.id
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user