/** * 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 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 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. */ 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> /** * 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, ): Array<{ letter: string; label: string }> { const seen = new Set(VOUCHER_SERIES_PRESETS.map((p) => p.letter)) const extras = new Set() 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) })), ] } /** * 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> = { 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', rot_rut_reclaim: '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 * 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 } }