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
@@ -237,6 +237,84 @@ describe('POST /api/transactions/[id]/book', () => {
)
})
it("books into the bank account's own verifikationsserie when the cash account carries one", async () => {
const tx = makeTransaction({
id: 'tx-1',
amount: -500,
journal_entry_id: null,
cash_account_id: 'ca-card',
})
const je = makeJournalEntry({ id: 'je-new', voucher_series: 'M' })
// Fetch transaction
enqueue({ data: tx, error: null })
// guardBookedCounterLines own-row lookup (1930 matches the own ledger: clean)
enqueue({ data: { ledger_account: '1930' }, error: null })
// Cash account series override
enqueue({ data: { voucher_series: 'M' }, error: null })
mockCreateJournalEntry.mockResolvedValue(je)
// Update transaction
enqueue({ data: [{ id: 'tx-1' }], error: null })
const request = createMockRequest('/api/transactions/tx-1/book', {
method: 'POST',
body: validBody,
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
expect(mockCreateJournalEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
expect.objectContaining({ source_type: 'bank_transaction', voucher_series: 'M' }),
)
})
it('lets an explicit voucher_series from the dialog win over the cash account override', async () => {
const tx = makeTransaction({
id: 'tx-1',
amount: -500,
journal_entry_id: null,
cash_account_id: 'ca-card',
})
const je = makeJournalEntry({ id: 'je-new', voucher_series: 'V' })
enqueue({ data: tx, error: null })
enqueue({ data: { ledger_account: '1930' }, error: null })
mockCreateJournalEntry.mockResolvedValue(je)
enqueue({ data: [{ id: 'tx-1' }], error: null })
const request = createMockRequest('/api/transactions/tx-1/book', {
method: 'POST',
body: { ...validBody, voucher_series: 'V' },
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
expect(mockCreateJournalEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
expect.objectContaining({ voucher_series: 'V' }),
)
// No cash_accounts series lookup: the explicit pick short-circuits it.
const seriesLookups = findCalls('cash_accounts', 'select').filter((args) => args[0] === 'voucher_series')
expect(seriesLookups).toHaveLength(0)
})
it('rejects a malformed voucher_series with 400', async () => {
const request = createMockRequest('/api/transactions/tx-1/book', {
method: 'POST',
body: { ...validBody, voucher_series: 'ab' },
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
expect(response.status).toBe(400)
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
})
it('returns 400 TX_CATEGORIZE_ORPHANED_COUNTER_ACCOUNT when a line books the settlement row against its active twin (#1643)', async () => {
// The issue's dialog shape: 1930 and 1931 both enabled on one active
// connection; "Ändra rader" pre-filled 1930 debit / 1931 credit from a
@@ -288,6 +366,7 @@ describe('POST /api/transactions/[id]/book', () => {
],
}) // cash_accounts topology
enqueue({ data: [{ id: 'conn-live', status: 'active' }] }) // bank_connections statuses
enqueue({ data: { voucher_series: 'M' } }) // series override of the LIVE twin the row moves to
mockCreateJournalEntry.mockResolvedValue(makeJournalEntry({ id: 'je-new' }))
enqueue({ data: [{ id: 'tx-1' }], error: null }) // link update
@@ -308,6 +387,14 @@ describe('POST /api/transactions/[id]/book', () => {
expect(findCalls('transactions', 'update')).toContainEqual([
expect.objectContaining({ journal_entry_id: 'je-new', cash_account_id: 'ca-live' }),
])
// The series follows the account the row ends up on, not the stale one.
expect(findCalls('cash_accounts', 'eq')).toContainEqual(['id', 'ca-live'])
expect(mockCreateJournalEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
expect.objectContaining({ voucher_series: 'M' }),
)
})
it('returns 400 TX_CATEGORIZE_ORPHANED_COUNTER_ACCOUNT when the single bank line sits on a dead twin of the live own row (#1643 round 5)', async () => {
+15
View File
@@ -3,6 +3,7 @@ import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { resolveCashAccountVoucherSeries } from '@/lib/bookkeeping/cash-account-voucher-series'
import { guardBookedCounterLines } from '@/lib/cash-accounts/service'
import { reverseOrphanedJournalEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
@@ -167,6 +168,19 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
})
}
// Series: the dialog's explicit pick wins; otherwise the bank account the
// row will sit on after this booking (the live sibling when the guard
// re-points a stranded row, else its own) may carry its own
// verifikationsserie; otherwise the engine falls back to the
// per-source-type default.
const voucherSeries =
validation.data.voucher_series ??
(await resolveCashAccountVoucherSeries(
supabase,
companyId,
repointCashAccountId ?? (transaction as Transaction).cash_account_id,
))
// Create journal entry via the engine
let journalEntry
try {
@@ -177,6 +191,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
source_type: 'bank_transaction',
source_id: id,
lines,
...(voucherSeries ? { voucher_series: voucherSeries } : {}),
})
} catch (err) {
const typed = bookkeepingErrorResponse(err)