16fbcefbbc
* 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>
148 lines
5.7 KiB
TypeScript
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}`
|
|
}
|