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>
This commit is contained in:
Mattsson
2026-09-02 15:25:34 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 678acfe7ef
commit f1230282a9
27 changed files with 990 additions and 19 deletions
@@ -0,0 +1,62 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import {
cashAccountSeriesOverride,
resolveCashAccountVoucherSeries,
} from '../cash-account-voucher-series'
const { supabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
describe('cashAccountSeriesOverride', () => {
it('returns the letter when the account carries a valid override', () => {
expect(cashAccountSeriesOverride({ voucher_series: 'M' })).toBe('M')
})
it('returns undefined for null, missing, lowercase or multi-letter values', () => {
expect(cashAccountSeriesOverride(null)).toBeUndefined()
expect(cashAccountSeriesOverride(undefined)).toBeUndefined()
expect(cashAccountSeriesOverride({ voucher_series: null })).toBeUndefined()
expect(cashAccountSeriesOverride({})).toBeUndefined()
expect(cashAccountSeriesOverride({ voucher_series: 'm' })).toBeUndefined()
expect(cashAccountSeriesOverride({ voucher_series: 'AB' })).toBeUndefined()
expect(cashAccountSeriesOverride({ voucher_series: '' })).toBeUndefined()
})
})
describe('resolveCashAccountVoucherSeries', () => {
beforeEach(() => {
reset()
})
it('skips the lookup entirely when the transaction has no cash account', async () => {
expect(await resolveCashAccountVoucherSeries(supabase as never, 'company-1', null)).toBeUndefined()
expect(await resolveCashAccountVoucherSeries(supabase as never, 'company-1', undefined)).toBeUndefined()
expect(findCalls('cash_accounts', 'select')).toHaveLength(0)
})
it('returns the account override, scoped to the company', async () => {
enqueue({ data: { voucher_series: 'M' }, error: null })
const series = await resolveCashAccountVoucherSeries(supabase as never, 'company-1', 'ca-1')
expect(series).toBe('M')
const eqCalls = findCalls('cash_accounts', 'eq')
expect(eqCalls).toContainEqual(['company_id', 'company-1'])
expect(eqCalls).toContainEqual(['id', 'ca-1'])
})
it('returns undefined when the account has no override', async () => {
enqueue({ data: { voucher_series: null }, error: null })
expect(await resolveCashAccountVoucherSeries(supabase as never, 'company-1', 'ca-1')).toBeUndefined()
})
it('returns undefined when the account is unknown', async () => {
enqueue({ data: null, error: null })
expect(await resolveCashAccountVoucherSeries(supabase as never, 'company-1', 'ca-missing')).toBeUndefined()
})
it('fails open (undefined) on a query error so the booking still goes through', async () => {
enqueue({ data: null, error: { message: 'boom' } })
expect(await resolveCashAccountVoucherSeries(supabase as never, 'company-1', 'ca-1')).toBeUndefined()
})
})
@@ -468,6 +468,30 @@ describe('createTransactionJournalEntry', () => {
expect(input.source_id).toBe('tx-abc-123')
})
it("books into the cash account's verifikationsserie when the account carries one", async () => {
const { supabase, enqueue, reset } = createQueuedMockSupabase()
reset()
enqueue({ data: { voucher_series: 'M' }, error: null })
const tx = makeTransaction({ amount: -100, cash_account_id: 'ca-card' })
await createTransactionJournalEntry(supabase as never, 'company-1', 'user-1', tx, makeMappingResult())
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.voucher_series).toBe('M')
})
it('omits voucher_series (engine resolves the per-type default) when the account has no override', async () => {
const { supabase, enqueue, reset } = createQueuedMockSupabase()
reset()
enqueue({ data: { voucher_series: null }, error: null })
const tx = makeTransaction({ amount: -100, cash_account_id: 'ca-main' })
await createTransactionJournalEntry(supabase as never, 'company-1', 'user-1', tx, makeMappingResult())
const input = mockedCreateEntry.mock.calls[0][3]
expect('voucher_series' in input).toBe(false)
})
it('uses transaction.date as entry_date', async () => {
const tx = makeTransaction({ date: '2024-09-15', amount: -100 })
const mapping = makeMappingResult()
@@ -0,0 +1,71 @@
/**
* Verifikationsserie per bankkonto.
*
* Resolution order for an entry booked from a bank transaction:
* 1. explicit voucher_series in the request (the booking dialog's picker)
* 2. cash_accounts.voucher_series of the transaction's account (this module)
* 3. company_settings.default_voucher_series_per_source_type (engine)
* 4. 'A' (engine)
*
* This helper covers step 2 only. It returns undefined whenever there is no
* override so callers can pass the result straight into CreateJournalEntryInput
* and let the engine handle steps 3 and 4. Lookup failures also resolve to
* undefined: a broken override must never block a booking, the entry then
* lands in the per-type default exactly as before this feature.
*
* Scope: bank-transaction bookings (book route, categorize, agent, pending
* operations). Invoice settlements matched from the bank keep the invoice
* payment series (invoice_paid, supplier_invoice_paid): those series describe
* the payment kind, not the account the money moved through.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
const log = createLogger('cash-account-voucher-series')
const SERIES_LETTER_RE = /^[A-Z]$/
/** Pure: the override letter of a cash account row, or undefined. */
export function cashAccountSeriesOverride(
account: { voucher_series?: string | null } | null | undefined,
): string | undefined {
const value = account?.voucher_series
return typeof value === 'string' && SERIES_LETTER_RE.test(value) ? value : undefined
}
/**
* Look up the series override of one of the company's cash accounts.
* undefined when the account is unknown, has no override, or the query fails.
*/
export async function resolveCashAccountVoucherSeries(
supabase: SupabaseClient,
companyId: string,
cashAccountId: string | null | undefined,
): Promise<string | undefined> {
if (!cashAccountId) return undefined
try {
const { data, error } = await supabase
.from('cash_accounts')
.select('voucher_series')
.eq('company_id', companyId)
.eq('id', cashAccountId)
.maybeSingle()
if (error) {
// Fail open, but never silently: the entry lands in the per-type
// default and the log says why.
log.warn('cash_accounts voucher_series lookup failed; using per-type default', {
companyId,
cashAccountId,
error: error.message,
})
return undefined
}
return cashAccountSeriesOverride(data as { voucher_series?: string | null } | null)
} catch (err) {
log.warn('cash_accounts voucher_series lookup threw; using per-type default', {
companyId,
cashAccountId,
error: err instanceof Error ? err.message : String(err),
})
return undefined
}
}
+10
View File
@@ -1,4 +1,5 @@
import { createJournalEntry, findFiscalPeriod } from './engine'
import { resolveCashAccountVoucherSeries } from './cash-account-voucher-series'
import { resolveSekAmount, buildCurrencyMetadata } from './currency-utils'
import { coerceDimensionsBag } from './dimension-resolver'
import { extractNetAmount, extractVatAmount } from './vat-entries'
@@ -338,6 +339,14 @@ export async function createTransactionJournalEntry(
? [baseDescription, ...extraParts].filter(Boolean).join(' · ').slice(0, 500)
: baseDescription
// The transaction's bank account may carry its own verifikationsserie;
// undefined lets the engine fall back to the per-source-type default.
const voucherSeries = await resolveCashAccountVoucherSeries(
supabase,
companyId,
transaction.cash_account_id,
)
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: entryDate,
@@ -345,6 +354,7 @@ export async function createTransactionJournalEntry(
source_type: 'bank_transaction',
source_id: transaction.id,
lines,
...(voucherSeries ? { voucher_series: voucherSeries } : {}),
}
return createJournalEntry(supabase, companyId, userId, input)