Files
accounted/lib/invariants/org-number.ts
T
Jakob Wennberg 16fbcefbbc feat(invariants): shared format contracts + upgrade-path CI (#1364)
* feat(invariants): centralise shared format contracts, reconcile the org-number paths

The same format rules were written out independently across the codebase, and
where they disagreed the disagreement was invisible until a filing failed.

Worst case, now fixed: four Skatteverket- and Bolagsverket-bound export paths
each had their own idea of a valid organisationsnummer.

  lib/skatteverket/format.ts      strip '-' only      threw on any input with a space
  lib/salary/ku/ku10-generator.ts replace('-', '')    first hyphen only, spaces survived
  lib/salary/agi/xml-generator.ts strip non-digits    stray letters passed the length check
  lib/bokslut/ixbrl/validate      /^\d{6}-?\d{4}$/    rejected the 12-digit form, no Luhn

A company stored with a space or in 12-digit form could file AGI all year and
then fail at the arsredovisning deadline with a message that did not say why.

lib/invariants/ now owns account number, ISO date, four-digit fiscal year and
org number, each with the rationale recorded next to the rule. normalizeOrgNumber
moves here from lib/company-lookup/ and isSaneDateString from lib/utils.ts; both
old paths re-export, so no caller changes. lib/api/schemas.ts builds its
primitives on the module, so ~100 schemas inherit any correction.

The arsredovisning check-digit verdict is a warn, not an error: a wrong Luhn
digit is almost certainly a typo worth surfacing, but whether every org number
Bolagsverket accepts satisfies Luhn is a Swedish domain question we have not
verified against a primary source, and an error there blocks Skicka in. We do
not block a statutory filing on an unverified assumption.

KU10 still passes a 12-digit stored org number through unfolded. That is
pre-existing, and whether the KU10 schema wants 10 or 12 digits is not covered
by the swedish-payroll skill, so it is pinned by a test rather than changed
silently.

Guard 8 (hand-rolled-invariant) tracks the remaining 114 inline copies as a
ratchet that may only go down, same mechanism as the roundOre guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(ci): add an upgrade-path job that applies new migrations against real data

The pg-real job applies all 548 migrations to an EMPTY database. Empty means
zero rows, so a migration that adds a NOT NULL, adds a CHECK, creates a unique
index or backfills passes trivially in CI and can still fail on production,
where the rows exist. CI proved that a fresh install works; nothing proved that
an existing install upgrades.

The new pg-upgrade job: apply the schema as it stands at the merge base, seed a
small real company (three posted verifikat, balanced lines, one ore-level
amount), then apply ONLY the migrations this PR adds, then assert the data
survived (entries still posted, lines intact, ledger still balances, ore
unchanged, voucher numbers sequential). A PR with no migration no-ops.

Verified locally against supabase/postgres:15.8.1.060 rather than assumed, with
three deliberately bad migrations:

  rescale money on posted lines   empty: would pass   seeded: ERROR (immutability trigger)
  CHECK violating the ore row     empty: exit 0       seeded: exit 3
  NOT NULL on a populated column  empty: exit 0       seeded: exit 3

Base migrations are read out of the merge-base git tree, not the working tree,
so a PR that edits an already-shipped migration still gets the original applied
and the edit surfaces as a failure here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: record the invariants and upgrade-CI decisions

Two entries covering what this PR changes and, more importantly, the calls that
are not obvious from the diff: why the arsredovisning check-digit verdict is a
warning rather than an error, why KU10's 12-digit passthrough is pinned instead
of fixed, and why the ROT/RUT brf org-number schemas stay on their own rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(test): mark the upgrade fixture as CI-only, never a production template

The fixture writes posted journal_entries and their lines directly, bypassing
the engine and the atomic commit RPC. That is the only way to hand a migration
pre-existing posted rows to break, and it is safe against a throwaway CI
database, but it reads like a sanctioned pattern to anyone who finds it later.

Says so explicitly, with the reason it is confined here (no voucher sequence to
keep gapless, no retention obligation on a database destroyed with the job) and
a pointer back to Hard Rule 2 for anything touching a real database.

Raised by the Swedish compliance review bot on #1364.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 16:30:14 +02:00

148 lines
5.7 KiB
TypeScript

import { luhnValidate } from '@/lib/bankgiro/luhn'
/**
* Swedish organisationsnummer / personnummer: the one place that decides what
* "a valid org number" means.
*
* ## The rule
*
* - **Canonical storage form is 10 digits, no separators** (`5560125790`).
* - Input may arrive as 10 or 12 digits, with spaces or hyphens, because that
* is what users type and what provider APIs return. Both forms normalize to
* the same 10 digits; the century prefix is dropped.
* - The last digit is a Luhn (mod-10) check digit, the structural rule
* Bolagsverket and personnummer share.
*
* ## Why this module exists
*
* Before it, seven call sites each had their own idea of the rule, and four of
* them fed Skatteverket-bound output that must agree:
*
* | Site | Old rule | Failure |
* |---|---|---|
* | `lib/skatteverket/format.ts` | strip `-` only | threw on any input containing a space |
* | `lib/salary/ku/ku10-generator.ts` | `replace('-', '')` | first hyphen only, no space handling |
* | `lib/salary/agi/xml-generator.ts` | strip non-digits | no check-digit validation |
* | `lib/bokslut/ixbrl/validate/rules.ts` | `/^\d{6}-?\d{4}$/` | rejected the 12-digit form outright |
*
* A company stored with a space or in 12-digit form could file AGI all year and
* then fail on the årsredovisning, with no way for the user to tell why. The
* rules only stay in agreement if there is exactly one of them.
*
* ## Deliberate asymmetry: normalize everywhere, Luhn only at the boundary
*
* `normalizeOrgNumber` (Luhn-checked) guards data coming *in*. The export-time
* converter `toRedovisare12` is structural only: it must not start rejecting
* numbers that are already stored and have been filing successfully, because a
* failed export at a deadline is worse than a number Skatteverket will reject
* with its own message. Tighten the intake, not the outflow.
*/
/** Digits-only canonical storage length. */
export const ORG_NUMBER_LENGTH = 10
/**
* Strip the separators Swedish users and provider APIs put in org numbers.
* Does not validate: use {@link isOrgNumberShaped} or {@link normalizeOrgNumber}.
*/
export function stripOrgNumberFormatting(raw: string): string {
return raw.replace(/[\s-]/g, '')
}
/**
* True when the input is structurally an org number (10 or 12 digits after
* separators are stripped), regardless of check digit.
*/
export function isOrgNumberShaped(raw: string | null | undefined): boolean {
if (!raw) return false
const cleaned = stripOrgNumberFormatting(raw)
return /^\d{10}$/.test(cleaned) || /^\d{12}$/.test(cleaned)
}
/**
* Normalize an org number to Accounted's canonical 10-digit storage form.
*
* Accepts hyphen/space-formatted input in either of the two shapes Swedish
* users commonly type:
* - 10 digits (5560125790 or 8001011231): stored as-is
* - 12 digits (198001011231): century prefix stripped
*
* Returns null for any other length, non-digit content, or invalid Luhn check
* digit. Storing a structurally invalid org number would later be caught by
* Skatteverket SRU and any receiving SIE4 system: refusing at the boundary
* keeps Accounted's bookkeeping from accumulating under an unusable identifier.
*/
export function normalizeOrgNumber(raw: string | null | undefined): string | null {
if (!raw) return null
const cleaned = stripOrgNumberFormatting(raw)
let canonical: string
if (/^\d{10}$/.test(cleaned)) {
canonical = cleaned
} else if (/^\d{12}$/.test(cleaned)) {
canonical = cleaned.substring(2)
} else {
return null
}
return luhnValidate(canonical) ? canonical : null
}
/** True when {@link normalizeOrgNumber} accepts the input. */
export function isValidOrgNumber(raw: string | null | undefined): boolean {
return normalizeOrgNumber(raw) !== null
}
/**
* True when the input is shaped like an org number but its check digit is
* wrong. Lets a validator tell the user *which* problem they have instead of
* one undifferentiated "ogiltigt organisationsnummer".
*/
export function hasInvalidOrgNumberCheckDigit(raw: string | null | undefined): boolean {
return isOrgNumberShaped(raw) && !isValidOrgNumber(raw)
}
/**
* Format a canonical org number for display: `NNNNNN-NNNN`.
* Returns the input unchanged when it is not org-number shaped.
*/
export function formatOrgNumberDisplay(raw: string | null | undefined): string {
if (!raw) return ''
const cleaned = stripOrgNumberFormatting(raw)
const ten = /^\d{12}$/.test(cleaned) ? cleaned.substring(2) : cleaned
if (!/^\d{10}$/.test(ten)) return raw
return `${ten.substring(0, 6)}-${ten.substring(6)}`
}
/**
* Convert an org number to Skatteverket's 12-digit "redovisare" format.
*
* - Organisationsnummer (aktiebolag): prefix `16` (5020000013 -> 165020000013)
* - Personnummer (enskild firma): prefix `19` or `20` by century
* - Input already in 12-digit form passes through untouched
*
* Structural only, no check-digit validation: see the module docblock for why
* the export path stays permissive.
*
* @throws when the input is not 10 or 12 digits after separators are stripped.
*/
export function toRedovisare12(
orgNumber: string,
entityType: 'enskild_firma' | 'aktiebolag',
): string {
const clean = stripOrgNumberFormatting(orgNumber)
if (/^\d{12}$/.test(clean)) return clean
if (!/^\d{10}$/.test(clean)) {
throw new Error(`Ogiltigt organisationsnummer: ${orgNumber} (förväntar 10 eller 12 siffror)`)
}
if (entityType === 'aktiebolag') return `16${clean}`
// Enskild firma: personnummer. A two-digit year above the current one must
// belong to the previous century (someone born in 98 is 1998, not 2098).
const yearDigits = parseInt(clean.substring(0, 2), 10)
const currentTwoDigitYear = new Date().getFullYear() % 100
const prefix = yearDigits > currentTwoDigitYear ? '19' : '20'
return `${prefix}${clean}`
}