* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n namespaces and 4 unused dependencies; fold byte-identical helper copies into one canonical home each (lib/utils chunk/sleep/utcDateStamp, lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format, lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body + v1ValidationError rolled out to ~55 v1 routes, booking-template schemas). No behaviour change: v1 bodies and status codes, MCP tool schemas, DB writes and money math are untouched. Naive ore rounding was deliberately not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list of things left alone on purpose. tsc, lint, 19588 unit tests and check:guards green; antipattern baseline ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(transactions): import RawTransaction from @/types after the ingest re-export removal CI's type ratchet (check:types, full tsconfig) caught the one test file that still imported the type through lib/transactions/ingest. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
146 lines
5.7 KiB
TypeScript
146 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.
|
|
*/
|
|
|
|
|
|
/**
|
|
* 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}`
|
|
}
|