Files
accounted/lib/salary/personnummer-format.ts
T
f266c386f3 chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* 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>
2026-09-02 11:51:16 +02:00

169 lines
6.8 KiB
TypeScript

/**
* Personnummer parsing, validation and formatting. Pure string/date logic,
* no Node imports: lib/salary/personnummer.ts (which also encrypts with
* `crypto`) re-exports everything here for its server callers, while
* client components reach these through lib/salary/tax-column.ts without
* the browser crypto polyfill.
*/
/**
* Extract the last 4 digits of a personnummer for display.
*/
import { luhnValidate } from '@/lib/bankgiro/luhn'
export function extractLast4(personnummer: string): string {
const digits = personnummer.replace(/\D/g, '')
return digits.slice(-4)
}
/**
* Validate a Swedish personnummer or samordningsnummer (12-digit format:
* YYYYMMDDNNNN). Checks format + Luhn checksum on last 10 digits.
*
* A samordningsnummer is the identity number Skatteverket assigns to a person
* who has no personnummer. It has the same shape, except the day field carries
* an added 60, so the printed day is 61-91 instead of 1-31. Skatteverket files
* these under FK215 in the arbetsgivardeklaration exactly like a personnummer,
* and our own AGI generator accepts them (see IDENTITET_PATTERN in
* lib/salary/agi/xml-generator.ts, which spells out "samordningsnummer where
* day = actual_day + 60"). Rejecting them here meant the system could file an
* AGI for someone it refused to register as an employee.
*
* The Luhn check digit is computed over the printed digits, the +60 day
* included: a samordningsnummer has no underlying non-offset form to compute it
* from. So the checksum below is deliberately untouched by the offset.
*/
export function validatePersonnummer(personnummer: string): { valid: boolean; error?: string } {
const digits = personnummer.replace(/\D/g, '')
if (digits.length !== 12) {
return { valid: false, error: 'Personnummer måste vara 12 siffror (ÅÅÅÅMMDDNNNN)' }
}
const year = parseInt(digits.slice(0, 4))
const month = parseInt(digits.slice(4, 6))
const day = parseInt(digits.slice(6, 8))
if (year < 1900 || year > 2100) {
return { valid: false, error: 'Ogiltigt år' }
}
if (month < 1 || month > 12) {
return { valid: false, error: 'Ogiltig månad' }
}
// Strip the samordningsnummer offset before range-checking the day, so both
// forms collapse to a real 1-31 calendar day. This accepts 1-31 (personnummer)
// and 61-91 (samordningsnummer) while still rejecting 32-60 and 92-99, which
// are neither: 32-60 is an out-of-range day that has not been offset, and
// 92-99 offsets back to day 32-39.
const birthDay = day > 60 ? day - 60 : day
if (birthDay < 1 || birthDay > 31) {
return { valid: false, error: 'Ogiltig dag' }
}
// Luhn check on digits 3-12 (YYMMDDNNNN, 10 digits)
const luhnDigits = digits.slice(2)
if (!luhnValidate(luhnDigits)) {
return { valid: false, error: 'Ogiltigt kontrollnummer (Luhn)' }
}
return { valid: true }
}
/**
* Extract birth date from a 12-digit personnummer or samordningsnummer.
*
* A samordningsnummer prints the day offset by 60 (61-91). The offset is a
* numbering convention, not a calendar fact, so the returned `day` is always
* the real 1-31 calendar day: consumers doing date math (calculateAge's
* birthday comparison, or anything constructing a Date) would otherwise be
* off by 60 days. The Luhn checksum is computed over the printed, offset
* digits and is untouched by this normalization (see validatePersonnummer).
*/
export function extractBirthDate(personnummer: string): { year: number; month: number; day: number } {
const digits = personnummer.replace(/\D/g, '')
const printedDay = parseInt(digits.slice(6, 8))
return {
year: parseInt(digits.slice(0, 4)),
month: parseInt(digits.slice(4, 6)),
day: printedDay > 60 ? printedDay - 60 : printedDay,
}
}
/**
* Calculate age at a given date from a personnummer.
*/
export function calculateAge(personnummer: string, atDate: string): number {
const birth = extractBirthDate(personnummer)
const [refYear, refMonth, refDay] = atDate.split('-').map(Number)
let age = refYear - birth.year
if (refMonth < birth.month || (refMonth === birth.month && refDay < birth.day)) {
age--
}
return age
}
/**
* Age tier for "vid årets ingång fyllt X" rules (avgifter age tiers).
*
* Skatteverket applies these rules as BIRTH-YEAR ranges (the 2026
* ungdomsrabatt covers born 2003-2007; the 66/67+ reduction for 2026 covers
* born 1958 or earlier), which equals the age attained by December 31 of
* the PRIOR year. Birthday-inclusive age at January 1 (calculateAge
* semantics) misclassifies employees born exactly on January 1 in both
* directions: born 2008-01-01 would get the 2026 youth rate (Skatteverket's
* AGI validation rejects it) and born 2003-01-01 would be denied it.
*/
export function calculateAgeAtYearStart(personnummer: string, year: number): number {
return year - 1 - extractBirthDate(personnummer).year
}
/**
* Mask personnummer for display: YYYYMMDD-XXXX (birthdate visible, suffix hidden).
*/
export function maskPersonnummer(personnummer: string): string {
const digits = personnummer.replace(/\D/g, '')
return `${digits.slice(0, 8)}-XXXX`
}
/**
* Format personnummer with dash: YYYYMMDD-NNNN
*/
export function formatPersonnummer(personnummer: string): string {
const digits = personnummer.replace(/\D/g, '')
return `${digits.slice(0, 8)}-${digits.slice(8)}`
}
/**
* Expand a personnummer to the 12-digit form (YYYYMMDDNNNN).
*
* Accepts the shapes the customer card stores (10 or 12 digits, optional -/+
* separator; see PERSONAL_NUMBER_INPUT_RE in lib/customers). A 10-digit value
* gets its century inferred the standard Skatteverket way: the most recent
* birth date not after `now`, minus a further hundred years when the
* separator is '+' (the over-100 marker). Samordningsnummer day offsets
* (+60) are stripped for the calendar comparison only; the returned digits
* keep the printed day. Returns digits only, or null when the input has
* neither shape. No checksum validation here: callers that need it run the
* result through validatePersonnummer.
*/
export function expandPersonnummerTo12(value: string, now: Date = new Date()): string | null {
const trimmed = value.trim()
const digits = trimmed.replace(/\D/g, '')
if (digits.length === 12) return digits
if (digits.length !== 10) return null
const yy = parseInt(digits.slice(0, 2), 10)
const month = parseInt(digits.slice(2, 4), 10)
const day = parseInt(digits.slice(4, 6), 10)
const birthDay = day > 60 ? day - 60 : day
// Compare dates as yyyymmdd integers: immune to Date rollover on the
// not-yet-validated month/day values.
const today = now.getFullYear() * 10000 + (now.getMonth() + 1) * 100 + now.getDate()
let year = Math.floor(now.getFullYear() / 100) * 100 + yy
if (year * 10000 + month * 100 + birthDay > today) year -= 100
if (trimmed.includes('+')) year -= 100
return `${year}${digits.slice(2)}`
}