* 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>
220 lines
8.8 KiB
TypeScript
220 lines
8.8 KiB
TypeScript
/**
|
|
* Voucher series resolver: pure helpers for mapping journal_entries.source_type
|
|
* to a default voucher_series per company_settings, formatting voucher labels
|
|
* for display, and parsing them back.
|
|
*
|
|
* Source-type → series mapping lives in
|
|
* company_settings.default_voucher_series_per_source_type (JSONB).
|
|
*
|
|
* Defaults to 'A' when:
|
|
* - the settings row is null/undefined
|
|
* - the JSONB is missing the source_type key
|
|
* - the configured value is not a single uppercase letter A-Z
|
|
*
|
|
* These functions are pure; no I/O. Engine call-sites read the settings row
|
|
* once and pass it in.
|
|
*/
|
|
import type { JournalEntrySourceType } from '@/types'
|
|
|
|
export type VoucherSeriesMap = Partial<Record<JournalEntrySourceType, string>> &
|
|
Record<string, string>
|
|
|
|
const SERIES_LETTER_RE = /^[A-Z]$/
|
|
|
|
/**
|
|
* The conventional Swedish verifikationsserier and what each one is for.
|
|
*
|
|
* The column accepts any A-Z letter, but a free-text field invites typos and
|
|
* lets the same letter mean different things across a company's ledger, so the
|
|
* entry form offers this fixed set (plus whatever series the company has
|
|
* already configured or used).
|
|
*
|
|
* The letters are NOT prescribed by law: BFL 5 kap. 7 § only requires an
|
|
* unbroken, systematically ordered numbering within each series. This list is
|
|
* Fortnox's, taken verbatim from their Systemdokumentation (Fortnox Lön,
|
|
* section 4 Behandlingsregler), because Fortnox is the system most companies
|
|
* migrate here from and an imported ledger should keep its meaning. Note that
|
|
* 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'.
|
|
*
|
|
* The labels are bookkeeping-domain terms that stay Swedish in both locales,
|
|
* same convention as VoucherSeriesPerSourceTypeForm.
|
|
*/
|
|
export const VOUCHER_SERIES_PRESETS: ReadonlyArray<{ letter: string; label: string }> = [
|
|
{ letter: 'A', label: 'Redovisning' },
|
|
{ letter: 'B', label: 'Kundfakturor' },
|
|
{ letter: 'C', label: 'Inbetalningar från kunder' },
|
|
{ letter: 'D', label: 'Leverantörsfakturor' },
|
|
{ letter: 'E', label: 'Utbetalningar till leverantörer' },
|
|
{ letter: 'F', label: 'Kassa' },
|
|
{ letter: 'G', label: 'Avskrivning' },
|
|
{ letter: 'H', label: 'Periodisering' },
|
|
{ letter: 'I', label: 'Bokslut' },
|
|
{ letter: 'J', label: 'Revisor' },
|
|
{ letter: 'K', label: 'Lön' },
|
|
{ letter: 'L', label: 'Kontantfaktura' },
|
|
{ letter: 'M', label: 'Momsrapport' },
|
|
]
|
|
|
|
/**
|
|
* Company-defined series names, company_settings.voucher_series_labels:
|
|
* {"L": "Lön"}. Keys are single uppercase letters, values non-empty names.
|
|
*/
|
|
export type VoucherSeriesLabels = Partial<Record<string, string>>
|
|
|
|
/**
|
|
* Display name for a series letter: the company's own name first, the Swedish
|
|
* preset second, empty when neither exists. The ONLY place that decides what a
|
|
* letter is called; every picker and list goes through it so a company that
|
|
* lays its series out differently from the presets (L for löner instead of K)
|
|
* sees its own words everywhere, not Fortnox's.
|
|
*/
|
|
export function voucherSeriesLabel(
|
|
letter: string,
|
|
labels?: VoucherSeriesLabels | null,
|
|
): string {
|
|
const custom = labels?.[letter]
|
|
if (typeof custom === 'string' && custom.trim().length > 0) return custom.trim()
|
|
const match = VOUCHER_SERIES_PRESETS.find((p) => p.letter === letter)
|
|
return match ? match.label : ''
|
|
}
|
|
|
|
/**
|
|
* The closed list every series picker offers: the presets in their fixed
|
|
* order, then every other letter the company already uses or has named,
|
|
* deduplicated and sorted. Each entry carries the display name resolved by
|
|
* voucherSeriesLabel, so a custom name overrides a preset in place.
|
|
*
|
|
* `extraLetters` may hold anything (settings values, draft state, account
|
|
* rows); only single uppercase letters survive, so an empty string, null or a
|
|
* typo never becomes an option. A free A-Z list would let a slip start an
|
|
* undocumented series (BFNAR 2013:2 p. 9.2-9.15 wants the series in use
|
|
* enumerated in the systemdokumentation).
|
|
*/
|
|
export function buildVoucherSeriesOptions(
|
|
labels: VoucherSeriesLabels | null | undefined,
|
|
extraLetters: Iterable<unknown>,
|
|
): Array<{ letter: string; label: string }> {
|
|
const seen = new Set(VOUCHER_SERIES_PRESETS.map((p) => p.letter))
|
|
const extras = new Set<string>()
|
|
const consider = (value: unknown) => {
|
|
if (typeof value === 'string' && SERIES_LETTER_RE.test(value) && !seen.has(value)) {
|
|
extras.add(value)
|
|
}
|
|
}
|
|
for (const value of extraLetters) consider(value)
|
|
for (const key of Object.keys(labels ?? {})) consider(key)
|
|
return [
|
|
...VOUCHER_SERIES_PRESETS.map((p) => ({ letter: p.letter, label: voucherSeriesLabel(p.letter, labels) })),
|
|
...Array.from(extras)
|
|
.sort()
|
|
.map((letter) => ({ letter, label: voucherSeriesLabel(letter, labels) })),
|
|
]
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* configured for that source_type.
|
|
*
|
|
* @param settings - Either a full CompanySettings row or just the per-source
|
|
* map. `null`/`undefined` is allowed (returns 'A').
|
|
* @param sourceType - The journal_entries.source_type value.
|
|
*/
|
|
export function resolveDefaultSeriesForSource(
|
|
settings:
|
|
| { default_voucher_series_per_source_type?: VoucherSeriesMap | null }
|
|
| VoucherSeriesMap
|
|
| null
|
|
| undefined,
|
|
sourceType: JournalEntrySourceType,
|
|
): string {
|
|
if (!settings) return 'A'
|
|
|
|
// Accept both the full settings row and a bare map. Both shapes are
|
|
// narrowed via duck-typing on the column key: when present, treat it as
|
|
// the settings row; otherwise treat the argument itself as the map.
|
|
const raw = settings as {
|
|
default_voucher_series_per_source_type?: VoucherSeriesMap | null
|
|
} & VoucherSeriesMap
|
|
const mapCandidate =
|
|
raw.default_voucher_series_per_source_type !== undefined
|
|
? raw.default_voucher_series_per_source_type
|
|
: (settings as VoucherSeriesMap)
|
|
|
|
if (!mapCandidate || typeof mapCandidate !== 'object') return 'A'
|
|
|
|
const value = (mapCandidate as VoucherSeriesMap)[sourceType]
|
|
if (typeof value === 'string' && SERIES_LETTER_RE.test(value)) {
|
|
return value
|
|
}
|
|
return 'A'
|
|
}
|
|
|
|
/**
|
|
* Propagate a change to the global default voucher series across the
|
|
* per-source-type map. Source types that were still following the previous
|
|
* default move to the new default; explicit overrides (values that differ from
|
|
* the previous default) are preserved untouched.
|
|
*
|
|
* The booking engine resolves series from the per-source-type map, not from the
|
|
* global default, so the bookkeeping settings form calls this when the user
|
|
* changes the "Standardserie" dropdown, otherwise that control would be a
|
|
* no-op for bookkeeping. Pure; returns the next map (input is not mutated).
|
|
*/
|
|
export function applyDefaultSeriesToMap(
|
|
currentMap: VoucherSeriesMap | null | undefined,
|
|
prevDefault: string,
|
|
nextDefault: string,
|
|
): VoucherSeriesMap {
|
|
const out: VoucherSeriesMap = {}
|
|
for (const [key, value] of Object.entries(currentMap || {})) {
|
|
out[key] = value === prevDefault ? nextDefault : value
|
|
}
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* Format a voucher (series + number) for UI display. Returns "-" when the
|
|
* voucher number is null (e.g. a draft entry that has not been committed yet).
|
|
*
|
|
* Always lifts the series to uppercase. Falls back to 'A' when the series is
|
|
* null/empty for forward-compat with legacy rows. Accepts partial inputs so
|
|
* callsites can pass through API responses without re-shaping them.
|
|
*/
|
|
export function formatVoucher(entry: {
|
|
voucher_series?: string | null
|
|
voucher_number?: number | null
|
|
}): string {
|
|
if (entry.voucher_number == null || entry.voucher_number === 0) {
|
|
return '-'
|
|
}
|
|
const series =
|
|
entry.voucher_series && typeof entry.voucher_series === 'string'
|
|
? entry.voucher_series.toUpperCase()
|
|
: 'A'
|
|
return `${series}${entry.voucher_number}`
|
|
}
|
|
|
|
/**
|
|
* Parse a formatted voucher label back into its parts. Returns null when the
|
|
* input does not match the expected shape: a single letter followed by a
|
|
* positive integer, optionally separated by one space or hyphen ("A209",
|
|
* "a 209", "A-209"). Use for filter inputs / search: these are the shapes
|
|
* users type when they look for a voucher by its number.
|
|
*/
|
|
export function parseVoucher(
|
|
formatted: string,
|
|
): { series: string; number: number } | null {
|
|
if (typeof formatted !== 'string') return null
|
|
const trimmed = formatted.trim().toUpperCase()
|
|
const match = trimmed.match(/^([A-Z])[ -]?(\d+)$/)
|
|
if (!match) return null
|
|
const number = parseInt(match[2], 10)
|
|
if (!Number.isFinite(number) || number <= 0) return null
|
|
return { series: match[1], number }
|
|
}
|