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:
co-authored by
Claude Fable 5.1
parent
678acfe7ef
commit
f1230282a9
@@ -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')}>
|
||||
|
||||
Reference in New Issue
Block a user