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
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { insertAuthUser, insertCompany, insertCompanyMember } from './fixtures'
|
||||
import { insertAuthUser, insertCashAccount, insertCompany, insertCompanyMember } from './fixtures'
|
||||
import { getPool, withUserContext } from './setup'
|
||||
|
||||
/**
|
||||
@@ -128,6 +128,40 @@ describe('behandlingshistorik audit triggers (BFNAR 2013:2 p. 9.16)', () => {
|
||||
expect(rows).toEqual([{ action: 'INSERT', company_id: companyId, user_id: userId }])
|
||||
})
|
||||
|
||||
it('logs a cash_accounts voucher_series change but not bank-sync churn (20260902124513)', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: userId })
|
||||
const cashAccountId = await insertCashAccount({ companyId, ledgerAccount: '1931' })
|
||||
|
||||
// Bank sync touches balance and name all day: no behandlingsregel, no row.
|
||||
await getPool().query(
|
||||
`UPDATE public.cash_accounts SET balance = 250, name = 'Företagskort' WHERE id = $1`,
|
||||
[cashAccountId],
|
||||
)
|
||||
expect(await auditActions('cash_accounts', cashAccountId)).toEqual([])
|
||||
|
||||
// Assigning the account its own verifikationsserie is a behandlingsregel.
|
||||
await getPool().query(`UPDATE public.cash_accounts SET voucher_series = 'M' WHERE id = $1`, [cashAccountId])
|
||||
// Clearing it back to "follow the per-type default" is one too.
|
||||
await getPool().query(`UPDATE public.cash_accounts SET voucher_series = NULL WHERE id = $1`, [cashAccountId])
|
||||
|
||||
const rows = await getPool().query<{ action: string; company_id: string | null; old_series: string | null; new_series: string | null }>(
|
||||
`SELECT action, company_id, old_state->>'voucher_series' AS old_series, new_state->>'voucher_series' AS new_series
|
||||
FROM public.audit_log WHERE table_name = 'cash_accounts' AND record_id = $1
|
||||
ORDER BY created_at, id`,
|
||||
[cashAccountId],
|
||||
)
|
||||
expect(rows.rows).toEqual([
|
||||
{ action: 'UPDATE', company_id: companyId, old_series: null, new_series: 'M' },
|
||||
{ action: 'UPDATE', company_id: companyId, old_series: 'M', new_series: null },
|
||||
])
|
||||
|
||||
// The CHECK from 20260902121420 still guards the column.
|
||||
await expect(
|
||||
getPool().query(`UPDATE public.cash_accounts SET voucher_series = 'ab' WHERE id = $1`, [cashAccountId]),
|
||||
).rejects.toThrow(/check constraint/i)
|
||||
})
|
||||
|
||||
it('logs the global payroll constants without a company (read via service role by the report)', async () => {
|
||||
const id = randomUUID()
|
||||
await getPool().query(
|
||||
|
||||
Reference in New Issue
Block a user