Files
accounted/lib/__tests__/resolve-account.test.ts
T
Mattsson f1230282a9 feat(bookkeeping): verifikationsserie per bankkonto for bank-transaction bookings (#2160)
* feat(bookkeeping): verifikationsserie per bankkonto for bank-transaction bookings

A company running several bank accounts (main bank on A, company card on M,
both imported via CSV) could not route each account's bookings into its own
series: every bank_transaction booking took the single company-wide default
from default_voucher_series_per_source_type.

- cash_accounts.voucher_series (nullable, single letter): per-account override,
  editable under Inställningar → Bokföring → Verifikationsserier per bankkonto
  (new PATCH /api/cash-accounts/[id]).
- resolveCashAccountVoucherSeries(): step 2 of the resolution order
  (explicit pick → account override → per-type map → A). Wired into the book
  route and createTransactionJournalEntry, which covers categorize, the agent,
  pending operations and the v1 API.
- Booking dialog gets the series picker, seeded from the server via
  /voucher-sequences/next?source_type&cash_account_id so dialog and route can
  never disagree. An unresolved embedded picker omits voucher_series so a
  stray 'A' never overrides the account's series.

Scope: bank_transaction bookings only. Invoice settlements matched from the
bank keep their payment series; bulk-book resolves inside its RPC (see
DECISIONS.md).

Migration applied to staging as 20260902121420.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JWSLbQc3jgpfqnxWe6nteh

* fix(bookkeeping): audit and document the per-bankkonto series, tighten preview and PATCH

Consolidated pass over the PR #2160 findings (skeptics, CodeRabbit, Swedish
compliance review):

- Behandlingshistorik (BFNAR 2013:2 p. 9.16): changing cash_accounts.voucher_series
  is a behandlingsregel that outranks the audited per-type map. New trigger
  audit_cash_accounts_voucher_series (UPDATE only, WHEN the series changes, so
  bank-sync churn never logs), cash_accounts added to AUDITED_TABLES and the
  audit_log filter, "Bankkonto ... Verifikationsserie: (tomt) -> M" events in
  the report, pg-real test. Applied to staging as 20260902124513.
- Systemdokumentation (p. 9.2-9.15): revision/systemdokumentation.json gains a
  verifikationsserier_regler block with the resolution order and the two
  exceptions (invoice settlements, samlingsverifikat); the per-account mapping
  itself is in data/cash_accounts.json.
- Settings picker uses the same closed list as the manual verifikat form
  (presets plus letters already in use) instead of all 26 letters; strings
  moved to messages/sv.json and messages/en.json.
- /voucher-sequences/next applies the account override only for
  source_type=bank_transaction (CodeRabbit), so a manual-entry preview cannot
  show a series the entry will not get.
- Book route resolves the series from the account the row ends up on after a
  stranded-row repoint, not the stale one.
- PATCH /api/cash-accounts/[id] answers 404 for a non-UUID id instead of a
  Postgres cast 500; the series lookup logs a warning when it fails open.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JWSLbQc3jgpfqnxWe6nteh

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 15:25:34 +02:00

75 lines
2.8 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { resolveAccount } from '@/lib/cash-accounts/resolve-account'
import type { CashAccount } from '@/types'
function makeCashAccount(overrides: Partial<CashAccount> = {}): CashAccount {
return {
id: 'ca-1',
company_id: 'company-1',
bank_connection_id: null,
external_uid: null,
iban: null,
bg_pg: null,
name: null,
currency: 'SEK',
ledger_account: '1930',
balance: null,
available_balance: null,
balance_updated_at: null,
enabled: true,
is_primary: true,
source: 'manual',
voucher_series: null,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
...overrides,
}
}
describe('resolveAccount', () => {
it('returns the ledger_account for the bound cash account when cash_account_id matches', () => {
const accounts = [
makeCashAccount({ id: 'ca-1', ledger_account: '1930' }),
makeCashAccount({ id: 'ca-2', ledger_account: '1940', is_primary: false }),
]
const result = resolveAccount(accounts, 'ca-2', 'SEK')
expect(result).toEqual({ account: '1940', fallback: false })
})
it('falls back to the sole enabled same-currency account when cash_account_id is null', () => {
const accounts = [makeCashAccount({ id: 'ca-1', ledger_account: '1920', currency: 'EUR' })]
const result = resolveAccount(accounts, null, 'EUR')
expect(result).toEqual({ account: '1920', fallback: false })
})
it('falls back to 1930 when cash_account_id is null and multiple same-currency accounts exist', () => {
const accounts = [
makeCashAccount({ id: 'ca-1', ledger_account: '1930', currency: 'SEK' }),
makeCashAccount({ id: 'ca-2', ledger_account: '1940', currency: 'SEK', is_primary: false }),
]
const result = resolveAccount(accounts, null, 'SEK')
expect(result).toEqual({ account: '1930', fallback: true })
})
it('falls back to 1930 when cash_account_id does not match any account', () => {
const accounts = [makeCashAccount({ id: 'ca-1', ledger_account: '1930' })]
const result = resolveAccount(accounts, 'ca-unknown', 'SEK')
expect(result).toEqual({ account: '1930', fallback: true })
})
it('falls back to 1930 when accounts list is empty', () => {
const result = resolveAccount([], null, 'SEK')
expect(result).toEqual({ account: '1930', fallback: true })
})
it('ignores disabled accounts in the currency fallback path', () => {
const accounts = [
makeCashAccount({ id: 'ca-1', ledger_account: '1930', enabled: true }),
makeCashAccount({ id: 'ca-2', ledger_account: '1940', enabled: false, is_primary: false }),
]
// Only one enabled SEK account → resolves without fallback
const result = resolveAccount(accounts, null, 'SEK')
expect(result).toEqual({ account: '1930', fallback: false })
})
})