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
parent 678acfe7ef
commit f1230282a9
27 changed files with 990 additions and 19 deletions
+1
View File
@@ -1483,6 +1483,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-01] ENABLE_BANKING_SANDBOX removed from the enable-banking manifest and the index.ts header (#2131): the variable was declared as optional but never read anywhere; sandbox vs production is decided by ENABLE_BANKING_API_URL (api.tilisy.com vs api.enablebanking.com, api-client.ts derives isSandbox from the host). A dead variable declared in the manifest is what the self-hosting docs would otherwise have copied. The manifest now lists the two optional variables the code actually reads (API_URL, PSU_TYPE); the _PRODUCTION aliases stay undeclared on purpose, they are a hosted Vercel convention, not an operator contract.
[2026-09-02] Repo-wide bloat sweep (chore/bloat-sweep-2026-09): removed dead files/exports/types/i18n namespaces and deduplicated byte-identical helpers into canonical homes (lib/utils chunk/sleep/utcDateStamp, lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format, lib/pdf/number-text pdfAmount/formatDateSv, lib/browser/panel-request, lib/api/v1/body + v1ValidationError, lib/bookkeeping/booking-template-schemas). Deliberately NOT done: naive Math.round(x*100)/100 helpers were not swapped for roundOre (behaviour change at half-ore values, ratchet campaign owns it); lib/bokslut/rounding.ts shim kept because money.test.ts asserts the back-compat re-export; text-based v1 body parsers (empty body allowed) kept inline because readV1JsonBody has different empty-body semantics; the four HTTP endpoints with no first-party caller (skatteverket agi/underlag + agi/sparad DELETE, invoice-inbox items/:id/history, mail connections/backfill) stay because removing a reachable endpoint is a surface change; VacationBalanceCard deleted as unreachable since #1130, the v1/MCP vacation-year-close routes stay.
[2026-09-01] PR #2130 security-scan round: the register's djuplank is validated (https + skatteverket.se host) before it is returned or navigated to, since the settings page follows it; a contested org number now WITHDRAWS an already-recorded grant nightly (not only blocks new ones), outside the downgrade guards on purpose. NOT done: proof of org-number ownership (Bolagsverket firmatecknare / BankID) before any ombud grant; the org number is tenant-editable across the product (AGI, invoices, årsredovisning) and binding it to a verified identity is a product decision for Emil, tracked as a follow-up rather than declined.
[2026-09-02] Verifikationsserie per bankkonto applies to bank_transaction bookings only (book route, categorize, agent, pending operations), not to invoice settlements matched from the bank or to bulk-book samlingsverifikat: invoice_paid/supplier_invoice_paid series describe the payment kind and match-* routes have eight builder branches that do not take a series; bulk-book resolves its series inside the bulk_book_transactions RPC. One predictable rule beats partial coverage; extend later if a user asks.
[2026-09-02] Grok custom connectors are allowlisted by the exact callback https://grok.com/connectors-oauth-exchange-code/ (trailing slash optional), not a grok.com prefix: the value is published by X Corp at docs.x.com/x-ads-api/mcp ("Grok (web)" redirect URL) and grok.com serves the path itself (slash form 308s to no-slash on the same origin), and a prefix would let any future grok.com path receive authorization codes. Grok gets side doors next to ChatGPT (onboarding) and a row under "Other clients" (settings); Claude stays the visual primary per the 2026-08-27 founder call. No new client marker plumbing: the settings URL uses the existing ?client= param with value grok.
[2026-09-02] Viewer write gate as ONE table-level trigger (enforce_company_writer_role) instead of re-emitting 15 SECURITY DEFINER bodies and ~130 policies: keyed on the JWT role claim so it fires inside definer functions too; no-op for service_role and trigger cascades. agent_conversations/agent_messages and telemetry tables deliberately excluded.
[2026-09-02] Posting-integrity guards key on current_user IN ('anon','authenticated'), not the JWT claim: inside SECURITY DEFINER RPCs current_user is the definer, so commit_journal_entry, SIE import, storno and rättelse keep working while direct PostgREST manipulation of posted vouchers is blocked. Residual: a direct draft->posted flip may still reuse an unused number below the sequence high-water mark.
@@ -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 } })
@@ -0,0 +1,143 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
parseJsonResponse,
createMockRouteParams,
createQueuedMockSupabase,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: vi.fn(),
}))
import { PATCH } from '../route'
import { requireAuth } from '@/lib/auth/require-auth'
const CA_1 = '11111111-1111-4111-8111-111111111111'
const CA_OTHER = '22222222-2222-4222-8222-222222222222'
describe('PATCH /api/cash-accounts/[id] (verifikationsserie per bankkonto)', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
function patchReq(body: unknown) {
return new Request('http://localhost/api/cash-accounts/ca-1', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}
beforeEach(() => {
vi.clearAllMocks()
reset()
vi.mocked(requireAuth).mockResolvedValue({
user: mockUser as never,
supabase: mockSupabase as never,
error: null,
})
requireWriteMock.mockResolvedValue({ ok: true })
})
it('returns 401 when not authenticated', async () => {
vi.mocked(requireAuth).mockResolvedValue({
user: null as never,
supabase: mockSupabase as never,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const response = await PATCH(patchReq({ voucher_series: 'M' }), createMockRouteParams({ id: CA_1 }))
expect(response.status).toBe(401)
})
it('returns 403 when the caller is a viewer', async () => {
requireWriteMock.mockResolvedValue({
ok: false,
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
})
const response = await PATCH(patchReq({ voucher_series: 'M' }), createMockRouteParams({ id: CA_1 }))
expect(response.status).toBe(403)
})
it('returns 400 on a malformed series (must be one uppercase letter)', async () => {
for (const bad of ['m', 'AB', '', 7]) {
const response = await PATCH(patchReq({ voucher_series: bad }), createMockRouteParams({ id: CA_1 }))
expect(response.status).toBe(400)
}
expect(findCalls('cash_accounts', 'update')).toHaveLength(0)
})
it('returns 400 when voucher_series is missing entirely', async () => {
const response = await PATCH(patchReq({}), createMockRouteParams({ id: CA_1 }))
expect(response.status).toBe(400)
})
it('returns 404 for an id that is not a UUID, without touching the database', async () => {
const response = await PATCH(patchReq({ voucher_series: 'M' }), createMockRouteParams({ id: 'not-a-uuid' }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(404)
expect(body.error.code).toBe('CASH_ACCOUNT_NOT_FOUND')
expect(findCalls('cash_accounts', 'update')).toHaveLength(0)
})
it('returns 404 when the account does not belong to the company', async () => {
enqueue({ data: null, error: null })
const response = await PATCH(patchReq({ voucher_series: 'M' }), createMockRouteParams({ id: CA_OTHER }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(404)
expect(body.error.code).toBe('CASH_ACCOUNT_NOT_FOUND')
const eqCalls = findCalls('cash_accounts', 'eq')
expect(eqCalls).toContainEqual(['company_id', 'company-1'])
expect(eqCalls).toContainEqual(['id', CA_OTHER])
})
it('sets the series and returns the updated account (happy path)', async () => {
enqueue({ data: { id: 'ca-1', ledger_account: '1931', voucher_series: 'M' }, error: null })
const response = await PATCH(patchReq({ voucher_series: 'M' }), createMockRouteParams({ id: CA_1 }))
const { status, body } = await parseJsonResponse<{ data: { voucher_series: string } }>(response)
expect(status).toBe(200)
expect(body.data.voucher_series).toBe('M')
expect(findCalls('cash_accounts', 'update')).toContainEqual([{ voucher_series: 'M' }])
})
it('clears the override with null so the account follows the per-type default again', async () => {
enqueue({ data: { id: 'ca-1', ledger_account: '1931', voucher_series: null }, error: null })
const response = await PATCH(patchReq({ voucher_series: null }), createMockRouteParams({ id: CA_1 }))
const { status, body } = await parseJsonResponse<{ data: { voucher_series: string | null } }>(response)
expect(status).toBe(200)
expect(body.data.voucher_series).toBeNull()
expect(findCalls('cash_accounts', 'update')).toContainEqual([{ voucher_series: null }])
})
it('maps a database error to the canonical error envelope', async () => {
enqueue({ data: null, error: { message: 'boom', code: '42P01' } })
const response = await PATCH(patchReq({ voucher_series: 'M' }), createMockRouteParams({ id: CA_1 }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBeGreaterThanOrEqual(400)
expect(body.error).toBeDefined()
})
})
+53
View File
@@ -0,0 +1,53 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { UpdateCashAccountVoucherSeriesSchema } from '@/lib/api/schemas'
import { errorResponse } from '@/lib/errors/get-structured-error'
import { setVoucherSeries } from '@/lib/cash-accounts/service'
import { UUID_RE } from '@/lib/invariants/uuid'
/** Canonical 404 for an id that is not one of the company's bank accounts. */
function notFound(): NextResponse {
return NextResponse.json(
{
error: {
code: 'CASH_ACCOUNT_NOT_FOUND',
message: 'Bankkontot hittades inte.',
message_en: 'Bank account not found.',
},
},
{ status: 404 },
)
}
/**
* PATCH /api/cash-accounts/[id]
*
* Sets or clears the verifikationsserie override on one of the company's
* bank accounts. Only this one field is editable here: ledger account and
* primary flag have their own guarded flows (unique constraint, atomic RPC).
*/
export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
'cash_accounts.update',
async (request, { supabase, companyId, log, requestId }, { params }) => {
const { id } = await params
// A non-UUID id can never match a row; answer 404 instead of letting the
// uuid cast surface as a 500 from Postgres.
if (!UUID_RE.test(id)) return notFound()
const validation = await validateBody(request, UpdateCashAccountVoucherSeriesSchema)
if (!validation.success) return validation.response
let updated
try {
updated = await setVoucherSeries(supabase, companyId, id, validation.data.voucher_series)
} catch (err) {
log.error('cash_accounts voucher_series update failed', err as Error)
return errorResponse(err, log, { requestId })
}
if (!updated) return notFound()
return NextResponse.json({ data: updated })
},
{ requireWrite: true },
)
@@ -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)
+48 -7
View File
@@ -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>
@@ -0,0 +1,139 @@
'use client'
import { useMemo, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Loader2 } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { SettingsGroup, SettingsRow, SettingsSelect } from '@/components/settings/SettingsRows'
import { useCashAccounts } from '@/lib/reference-data/hooks'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { VOUCHER_SERIES_PRESETS } from '@/lib/bookkeeping/voucher-series-resolver'
import type { CashAccount, CompanySettings } from '@/types'
// Sentinel for "no override" in the <select>: an empty option value renders
// as the placeholder in some browsers, so use an explicit token instead.
const FOLLOW_DEFAULT = '__default__'
const SERIES_LETTER_RE = /^[A-Z]$/
interface Props {
/** Company settings, for the letters the company has already configured. */
settings: Pick<CompanySettings, 'default_voucher_series' | 'default_voucher_series_per_source_type'>
}
/** "Företagskort (1931)" or the bare ledger account when the row has no name. */
function accountLabel(account: CashAccount): string {
const name = account.name?.trim()
return name ? `${name} (${account.ledger_account})` : account.ledger_account
}
/**
* Verifikationsserie per bankkonto. A company that runs several bank accounts
* (main bank on A, a company-card account on M) can route each account's
* bookings into its own series. Blank = follow "Verifikationsserier per typ".
* Saves per row on change, no separate save button: each row is one field on
* one account, and the bank-transaction booking dialog reads it live.
*
* The picker is the same closed list as the manual verifikat form: the fixed
* Swedish presets plus every letter the company already uses. A free A-Z list
* would let a typo start an undocumented series (BFNAR 2013:2 p. 9.2-9.15
* wants the series in use enumerated in the systemdokumentation).
*/
export function VoucherSeriesPerCashAccountForm({ settings }: Props) {
const t = useTranslations('settings_voucher_series')
const { toast } = useToast()
const { cashAccounts, isLoading, refresh } = useCashAccounts({ enabledOnly: true })
const [savingId, setSavingId] = useState<string | null>(null)
// Presets first, then any configured or already-assigned letter the presets
// do not cover, so a Select never renders blank on a value it does not offer.
const seriesOptions = useMemo(() => {
const preset = new Set(VOUCHER_SERIES_PRESETS.map((p) => p.letter))
const extras = [
settings.default_voucher_series,
...Object.values(settings.default_voucher_series_per_source_type ?? {}),
...cashAccounts.map((a) => a.voucher_series),
]
.filter((v): v is string => typeof v === 'string' && SERIES_LETTER_RE.test(v) && !preset.has(v))
const uniqueExtras = Array.from(new Set(extras)).sort()
return [
...VOUCHER_SERIES_PRESETS,
...uniqueExtras.map((letter) => ({ letter, label: '' })),
]
}, [settings.default_voucher_series, settings.default_voucher_series_per_source_type, cashAccounts])
/** PATCH one account's override, then refresh the shared cash-account cache. */
const handleChange = async (account: CashAccount, value: string) => {
const next = value === FOLLOW_DEFAULT ? null : value
if ((account.voucher_series ?? null) === next) return
setSavingId(account.id)
try {
const res = await fetch(`/api/cash-accounts/${account.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voucher_series: next }),
})
const json = await res.json().catch(() => null)
if (!res.ok) {
toast({
title: t('per_account_save_failed'),
description: getErrorMessage(json, { context: 'settings', statusCode: res.status }),
variant: 'destructive',
})
return
}
await refresh()
toast({
title: t('per_account_saved_title'),
description: next
? t('per_account_saved_set', { account: accountLabel(account), series: next })
: t('per_account_saved_cleared', { account: accountLabel(account) }),
})
} catch (err) {
toast({
title: t('per_account_save_failed'),
description: getErrorMessage(err, { context: 'settings' }),
variant: 'destructive',
})
} finally {
setSavingId(null)
}
}
return (
<SettingsGroup label={t('per_account_heading')} help={t('per_account_help')}>
{isLoading ? (
<div className="flex items-center gap-2 px-1 py-3 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
{t('per_account_loading')}
</div>
) : cashAccounts.length === 0 ? (
<p className="px-1 py-3 text-sm text-muted-foreground">{t('per_account_empty')}</p>
) : (
cashAccounts.map((account, i) => (
<SettingsRow
key={account.id}
label={accountLabel(account)}
htmlFor={`series-cash-account-${account.id}`}
borderless={i === cashAccounts.length - 1}
>
<SettingsSelect
id={`series-cash-account-${account.id}`}
value={account.voucher_series ?? FOLLOW_DEFAULT}
onChange={(e) => void handleChange(account, e.target.value)}
disabled={savingId === account.id}
className="font-mono"
>
<option value={FOLLOW_DEFAULT}>{t('per_account_follow_default')}</option>
{seriesOptions.map((option) => (
<option key={option.letter} value={option.letter}>
{option.label ? `${option.letter} ${option.label}` : option.letter}
</option>
))}
</SettingsSelect>
</SettingsRow>
))
)}
</SettingsGroup>
)
}
@@ -10,6 +10,7 @@ import { PeriodLockingSettings } from '@/components/settings/PeriodLockingSettin
import { FiscalYearsManager } from '@/components/settings/FiscalYearsManager'
import { VoucherSeriesManager } from '@/components/settings/VoucherSeriesManager'
import { VoucherSeriesPerSourceTypeForm } from '@/components/settings/VoucherSeriesPerSourceTypeForm'
import { VoucherSeriesPerCashAccountForm } from '@/components/settings/VoucherSeriesPerCashAccountForm'
import { applyDefaultSeriesToMap } from '@/lib/bookkeeping/voucher-series-resolver'
import { DimensionsToggle } from '@/components/settings/DimensionsToggle'
import { MileageToggle } from '@/components/settings/MileageToggle'
@@ -169,6 +170,8 @@ export function BookkeepingSettingsContent() {
onSettingsUpdated={updateSettings}
/>
<VoucherSeriesPerCashAccountForm settings={settings} />
<VoucherSeriesManager defaultSeries={settings.default_voucher_series || 'A'} />
<SettingsGroup label={t('group_automation')}>
@@ -430,6 +430,10 @@ export default function TransactionBookingDialog({
submitUrl={`/api/transactions/${transaction.id}/book`}
sourceType="bank_transaction"
sourceId={transaction.id}
// Series picker seeded from the bank account's own series
// (Inställningar → Bokföring → Verifikationsserie per bankkonto).
seriesPicker
cashAccountId={transaction.cash_account_id ?? null}
onEntryCreated={(entryId) => handleBooked(transaction.id, entryId)}
duplicateMatchTransaction={{
id: transaction.id,
+1
View File
@@ -19,6 +19,7 @@ function makeCashAccount(overrides: Partial<CashAccount> = {}): CashAccount {
enabled: true,
is_primary: true,
source: 'manual',
voucher_series: null,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
...overrides,
+18
View File
@@ -291,6 +291,9 @@ export const VoucherSequenceNextQuerySchema = z.object({
period_id: uuid.optional(),
series: z.string().regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav A-Z').optional(),
source_type: JournalEntrySourceTypeSchema.optional(),
// Bank account the entry is booked from: its voucher_series override (when
// set) takes precedence over the per-source-type default.
cash_account_id: uuid.optional(),
date: isoDate.optional(),
})
@@ -1590,6 +1593,9 @@ export const BookTransactionSchema = z
entry_date: isoDate,
description: z.string().min(1, 'Description is required'),
lines: z.array(CreateJournalEntryLineSchema).min(1, 'At least one line is required'),
// Explicit series from the booking dialog's picker. Omitted: the route
// resolves it from the transaction's cash account, then the per-type map.
voucher_series: z.string().regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav A-Z').optional(),
// Booking-time duplicate guard: see CategorizeTransactionSchema.
force: z.boolean().optional(),
expected_duplicate_transaction_id: uuid.optional(),
@@ -1721,6 +1727,18 @@ export const MoveTransactionCashAccountSchema = z.object({
.regex(/^19\d{2}$/, 'Expected a BAS 19xx bank account number'),
})
/**
* Set or clear the verifikationsserie override on one of the company's cash
* accounts. null clears the override: entries booked from the account then
* follow the per-source-type default again.
*/
export const UpdateCashAccountVoucherSeriesSchema = z.object({
voucher_series: z
.string()
.regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav A-Z')
.nullable(),
})
export const BookInboxItemDirectlySchema = z.object({
fiscal_period_id: uuid,
entry_date: isoDate,
@@ -0,0 +1,62 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import {
cashAccountSeriesOverride,
resolveCashAccountVoucherSeries,
} from '../cash-account-voucher-series'
const { supabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
describe('cashAccountSeriesOverride', () => {
it('returns the letter when the account carries a valid override', () => {
expect(cashAccountSeriesOverride({ voucher_series: 'M' })).toBe('M')
})
it('returns undefined for null, missing, lowercase or multi-letter values', () => {
expect(cashAccountSeriesOverride(null)).toBeUndefined()
expect(cashAccountSeriesOverride(undefined)).toBeUndefined()
expect(cashAccountSeriesOverride({ voucher_series: null })).toBeUndefined()
expect(cashAccountSeriesOverride({})).toBeUndefined()
expect(cashAccountSeriesOverride({ voucher_series: 'm' })).toBeUndefined()
expect(cashAccountSeriesOverride({ voucher_series: 'AB' })).toBeUndefined()
expect(cashAccountSeriesOverride({ voucher_series: '' })).toBeUndefined()
})
})
describe('resolveCashAccountVoucherSeries', () => {
beforeEach(() => {
reset()
})
it('skips the lookup entirely when the transaction has no cash account', async () => {
expect(await resolveCashAccountVoucherSeries(supabase as never, 'company-1', null)).toBeUndefined()
expect(await resolveCashAccountVoucherSeries(supabase as never, 'company-1', undefined)).toBeUndefined()
expect(findCalls('cash_accounts', 'select')).toHaveLength(0)
})
it('returns the account override, scoped to the company', async () => {
enqueue({ data: { voucher_series: 'M' }, error: null })
const series = await resolveCashAccountVoucherSeries(supabase as never, 'company-1', 'ca-1')
expect(series).toBe('M')
const eqCalls = findCalls('cash_accounts', 'eq')
expect(eqCalls).toContainEqual(['company_id', 'company-1'])
expect(eqCalls).toContainEqual(['id', 'ca-1'])
})
it('returns undefined when the account has no override', async () => {
enqueue({ data: { voucher_series: null }, error: null })
expect(await resolveCashAccountVoucherSeries(supabase as never, 'company-1', 'ca-1')).toBeUndefined()
})
it('returns undefined when the account is unknown', async () => {
enqueue({ data: null, error: null })
expect(await resolveCashAccountVoucherSeries(supabase as never, 'company-1', 'ca-missing')).toBeUndefined()
})
it('fails open (undefined) on a query error so the booking still goes through', async () => {
enqueue({ data: null, error: { message: 'boom' } })
expect(await resolveCashAccountVoucherSeries(supabase as never, 'company-1', 'ca-1')).toBeUndefined()
})
})
@@ -468,6 +468,30 @@ describe('createTransactionJournalEntry', () => {
expect(input.source_id).toBe('tx-abc-123')
})
it("books into the cash account's verifikationsserie when the account carries one", async () => {
const { supabase, enqueue, reset } = createQueuedMockSupabase()
reset()
enqueue({ data: { voucher_series: 'M' }, error: null })
const tx = makeTransaction({ amount: -100, cash_account_id: 'ca-card' })
await createTransactionJournalEntry(supabase as never, 'company-1', 'user-1', tx, makeMappingResult())
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.voucher_series).toBe('M')
})
it('omits voucher_series (engine resolves the per-type default) when the account has no override', async () => {
const { supabase, enqueue, reset } = createQueuedMockSupabase()
reset()
enqueue({ data: { voucher_series: null }, error: null })
const tx = makeTransaction({ amount: -100, cash_account_id: 'ca-main' })
await createTransactionJournalEntry(supabase as never, 'company-1', 'user-1', tx, makeMappingResult())
const input = mockedCreateEntry.mock.calls[0][3]
expect('voucher_series' in input).toBe(false)
})
it('uses transaction.date as entry_date', async () => {
const tx = makeTransaction({ date: '2024-09-15', amount: -100 })
const mapping = makeMappingResult()
@@ -0,0 +1,71 @@
/**
* Verifikationsserie per bankkonto.
*
* Resolution order for an entry booked from a bank transaction:
* 1. explicit voucher_series in the request (the booking dialog's picker)
* 2. cash_accounts.voucher_series of the transaction's account (this module)
* 3. company_settings.default_voucher_series_per_source_type (engine)
* 4. 'A' (engine)
*
* This helper covers step 2 only. It returns undefined whenever there is no
* override so callers can pass the result straight into CreateJournalEntryInput
* and let the engine handle steps 3 and 4. Lookup failures also resolve to
* undefined: a broken override must never block a booking, the entry then
* lands in the per-type default exactly as before this feature.
*
* Scope: bank-transaction bookings (book route, categorize, agent, pending
* operations). Invoice settlements matched from the bank keep the invoice
* payment series (invoice_paid, supplier_invoice_paid): those series describe
* the payment kind, not the account the money moved through.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
const log = createLogger('cash-account-voucher-series')
const SERIES_LETTER_RE = /^[A-Z]$/
/** Pure: the override letter of a cash account row, or undefined. */
export function cashAccountSeriesOverride(
account: { voucher_series?: string | null } | null | undefined,
): string | undefined {
const value = account?.voucher_series
return typeof value === 'string' && SERIES_LETTER_RE.test(value) ? value : undefined
}
/**
* Look up the series override of one of the company's cash accounts.
* undefined when the account is unknown, has no override, or the query fails.
*/
export async function resolveCashAccountVoucherSeries(
supabase: SupabaseClient,
companyId: string,
cashAccountId: string | null | undefined,
): Promise<string | undefined> {
if (!cashAccountId) return undefined
try {
const { data, error } = await supabase
.from('cash_accounts')
.select('voucher_series')
.eq('company_id', companyId)
.eq('id', cashAccountId)
.maybeSingle()
if (error) {
// Fail open, but never silently: the entry lands in the per-type
// default and the log says why.
log.warn('cash_accounts voucher_series lookup failed; using per-type default', {
companyId,
cashAccountId,
error: error.message,
})
return undefined
}
return cashAccountSeriesOverride(data as { voucher_series?: string | null } | null)
} catch (err) {
log.warn('cash_accounts voucher_series lookup threw; using per-type default', {
companyId,
cashAccountId,
error: err instanceof Error ? err.message : String(err),
})
return undefined
}
}
+10
View File
@@ -1,4 +1,5 @@
import { createJournalEntry, findFiscalPeriod } from './engine'
import { resolveCashAccountVoucherSeries } from './cash-account-voucher-series'
import { resolveSekAmount, buildCurrencyMetadata } from './currency-utils'
import { coerceDimensionsBag } from './dimension-resolver'
import { extractNetAmount, extractVatAmount } from './vat-entries'
@@ -338,6 +339,14 @@ export async function createTransactionJournalEntry(
? [baseDescription, ...extraParts].filter(Boolean).join(' · ').slice(0, 500)
: baseDescription
// The transaction's bank account may carry its own verifikationsserie;
// undefined lets the engine fall back to the per-source-type default.
const voucherSeries = await resolveCashAccountVoucherSeries(
supabase,
companyId,
transaction.cash_account_id,
)
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: entryDate,
@@ -345,6 +354,7 @@ export async function createTransactionJournalEntry(
source_type: 'bank_transaction',
source_id: transaction.id,
lines,
...(voucherSeries ? { voucher_series: voucherSeries } : {}),
}
return createJournalEntry(supabase, companyId, userId, input)
+22
View File
@@ -1523,6 +1523,28 @@ export async function setLedgerAccount(
if (error) throw new Error(`cash_accounts setLedgerAccount failed: ${error.message}`)
}
/**
* Set or clear the verifikationsserie override for a cash account. null means
* "follow the per-source-type default"; the engine reads this via
* resolveCashAccountVoucherSeries() when it books from the account.
*/
export async function setVoucherSeries(
supabase: SupabaseClient,
companyId: string,
cashAccountId: string,
voucherSeries: string | null,
): Promise<CashAccount | null> {
const { data, error } = await supabase
.from('cash_accounts')
.update({ voucher_series: voucherSeries })
.eq('company_id', companyId)
.eq('id', cashAccountId)
.select('*')
.maybeSingle()
if (error) throw new Error(`cash_accounts setVoucherSeries failed: ${error.message}`)
return (data as CashAccount | null) ?? null
}
/**
* Mark a cash account as the primary for its company. Delegates to the
* `set_cash_account_primary` RPC so the clear-old-primary and set-new-primary
@@ -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,
+10 -1
View File
@@ -2335,7 +2335,16 @@
"series_prefix": "Series",
"default_badge": "default",
"latest_number": "Latest no.",
"footnote": "New series are created automatically the first time they are used in bookkeeping."
"footnote": "New series are created automatically the first time they are used in bookkeeping.",
"per_account_heading": "Voucher series per bank account",
"per_account_help": "Let a bank account book into its own series, for example the main bank on A and the company card on M. Default follows the bank-transaction series above. Applies to booking bank transactions; invoice matches keep their payment series.",
"per_account_follow_default": "Default",
"per_account_loading": "Loading bank accounts",
"per_account_empty": "No active bank accounts yet. Accounts are created when you connect a bank or import transactions.",
"per_account_saved_title": "Voucher series saved",
"per_account_saved_set": "New vouchers from {account} land in series {series}.",
"per_account_saved_cleared": "{account} now follows the default bank-transaction series.",
"per_account_save_failed": "Could not save"
},
"settings_team_panel": {
"role_owner": "Owner",
+10 -1
View File
@@ -2335,7 +2335,16 @@
"series_prefix": "Serie",
"default_badge": "standard",
"latest_number": "Senaste nr",
"footnote": "Nya serier skapas automatiskt första gången de används vid bokföring."
"footnote": "Nya serier skapas automatiskt första gången de används vid bokföring.",
"per_account_heading": "Verifikationsserier per bankkonto",
"per_account_help": "Låt ett bankkonto bokföra i en egen serie, till exempel huvudbanken på A och företagskortet på M. Standard följer serien för banktransaktioner ovan. Gäller bokföring av banktransaktioner; fakturamatchningar behåller sin betalningsserie.",
"per_account_follow_default": "Standard",
"per_account_loading": "Hämtar bankkonton",
"per_account_empty": "Inga aktiva bankkonton ännu. Konton skapas när du kopplar en bank eller importerar transaktioner.",
"per_account_saved_title": "Verifikationsserie sparad",
"per_account_saved_set": "Nya verifikat från {account} hamnar i serie {series}.",
"per_account_saved_cleared": "{account} följer nu standardserien för banktransaktioner.",
"per_account_save_failed": "Kunde inte spara"
},
"settings_team_panel": {
"role_owner": "Ägare",
@@ -0,0 +1,19 @@
-- Verifikationsserie per bankkonto.
--
-- A company that runs several bank accounts (main bank on series A, a
-- company-card account on series M, both imported via CSV) wants entries
-- booked from each account to land in that account's own series. Until now
-- every bank-transaction booking took the single company-wide default from
-- company_settings.default_voucher_series_per_source_type.bank_transaction.
--
-- NULL means "no override": the engine keeps resolving the series from the
-- per-source-type map, so existing accounts and bookings are untouched.
-- Single uppercase letter, same rule as journal_entries.voucher_series.
ALTER TABLE public.cash_accounts
ADD COLUMN IF NOT EXISTS voucher_series text
CHECK (voucher_series IS NULL OR voucher_series ~ '^[A-Z]$');
COMMENT ON COLUMN public.cash_accounts.voucher_series IS
'Optional verifikationsserie (single letter A-Z) for entries booked from this bank account. NULL = follow the per-source-type default in company_settings.';
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,18 @@
-- Behandlingshistorik for the per-bankkonto verifikationsserie.
--
-- cash_accounts.voucher_series (20260902121420) takes precedence over the
-- company-wide default_voucher_series_per_source_type map, which is already
-- audited and reported as a behandlingsregel (BFNAR 2013:2 p. 9.16 second
-- paragraph: changes that affect how bokföringsposter are processed, and when
-- they were introduced). The override must leave the same dated trace.
--
-- UPDATE only, and only when the series itself changes: cash_accounts rows are
-- created and touched by bank sync (balances, names, enabled flags) many times
-- a day, and none of that is a behandlingsregel. Same WHEN pattern as the
-- categorization_templates learning filter in 20260901103000.
DROP TRIGGER IF EXISTS audit_cash_accounts_voucher_series ON public.cash_accounts;
CREATE TRIGGER audit_cash_accounts_voucher_series
AFTER UPDATE ON public.cash_accounts
FOR EACH ROW
WHEN (OLD.voucher_series IS DISTINCT FROM NEW.voucher_series)
EXECUTE FUNCTION public.write_audit_log();
@@ -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(
+4
View File
@@ -647,6 +647,10 @@ export interface CashAccount {
enabled: boolean
is_primary: boolean
source: CashAccountSource
// Optional verifikationsserie (single letter) for entries booked from this
// account. null = follow company_settings.default_voucher_series_per_source_type.
// See 20260902121420_cash_accounts_voucher_series.sql.
voucher_series: string | null
created_at: string
updated_at: string
}