Skip to main content

idkollen_client/models/
ssn.rs

1use fmt::Display;
2use serde::{Deserialize, Deserializer, Serialize, Serializer};
3use std::fmt;
4use thiserror::Error;
5
6// ── Swedish personnummer (Pno) ─────────────────────────────────────────────
7
8/// A validated Swedish personal identity number (personnummer).
9///
10/// Accepts 10-digit (`YYMMDDXXXX`), 10-digit with separator (`YYMMDD-XXXX`,
11/// `YYMMDD+XXXX`), 12-digit (`YYYYMMDDXXXX`), or 12-digit with separator
12/// (`YYYYMMDD-XXXX`). Strips separators and validates the Luhn check digit.
13/// Stored without separators; serializes/deserializes as a plain JSON string.
14#[derive(Debug, Clone, PartialEq, Eq, Hash)]
15pub struct Pno(String);
16
17#[derive(Debug, Error)]
18#[error("invalid personnummer: {0}")]
19pub struct PnoError(String);
20
21impl Pno {
22    pub fn parse(s: &str) -> Result<Self, PnoError> {
23        let cleaned = s
24            .chars()
25            .filter(|&c| c != '-' && c != '+')
26            .collect::<String>();
27
28        if !cleaned.chars().all(|c| c.is_ascii_digit()) {
29            return Err(PnoError("contains non-digit characters".to_owned()));
30        }
31
32        let ten = match cleaned.len() {
33            10 => cleaned.as_str().to_owned(),
34            12 => cleaned[2..].to_owned(),
35            n => {
36                return Err(PnoError(format!(
37                    "expected 10 or 12 digits after stripping separators, got {n}"
38                )));
39            },
40        };
41
42        if !luhn10(&ten) {
43            return Err(PnoError("Luhn check failed".to_owned()));
44        }
45
46        Ok(Self(cleaned))
47    }
48
49    #[inline]
50    #[must_use]
51    pub fn as_str(&self) -> &str {
52        &self.0
53    }
54}
55
56impl From<Pno> for String {
57    #[inline]
58    fn from(p: Pno) -> String {
59        p.0
60    }
61}
62
63impl AsRef<str> for Pno {
64    #[inline]
65    fn as_ref(&self) -> &str {
66        &self.0
67    }
68}
69
70impl Display for Pno {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        f.write_str(&self.0)
73    }
74}
75
76impl Serialize for Pno {
77    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
78        self.0.serialize(s)
79    }
80}
81
82impl<'de> Deserialize<'de> for Pno {
83    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
84        let s = String::deserialize(d)?;
85
86        Pno::parse(&s).map_err(serde::de::Error::custom)
87    }
88}
89
90// ── Norwegian fødselsnummer / D-number (Nnin) ─────────────────────────────
91
92/// A validated Norwegian national identity number (fødselsnummer or D-number).
93///
94/// Accepts 11-digit strings, optionally with a dash at position 6. Validates
95/// both modulo-11 control digits. Stored as 11 digits without separator.
96#[derive(Debug, Clone, PartialEq, Eq, Hash)]
97pub struct Nnin(String);
98
99#[derive(Debug, Error)]
100#[error("invalid fødselsnummer: {0}")]
101pub struct NninError(String);
102
103impl Nnin {
104    pub fn parse(s: &str) -> Result<Self, NninError> {
105        let cleaned = s.chars().filter(|&c| c != '-').collect::<String>();
106
107        if cleaned.len() != 11 {
108            return Err(NninError(format!(
109                "expected 11 digits, got {}",
110                cleaned.len()
111            )));
112        }
113
114        if !cleaned.chars().all(|c| c.is_ascii_digit()) {
115            return Err(NninError("contains non-digit characters".to_owned()));
116        }
117
118        if !nnin_valid(&cleaned) {
119            return Err(NninError("modulo-11 check failed".to_owned()));
120        }
121
122        Ok(Self(cleaned))
123    }
124
125    #[inline]
126    #[must_use]
127    pub fn as_str(&self) -> &str {
128        &self.0
129    }
130}
131
132impl From<Nnin> for String {
133    #[inline]
134    fn from(n: Nnin) -> String {
135        n.0
136    }
137}
138
139impl AsRef<str> for Nnin {
140    #[inline]
141    fn as_ref(&self) -> &str {
142        &self.0
143    }
144}
145
146impl Display for Nnin {
147    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
148        f.write_str(&self.0)
149    }
150}
151
152impl Serialize for Nnin {
153    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
154        self.0.serialize(s)
155    }
156}
157
158impl<'de> Deserialize<'de> for Nnin {
159    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
160        let s = String::deserialize(d)?;
161
162        Nnin::parse(&s).map_err(serde::de::Error::custom)
163    }
164}
165
166// ── Danish CPR number (Cpr) ───────────────────────────────────────────────
167
168/// A validated Danish personal identification number (CPR-nummer).
169///
170/// Accepts 10-digit strings or the `DDMMYY-XXXX` form with a dash at position 6.
171/// Validates that the date portion (DDMMYY) represents a plausible calendar date.
172/// Stored as 10 digits without separator.
173///
174/// Note: the historical modulo-11 check is not applied because it was abolished
175/// for persons born on or after 1 January 2007.
176#[derive(Debug, Clone, PartialEq, Eq, Hash)]
177pub struct Cpr(String);
178
179#[derive(Debug, Error)]
180#[error("invalid CPR number: {0}")]
181pub struct CprError(String);
182
183impl Cpr {
184    pub fn parse(s: &str) -> Result<Self, CprError> {
185        let cleaned = s.chars().filter(|&c| c != '-').collect::<String>();
186
187        if cleaned.len() != 10 {
188            return Err(CprError(format!(
189                "expected 10 digits, got {}",
190                cleaned.len()
191            )));
192        }
193
194        if !cleaned.chars().all(|c| c.is_ascii_digit()) {
195            return Err(CprError("contains non-digit characters".to_owned()));
196        }
197
198        let day: u32 = cleaned[..2].parse().unwrap();
199        let month: u32 = cleaned[2..4].parse().unwrap();
200
201        // Allow day 1-31 and day 61-91 for corrected CPRs; month 1-12.
202        if !(1..=31).contains(&(day % 60)) || !(1..=12).contains(&month) {
203            return Err(CprError(
204                "date portion is not a valid calendar date".to_owned(),
205            ));
206        }
207
208        Ok(Self(cleaned))
209    }
210
211    #[inline]
212    #[must_use]
213    pub fn as_str(&self) -> &str {
214        &self.0
215    }
216}
217
218impl From<Cpr> for String {
219    #[inline]
220    fn from(c: Cpr) -> String {
221        c.0
222    }
223}
224
225impl AsRef<str> for Cpr {
226    #[inline]
227    fn as_ref(&self) -> &str {
228        &self.0
229    }
230}
231
232impl Display for Cpr {
233    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
234        f.write_str(&self.0)
235    }
236}
237
238impl Serialize for Cpr {
239    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
240        self.0.serialize(s)
241    }
242}
243
244impl<'de> Deserialize<'de> for Cpr {
245    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
246        let s = String::deserialize(d)?;
247
248        Cpr::parse(&s).map_err(serde::de::Error::custom)
249    }
250}
251
252// ── Finnish personal identity code (Hetu) ────────────────────────────────
253
254/// A validated Finnish personal identity code (henkilötunnus, HETU).
255///
256/// Format: `DDMMYYcXXXK` where `c` is the century marker (`-` = 1900s,
257/// `+` = 1800s, `A` = 2000s), `XXX` is an individual number, and `K` is
258/// a check character from the alphabet `0123456789ABCDEFHJKLMNPRSTUVWXY`.
259/// Stored in the original 11-character form.
260#[derive(Debug, Clone, PartialEq, Eq, Hash)]
261pub struct Hetu(String);
262
263#[derive(Debug, Error)]
264#[error("invalid henkilötunnus: {0}")]
265pub struct HetuError(String);
266
267const HETU_ALPHABET: &[u8] = b"0123456789ABCDEFHJKLMNPRSTUVWXY";
268
269impl Hetu {
270    pub fn parse(s: &str) -> Result<Self, HetuError> {
271        let upper = s.to_ascii_uppercase();
272        let b = upper.as_bytes();
273
274        if b.len() != 11 {
275            return Err(HetuError(format!(
276                "expected 11 characters, got {}",
277                b.len()
278            )));
279        }
280
281        let sep = b[6] as char;
282
283        if sep != '-' && sep != '+' && sep != 'A' {
284            return Err(HetuError(format!(
285                "invalid century marker '{sep}'; expected '-', '+', or 'A'"
286            )));
287        }
288
289        let date_part = &upper[..6];
290        let ind_part = &upper[7..10];
291
292        if !date_part.chars().all(|c| c.is_ascii_digit()) {
293            return Err(HetuError(
294                "date portion contains non-digit characters".to_owned(),
295            ));
296        }
297
298        if !ind_part.chars().all(|c| c.is_ascii_digit()) {
299            return Err(HetuError(
300                "individual number contains non-digit characters".to_owned(),
301            ));
302        }
303
304        let n: u64 = format!("{date_part}{ind_part}").parse().unwrap();
305        let expected = HETU_ALPHABET[(n % 31) as usize] as char;
306
307        if b[10] as char != expected {
308            return Err(HetuError(format!(
309                "check character mismatch: got '{}', expected '{expected}'",
310                b[10] as char
311            )));
312        }
313
314        Ok(Self(upper))
315    }
316
317    #[inline]
318    #[must_use]
319    pub fn as_str(&self) -> &str {
320        &self.0
321    }
322}
323
324impl From<Hetu> for String {
325    #[inline]
326    fn from(h: Hetu) -> String {
327        h.0
328    }
329}
330
331impl AsRef<str> for Hetu {
332    #[inline]
333    fn as_ref(&self) -> &str {
334        &self.0
335    }
336}
337
338impl Display for Hetu {
339    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
340        f.write_str(&self.0)
341    }
342}
343
344impl Serialize for Hetu {
345    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
346        self.0.serialize(s)
347    }
348}
349
350impl<'de> Deserialize<'de> for Hetu {
351    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
352        let s = String::deserialize(d)?;
353
354        Hetu::parse(&s).map_err(serde::de::Error::custom)
355    }
356}
357
358// ── Shared helpers ────────────────────────────────────────────────────────
359
360/// Standard Luhn algorithm over a 10-digit ASCII string (weights 2,1 from left).
361fn luhn10(s: &str) -> bool {
362    let sum: u32 = s
363        .chars()
364        .enumerate()
365        .map(|(i, c)| {
366            let d = c.to_digit(10).unwrap();
367            let v = if i % 2 == 0 { d * 2 } else { d };
368            if v >= 10 { v - 9 } else { v }
369        })
370        .sum();
371
372    sum.is_multiple_of(10)
373}
374
375/// Norwegian fødselsnummer modulo-11 double-check-digit validation.
376fn nnin_valid(s: &str) -> bool {
377    let d = s
378        .chars()
379        .map(|c| c.to_digit(10).unwrap())
380        .collect::<Vec<_>>();
381    let s1: u32 = [3u32, 7, 6, 1, 8, 9, 4, 5, 2, 1]
382        .iter()
383        .zip(d.iter())
384        .map(|(w, v)| w * v)
385        .sum();
386
387    if !s1.is_multiple_of(11) {
388        return false;
389    }
390
391    let s2: u32 = [5u32, 4, 3, 2, 7, 6, 5, 4, 3, 2, 1]
392        .iter()
393        .zip(d.iter())
394        .map(|(w, v)| w * v)
395        .sum();
396
397    s2.is_multiple_of(11)
398}