Files
accounted/lib/bookkeeping/voucher-series-resolver.ts
T
MattssonandClaude Fable 5.1 fdcb7d937e feat(rot-rut): overview page, beslutsfil import, avslag reclaim, MCP list + settle (#2397)
* feat(rot-rut): overview page, beslutsfil import, avslag reclaim, MCP list + settle

Follow-up to #2239/#2360 for firms whose every invoice carries ROT/RUT.

- /invoices/rot-rut: tiles (at Skatteverket on 1513, awaiting beslut,
  refused to book, ready to request) and one row per begaran with mark
  uploaded, cancel, download and "Bokfor nekat belopp"; the Fakturor
  button links here, ?rot-rut=1 still opens the file dialog.
- Beslutsfil import from the UI through the existing import route.
- Reclaim of the share Skatteverket refused: one voucher debit 1510 /
  credit 1513 per invoice (source_type rot_rut_reclaim), CAS-attached to
  the begaran and guarded by a partial unique index; the invoice reopens
  for the refused share via invoices.deduction_reclaimed_total, with the
  customer-share formula and its SQL twin gaining the same term. The
  payment dialog and bank match then settle the reopened remaining as a
  plain 1510 clearing; a booked kontantmetod invoice is proposed accrual-
  shaped so revenue is never recognised twice. Unknown per-invoice split
  of a partial beslut is refused, never allocated.
- MCP: gnubok_list_rot_rut_payout_requests (search-only read) and
  gnubok_settle_rot_rut_payout (staged write, op settle_rot_rut_payout)
  sharing one pre-flight + settle with the dashboard match route.
- Migrations 20260907140000 (reclaim state, source_type, INSERT guard),
  20260907140100/140101 (pending_operations op type).

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

* chore(rot-rut): renumber migrations after merging main

Main already carries 20260907143000 and 20260907150000, so the three
rot-rut migrations move to 20260907160000/160100/160101 to keep the
applied order monotonic (see memory: migration-version-collisions).

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

* fix(rot-rut): close the reclaim gaps found by skeptics, CI and review

Skeptic refutations (#2397):
- payment-sync recomputes remaining with deduction_reclaimed_total, so a
  storno of a payment on a reopened invoice no longer strands the refused
  share (R1).
- Reclaim refused while an invoice sits in a later live begäran
  (ROT_RUT_RECLAIM_INVOICE_REREQUESTED); the overview and the MCP list hide
  the action for the same case (C2).
- A reclaimed invoice is blocked from a new begäran (DEDUCTION_RECLAIMED)
  until the reclaim voucher is reversed (R2/C3).
- Storno of the reclaim voucher syncs the invoices and the begäran back
  (rot-rut-reclaim-reversal.ts, hooked into reverseEntry) (R3).
- A paid invoice with NULL paid_amount counts its customer share as paid
  (C4). Crediting an invoice with a reclaimed share is refused on the
  dashboard, v1 and MCP paths (R4).

CI and review:
- Build: custom-coded MCP errors via Object.assign, not codedError.
- pg-real: column default for default_voucher_series_per_source_type
  re-stated with rot_rut_reclaim (20260907160200); the default test now
  re-applies the latest default migration.
- Checks: accounted-api skill regenerated (journal-entries source types).
- CodeRabbit/Superagent: per-item refused shares must reconcile with the
  request-level beslut; per-invoice reopen through the idempotent RPC
  apply_rot_rut_reclaim_invoice (20260907160300) with a resume path;
  update-stage settle failures keep the voucher id (failed_partial);
  Stockholm calendar date for the booking; existing-voucher tab uses the
  same proposal method; MCP stage checks bank_line junction rows.

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

* fix(rot-rut): carry the voucher id through the match outcome type; date the reclaim on the beslut

- The shared match outcome now declares journalEntryId on update-stage
  errors, matching the settle service (Core Build TS2339 on 2d6cece1a).
- The reclaim voucher is dated on the Swedish calendar day of Skatteverkets
  beslut (decided_at), today only when no decision date is recorded, and
  the confirm dialog states the date (Swedish accounting review: BFL 5 kap
  6-7 §, datum for affarshandelsen).

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

* fix(rot-rut): reclaim RPCs validate the share and derive the invoice state; idempotent revert; v1 credit guard reads the column

- apply_rot_rut_reclaim_invoice (20260907160400 replaces the 160300
  signature) takes only the refused share, validates it against the locked
  item, request and invoice, and derives remaining_amount and status from
  the INSERT-guard formula (review: caller-supplied accounting values,
  CWE-862). revert_rot_rut_reclaim_invoice mirrors it for a reversed
  reclaim voucher; the request link is cleared only after every leg.
- v1 credit route projection includes deduction_reclaimed_total so the
  reclaim guard actually fires there.
- Overview keeps "Bokfor nekat belopp" available while legs are pending
  (resume after a partial failure).
- Match and settle routes attach journal_entry_id on update-stage errors.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 00:13:05 +02:00

298 lines
12 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 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<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) })),
]
}
/**
* 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',
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 }
}