From db89f4c62c2b16421112a53503537b74a33b1071 Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Wed, 3 Jun 2026 23:28:56 +0200 Subject: [PATCH 1/2] fix(at/vnr,us/rtn): align with primary sources from official documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two narrow correctness fixes surfaced by an audit of the oracle drift against python-stdnum. at/vnr — drop the strict calendar-date check on the embedded DDMMYY field. sozialversicherung.at documents that months 13/14/etc. are issued when the daily serial pool for a substitute birth date (used when DOB is unknown) is exhausted, and that the 3-digit serial never starts with zero. The validator now enforces only the checksum and the non-leading-zero serial rule; both are gating per the official guidance. us/rtn — extend the Federal Reserve prefix allowlist from 01-12 to 00-12. Per the ABA Routing Number Policy (summarised on Wikipedia, which is what we already cite), prefix 00 is reserved for U.S. Government and federal agencies and is a valid routing-number range. The PREFIX_RANGES table used by the generator is updated to match. Tests are updated to reflect the new semantics (the previously "invalid prefix (00)" RTN test becomes a positive test using 000000026, a checksum-valid 00-prefixed RTN; the at.vnr month-13 and day-0 tests now assert INVALID_CHECKSUM rather than INVALID_COMPONENT). --- __test__/at.test.ts | 14 ++++++++++---- __test__/us.test.ts | 13 +++++++------ src/at/vnr.ts | 20 ++++++++++++++------ src/us/rtn.ts | 14 +++++++++----- 4 files changed, 40 insertions(+), 21 deletions(-) diff --git a/__test__/at.test.ts b/__test__/at.test.ts index b68d6ef..553d090 100644 --- a/__test__/at.test.ts +++ b/__test__/at.test.ts @@ -179,19 +179,25 @@ describe("at.vnr", () => { } }); - test("invalid birth date (month > 12)", () => { + // VNRs may legitimately encode "month > 12" when the + // daily-serial pool for a substitute date is exhausted + // (per sozialversicherung.at). We do not enforce + // calendar validity on the embedded date; only the + // checksum is gating. A month-13 value with a non- + // matching check digit therefore fails on checksum. + test("non-matching month-13 fails on checksum", () => { const r = at.vnr.validate("1237011380"); expect(r.valid).toBe(false); if (!r.valid) { - expect(r.error.code).toBe("INVALID_COMPONENT"); + expect(r.error.code).toBe("INVALID_CHECKSUM"); } }); - test("invalid birth date (day 0)", () => { + test("non-matching day-0 fails on checksum", () => { const r = at.vnr.validate("1237000180"); expect(r.valid).toBe(false); if (!r.valid) { - expect(r.error.code).toBe("INVALID_COMPONENT"); + expect(r.error.code).toBe("INVALID_CHECKSUM"); } }); diff --git a/__test__/us.test.ts b/__test__/us.test.ts index 01eda5a..de07c6d 100644 --- a/__test__/us.test.ts +++ b/__test__/us.test.ts @@ -265,12 +265,13 @@ describe("us.rtn", () => { } }); - test("invalid prefix (00)", () => { - const r = us.rtn.validate("001000021"); - expect(r.valid).toBe(false); - if (!r.valid) { - expect(r.error.code).toBe("INVALID_COMPONENT"); - } + // Prefix 00 is reserved for U.S. Government and + // federal agencies per the ABA Routing Number Policy + // (https://en.wikipedia.org/wiki/ABA_routing_transit_number). + test("valid prefix (00, U.S. Government)", () => { + const r = us.rtn.validate("000000026"); + expect(r.valid).toBe(true); + if (r.valid) expect(r.compact).toBe("000000026"); }); test("invalid prefix (13)", () => { diff --git a/src/at/vnr.ts b/src/at/vnr.ts index 0dce509..e1b34c9 100644 --- a/src/at/vnr.ts +++ b/src/at/vnr.ts @@ -6,8 +6,17 @@ * The check digit is computed using a weighted sum * mod 11 over the 9 non-check digits. * + * The 6-digit "birth date" field is not always a real + * calendar date. Sozialversicherung.at documents that + * persons with unknown date of birth are issued + * substitute values (01.01. or 01.07.), and that + * months 13, 14, etc. are issued when the daily serial + * pool for a given substitute date is exhausted. We + * therefore do not enforce calendar validity on this + * field — only the checksum is gating. + * * @see https://de.wikipedia.org/wiki/Sozialversicherungsnummer#%C3%96sterreich - * @see https://www.sozialversicherung.at/ + * @see https://www.sozialversicherung.at/cdscontent/?contentid=10007.820902&viewmode=content */ import type { ValidateResult, Validator } from "../types"; @@ -44,13 +53,12 @@ const validate = (value: string): ValidateResult => { ); } - // Validate birth date (DDMMYY in positions 4-9) - const day = Number(v.slice(4, 6)); - const month = Number(v.slice(6, 8)); - if (day < 1 || day > 31 || month < 1 || month > 12) { + // Per sozialversicherung.at, the 3-digit serial + // (positions 0-2) never starts with zero. + if (v[0] === "0") { return err( "INVALID_COMPONENT", - "Austrian VNR contains an invalid birth date", + "Austrian VNR serial must not start with zero", ); } diff --git a/src/us/rtn.ts b/src/us/rtn.ts index 8b4cda2..9232d49 100644 --- a/src/us/rtn.ts +++ b/src/us/rtn.ts @@ -7,9 +7,9 @@ * [3, 7, 1] repeated three times. * * The first two digits identify the Federal Reserve - * district (01-12) or special ranges (21-32 for - * thrifts, 61-72 for electronic, 80 for traveler's - * cheques). + * district (01-12) or special ranges (00 for U.S. + * Government, 21-32 for thrifts, 61-72 for electronic, + * 80 for traveler's cheques). * * @see https://en.wikipedia.org/wiki/ABA_routing_transit_number * @see https://www.frbservices.org/ @@ -48,7 +48,11 @@ const validate = (value: string): ValidateResult => { // Validate Federal Reserve prefix (first 2 digits) const prefix = Number(v.slice(0, 2)); const validPrefixRanges = - (prefix >= 1 && prefix <= 12) || + // 00 is reserved for U.S. Government and federal + // agencies per the ABA Routing Number Policy; + // 01-12 are the 12 Federal Reserve districts. + // @see https://en.wikipedia.org/wiki/ABA_routing_transit_number + (prefix >= 0 && prefix <= 12) || (prefix >= 21 && prefix <= 32) || (prefix >= 61 && prefix <= 72) || prefix === 80; @@ -78,7 +82,7 @@ const format = (value: string): string => compact(value); /** All valid Federal Reserve prefix ranges. */ const PREFIX_RANGES: readonly [number, number][] = [ - [1, 12], + [0, 12], [21, 32], [61, 72], [80, 80], From fd6efdf10ca9026f5c4da3a58a7045b5c6cc4d4c Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Thu, 4 Jun 2026 00:01:50 +0200 Subject: [PATCH 2/2] fix(at/vnr): restore day-bounds check and fix generator serial range Address reviewer feedback (Codex P2 and Gemini high) on the previous commit: - The earlier change dropped both day and month calendar validation, but sozialversicherung.at only documents month overflow (months 13, 14, etc. for serial-exhausted substitute dates). The day field always stays within 01-31, even in the substitute cases (day "01" is used). Restore the day-bounds check so values like "1002000180" (encoding day 00) are correctly rejected. - Generator: with the new non-leading-zero serial rule, randomDigits(3) could still emit serials in 000-099 ~10% of the time, causing generate() to occasionally produce invalid VNRs. Build the serial as randomInt(1,9) || randomDigits(2) to enforce the first-digit invariant deterministically. Fixes the CI generator roundtrip test. - Add a positive test for a month-13 VNR with a matching checksum (1234011380) to lock in the documented overflow case, plus a leading-zero-serial test. --- __test__/at.test.ts | 29 ++++++++++++++++++++++------- src/at/vnr.ts | 22 +++++++++++++++++++--- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/__test__/at.test.ts b/__test__/at.test.ts index 553d090..a9daea4 100644 --- a/__test__/at.test.ts +++ b/__test__/at.test.ts @@ -181,11 +181,15 @@ describe("at.vnr", () => { // VNRs may legitimately encode "month > 12" when the // daily-serial pool for a substitute date is exhausted - // (per sozialversicherung.at). We do not enforce - // calendar validity on the embedded date; only the - // checksum is gating. A month-13 value with a non- - // matching check digit therefore fails on checksum. - test("non-matching month-13 fails on checksum", () => { + // (per sozialversicherung.at). Month is therefore not + // calendar-checked; only the checksum is gating. + test("month-13 with matching checksum is valid", () => { + const r = at.vnr.validate("1234011380"); + expect(r.valid).toBe(true); + if (r.valid) expect(r.compact).toBe("1234011380"); + }); + + test("month-13 with non-matching checksum fails on checksum", () => { const r = at.vnr.validate("1237011380"); expect(r.valid).toBe(false); if (!r.valid) { @@ -193,11 +197,22 @@ describe("at.vnr", () => { } }); - test("non-matching day-0 fails on checksum", () => { + // Day, by contrast, is always 01-31 per the source — + // even substitute and overflow VNRs use day "01" — + // so day "00" is rejected as a component error. + test("day-0 is invalid", () => { const r = at.vnr.validate("1237000180"); expect(r.valid).toBe(false); if (!r.valid) { - expect(r.error.code).toBe("INVALID_CHECKSUM"); + expect(r.error.code).toBe("INVALID_COMPONENT"); + } + }); + + test("leading-zero serial is invalid", () => { + const r = at.vnr.validate("0000000000"); + expect(r.valid).toBe(false); + if (!r.valid) { + expect(r.error.code).toBe("INVALID_COMPONENT"); } }); diff --git a/src/at/vnr.ts b/src/at/vnr.ts index e1b34c9..8360c66 100644 --- a/src/at/vnr.ts +++ b/src/at/vnr.ts @@ -12,8 +12,10 @@ * substitute values (01.01. or 01.07.), and that * months 13, 14, etc. are issued when the daily serial * pool for a given substitute date is exhausted. We - * therefore do not enforce calendar validity on this - * field — only the checksum is gating. + * therefore do not enforce a calendar-valid month here. + * The day field, however, is always within 01-31 — even + * in the documented overflow cases the day stays "01" + * — so we keep the day-bounds check. * * @see https://de.wikipedia.org/wiki/Sozialversicherungsnummer#%C3%96sterreich * @see https://www.sozialversicherung.at/cdscontent/?contentid=10007.820902&viewmode=content @@ -62,6 +64,18 @@ const validate = (value: string): ValidateResult => { ); } + // Day (positions 4-5) is always within 01-31, even + // in the documented substitute and overflow cases + // where the month is shifted. The month is left + // unchecked per the source. + const day = Number(v.slice(4, 6)); + if (day < 1 || day > 31) { + return err( + "INVALID_COMPONENT", + "Austrian VNR day field must be within 01-31", + ); + } + // Check digit at position 3: weighted sum of the // other 9 digits mod 11. If remainder is 10 the // number is invalid. @@ -96,7 +110,9 @@ const format = (value: string): string => { /** Generate a random valid Austrian VNR. */ const generate = (): string => { for (;;) { - const serial = randomDigits(3); + // Serial must not start with zero (see validate). + const serial = + String(randomInt(1, 9)) + randomDigits(2); const day = String(randomInt(1, 28)).padStart(2, "0"); const month = String(randomInt(1, 12)).padStart(2, "0"); const year = String(randomInt(0, 99)).padStart(2, "0");