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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user