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
+48 -7
View File
@@ -113,6 +113,13 @@ interface Props {
*/
extraBody?: Record<string, unknown>
duplicateMatchTransaction?: DuplicateMatchTransaction
/** Embedded variant only: show the series picker anyway. The series is
* seeded from the server (source type + cash account override) so the
* dialog and the booking route can never disagree. */
seriesPicker?: boolean
/** Bank account the entry is booked from; its voucher_series override
* (Inställningar → Bokföring) seeds the picker. */
cashAccountId?: string | null
/** Fired after the duplicate guard's match action links the transaction to
* the existing voucher (no new entry was created). */
onDuplicateMatched?: (journalEntryId: string) => void
@@ -140,6 +147,8 @@ export default function JournalEntryForm({
onUpdated,
extraBody,
duplicateMatchTransaction,
seriesPicker,
cashAccountId,
onDuplicateMatched,
}: Props) {
const { canWrite } = useCanWrite()
@@ -181,6 +190,14 @@ export default function JournalEntryForm({
initialLines ?? [{ ...BLANK_LINE }, { ...BLANK_LINE }]
)
const [voucherSeries, setVoucherSeries] = useState(initialVoucherSeries ?? 'A')
// Embedded forms show the picker only on request (bank transaction dialog).
const showSeries = !embedded || !!seriesPicker
// Whether voucherSeries is authoritative. The standalone form seeds it from
// company settings; the embedded picker asks the server once (source type +
// cash account override) and marks it resolved, or when the user picks. Until
// then the submit omits voucher_series so the route resolves it itself: an
// unresolved 'A' must never override the bank account's own series.
const [seriesResolved, setSeriesResolved] = useState(!embedded)
// The source_type the entry will be committed with. Seeded from the prop
// (undefined -> 'manual' for the standalone form). Applying a booking template
// whose category maps to a dedicated source type (e.g. VAT -> vat_settlement)
@@ -359,7 +376,7 @@ export default function JournalEntryForm({
// Read-only hint; the actual number is reserved atomically at commit time,
// so this may shift by one if another entry lands first.
useEffect(() => {
if (embedded || !entryDate || !voucherSeries) {
if (!showSeries || !entryDate || !voucherSeries) {
setNextVoucherNumber(null)
return
}
@@ -367,13 +384,29 @@ export default function JournalEntryForm({
// Keyed on the entry date rather than the resolved period so the preview
// fires as soon as the series is known: the route resolves the period
// from the date itself, which is exactly how selectedPeriod is derived.
const qs = new URLSearchParams({ date: entryDate, series: voucherSeries })
// Before the embedded picker is resolved, ask by source type + cash
// account instead of by series: the route answers with the series the
// booking would actually get, and that seeds the picker.
const qs = new URLSearchParams({ date: entryDate })
if (seriesResolved) {
qs.set('series', voucherSeries)
} else {
if (sourceType) qs.set('source_type', sourceType)
if (cashAccountId) qs.set('cash_account_id', cashAccountId)
}
fetch(`/api/bookkeeping/voucher-sequences/next?${qs}`)
.then((r) => (r.ok ? r.json() : null))
.then((body) => {
if (cancelled) return
const next = body?.data?.next
setNextVoucherNumber(typeof next === 'number' ? next : null)
if (!seriesResolved && body) {
const resolved = body?.data?.series
if (typeof resolved === 'string' && /^[A-Z]$/.test(resolved)) {
setVoucherSeries(resolved)
}
setSeriesResolved(true)
}
})
.catch(() => {
if (!cancelled) setNextVoucherNumber(null)
@@ -381,7 +414,7 @@ export default function JournalEntryForm({
return () => {
cancelled = true
}
}, [embedded, entryDate, voucherSeries])
}, [showSeries, seriesResolved, entryDate, voucherSeries, sourceType, cashAccountId])
// Fetch exchange rate from Riksbanken when currency changes
const fetchRate = useCallback(async (currency: Currency) => {
@@ -1042,7 +1075,9 @@ export default function JournalEntryForm({
description,
source_type: effectiveSourceType,
source_id: sourceId,
voucher_series: voucherSeries || 'A',
// Omitted while an embedded picker is still unresolved: see
// seriesResolved. Endpoints that do not declare the key strip it.
...(seriesResolved ? { voucher_series: voucherSeries || 'A' } : {}),
notes: notes || undefined,
lines: entryLines,
// Set only when retrying past the booking-time duplicate guard (see
@@ -1056,7 +1091,7 @@ export default function JournalEntryForm({
}),
})
return (await throwOnStructuredError(res)) as { data?: { id?: string; voucher_series?: string; voucher_number?: number }; journal_entry_id?: string }
}, [lines, rate, entryCurrency, computedForeignAmount, t, submitUrl, editEntryId, selectedPeriod, entryDate, description, effectiveSourceType, sourceId, voucherSeries, notes, extraBody])
}, [lines, rate, entryCurrency, computedForeignAmount, t, submitUrl, editEntryId, selectedPeriod, entryDate, description, effectiveSourceType, sourceId, voucherSeries, seriesResolved, notes, extraBody])
const { runSubmit, dialog: activationDialog, confirm: confirmActivation, cancel: cancelActivation } =
useSubmitWithAccountActivation(postJournalEntry)
@@ -1410,13 +1445,19 @@ export default function JournalEntryForm({
className="mt-1 h-8"
/>
</div>
{!embedded && (
{showSeries && (
// Closed list, not free text: the letters carry fixed meanings
// (A = redovisning, B = kundfakturor, ...) and a typo here silently
// starts a new series with its own number sequence.
<div className="w-full sm:w-72">
<Label className="text-xs text-muted-foreground">{t('series')}</Label>
<Select value={voucherSeries} onValueChange={(v) => setVoucherSeries(v)}>
<Select
value={voucherSeries}
onValueChange={(v) => {
setVoucherSeries(v)
setSeriesResolved(true)
}}
>
<SelectTrigger className="mt-1 h-8">
<SelectValue />
</SelectTrigger>
@@ -0,0 +1,139 @@
'use client'
import { useMemo, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Loader2 } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { SettingsGroup, SettingsRow, SettingsSelect } from '@/components/settings/SettingsRows'
import { useCashAccounts } from '@/lib/reference-data/hooks'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { VOUCHER_SERIES_PRESETS } from '@/lib/bookkeeping/voucher-series-resolver'
import type { CashAccount, CompanySettings } from '@/types'
// Sentinel for "no override" in the <select>: an empty option value renders
// as the placeholder in some browsers, so use an explicit token instead.
const FOLLOW_DEFAULT = '__default__'
const SERIES_LETTER_RE = /^[A-Z]$/
interface Props {
/** Company settings, for the letters the company has already configured. */
settings: Pick<CompanySettings, 'default_voucher_series' | 'default_voucher_series_per_source_type'>
}
/** "Företagskort (1931)" or the bare ledger account when the row has no name. */
function accountLabel(account: CashAccount): string {
const name = account.name?.trim()
return name ? `${name} (${account.ledger_account})` : account.ledger_account
}
/**
* Verifikationsserie per bankkonto. A company that runs several bank accounts
* (main bank on A, a company-card account on M) can route each account's
* bookings into its own series. Blank = follow "Verifikationsserier per typ".
* Saves per row on change, no separate save button: each row is one field on
* one account, and the bank-transaction booking dialog reads it live.
*
* The picker is the same closed list as the manual verifikat form: the fixed
* Swedish presets plus every letter the company already uses. A free A-Z list
* would let a typo start an undocumented series (BFNAR 2013:2 p. 9.2-9.15
* wants the series in use enumerated in the systemdokumentation).
*/
export function VoucherSeriesPerCashAccountForm({ settings }: Props) {
const t = useTranslations('settings_voucher_series')
const { toast } = useToast()
const { cashAccounts, isLoading, refresh } = useCashAccounts({ enabledOnly: true })
const [savingId, setSavingId] = useState<string | null>(null)
// Presets first, then any configured or already-assigned letter the presets
// do not cover, so a Select never renders blank on a value it does not offer.
const seriesOptions = useMemo(() => {
const preset = new Set(VOUCHER_SERIES_PRESETS.map((p) => p.letter))
const extras = [
settings.default_voucher_series,
...Object.values(settings.default_voucher_series_per_source_type ?? {}),
...cashAccounts.map((a) => a.voucher_series),
]
.filter((v): v is string => typeof v === 'string' && SERIES_LETTER_RE.test(v) && !preset.has(v))
const uniqueExtras = Array.from(new Set(extras)).sort()
return [
...VOUCHER_SERIES_PRESETS,
...uniqueExtras.map((letter) => ({ letter, label: '' })),
]
}, [settings.default_voucher_series, settings.default_voucher_series_per_source_type, cashAccounts])
/** PATCH one account's override, then refresh the shared cash-account cache. */
const handleChange = async (account: CashAccount, value: string) => {
const next = value === FOLLOW_DEFAULT ? null : value
if ((account.voucher_series ?? null) === next) return
setSavingId(account.id)
try {
const res = await fetch(`/api/cash-accounts/${account.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voucher_series: next }),
})
const json = await res.json().catch(() => null)
if (!res.ok) {
toast({
title: t('per_account_save_failed'),
description: getErrorMessage(json, { context: 'settings', statusCode: res.status }),
variant: 'destructive',
})
return
}
await refresh()
toast({
title: t('per_account_saved_title'),
description: next
? t('per_account_saved_set', { account: accountLabel(account), series: next })
: t('per_account_saved_cleared', { account: accountLabel(account) }),
})
} catch (err) {
toast({
title: t('per_account_save_failed'),
description: getErrorMessage(err, { context: 'settings' }),
variant: 'destructive',
})
} finally {
setSavingId(null)
}
}
return (
<SettingsGroup label={t('per_account_heading')} help={t('per_account_help')}>
{isLoading ? (
<div className="flex items-center gap-2 px-1 py-3 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
{t('per_account_loading')}
</div>
) : cashAccounts.length === 0 ? (
<p className="px-1 py-3 text-sm text-muted-foreground">{t('per_account_empty')}</p>
) : (
cashAccounts.map((account, i) => (
<SettingsRow
key={account.id}
label={accountLabel(account)}
htmlFor={`series-cash-account-${account.id}`}
borderless={i === cashAccounts.length - 1}
>
<SettingsSelect
id={`series-cash-account-${account.id}`}
value={account.voucher_series ?? FOLLOW_DEFAULT}
onChange={(e) => void handleChange(account, e.target.value)}
disabled={savingId === account.id}
className="font-mono"
>
<option value={FOLLOW_DEFAULT}>{t('per_account_follow_default')}</option>
{seriesOptions.map((option) => (
<option key={option.letter} value={option.letter}>
{option.label ? `${option.letter} ${option.label}` : option.letter}
</option>
))}
</SettingsSelect>
</SettingsRow>
))
)}
</SettingsGroup>
)
}
@@ -10,6 +10,7 @@ import { PeriodLockingSettings } from '@/components/settings/PeriodLockingSettin
import { FiscalYearsManager } from '@/components/settings/FiscalYearsManager'
import { VoucherSeriesManager } from '@/components/settings/VoucherSeriesManager'
import { VoucherSeriesPerSourceTypeForm } from '@/components/settings/VoucherSeriesPerSourceTypeForm'
import { VoucherSeriesPerCashAccountForm } from '@/components/settings/VoucherSeriesPerCashAccountForm'
import { applyDefaultSeriesToMap } from '@/lib/bookkeeping/voucher-series-resolver'
import { DimensionsToggle } from '@/components/settings/DimensionsToggle'
import { MileageToggle } from '@/components/settings/MileageToggle'
@@ -169,6 +170,8 @@ export function BookkeepingSettingsContent() {
onSettingsUpdated={updateSettings}
/>
<VoucherSeriesPerCashAccountForm settings={settings} />
<VoucherSeriesManager defaultSeries={settings.default_voucher_series || 'A'} />
<SettingsGroup label={t('group_automation')}>
@@ -430,6 +430,10 @@ export default function TransactionBookingDialog({
submitUrl={`/api/transactions/${transaction.id}/book`}
sourceType="bank_transaction"
sourceId={transaction.id}
// Series picker seeded from the bank account's own series
// (Inställningar → Bokföring → Verifikationsserie per bankkonto).
seriesPicker
cashAccountId={transaction.cash_account_id ?? null}
onEntryCreated={(entryId) => handleBooked(transaction.id, entryId)}
duplicateMatchTransaction={{
id: transaction.id,