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
@@ -173,6 +173,113 @@ describe('GET /api/bookkeeping/voucher-sequences/next', () => {
expect(body.data).toEqual({ next: 5, series: 'V', fiscal_period_id: 'period-1' })
})
it("prefers the cash account's own series over the per-source-type map", async () => {
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
mockFrom.mockImplementation((table: string) => {
if (table === 'fiscal_periods') {
return mockChain({ data: { id: 'period-1' }, error: null })
}
if (table === 'company_settings') {
return mockChain({
data: {
default_voucher_series: 'A',
default_voucher_series_per_source_type: { bank_transaction: 'B' },
},
error: null,
})
}
if (table === 'cash_accounts') {
return mockChain({ data: { voucher_series: 'M' }, error: null })
}
if (table === 'voucher_sequences') {
return mockChain({ data: { last_number: 9 }, error: null })
}
throw new Error(`Unexpected table: ${table}`)
})
const response = await GET(
mkReq('?source_type=bank_transaction&cash_account_id=11111111-1111-4111-8111-111111111111'),
mkParams(),
)
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data).toEqual({ next: 10, series: 'M', fiscal_period_id: 'period-1' })
})
it('falls through to the per-source-type map when the cash account has no override', async () => {
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
mockFrom.mockImplementation((table: string) => {
if (table === 'fiscal_periods') {
return mockChain({ data: { id: 'period-1' }, error: null })
}
if (table === 'company_settings') {
return mockChain({
data: {
default_voucher_series: 'A',
default_voucher_series_per_source_type: { bank_transaction: 'B' },
},
error: null,
})
}
if (table === 'cash_accounts') {
return mockChain({ data: { voucher_series: null }, error: null })
}
if (table === 'voucher_sequences') {
return mockChain({ data: null, error: null })
}
throw new Error(`Unexpected table: ${table}`)
})
const response = await GET(
mkReq('?source_type=bank_transaction&cash_account_id=11111111-1111-4111-8111-111111111111'),
mkParams(),
)
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data).toEqual({ next: 1, series: 'B', fiscal_period_id: 'period-1' })
})
it('ignores the cash account override for source types other than bank_transaction', async () => {
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
mockFrom.mockImplementation((table: string) => {
if (table === 'fiscal_periods') {
return mockChain({ data: { id: 'period-1' }, error: null })
}
if (table === 'company_settings') {
return mockChain({
data: { default_voucher_series: 'A', default_voucher_series_per_source_type: { manual: 'V' } },
error: null,
})
}
if (table === 'voucher_sequences') {
return mockChain({ data: { last_number: 2 }, error: null })
}
throw new Error(`Unexpected table: ${table}`)
})
const response = await GET(
mkReq('?source_type=manual&cash_account_id=11111111-1111-4111-8111-111111111111'),
mkParams(),
)
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data).toEqual({ next: 3, series: 'V', fiscal_period_id: 'period-1' })
// cash_accounts was never consulted (the mock would have thrown).
})
it('rejects a malformed cash_account_id with 400 before touching the database', async () => {
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
const response = await GET(mkReq('?source_type=bank_transaction&cash_account_id=nope'), mkParams())
expect(response.status).toBe(400)
expect(mockFrom).not.toHaveBeenCalled()
})
it('falls back to A when the source_type has no per-source-type mapping', async () => {
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
@@ -4,6 +4,7 @@ import { errorResponse } from '@/lib/errors/get-structured-error'
import { validateQuery } from '@/lib/api/validate'
import { VoucherSequenceNextQuerySchema } from '@/lib/api/schemas'
import { resolveDefaultSeriesForSource } from '@/lib/bookkeeping/voucher-series-resolver'
import { resolveCashAccountVoucherSeries } from '@/lib/bookkeeping/cash-account-voucher-series'
export const GET = withRouteContext(
'voucher_sequence.next',
@@ -15,7 +16,12 @@ export const GET = withRouteContext(
operation: 'voucher_sequence.next',
})
if (!query.success) return query.response
const { period_id: overridePeriodId, series: overrideSeries, source_type: sourceType } = query.data
const {
period_id: overridePeriodId,
series: overrideSeries,
source_type: sourceType,
cash_account_id: cashAccountId,
} = query.data
const today = new Date().toISOString().split('T')[0]
// Vouchers are numbered per fiscal period, so the preview must reflect the
@@ -57,14 +63,23 @@ export const GET = withRouteContext(
}
// When a source_type is supplied, resolve the series exactly as the booking
// engine does (per-source-type map → 'A'), so the preview can never disagree
// with the verifikat that actually gets created. Without a source_type, keep
// the legacy generic default for callers that just want "the next number".
// engine does (cash account override → per-source-type map → 'A'), so the
// preview can never disagree with the verifikat that actually gets created.
// Without a source_type, keep the legacy generic default for callers that
// just want "the next number".
// The account override only applies to entries booked from bank
// transactions; for any other source type it must not colour the preview.
const cashAccountSeries =
!overrideSeries && cashAccountId && sourceType === 'bank_transaction'
? await resolveCashAccountVoucherSeries(supabase, companyId, cashAccountId)
: undefined
const series = overrideSeries
? overrideSeries
: sourceType
? resolveDefaultSeriesForSource(settings, sourceType)
: settings?.default_voucher_series || 'A'
: cashAccountSeries
? cashAccountSeries
: sourceType
? resolveDefaultSeriesForSource(settings, sourceType)
: settings?.default_voucher_series || 'A'
if (!period) {
return NextResponse.json({ data: { next: null, series, fiscal_period_id: null } })