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
@@ -707,6 +707,30 @@ describe('auditRowToEvent: behandlingsregler', () => {
expect(upd.details).toEqual(['Debetkonto: 6540 → 6212'])
})
it('cash_accounts: a verifikationsserie change names the account and diffs the series', () => {
const upd = auditRowToEvent(
auditRow({
table_name: 'cash_accounts',
action: 'UPDATE',
old_state: { name: 'Företagskort', ledger_account: '1931', voucher_series: null, balance: 100, updated_at: 'x' },
new_state: { name: 'Företagskort', ledger_account: '1931', voucher_series: 'M', balance: 250, updated_at: 'y' },
}),
)!
expect(upd).toMatchObject({ category: 'installningar', code: 'cash_account.updated', object: 'Företagskort 1931' })
expect(upd.details).toEqual(['Verifikationsserie: (tomt) → M'])
// Bank-sync churn (balance, name) is not a behandlingsregel: no event.
const churn = auditRowToEvent(
auditRow({
table_name: 'cash_accounts',
action: 'UPDATE',
old_state: { name: 'Företagskort', ledger_account: '1931', voucher_series: 'M', balance: 100 },
new_state: { name: 'Företagskort', ledger_account: '1931', voucher_series: 'M', balance: 250 },
}),
)
expect(churn).toBeNull()
})
it('categorization_templates: the learning columns never reach the report', () => {
// The DB trigger filters these already (20260901103000 + 20260901200000);
// the read model must not resurrect them if a row slips through, or every
+23 -2
View File
@@ -209,6 +209,10 @@ export const AUDITED_TABLES = [
'booking_template_library',
'sie_imports',
'bank_file_imports',
// Verifikationsserie per bankkonto (audited since migration 20260902124513):
// the per-account override outranks the per-source-type map above, so it is
// a behandlingsregel in the same sense.
'cash_accounts',
] as const
/**
@@ -232,7 +236,7 @@ export const GLOBAL_ACTIONS = [
* names statically; a unit test pins it to AUDITED_TABLES / GLOBAL_ACTIONS.
*/
export const AUDIT_ROW_FILTER =
'table_name.in.(journal_entries,chart_of_accounts,company_settings,fiscal_periods,api_keys,dimensions,dimension_values,account_dimension_rules,accrual_schedules,document_attachments,mapping_rules,categorization_templates,booking_template_library,sie_imports,bank_file_imports),action.in.(SECURITY_EVENT,INTEGRITY_FAILURE,RETENTION_BLOCK,DOCUMENT_DELETE_BLOCKED)'
'table_name.in.(journal_entries,chart_of_accounts,company_settings,fiscal_periods,api_keys,dimensions,dimension_values,account_dimension_rules,accrual_schedules,document_attachments,mapping_rules,categorization_templates,booking_template_library,sie_imports,bank_file_imports,cash_accounts),action.in.(SECURITY_EVENT,INTEGRITY_FAILURE,RETENTION_BLOCK,DOCUMENT_DELETE_BLOCKED)'
const SOURCE_TYPE_LABELS: Record<string, string> = {
manual: 'Manuell',
@@ -411,6 +415,15 @@ const MAPPING_RULE_FIELDS: Record<string, string> = {
is_active: 'Aktiv',
}
/**
* cash_accounts columns that are behandlingsregler. Only voucher_series: the
* trigger (20260902124513) fires on that column alone, and the read model
* must not resurrect balance/name churn from bank sync if a row slips through.
*/
const CASH_ACCOUNT_FIELDS: Record<string, string> = {
voucher_series: 'Verifikationsserie',
}
const CATEGORIZATION_TEMPLATE_FIELDS: Record<string, string> = {
counterparty_name: 'Motpart',
// counterparty_aliases deliberately absent: aliases grow in the same
@@ -1128,6 +1141,14 @@ export function auditRowToEvent(
fields: BOOKING_TEMPLATE_FIELDS,
objectKeys: ['name'],
})
case 'cash_accounts':
return genericAuditEvent(row, {
category: 'installningar',
codePrefix: 'cash_account',
noun: 'Bankkonto',
fields: CASH_ACCOUNT_FIELDS,
objectKeys: ['name', 'ledger_account'],
})
case 'salary_payroll_config':
return payrollConfigAuditEvent(row)
// The import tables emit their own events from the rows themselves; the
@@ -1584,7 +1605,7 @@ async function fetchAuditRows(
// Literal on purpose (not AUDIT_ROW_FILTER): the schema guard only
// resolves string literals here. A test pins the two to each other.
.or(
'table_name.in.(journal_entries,chart_of_accounts,company_settings,fiscal_periods,api_keys,dimensions,dimension_values,account_dimension_rules,accrual_schedules,document_attachments,mapping_rules,categorization_templates,booking_template_library,sie_imports,bank_file_imports),action.in.(SECURITY_EVENT,INTEGRITY_FAILURE,RETENTION_BLOCK,DOCUMENT_DELETE_BLOCKED)',
'table_name.in.(journal_entries,chart_of_accounts,company_settings,fiscal_periods,api_keys,dimensions,dimension_values,account_dimension_rules,accrual_schedules,document_attachments,mapping_rules,categorization_templates,booking_template_library,sie_imports,bank_file_imports,cash_accounts),action.in.(SECURITY_EVENT,INTEGRITY_FAILURE,RETENTION_BLOCK,DOCUMENT_DELETE_BLOCKED)',
)
.order('created_at', { ascending: true })
.order('id', { ascending: true })
+17
View File
@@ -1546,6 +1546,23 @@ async function buildSystemDoc(
fiscal_period_id: vs.fiscal_period_id ?? null,
})
),
// BFNAR 2013:2 p. 9.2-9.15: how verifikationer are assigned to a series
// is a behandlingsregel the systemdokumentation has to spell out, incl.
// the exceptions. The concrete mappings live in the data tables named
// here (company_settings.default_voucher_series_per_source_type and
// cash_accounts.voucher_series); changes to both are in behandlingshistorik.
verifikationsserier_regler: {
ordning: [
'Serie vald av användaren i bokföringsdialogen',
'Bankkontots egen verifikationsserie (data/cash_accounts.json, fältet voucher_series), gäller verifikat som skapas från banktransaktioner',
'Standardserie per verifikattyp (data/company_settings.json, fältet default_voucher_series_per_source_type)',
'Serie A',
],
undantag: [
'Betalningar av kund- och leverantörsfakturor som matchas mot en banktransaktion använder fakturatypens serie, inte bankkontots',
'Samlingsverifikat (bokföring av flera banktransaktioner i ett verifikat) använder standardserien för banktransaktioner',
],
},
behorighetskontroll: {
description: 'Rollbaserad atkomstkontroll med owner/admin/member/viewer',
mfa_stod: true,