Files
accounted/lib/bookkeeping/__tests__/voucher-series-resolver.test.ts
T
MattssonandClaude Fable 5.1 0c854ac54f feat(settings): let a company name each verifikationsserie letter (#2336)
* feat(settings): let a company name each verifikationsserie letter

The series pickers show a fixed preset label next to every letter (A
Redovisning ... M Momsrapport, Fortnox's layout). A byrå that lays its
series out differently sees a wrong or missing name in every dropdown: a
partner running löner on L saw "Kontantfaktura" in the verifikat form and
asked for the series name.

- company_settings.voucher_series_labels JSONB ({"L": "Lön"}), keys A-Z,
  values 1 to 40 chars, CHECK on the JSON shape. Display only; the engine
  never reads it.
- UpdateSettingsSchema validates the map, trims names and strips empty
  values so a cleared field removes the name.
- voucherSeriesLabel(letter, labels) is the one place that decides what a
  letter is called: company name, then preset, then empty.
  buildVoucherSeriesOptions replaces the three near-identical option
  builders in the verifikat form and the two settings pickers.
- The Verifikationsserier list in settings edits the names: rows are the
  union of used, configured and named letters, one save button.
- The SIE import review's two series pickers show the name too.

Migration applied to staging (metjnjrhvujscngnpzdv) as 20260906131300.

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

* fix(settings): keep imported series in the list and unsaved names through a refetch

Skeptic pass on the series-name editor refuted two things:

- The rewritten list filtered voucher_sequences to single letters, dropping
  multi-character series (FT, LB, SKV, ...) that 54 production companies
  carry over from Fortnox and Bokio imports; the old list showed them with
  their highest number. Rows are now every used series plus the configured
  and named letters; only single-letter series get a name input, since
  those are what the pickers offer and the schema accepts.
- The draft re-seeded on the identity of settings.voucher_series_labels,
  and the settings hook revalidates on window focus with a fresh object, so
  unsaved typing was wiped after any earlier save on the page. The re-seed
  is now keyed on the serialized content of the saved names.

Also folds the "new series are created on first use" footnote back into
the group help, which the rewrite had dropped.

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

* fix(settings): name the default-series options and enforce the label shape in the database

Review pass on #2336:

- CodeRabbit: the Standardserie selector under Bokföring still rendered
  bare letters; it now shows the same name the other pickers do, through
  voucherSeriesLabel.
- Compliance swarm (SOC 2 PI1.1, low): the key and length rules for
  voucher_series_labels lived only in UpdateSettingsSchema. Migration
  20260906134700 adds voucher_series_labels_valid(jsonb) and swaps the
  object-only CHECK for one that mirrors the Zod rules (keys A-Z, values
  non-blank strings of at most 40 characters), so a write that bypasses
  /api/settings cannot store a map the pickers cannot handle. Applied to
  staging with its schema_migrations row; verified against good, empty,
  lowercase, blank, over-long, numeric and array inputs.

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

* test(pg): cover the voucher_series_labels CHECK against real Postgres

The coverage gate refuses a migration that adds a function without a
*.pg.test.ts. voucher_series_labels_valid(jsonb) and the constraint that
wraps it now have one: accepts the empty map and single-letter keys with
names of 1 to 40 characters, rejects lowercase and multi-letter keys,
blank, over-long, numeric and null values, arrays and scalars, and leaves
the row untouched after a refused write.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 16:20:42 +02:00

287 lines
9.7 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import {
applyDefaultSeriesToMap,
buildVoucherSeriesOptions,
formatVoucher,
parseVoucher,
resolveDefaultSeriesForSource,
voucherSeriesLabel,
VOUCHER_SERIES_PRESETS,
} from '../voucher-series-resolver'
describe('resolveDefaultSeriesForSource', () => {
it('returns A when settings is null', () => {
expect(resolveDefaultSeriesForSource(null, 'manual')).toBe('A')
})
it('returns A when settings is undefined', () => {
expect(resolveDefaultSeriesForSource(undefined, 'manual')).toBe('A')
})
it('returns A when the map is missing entirely', () => {
expect(
resolveDefaultSeriesForSource(
{ default_voucher_series_per_source_type: null },
'manual',
),
).toBe('A')
})
it('returns A when the source_type is not in the map', () => {
expect(
resolveDefaultSeriesForSource(
{ default_voucher_series_per_source_type: { manual: 'A' } },
'supplier_invoice_registered',
),
).toBe('A')
})
it('returns the configured letter for a known source_type', () => {
expect(
resolveDefaultSeriesForSource(
{
default_voucher_series_per_source_type: {
manual: 'A',
supplier_invoice_registered: 'B',
salary_payment: 'C',
},
},
'supplier_invoice_registered',
),
).toBe('B')
expect(
resolveDefaultSeriesForSource(
{
default_voucher_series_per_source_type: {
manual: 'A',
supplier_invoice_registered: 'B',
salary_payment: 'C',
},
},
'salary_payment',
),
).toBe('C')
})
it('accepts a bare map (no settings wrapper)', () => {
expect(
resolveDefaultSeriesForSource(
{ manual: 'A', supplier_invoice_registered: 'B' },
'supplier_invoice_registered',
),
).toBe('B')
})
it('rejects invalid values and falls back to A', () => {
expect(
resolveDefaultSeriesForSource(
{ default_voucher_series_per_source_type: { manual: 'lowercase' } },
'manual',
),
).toBe('A')
expect(
resolveDefaultSeriesForSource(
{ default_voucher_series_per_source_type: { manual: 'AB' } },
'manual',
),
).toBe('A')
expect(
resolveDefaultSeriesForSource(
{ default_voucher_series_per_source_type: { manual: '' } },
'manual',
),
).toBe('A')
expect(
resolveDefaultSeriesForSource(
{ default_voucher_series_per_source_type: { manual: '1' } },
'manual',
),
).toBe('A')
})
})
describe('applyDefaultSeriesToMap', () => {
it('moves types following the old default onto the new default', () => {
const result = applyDefaultSeriesToMap(
{ manual: 'A', invoice_paid: 'A', invoice_cash_payment: 'A' },
'A',
'V',
)
expect(result).toEqual({ manual: 'V', invoice_paid: 'V', invoice_cash_payment: 'V' })
})
it('preserves explicit per-type overrides that differ from the old default', () => {
const result = applyDefaultSeriesToMap(
{ manual: 'A', supplier_invoice_paid: 'B', salary_payment: 'C' },
'A',
'V',
)
// Only the type that was following the old default (A) moves; B and C stay.
expect(result).toEqual({ manual: 'V', supplier_invoice_paid: 'B', salary_payment: 'C' })
})
it('does not mutate the input map', () => {
const input = { manual: 'A', invoice_paid: 'A' }
applyDefaultSeriesToMap(input, 'A', 'V')
expect(input).toEqual({ manual: 'A', invoice_paid: 'A' })
})
it('returns an empty map when given null/undefined', () => {
expect(applyDefaultSeriesToMap(null, 'A', 'V')).toEqual({})
expect(applyDefaultSeriesToMap(undefined, 'A', 'V')).toEqual({})
})
it('is a no-op on values when old and new default are equal', () => {
const result = applyDefaultSeriesToMap(
{ manual: 'A', supplier_invoice_paid: 'B' },
'A',
'A',
)
expect(result).toEqual({ manual: 'A', supplier_invoice_paid: 'B' })
})
})
describe('formatVoucher', () => {
it('formats series + number for a posted entry', () => {
expect(formatVoucher({ voucher_series: 'A', voucher_number: 1 })).toBe('A1')
expect(formatVoucher({ voucher_series: 'B', voucher_number: 12 })).toBe('B12')
})
it('returns hyphen for null voucher_number', () => {
expect(formatVoucher({ voucher_series: 'A', voucher_number: null })).toBe('-')
})
it('returns hyphen for voucher_number 0 (uncommitted draft placeholder)', () => {
expect(formatVoucher({ voucher_series: 'A', voucher_number: 0 })).toBe('-')
})
it('falls back to series A when series is null', () => {
expect(formatVoucher({ voucher_series: null, voucher_number: 5 })).toBe('A5')
})
it('uppercases the series', () => {
expect(formatVoucher({ voucher_series: 'b', voucher_number: 3 })).toBe('B3')
})
})
describe('parseVoucher', () => {
it('parses a well-formed label', () => {
expect(parseVoucher('A1')).toEqual({ series: 'A', number: 1 })
expect(parseVoucher('B12')).toEqual({ series: 'B', number: 12 })
})
it('round-trips with formatVoucher', () => {
const label = formatVoucher({ voucher_series: 'C', voucher_number: 42 })
expect(parseVoucher(label)).toEqual({ series: 'C', number: 42 })
})
it('uppercases and trims input', () => {
expect(parseVoucher(' a5 ')).toEqual({ series: 'A', number: 5 })
})
it('accepts one space or hyphen between series and number (how users type it in search)', () => {
expect(parseVoucher('A 209')).toEqual({ series: 'A', number: 209 })
expect(parseVoucher('A-209')).toEqual({ series: 'A', number: 209 })
expect(parseVoucher('a-1')).toEqual({ series: 'A', number: 1 })
})
it('returns null for malformed input', () => {
expect(parseVoucher('')).toBeNull()
expect(parseVoucher('-')).toBeNull()
expect(parseVoucher('123')).toBeNull()
expect(parseVoucher('AA1')).toBeNull()
expect(parseVoucher('A0')).toBeNull()
expect(parseVoucher('A--1')).toBeNull()
expect(parseVoucher('A 1')).toBeNull()
})
})
describe('VOUCHER_SERIES_PRESETS', () => {
it('offers the conventional Swedish series in a stable order', () => {
expect(VOUCHER_SERIES_PRESETS.map((p) => p.letter)).toEqual(
'ABCDEFGHIJKLM'.split(''),
)
})
it('gives every preset a non-empty label', () => {
for (const preset of VOUCHER_SERIES_PRESETS) {
expect(preset.label.length).toBeGreaterThan(0)
}
})
it('uses letters the resolver accepts as a series', () => {
for (const preset of VOUCHER_SERIES_PRESETS) {
expect(
resolveDefaultSeriesForSource({ manual: preset.letter }, 'manual'),
).toBe(preset.letter)
}
})
})
describe('voucherSeriesLabel', () => {
it('describes a preset letter', () => {
// A is the general series manual entries land in, not kundfakturor: it is
// the shipped default for every source_type. Guards against a relabelling
// that would mislabel every existing company's history.
expect(voucherSeriesLabel('A')).toBe('Redovisning')
expect(voucherSeriesLabel('B')).toBe('Kundfakturor')
expect(voucherSeriesLabel('K')).toBe('Lön')
})
it('returns an empty string for a letter with no preset meaning', () => {
expect(voucherSeriesLabel('N')).toBe('')
expect(voucherSeriesLabel('Z')).toBe('')
expect(voucherSeriesLabel('')).toBe('')
})
it('lets the company name beat the preset, in place', () => {
// A byrå that runs löner on L (not the preset K) must see its own word.
expect(voucherSeriesLabel('L', { L: 'Lön' })).toBe('Lön')
expect(voucherSeriesLabel('L', { L: ' Lön ' })).toBe('Lön')
})
it('names a letter with no preset when the company has named it', () => {
expect(voucherSeriesLabel('N', { N: 'Utlägg' })).toBe('Utlägg')
})
it('falls back to the preset when the company name is empty or missing', () => {
expect(voucherSeriesLabel('K', { K: '' })).toBe('Lön')
expect(voucherSeriesLabel('K', { K: ' ' })).toBe('Lön')
expect(voucherSeriesLabel('K', {})).toBe('Lön')
expect(voucherSeriesLabel('K', null)).toBe('Lön')
expect(voucherSeriesLabel('N', { K: 'x' })).toBe('')
})
})
describe('buildVoucherSeriesOptions', () => {
it('offers the presets first, in their fixed order, with preset labels', () => {
const options = buildVoucherSeriesOptions(null, [])
expect(options.map((o) => o.letter).join('')).toBe('ABCDEFGHIJKLM')
expect(options[0]).toEqual({ letter: 'A', label: 'Redovisning' })
})
it('appends extra letters once, sorted, after the presets', () => {
const options = buildVoucherSeriesOptions(null, ['Z', 'N', 'N', 'A'])
expect(options.map((o) => o.letter).join('')).toBe('ABCDEFGHIJKLMNZ')
expect(options.find((o) => o.letter === 'N')).toEqual({ letter: 'N', label: '' })
})
it('drops anything that is not a single uppercase letter', () => {
const options = buildVoucherSeriesOptions(null, ['', null, undefined, 'ab', 'n', 7, ' '])
expect(options.map((o) => o.letter).join('')).toBe('ABCDEFGHIJKLM')
})
it('applies company names to presets in place and lists named non-preset letters', () => {
const options = buildVoucherSeriesOptions({ L: 'Lön', N: 'Utlägg' }, [])
expect(options.find((o) => o.letter === 'L')).toEqual({ letter: 'L', label: 'Lön' })
expect(options.find((o) => o.letter === 'K')).toEqual({ letter: 'K', label: 'Lön' })
expect(options.find((o) => o.letter === 'N')).toEqual({ letter: 'N', label: 'Utlägg' })
expect(options.map((o) => o.letter).join('')).toBe('ABCDEFGHIJKLMN')
})
it('ignores malformed keys in the label map', () => {
const options = buildVoucherSeriesOptions({ ab: 'x', n: 'y', '': 'z' }, [])
expect(options.map((o) => o.letter).join('')).toBe('ABCDEFGHIJKLM')
})
})