// Österreichische Sozialversicherungsnummer (SVNR). // // Ten digits: three-digit serial, one check digit, then the date of birth as // TTMMJJ — e.g. "1237 010180". The check digit is the weighted sum of the // other nine digits modulo 11; a serial whose sum yields 11 leaves no usable // digit and is simply never issued, which is why 10 has to be rejected // rather than wrapped. // // Only employees at an Austrian location have one. Everyone else keeps the // field free-form, since the German/Czech/Slovenian equivalents have their // own formats and are not what this validates. const WEIGHTS = [3, 7, 9, 0, 5, 8, 4, 2, 1, 6] as const; const CHECK_INDEX = 3; export type SvnrError = "length" | "serial" | "checksum" | "date" | "birthDateMismatch"; const MESSAGES: Record = { length: "Die SV-Nummer muss aus 10 Ziffern bestehen (4 Ziffern, dann TTMMJJ).", serial: "Die laufende Nummer darf nicht 000 sein.", checksum: "Die Prüfziffer stimmt nicht. Bitte die Eingabe kontrollieren.", date: "Die letzten 6 Stellen ergeben kein gültiges Geburtsdatum (TTMMJJ).", birthDateMismatch: "Die SV-Nummer enthält ein anderes Geburtsdatum als im Stammdatensatz.", }; export function svnrErrorMessage(error: SvnrError): string { return MESSAGES[error]; } /** Strips spaces and separators; keeps everything else so bad input still fails loudly. */ export function normalizeSvnr(input: string): string { return input.replace(/[\s./-]/g, ""); } /** "1237010180" → "1237 010180"; leaves anything non-canonical untouched. */ export function formatSvnr(input: string): string { const n = normalizeSvnr(input); return /^\d{10}$/.test(n) ? `${n.slice(0, 4)} ${n.slice(4)}` : input; } export function svnrCheckDigit(digits: string): number | null { const n = normalizeSvnr(digits); if (!/^\d{10}$/.test(n)) return null; let sum = 0; for (let i = 0; i < 10; i++) { if (i === CHECK_INDEX) continue; sum += Number(n[i]) * WEIGHTS[i]; } const check = sum % 11; return check === 10 ? null : check; } /** * `birthDate` (ISO yyyy-mm-dd) is optional; when given, the TTMMJJ tail is * cross-checked against it — the single most common data-entry slip is a * transposed birth date, which the checksum alone will not catch. */ export function validateSvnr(input: string, birthDate?: string | null): SvnrError | null { const n = normalizeSvnr(input); if (!/^\d{10}$/.test(n)) return "length"; if (n.slice(0, 3) === "000") return "serial"; const day = Number(n.slice(4, 6)); const month = Number(n.slice(6, 8)); if (month < 1 || month > 12 || day < 1 || day > 31) return "date"; // Without a century the tail cannot be resolved to a real date, so the // day-of-month bound is the generous one; a supplied birthDate settles it. if (day > [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1]) return "date"; const expected = svnrCheckDigit(n); if (expected === null || expected !== Number(n[CHECK_INDEX])) return "checksum"; if (birthDate) { const [y, m, d] = birthDate.split("-"); if (d !== n.slice(4, 6) || m !== n.slice(6, 8) || y?.slice(-2) !== n.slice(8, 10)) return "birthDateMismatch"; } return null; } export function isValidSvnr(input: string, birthDate?: string | null): boolean { return validateSvnr(input, birthDate) === null; } /** Countries whose employees this validation applies to. */ export function requiresAustrianSvnr(locationCountry: string | null | undefined): boolean { return locationCountry === "Österreich"; }