Files
accounted/lib/bookkeeping/voucher-series-resolver.ts
T
Jakob WennbergandClaude Opus 5 e5fba9471e feat(bookkeeping): verifikationsserie dropdown, wider Ny verifikat modal, balancing amount on focus (#2120)
Three fixes to the Ny verifikation modal.

1. Verifikationsserie is a closed dropdown, not a one-letter free-text
   field. Ships Fortnox's table verbatim from their Systemdokumentation
   (A Redovisning, B Kundfakturor, C Inbetalningar från kunder,
   D Leverantörsfakturor, E Utbetalningar till leverantörer, F Kassa,
   G Avskrivning, H Periodisering, I Bokslut, J Revisor, K Lön,
   L Kontantfaktura, M Momsrapport). A is deliberately Redovisning, not
   Kundfakturor: it is the general series manual entries land in, and
   migration 20260526120700 ships every source_type defaulting to 'A', so
   every existing company's A series already holds everything. Any letter
   the company already configured is appended so no existing value can
   fall out of the picker.

2. The modal goes from sm:max-w-3xl to sm:max-w-5xl lg:max-w-6xl.

3. Tabbing or clicking into an untouched amount proposes the outstanding
   difference, pre-selected so typing replaces it. Only fires when the row
   has an account, both amounts are empty, and the difference belongs on
   that side.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnoyZCbfm2geRbwtUpi6XN
2026-09-01 20:05:36 +02:00

170 lines
6.7 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' },
]
/** Swedish description for a preset series letter; empty for unknown letters. */
export function voucherSeriesLabel(letter: string): string {
const match = VOUCHER_SERIES_PRESETS.find((p) => p.letter === letter)
return match ? match.label : ''
}
/**
* 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 }
}