fix(bookkeeping): settle unbound transactions on the company's single enabled cash account (#1831)
* fix(bookkeeping): settle unbound transactions on the company's single enabled cash account A transaction with no cash_account_id booked its bank leg on the hardcoded 1930 from the standard templates and category mappings even when the company's only bank account is e.g. 1920 (PlusGiro), while the booking dialogs previewed the right account via the client-side resolveAccount fallback. resolveSettlementAccount now mirrors that fallback: with a NULL cash_account_id it lists the company's enabled cash accounts and, when EXACTLY ONE matches the transaction's currency, settles there; zero or several candidates keep the 1930 fallback. The explicit-cash_account_id branch (including its throw-on-error path, issue #842) is byte-identical. Transaction currency is threaded into the categorize, batch-categorize, pending-operation edit, MCP staging, and invoice-inbox call sites; other callers get the SEK default. Forward-only: historical wrong verifikat are corrected only via the existing storno runbook (docs/SETTLEMENT_ACCOUNT_REMEDIATION.md). Fixes #1722 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SyDuePXxUFowaPBKpAv8SF * test: align duplicate-guard mock queue with combined pre-FY and settlement-fallback lookups The merge of main (PR #1828) into this branch combined two changes that each add one query to the categorize commit flow; the strictly ordered queued mock in the allow_duplicate test needed the cash_accounts listing entry inserted between the period lookup and the pre-FY clamp lookup. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SyDuePXxUFowaPBKpAv8SF --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
8e3015e541
commit
7cf15a105f
@@ -194,6 +194,7 @@ describe('PATCH /api/pending-operations/[id]', () => {
|
||||
},
|
||||
})
|
||||
enqueue({ data: { entity_type: 'aktiebolag' } })
|
||||
enqueue({ data: [] }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'op-1',
|
||||
@@ -247,6 +248,7 @@ describe('PATCH /api/pending-operations/[id]', () => {
|
||||
})
|
||||
enqueue({ data: { id: 'tx-1', company_id: 'company-1', amount: -500, currency: 'SEK' } })
|
||||
enqueue({ data: { entity_type: 'aktiebolag' } })
|
||||
enqueue({ data: [] }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
enqueue({ data: { id: 'op-1', params: {}, preview_data: {}, title: '', status: 'pending' } })
|
||||
|
||||
const mapping = {
|
||||
@@ -338,6 +340,7 @@ describe('PATCH /api/pending-operations/[id]', () => {
|
||||
})
|
||||
enqueue({ data: { id: 'tx-1', company_id: 'company-1', amount: -415.8, currency: 'SEK' } })
|
||||
enqueue({ data: { entity_type: 'enskild_firma' } })
|
||||
enqueue({ data: [] }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
enqueue({ data: { id: 'op-1', params: {}, preview_data: {}, title: '', status: 'pending' } })
|
||||
|
||||
const res = await PATCH(
|
||||
@@ -381,6 +384,7 @@ describe('PATCH /api/pending-operations/[id]', () => {
|
||||
})
|
||||
enqueue({ data: { id: 'tx-1', company_id: 'company-1', amount: -415.8, currency: 'SEK' } })
|
||||
enqueue({ data: { entity_type: 'enskild_firma' } })
|
||||
enqueue({ data: [] }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
enqueue({ data: { id: 'op-1', params: {}, preview_data: {}, title: '', status: 'pending' } })
|
||||
|
||||
const res = await PATCH(
|
||||
@@ -473,6 +477,7 @@ describe('PATCH /api/pending-operations/[id]', () => {
|
||||
})
|
||||
enqueue({ data: { id: 'tx-1', amount: -100, currency: 'SEK' } })
|
||||
enqueue({ data: { entity_type: 'enskild_firma' } })
|
||||
enqueue({ data: [] }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
mappingMock.mockReturnValueOnce({
|
||||
debit_account: null,
|
||||
credit_account: null,
|
||||
|
||||
@@ -133,6 +133,7 @@ describe('POST /api/pending-operations/:id/commit', () => {
|
||||
{ data: { id: 'op-1' } }, // CAS claim
|
||||
{ data: tx }, // fetch transaction
|
||||
{ data: settings }, // fetch company settings
|
||||
{ data: [] }, // resolveSettlementAccount: no cash accounts -> 1930
|
||||
{ data: [{ id: 'fp-1' }] }, // fiscal period check
|
||||
{ data: [{ id: 'tx-1' }], error: null }, // transaction CAS matched
|
||||
{ data: null, error: null }, // upsert counterparty template
|
||||
@@ -164,6 +165,7 @@ describe('POST /api/pending-operations/:id/commit', () => {
|
||||
{ data: { id: 'op-1' } }, // CAS claim (pending -> committing)
|
||||
{ data: tx }, // fetch transaction
|
||||
{ data: settings }, // fetch company settings
|
||||
{ data: [] }, // resolveSettlementAccount: no cash accounts -> 1930
|
||||
{ data: [{ id: 'fp-1' }] }, // fiscal period exists
|
||||
{ data: null, error: null }, // dispatcher releases op back to 'pending'
|
||||
])
|
||||
|
||||
@@ -176,6 +176,7 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
companyId,
|
||||
(tx as Transaction).cash_account_id,
|
||||
log,
|
||||
(tx as Transaction).currency,
|
||||
)
|
||||
mapping = applySettlementAccount(mapping, settlementAccount)
|
||||
} catch (err) {
|
||||
|
||||
@@ -138,6 +138,7 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
|
||||
enqueue({ data: tx, error: null }) // fetch transaction
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) // settings
|
||||
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
enqueue({ data: [{ id: 'period-1' }], error: null }) // ensureFiscalPeriod
|
||||
|
||||
mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
@@ -237,6 +238,7 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
enqueue({ data: [{ id: 'period-1' }], error: null })
|
||||
mockCreateTransactionJournalEntry.mockResolvedValueOnce(null)
|
||||
enqueue({ data: [], error: null })
|
||||
@@ -267,6 +269,7 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
enqueue({ data: tx, error: null })
|
||||
// Fetch company settings
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
// ensureFiscalPeriod: check existing
|
||||
enqueue({ data: [{ id: 'period-1' }], error: null })
|
||||
|
||||
@@ -310,6 +313,61 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('books a standardmall bank leg on the single enabled cash account when cash_account_id is NULL (#1722)', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
amount: -500,
|
||||
merchant_name: 'Banken',
|
||||
journal_entry_id: null,
|
||||
cash_account_id: null,
|
||||
})
|
||||
|
||||
// Fetch transaction
|
||||
enqueue({ data: tx, error: null })
|
||||
// Fetch company settings
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
// resolveSettlementAccount currency fallback: the company's ONLY enabled
|
||||
// SEK cash account is a PlusGiro on 1920, so the template's hardcoded
|
||||
// 1930 leg must be rewritten to 1920 before posting.
|
||||
enqueue({ data: [{ ledger_account: '1920' }], error: null })
|
||||
// ensureFiscalPeriod: check existing
|
||||
enqueue({ data: [{ id: 'period-1' }], error: null })
|
||||
|
||||
mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
mockSaveUserMappingRule.mockResolvedValue(undefined)
|
||||
|
||||
// Update transaction (CAS guard: returns matched row)
|
||||
enqueue({ data: [{ id: 'tx-1' }], error: null })
|
||||
enqueue({ data: [], error: null }) // inbox propagation: no matched items
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/categorize', {
|
||||
method: 'POST',
|
||||
body: { is_business: true, template_id: 'bank_fees' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
journal_entry_created: boolean
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.journal_entry_created).toBe(true)
|
||||
// The posted mapping carries the real settlement account, not the
|
||||
// template's hardcoded 1930 (bank_fees is Dr 6570 / Cr 1930).
|
||||
const mappingArg = mockCreateTransactionJournalEntry.mock.calls[0][4] as {
|
||||
debit_account: string
|
||||
credit_account: string
|
||||
}
|
||||
expect(mappingArg.debit_account).toBe('6570')
|
||||
expect(mappingArg.credit_account).toBe('1920')
|
||||
// The fallback listing was narrowed to enabled accounts in the
|
||||
// transaction's currency.
|
||||
const eqArgs = findCalls('cash_accounts', 'eq')
|
||||
expect(eqArgs).toContainEqual(['enabled', true])
|
||||
expect(eqArgs).toContainEqual(['currency', 'SEK'])
|
||||
})
|
||||
|
||||
it('books a transaction dated before the first fiscal year without minting a period (pre-FY, issue #1825)', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
@@ -322,6 +380,7 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
|
||||
enqueue({ data: tx, error: null }) // fetch transaction
|
||||
enqueue({ data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 }, error: null }) // settings
|
||||
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
enqueue({ data: [], error: null }) // ensureFiscalPeriod: no covering open period
|
||||
enqueue({ data: [{ period_start: '2026-05-12' }], error: null }) // ensureFiscalPeriod: earliest period
|
||||
|
||||
@@ -350,7 +409,6 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
// The pre-FY guard must not upsert a calendar-year (pre-registration) period.
|
||||
expect(findCalls('fiscal_periods', 'upsert')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('atomically unignores an ignored transaction when categorizing it', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
@@ -362,6 +420,7 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
enqueue({ data: [{ id: 'period-1' }], error: null })
|
||||
mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
enqueue({ data: [{ ...tx, is_business: false, category: 'private', is_ignored: false, journal_entry_id: 'je-1' }], error: null })
|
||||
@@ -404,6 +463,7 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
|
||||
enqueue({ data: tx, error: null }) // fetch transaction
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) // settings
|
||||
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
enqueue({ data: [{ id: 'period-1' }], error: null }) // fiscal period check
|
||||
mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
enqueue({ data: [{ id: 'tx-1' }], error: null }) // tx update (CAS matched)
|
||||
@@ -461,6 +521,7 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
|
||||
enqueue({ data: tx, error: null }) // fetch transaction
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) // settings
|
||||
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
enqueue({ data: [{ id: 'period-1' }], error: null }) // fiscal period check
|
||||
mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
enqueue({ data: [{ id: 'tx-1' }], error: null }) // tx update (CAS matched)
|
||||
@@ -495,6 +556,7 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
enqueue({ data: [{ id: 'period-1' }], error: null })
|
||||
mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
enqueue({ data: [{ id: 'tx-1' }], error: null }) // tx update
|
||||
@@ -522,6 +584,7 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
enqueue({ data: [{ id: 'period-1' }], error: null })
|
||||
|
||||
mockCreateTransactionJournalEntry.mockRejectedValue(new Error('Period locked'))
|
||||
@@ -558,6 +621,7 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
enqueue({ data: [{ id: 'period-1' }], error: null })
|
||||
|
||||
mockCreateTransactionJournalEntry.mockRejectedValue(new JournalEntryNotBalancedError(100, 80))
|
||||
@@ -595,6 +659,7 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
enqueue({ data: [{ id: 'period-1' }], error: null })
|
||||
|
||||
mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
@@ -629,6 +694,7 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
})
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
enqueue({ data: [{ id: 'period-1' }], error: null })
|
||||
mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
enqueue({
|
||||
@@ -670,6 +736,7 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
|
||||
mockBuildMappingResultFromCategory.mockReturnValue({
|
||||
...defaultMappingResult,
|
||||
@@ -698,6 +765,7 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
|
||||
mockBuildMappingResultFromCategory.mockReturnValue({
|
||||
...defaultMappingResult,
|
||||
@@ -744,6 +812,7 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
|
||||
mockBuildMappingResultFromCategory.mockReturnValue({
|
||||
...defaultMappingResult,
|
||||
@@ -784,6 +853,7 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
|
||||
mockBuildMappingResultFromCategory.mockReturnValue({
|
||||
...defaultMappingResult,
|
||||
@@ -843,6 +913,7 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
it('EUR transaction: a 1 000 SEK supplier invoice is not suggested for a 1 000 EUR payment', async () => {
|
||||
enqueue({ data: eurExpenseTx(), error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
|
||||
mockBuildMappingResultFromCategory.mockReturnValue({
|
||||
...defaultMappingResult,
|
||||
@@ -877,6 +948,7 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
it('EUR transaction with a rate: the 11 500 SEK supplier invoice IS suggested', async () => {
|
||||
enqueue({ data: eurExpenseTx(), error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
|
||||
mockBuildMappingResultFromCategory.mockReturnValue({
|
||||
...defaultMappingResult,
|
||||
@@ -907,6 +979,7 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
it('EUR transaction without a rate: kronor invoices are excluded, never compared raw', async () => {
|
||||
enqueue({ data: eurExpenseTx({ exchange_rate: null }), error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
|
||||
mockBuildMappingResultFromCategory.mockReturnValue({
|
||||
...defaultMappingResult,
|
||||
@@ -935,6 +1008,7 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
it('EUR transaction: a 1 000 EUR supplier invoice still matches in its own currency', async () => {
|
||||
enqueue({ data: eurExpenseTx(), error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
|
||||
mockBuildMappingResultFromCategory.mockReturnValue({
|
||||
...defaultMappingResult,
|
||||
@@ -980,6 +1054,7 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
|
||||
mockBuildMappingResultFromCategory.mockReturnValue({
|
||||
...defaultMappingResult,
|
||||
@@ -1039,6 +1114,7 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
|
||||
mockBuildMappingResultFromCategory.mockReturnValue({
|
||||
...defaultMappingResult,
|
||||
@@ -1093,6 +1169,7 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
|
||||
mockBuildMappingResultFromCategory.mockReturnValue({
|
||||
...defaultMappingResult,
|
||||
@@ -1151,6 +1228,7 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
|
||||
mockBuildMappingResultFromCategory.mockReturnValue({
|
||||
...defaultMappingResult,
|
||||
@@ -1217,6 +1295,7 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
|
||||
enqueue({ data: tx, error: null }) // fetch
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) // settings
|
||||
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
enqueue({ data: [{ id: 'period-1' }], error: null }) // ensureFiscalPeriod existing check
|
||||
mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
mockSaveUserMappingRule.mockResolvedValue(undefined)
|
||||
@@ -1267,6 +1346,7 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
enqueue({ data: [{ id: 'period-1' }], error: null })
|
||||
|
||||
mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
@@ -1302,6 +1382,7 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
enqueue({ data: tx, error: null })
|
||||
// Fetch company settings
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
|
||||
// Mapping built from category, but the debit account is missing/inactive
|
||||
// in this company's kontoplan. findMissingActiveAccounts is mocked at the
|
||||
@@ -1337,6 +1418,7 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
})
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
|
||||
// Multiple accounts missing: covers the common "imported a template with
|
||||
// accounts that this kontoplan never enabled" case.
|
||||
@@ -1370,6 +1452,7 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
// ensureFiscalPeriod existing-period check
|
||||
enqueue({ data: [{ id: 'period-1' }], error: null })
|
||||
|
||||
@@ -1414,6 +1497,7 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
// chart_of_accounts lookup for '5420': not in the company's chart.
|
||||
// Using a plain expense account (Programvaror) avoids the implication
|
||||
// that 4535 (Inköp av varor från annat EU-land, reverse-charge) would
|
||||
|
||||
@@ -358,6 +358,7 @@ export const POST = withRouteContext(
|
||||
companyId!,
|
||||
transaction.cash_account_id,
|
||||
txLog,
|
||||
transaction.currency,
|
||||
)
|
||||
mappingResult = applySettlementAccount(mappingResult, settlementAccount)
|
||||
|
||||
|
||||
@@ -294,6 +294,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
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: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
|
||||
mockFetchExchangeRate.mockResolvedValue({
|
||||
currency: 'USD',
|
||||
@@ -397,6 +398,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: [], error: null }) // hard-duplicate
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
|
||||
mockFetchExchangeRate.mockResolvedValue(null) // Riksbanken down: manual rate used instead
|
||||
mockCreateJournalEntry.mockResolvedValue({ id: 'je-fx-manual' })
|
||||
@@ -456,6 +458,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
enqueue({ data: [], error: null })
|
||||
// Fetch company settings
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
|
||||
mockCreateJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
|
||||
@@ -555,11 +558,11 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
mockReverseEntry.mockResolvedValue({ id: 'je-storno' })
|
||||
// Clear journal_entry_id on transaction
|
||||
enqueue({ data: null, error: null })
|
||||
// logMatchEvent for storno
|
||||
enqueue({ data: null, error: null })
|
||||
// (logMatchEvent does not consume a from() on the mocked route client)
|
||||
|
||||
// Fetch company settings
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
mockCreateJournalEntry.mockResolvedValue({ id: 'je-payment' })
|
||||
|
||||
// Update invoice (optimistic lock)
|
||||
@@ -629,6 +632,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
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: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
|
||||
mockCreateJournalEntry.mockResolvedValue({ id: 'je-partial' })
|
||||
|
||||
@@ -678,6 +682,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
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: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
mockCreateJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
enqueue({ data: [{ id: VALID_UUID }], error: null }) // update invoice
|
||||
enqueue({ data: null, error: null }) // insert invoice_payments
|
||||
@@ -723,6 +728,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
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: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
@@ -755,6 +761,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
// No hard-duplicate check here: it only runs for 'sent'/'overdue', so the
|
||||
// next query is the settings fetch.
|
||||
enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null })
|
||||
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
@@ -793,6 +800,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
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: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
|
||||
mockCreateJournalEntry.mockResolvedValue({ id: 'je-clearing' })
|
||||
|
||||
@@ -951,8 +959,10 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
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.
|
||||
// With cash_account_id null, resolveSettlementAccount lists the
|
||||
// company's enabled cash accounts for the currency (issue #1722); no
|
||||
// rows here, so it keeps the 1930 fallback.
|
||||
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts
|
||||
|
||||
mockCreateJournalEntry.mockResolvedValue({ id: 'je-default' })
|
||||
|
||||
@@ -1074,6 +1084,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
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: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
|
||||
mockCreateJournalEntry.mockResolvedValue({ id: 'je-clearing' })
|
||||
|
||||
@@ -1116,6 +1127,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
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: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
mockCreateJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
|
||||
// Optimistic lock returns 0 rows (another request fully paid it)
|
||||
@@ -1145,6 +1157,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
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: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
mockCreateJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
|
||||
// Optimistic lock succeeds
|
||||
@@ -1176,6 +1189,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
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: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
// Nothing else enqueued on purpose: the route must return before the
|
||||
// invoice update, payment insert, or transaction link ever run.
|
||||
|
||||
@@ -1195,9 +1209,10 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
// The raw English message never reaches the user (issue #337): the reason
|
||||
// detail carries the Swedish invoice-context fallback.
|
||||
expect(body.error.details?.reason).toBe('Kunde inte hantera fakturan. Försök igen.')
|
||||
// Exactly the four reads happened (tx, invoice, hard-dup, settings):
|
||||
// no invoice update, no invoice_payments insert, no transaction link.
|
||||
expect(mockSupabase.from).toHaveBeenCalledTimes(4)
|
||||
// Exactly the five reads happened (tx, invoice, hard-dup, settings,
|
||||
// settlement-account listing): no invoice update, no invoice_payments
|
||||
// insert, no transaction link.
|
||||
expect(mockSupabase.from).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
it('aborts the match when createJournalEntry resolves without an id (no half-state)', async () => {
|
||||
@@ -1208,6 +1223,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
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: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
|
||||
mockCreateJournalEntry.mockResolvedValue(null)
|
||||
|
||||
@@ -1220,7 +1236,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(body.error.code).toBe('MATCH_INVOICE_RECORD_PAYMENT_FAILED')
|
||||
expect(mockSupabase.from).toHaveBeenCalledTimes(4)
|
||||
expect(mockSupabase.from).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
@@ -1268,6 +1284,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
enqueue({ data: invoice, error: null })
|
||||
// Hard-duplicate check is skipped for partially_paid; jump straight to settings
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
|
||||
mockCreateJournalEntry.mockResolvedValue({ id: 'je-partial-extra' })
|
||||
enqueue({ data: [{ id: VALID_UUID }], error: null }) // update invoice
|
||||
@@ -1347,6 +1364,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
})
|
||||
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
|
||||
mockCreateJournalEntry.mockResolvedValue({ id: 'je-forced' })
|
||||
enqueue({ data: [{ id: VALID_UUID }], error: null }) // update invoice
|
||||
|
||||
@@ -143,9 +143,13 @@ function enqueueHappyPath(opts: {
|
||||
})
|
||||
// 3. company_settings fetch
|
||||
enqueue({ data: { accounting_method: opts.accountingMethod ?? 'accrual' }, error: null })
|
||||
// 4. cash_accounts lookup (only when the transaction is linked to one)
|
||||
// 4. cash_accounts lookup: by id when the transaction is linked to one,
|
||||
// otherwise the currency-fallback listing (issue #1722), empty here so the
|
||||
// 1930 fallback applies.
|
||||
if (opts.transaction.cash_account_id) {
|
||||
enqueue({ data: { ledger_account: opts.cashAccountLedger ?? '1930' }, error: null })
|
||||
} else {
|
||||
enqueue({ data: [], error: null })
|
||||
}
|
||||
// 5. supplier_invoices update (CAS)
|
||||
enqueue({ data: [{ id: SI_UUID }], error: null })
|
||||
|
||||
@@ -255,6 +255,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
ctx.companyId!,
|
||||
transaction.cash_account_id,
|
||||
txLog,
|
||||
transaction.currency,
|
||||
)
|
||||
mappingResult = applySettlementAccount(mappingResult, settlementAccount)
|
||||
|
||||
|
||||
@@ -212,6 +212,7 @@ async function categorizeOne(
|
||||
companyId,
|
||||
transaction.cash_account_id,
|
||||
log,
|
||||
transaction.currency,
|
||||
)
|
||||
mappingResult = applySettlementAccount(mappingResult, settlementAccount)
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user