feat(settings): standard verifikationsserier for new companies, opt-in action for existing (#2358)

* feat(settings): standard verifikationsserier for new companies, opt-in action for existing (#2184)

A new company_settings row now defaults to the standard series set
(A manual/bank, B kundfakturor, C inbetalningar, D leverantörsfakturor,
E utbetalningar, H periodisering, I bokslut, K lön, L kontantfaktura,
M moms) instead of everything on A. The set lives once, as the
exhaustive STANDARD_VOUCHER_SERIES_MAP in the resolver; a pg-real test
holds the column default equal to it and to the source_type CHECK.

Existing rows are not remapped: the per-type settings form gets an
"Använd standarduppsättningen" action that fills the set for review
and save through the existing PUT, so the switch is a deliberate,
audited act rather than a mid-year numbering change nobody decided.
Payment rows bound to the other bokföringsmetod are dimmed, not hidden.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1

* test(bookkeeping): fresh company_settings row asserts the standard series set, not all-A

voucher-series-defaults.pg.test.ts codified the pre-#2184 column default
(every source type on A). Migration 20260906210500 replaces that default
with the standard set, so the "freshly inserted row" case now asserts the
representative letters and full equality with STANDARD_VOUCHER_SERIES_MAP.
The explicit-override case keeps proving a company's own layout replaces
the default wholesale. No other pg or tool test asserted on the old map.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-06 21:18:22 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent 4fce2d7b94
commit bf7773d74b
11 changed files with 465 additions and 34 deletions
+9
View File
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest'
import { HouseworkTypeSchema } from '../schemas'
import { STANDARD_VOUCHER_SERIES_MAP } from '@/lib/bookkeeping/voucher-series-resolver'
import {
// Enums
EntityTypeSchema,
@@ -1955,6 +1956,14 @@ describe('UpdateSettingsSchema', () => {
})
expect(result.success).toBe(false)
})
it('accepts the full standard set, which the settings action sends with every source type', () => {
const result = UpdateSettingsSchema.safeParse({
default_voucher_series_per_source_type: { ...STANDARD_VOUCHER_SERIES_MAP },
})
expect(result.success).toBe(true)
expect(result.data?.default_voucher_series_per_source_type).toEqual(STANDARD_VOUCHER_SERIES_MAP)
})
})
describe('voucher_series_labels', () => {
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import { getPool } from '@/tests/pg/setup'
import { insertAuthUser, insertCompany, insertCompanyMember } from '@/tests/pg/fixtures'
import { STANDARD_VOUCHER_SERIES_MAP } from '@/lib/bookkeeping/voucher-series-resolver'
describe('company_settings.default_voucher_series_per_source_type', () => {
it('column exists with the expected default JSONB shape', async () => {
@@ -23,7 +24,12 @@ describe('company_settings.default_voucher_series_per_source_type', () => {
expect(defaultValue).toMatch(/salary_payment/)
})
it('a freshly inserted company_settings row gets all-A defaults', async () => {
// Since migration 20260906210500 (#2184) a fresh row starts on the standard
// set, not on all-A; the exhaustive equality with the TS map and the
// "existing rows untouched" rule live in
// tests/pg/voucher-series-standard-default.pg.test.ts. This test keeps the
// representative letters readable at a glance.
it('a freshly inserted company_settings row gets the standard series set', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId, role: 'owner' })
@@ -47,10 +53,16 @@ describe('company_settings.default_voucher_series_per_source_type', () => {
expect(rows).toHaveLength(1)
const map = rows[0]!.default_voucher_series_per_source_type
expect(map).toBeDefined()
// A stays the general series; the reskontra, lön and moms flows get their own.
expect(map.manual).toBe('A')
expect(map.supplier_invoice_registered).toBe('A')
expect(map.salary_payment).toBe('A')
expect(map.bank_transaction).toBe('A')
expect(map.invoice_created).toBe('B')
expect(map.invoice_paid).toBe('C')
expect(map.supplier_invoice_registered).toBe('D')
expect(map.supplier_invoice_paid).toBe('E')
expect(map.salary_payment).toBe('K')
expect(map.vat_settlement).toBe('M')
expect(map).toEqual(STANDARD_VOUCHER_SERIES_MAP)
})
it('accepts user updates to per-source-type series mapping', async () => {
@@ -65,12 +77,13 @@ describe('company_settings.default_voucher_series_per_source_type', () => {
[userId, companyId],
)
// Configure per common Swedish convention: B for supplier, C for salary.
// An explicit layout of the company's own (Björn Lundén style letters)
// replaces the default wholesale; nothing merges the standard set back in.
const updated = {
manual: 'A',
supplier_invoice_registered: 'B',
supplier_invoice_paid: 'B',
salary_payment: 'C',
supplier_invoice_registered: 'L',
supplier_invoice_paid: 'U',
salary_payment: 'N',
}
await getPool().query(
`UPDATE public.company_settings
@@ -3,11 +3,15 @@ import {
applyDefaultSeriesToMap,
buildVoucherSeriesOptions,
formatVoucher,
isStandardVoucherSeriesMap,
parseVoucher,
resolveDefaultSeriesForSource,
STANDARD_VOUCHER_SERIES_MAP,
voucherSeriesLabel,
VOUCHER_SERIES_PRESETS,
} from '../voucher-series-resolver'
import { JournalEntrySourceTypeSchema } from '@/lib/api/schemas'
import type { JournalEntrySourceType } from '@/types'
describe('resolveDefaultSeriesForSource', () => {
it('returns A when settings is null', () => {
@@ -284,3 +288,80 @@ describe('buildVoucherSeriesOptions', () => {
expect(options.map((o) => o.letter).join('')).toBe('ABCDEFGHIJKLM')
})
})
describe('STANDARD_VOUCHER_SERIES_MAP', () => {
const sourceTypes = JournalEntrySourceTypeSchema.options
const legacyAllA = Object.fromEntries(sourceTypes.map((type) => [type, 'A']))
it('assigns a letter to every journal source type, so a new type cannot fall back to A unnoticed', () => {
expect(Object.keys(STANDARD_VOUCHER_SERIES_MAP).sort()).toEqual([...sourceTypes].sort())
})
it('only uses letters the presets name, so every series in the set has a name in the pickers', () => {
for (const letter of new Set(Object.values(STANDARD_VOUCHER_SERIES_MAP))) {
expect(letter).toMatch(/^[A-Z]$/)
expect(voucherSeriesLabel(letter)).not.toBe('')
}
})
it('keeps manual entries and bank transactions in A, the general series', () => {
expect(STANDARD_VOUCHER_SERIES_MAP.manual).toBe('A')
expect(STANDARD_VOUCHER_SERIES_MAP.bank_transaction).toBe('A')
})
it('separates the reskontra, lön, moms, periodisering and bokslut flows', () => {
expect(STANDARD_VOUCHER_SERIES_MAP.invoice_created).toBe('B')
expect(STANDARD_VOUCHER_SERIES_MAP.invoice_paid).toBe('C')
expect(STANDARD_VOUCHER_SERIES_MAP.invoice_cash_payment).toBe('C')
expect(STANDARD_VOUCHER_SERIES_MAP.supplier_invoice_registered).toBe('D')
expect(STANDARD_VOUCHER_SERIES_MAP.supplier_invoice_paid).toBe('E')
expect(STANDARD_VOUCHER_SERIES_MAP.supplier_invoice_cash_payment).toBe('E')
expect(STANDARD_VOUCHER_SERIES_MAP.accrual).toBe('H')
expect(STANDARD_VOUCHER_SERIES_MAP.year_end).toBe('I')
expect(STANDARD_VOUCHER_SERIES_MAP.salary_payment).toBe('K')
expect(STANDARD_VOUCHER_SERIES_MAP.vat_settlement).toBe('M')
})
it('books each type of a fresh company in its series, and an explicit override still wins', () => {
const fresh = { default_voucher_series_per_source_type: { ...STANDARD_VOUCHER_SERIES_MAP } }
expect(resolveDefaultSeriesForSource(fresh, 'invoice_created')).toBe('B')
expect(resolveDefaultSeriesForSource(fresh, 'salary_payment')).toBe('K')
expect(resolveDefaultSeriesForSource(fresh, 'manual')).toBe('A')
const overridden = {
default_voucher_series_per_source_type: { ...STANDARD_VOUCHER_SERIES_MAP, invoice_created: 'F' },
}
expect(resolveDefaultSeriesForSource(overridden, 'invoice_created')).toBe('F')
})
it('never reaches an existing company: the resolver reads the row, not the standard set', () => {
const existing = { default_voucher_series_per_source_type: legacyAllA }
for (const type of sourceTypes) {
expect(resolveDefaultSeriesForSource(existing, type)).toBe('A')
}
// A row that predates a source type keeps falling back to A for it too.
expect(
resolveDefaultSeriesForSource(
{ default_voucher_series_per_source_type: { manual: 'A' } },
'expense_payout',
),
).toBe('A')
})
})
describe('isStandardVoucherSeriesMap', () => {
it('is true for the standard set and for a superset that adds an unknown key', () => {
expect(isStandardVoucherSeriesMap({ ...STANDARD_VOUCHER_SERIES_MAP })).toBe(true)
expect(isStandardVoucherSeriesMap({ ...STANDARD_VOUCHER_SERIES_MAP, future_type: 'Q' })).toBe(true)
})
it('is false when one type deviates, when a type is missing, and for no map', () => {
expect(isStandardVoucherSeriesMap({ ...STANDARD_VOUCHER_SERIES_MAP, salary_payment: 'L' })).toBe(false)
const partial: Partial<Record<JournalEntrySourceType, string>> = { ...STANDARD_VOUCHER_SERIES_MAP }
delete partial.storno
expect(isStandardVoucherSeriesMap(partial as Record<string, string>)).toBe(false)
expect(isStandardVoucherSeriesMap(null)).toBe(false)
expect(isStandardVoucherSeriesMap(undefined)).toBe(false)
expect(isStandardVoucherSeriesMap({})).toBe(false)
})
})
+79 -2
View File
@@ -37,8 +37,8 @@ const SERIES_LETTER_RE = /^[A-Z]$/
* the incumbents disagree with each other: Björn Lundén uses F for
* kundfakturor, L for leverantörsfakturor and N for löner. The one point
* they agree on is that A is the general series you post manual entries into,
* which also matches this codebase: every source_type in
* company_settings.default_voucher_series_per_source_type ships as 'A'.
* which is where STANDARD_VOUCHER_SERIES_MAP below keeps everything that is
* not a reskontra, lön, moms, periodisering or bokslut flow.
*
* The labels are bookkeeping-domain terms that stay Swedish in both locales,
* same convention as VoucherSeriesPerSourceTypeForm.
@@ -115,6 +115,83 @@ export function buildVoucherSeriesOptions(
]
}
/**
* The series layout a new company starts with, and what "Använd
* standarduppsättningen" under Inställningar > Bokföring fills in for an
* existing one. The DB column default (migration 20260906210500) is this map
* verbatim; tests/pg/voucher-series-standard-default.pg.test.ts holds the two
* equal.
*
* The letters are the presets above, so every series in the set already has
* a name in the pickers and a ledger imported from Fortnox continues its
* kundfakturor in B, leverantörsfakturor in D and löner in K instead of
* starting parallel series next to them. The principle: the flows a reader
* of the verifikationslista wants to see apart (kundfakturor, inbetalningar,
* leverantörsfakturor, utbetalningar, periodisering, bokslut, lön, moms) get
* their own series; everything else stays in A, the general series.
*
* Exhaustive over JournalEntrySourceType on purpose. Before this map the
* column default was "everything on A" and the resolver's 'A' fallback let
* every source type added since (webshop_order, vat_settlement,
* expense_payout, ...) join that series without anyone deciding; a new
* source type now fails to compile until it has a letter here.
*
* resolveDefaultSeriesForSource never reads this map. A company whose row
* predates it keeps 'A' for every type until it applies the set itself:
* remapping a live ledger by migration would move, say, the next kundfaktura
* from A341 into a fresh B1 mid-year with nothing in the behandlingshistorik
* saying who decided it. BFNAR 2013:2 p. 9.16 records changes to the
* behandlingsregler with date and actor; the settings save gives that, a
* migration cannot.
*/
export const STANDARD_VOUCHER_SERIES_MAP: Readonly<Record<JournalEntrySourceType, string>> = {
manual: 'A',
bank_transaction: 'A',
invoice_created: 'B',
credit_note: 'B',
reminder_fee: 'B',
invoice_paid: 'C',
invoice_cash_payment: 'C',
rot_rut_payout: 'C',
supplier_invoice_registered: 'D',
supplier_credit_note: 'D',
supplier_invoice_privately_paid: 'D',
supplier_invoice_paid: 'E',
supplier_invoice_cash_payment: 'E',
accrual: 'H',
year_end: 'I',
result_appropriation: 'I',
salary_payment: 'K',
webshop_order: 'L',
vat_settlement: 'M',
opening_balance: 'A',
currency_revaluation: 'A',
inbox_item: 'A',
import: 'A',
system: 'A',
storno: 'A',
correction: 'A',
stripe_payout: 'A',
expense_claim: 'A',
expense_payout: 'A',
}
/**
* True when `map` assigns every source type exactly the standard letter.
* The settings form uses it to disable "Använd standarduppsättningen" once
* the draft already is the standard set. Extra keys are ignored: a map that
* carries a letter for a source type this build does not know is still
* "on the standard set" for every type it can book.
*/
export function isStandardVoucherSeriesMap(
map: VoucherSeriesMap | null | undefined,
): boolean {
if (!map || typeof map !== 'object') return false
return (Object.keys(STANDARD_VOUCHER_SERIES_MAP) as JournalEntrySourceType[]).every(
(sourceType) => map[sourceType] === STANDARD_VOUCHER_SERIES_MAP[sourceType],
)
}
/**
* Resolve the default voucher_series letter for a given source_type from a
* company_settings row. Returns 'A' as a safe fallback when no mapping is