* 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>
159 lines
6.2 KiB
TypeScript
159 lines
6.2 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest'
|
|
import { createMockSupabase, createQueuedMockSupabase } from '@/tests/helpers'
|
|
import { resolveSettlementAccount } from '../settlement-account'
|
|
import { BookkeepingDatabaseError } from '../errors'
|
|
|
|
const noopLog = { warn: vi.fn() } as unknown as import('@/lib/logger').Logger
|
|
|
|
describe('resolveSettlementAccount', () => {
|
|
describe('no cash_account_id: single-enabled-account currency fallback (#1722)', () => {
|
|
it('resolves the single enabled account for the currency instead of 1930', async () => {
|
|
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
|
enqueue({ data: [{ ledger_account: '1920' }], error: null })
|
|
|
|
const result = await resolveSettlementAccount(supabase as never, 'company-1', null, noopLog)
|
|
|
|
expect(result).toBe('1920')
|
|
expect(supabase.from).toHaveBeenCalledWith('cash_accounts')
|
|
// The candidate listing must be narrowed to enabled accounts in the
|
|
// transaction's currency (SEK by default), mirroring the client-side
|
|
// resolveAccount semantics.
|
|
const eqArgs = findCalls('cash_accounts', 'eq')
|
|
expect(eqArgs).toContainEqual(['company_id', 'company-1'])
|
|
expect(eqArgs).toContainEqual(['enabled', true])
|
|
expect(eqArgs).toContainEqual(['currency', 'SEK'])
|
|
})
|
|
|
|
it('filters candidates by the currency argument', async () => {
|
|
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
|
enqueue({ data: [{ ledger_account: '1939' }], error: null })
|
|
|
|
const result = await resolveSettlementAccount(
|
|
supabase as never,
|
|
'company-1',
|
|
null,
|
|
noopLog,
|
|
'EUR',
|
|
)
|
|
|
|
expect(result).toBe('1939')
|
|
expect(findCalls('cash_accounts', 'eq')).toContainEqual(['currency', 'EUR'])
|
|
})
|
|
|
|
it('keeps the 1930 fallback when the company has no enabled account in the currency', async () => {
|
|
// A SEK transaction in a company whose only enabled cash account is EUR:
|
|
// the currency-narrowed listing comes back empty.
|
|
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
|
enqueue({ data: [], error: null })
|
|
|
|
const result = await resolveSettlementAccount(
|
|
supabase as never,
|
|
'company-1',
|
|
null,
|
|
noopLog,
|
|
'SEK',
|
|
)
|
|
|
|
expect(result).toBe('1930')
|
|
expect(findCalls('cash_accounts', 'eq')).toContainEqual(['currency', 'SEK'])
|
|
})
|
|
|
|
it('keeps the 1930 fallback when several enabled accounts share the currency', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({
|
|
data: [{ ledger_account: '1920' }, { ledger_account: '1930' }],
|
|
error: null,
|
|
})
|
|
|
|
const result = await resolveSettlementAccount(supabase as never, 'company-1', null, noopLog)
|
|
|
|
expect(result).toBe('1930')
|
|
})
|
|
|
|
it('warns and keeps the 1930 fallback when the candidate lookup errors', async () => {
|
|
// Unlike the explicit-cashAccountId branch (#842), this branch never
|
|
// queried before, so an infra error degrades to the historical fallback
|
|
// instead of failing the request.
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: null, error: { message: 'boom' } })
|
|
const warn = vi.fn()
|
|
|
|
const result = await resolveSettlementAccount(supabase as never, 'company-1', null, {
|
|
warn,
|
|
} as unknown as import('@/lib/logger').Logger)
|
|
|
|
expect(result).toBe('1930')
|
|
expect(warn).toHaveBeenCalledWith(
|
|
'settlement-account currency fallback lookup failed; defaulting to 1930',
|
|
expect.objectContaining({ companyId: 'company-1', currency: 'SEK' }),
|
|
)
|
|
})
|
|
|
|
it('warns and keeps the 1930 fallback when the single row has no ledger_account', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: [{ ledger_account: null }], error: null })
|
|
const warn = vi.fn()
|
|
|
|
const result = await resolveSettlementAccount(supabase as never, 'company-1', null, {
|
|
warn,
|
|
} as unknown as import('@/lib/logger').Logger)
|
|
|
|
expect(result).toBe('1930')
|
|
expect(warn).toHaveBeenCalledWith(
|
|
'settlement-account currency fallback row has no ledger_account; defaulting to 1930',
|
|
expect.objectContaining({ companyId: 'company-1', currency: 'SEK' }),
|
|
)
|
|
})
|
|
})
|
|
|
|
it('returns the linked cash account ledger_account', async () => {
|
|
const { supabase, mockResult } = createMockSupabase()
|
|
mockResult({ data: { ledger_account: '1940' }, error: null })
|
|
|
|
const result = await resolveSettlementAccount(supabase as never, 'company-1', 'ca-1', noopLog)
|
|
|
|
expect(result).toBe('1940')
|
|
expect(supabase.from).toHaveBeenCalledWith('cash_accounts')
|
|
})
|
|
|
|
it('throws a BookkeepingDatabaseError instead of silently falling back when the lookup errors', async () => {
|
|
const { supabase, mockResult } = createMockSupabase()
|
|
mockResult({ data: null, error: { message: 'boom' } })
|
|
|
|
await expect(
|
|
resolveSettlementAccount(supabase as never, 'company-1', 'ca-1', noopLog),
|
|
).rejects.toBeInstanceOf(BookkeepingDatabaseError)
|
|
await expect(
|
|
resolveSettlementAccount(supabase as never, 'company-1', 'ca-1', noopLog),
|
|
).rejects.toMatchObject({
|
|
operation: 'resolve_settlement_account',
|
|
message: expect.stringContaining('boom'),
|
|
})
|
|
})
|
|
|
|
it('falls back to 1930 when cash_account_id does not match any row', async () => {
|
|
const { supabase, mockResult } = createMockSupabase()
|
|
mockResult({ data: null, error: null })
|
|
|
|
const result = await resolveSettlementAccount(supabase as never, 'company-1', 'ca-unknown', noopLog)
|
|
|
|
expect(result).toBe('1930')
|
|
})
|
|
|
|
it('falls back to 1930 and warns when the row has no ledger_account', async () => {
|
|
const { supabase, mockResult } = createMockSupabase()
|
|
mockResult({ data: { ledger_account: null }, error: null })
|
|
const warn = vi.fn()
|
|
|
|
const result = await resolveSettlementAccount(supabase as never, 'company-1', 'ca-1', {
|
|
warn,
|
|
} as unknown as import('@/lib/logger').Logger)
|
|
|
|
expect(result).toBe('1930')
|
|
expect(warn).toHaveBeenCalledWith(
|
|
'settlement-account lookup returned no ledger_account; defaulting to 1930',
|
|
expect.objectContaining({ cashAccountId: 'ca-1' }),
|
|
)
|
|
})
|
|
})
|