sepa 0.6.0

SEPA payment utilities: IBAN validation (ISO 13616 + 89-country registry), BIC validation (ISO 9362), SEPA Creditor Identifier (EPC AT-02), pain.001 SCT + SCT Inst XML builder (pain.001.001.09 / .001.03 / .003.03), pain.008 SDD CORE + B2B XML builder (pain.008.001.08 / .001.02 / .003.02), pain.007 SDD reversal XML builder (pain.007.001.09), pain.002 status report parser incl. Verification of Payee, camt.052/053/054 parsers, structured and hybrid postal addresses for the Nov 2026 EPC cut-over, ISO 11649 RF creditor references, EPC field validation and SEPA charset transliteration. Output is XSD-validated in CI. Typed ISO 8601 dates. Zero I/O. No async. No f64 in monetary arithmetic.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
//! SEPA Creditor Identifier (CI) — EPC AT-02.
//!
//! The SEPA Creditor Identifier is a mandatory field on all SEPA Core Direct
//! Debit batches. It is assigned by the creditor's local banking authority and
//! appears as `CdtrSchmeId` in the pain.008 XML at the `PmtInf` level.
//!
//! ## Format
//!
//! `CC##ZZZxxxxxxxxxxxxxxx`
//!
//! | Part | Length | Content |
//! |---|---|---|
//! | Country code | 2 | ISO 3166-1 alpha-2 |
//! | Check digits | 2 | Mod-97 over the national identifier (see below) |
//! | Creditor Business Code | 3 | Alphanumeric (usually `ZZZ`) |
//! | National Identifier | 1–28 | Alphanumeric, country-specific |
//!
//! ## Check digit algorithm (EPC262-08)
//!
//! The check digits are computed over the **national identifier only** — the
//! 3-character Creditor Business Code is *excluded*:
//!
//! 1. Take the national identifier (position 8 onwards).
//! 2. Strip every non-alphanumeric character.
//! 3. Append the country code and `"00"`.
//! 4. Expand letters to decimals (A=10 … Z=35).
//! 5. `check_digits = 98 − (value mod 97)`.
//!
//! This differs from ISO 13616 (IBAN), where the whole string participates and
//! the expected remainder is 1. Applying the IBAN rule to a Creditor Identifier
//! rejects every genuine CI, because it folds the `ZZZ` business code into the
//! checksum.
//!
//! ## Examples
//!
//! ```
//! use sepa::creditor_id::validate_creditor_id;
//!
//! // Canonical EPC example identifier
//! assert!(validate_creditor_id("DE98ZZZ09999999999").is_ok());
//! assert!(validate_creditor_id("INVALID").is_err());
//! ```

use std::str::FromStr;

/// A validated SEPA Creditor Identifier.
///
/// Created only via [`validate_creditor_id`] or [`CreditorId::from_str`].
/// Cannot be forged — the mod-97 check digit is validated on construction.
///
/// Carries the same conversions as [`Iban`](crate::Iban) and
/// [`Bic`](crate::Bic): `Deref<Target = str>`, `Borrow<str>` (so it keys a
/// `HashMap<CreditorId, _>` that can be looked up with a `&str`), `Ord`, and
/// `TryFrom` for both `&str` and `String`.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct CreditorId(String);

impl CreditorId {
    /// The normalised CI string (whitespace-stripped, uppercase).
    #[inline]
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }

    /// Two-letter ISO 3166-1 country code.
    #[inline]
    #[must_use]
    pub fn country_code(&self) -> &str {
        &self.0[..2]
    }

    /// Check digits (characters 3–4).
    #[inline]
    #[must_use]
    pub fn check_digits(&self) -> &str {
        &self.0[2..4]
    }

    /// Creditor Business Code (characters 5–7, usually `ZZZ`).
    #[inline]
    #[must_use]
    pub fn business_code(&self) -> &str {
        &self.0[4..7]
    }

    /// National identifier (characters 8 onward).
    #[inline]
    #[must_use]
    pub fn national_id(&self) -> &str {
        &self.0[7..]
    }
}

impl std::fmt::Display for CreditorId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

impl AsRef<str> for CreditorId {
    fn as_ref(&self) -> &str {
        &self.0
    }
}

impl std::ops::Deref for CreditorId {
    type Target = str;
    fn deref(&self) -> &str {
        &self.0
    }
}

impl std::borrow::Borrow<str> for CreditorId {
    fn borrow(&self) -> &str {
        &self.0
    }
}

impl From<CreditorId> for String {
    fn from(ci: CreditorId) -> Self {
        ci.0
    }
}

impl FromStr for CreditorId {
    type Err = CreditorIdError;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        validate_creditor_id(s)
    }
}

impl TryFrom<&str> for CreditorId {
    type Error = CreditorIdError;
    fn try_from(s: &str) -> Result<Self, Self::Error> {
        validate_creditor_id(s)
    }
}

impl TryFrom<String> for CreditorId {
    type Error = CreditorIdError;
    fn try_from(s: String) -> Result<Self, Self::Error> {
        validate_creditor_id(&s)
    }
}

#[cfg(feature = "serde")]
impl serde::Serialize for CreditorId {
    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
        s.serialize_str(self.as_str())
    }
}

#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for CreditorId {
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
        let s = String::deserialize(d)?;
        validate_creditor_id(&s).map_err(serde::de::Error::custom)
    }
}

// ── Error ─────────────────────────────────────────────────────────────────────

/// Error returned when SEPA Creditor Identifier validation fails.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum CreditorIdError {
    /// CI length is outside the valid range 8–35 characters.
    #[error("Creditor Identifier length {len} is outside the valid range 8–35")]
    InvalidLength {
        /// The actual length.
        len: usize,
    },

    /// Characters 1–2 are not an ISO 3166-1 alpha-2 country.
    ///
    /// The check digits are computed *over* the country code, so a forged
    /// identifier in a country that does not exist — `ZZ99ZZZ…` — has perfectly
    /// consistent check digits. Only the registry rules it out.
    #[error("Creditor Identifier country code {code:?} is not an ISO 3166-1 alpha-2 country")]
    InvalidCountryCode {
        /// The country code that was rejected.
        code: String,
    },

    /// CI contains a character that is not alphanumeric.
    #[error("Creditor Identifier contains invalid character: {ch:?}")]
    InvalidCharacter {
        /// The offending character.
        ch: char,
    },

    /// The EPC check digits do not match the national identifier.
    #[error("Creditor Identifier check digits are {actual}, expected {expected}")]
    InvalidChecksum {
        /// The check digits required by EPC262-08.
        expected: String,
        /// The check digits actually present in the input.
        actual: String,
    },
}

// ── Validation ────────────────────────────────────────────────────────────────

/// Validate a SEPA Creditor Identifier using the EPC AT-02 format rules.
///
/// Accepts any country variant (DE, FR, GB, NL, …).
/// Whitespace is stripped, input is uppercased before validation.
///
/// # Errors
///
/// Returns [`CreditorIdError::InvalidLength`] when not 8–35 chars (after stripping),
/// [`CreditorIdError::InvalidCountryCode`] when chars 1–2 are not letters,
/// [`CreditorIdError::InvalidCharacter`] for non-alphanumeric characters, or
/// [`CreditorIdError::InvalidChecksum`] when the mod-97 check fails.
///
/// # Examples
///
/// ```
/// use sepa::creditor_id::{validate_creditor_id, CreditorIdError};
///
/// assert!(validate_creditor_id("DE98ZZZ09999999999").is_ok());
/// assert!(validate_creditor_id("de98zzz09999999999").is_ok()); // normalised to uppercase
///
/// let err = validate_creditor_id("DE00ZZZ09999999999").unwrap_err();
/// assert!(matches!(err, CreditorIdError::InvalidChecksum { .. }));
/// ```
#[must_use = "ignoring a validated Creditor ID loses the result"]
pub fn validate_creditor_id(raw: &str) -> Result<CreditorId, CreditorIdError> {
    let normalised: String = raw
        .chars()
        .filter(|c| !c.is_whitespace())
        .map(|c| c.to_ascii_uppercase())
        .collect();

    // Counted in characters: a non-ASCII character is rejected just below, but
    // the length reported for one must not be its UTF-8 byte count.
    let len = normalised.chars().count();
    if !(8..=35).contains(&len) {
        return Err(CreditorIdError::InvalidLength { len });
    }

    for c in normalised.chars() {
        if !c.is_ascii_alphanumeric() {
            return Err(CreditorIdError::InvalidCharacter { ch: c });
        }
    }

    // Characters 1–2 name the assigning country. Checking only that they are
    // letters would accept `ZZ`, whose check digits are computed over `ZZ` and
    // therefore agree — the same gap `validate_bic` closes for a BIC.
    let cc = &normalised[..2];
    if !crate::country::is_country_code(cc) {
        return Err(CreditorIdError::InvalidCountryCode {
            code: cc.to_owned(),
        });
    }

    // EPC262-08: the check digits cover the national identifier ONLY — the
    // 3-character Creditor Business Code (positions 5–7) is excluded.
    let expected = creditor_id_check_digits(&normalised[7..], cc);
    let actual = &normalised[2..4];
    if expected == actual {
        Ok(CreditorId(normalised))
    } else {
        Err(CreditorIdError::InvalidChecksum {
            expected,
            actual: actual.to_owned(),
        })
    }
}

/// Compute the two EPC check digits for a national identifier + country code.
///
/// Implements EPC262-08: strip non-alphanumerics from `national_id`, append
/// `country` and `"00"`, expand letters (A=10 … Z=35), then `98 − (n mod 97)`.
///
/// Both inputs must already be uppercased. The result is always two ASCII
/// digits, zero-padded.
///
/// # Examples
///
/// ```
/// use sepa::creditor_id::creditor_id_check_digits;
/// assert_eq!(creditor_id_check_digits("09999999999", "DE"), "98");
/// ```
#[must_use]
pub fn creditor_id_check_digits(national_id: &str, country: &str) -> String {
    // 0–9 contribute one decimal digit; A–Z contribute two (their 10–35 value).
    fn feed(remainder: u64, c: char) -> u64 {
        match c.to_digit(36) {
            Some(d) if d < 10 => (remainder * 10 + u64::from(d)) % 97,
            Some(d) => (remainder * 100 + u64::from(d)) % 97,
            None => remainder,
        }
    }

    let mut remainder = national_id
        .chars()
        .filter(char::is_ascii_alphanumeric)
        .fold(0u64, feed);
    remainder = country.chars().fold(remainder, feed);
    remainder = feed(remainder, '0');
    remainder = feed(remainder, '0');

    format!("{:02}", 98 - remainder)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn de_creditor_id_valid() {
        assert!(validate_creditor_id("DE98ZZZ09999999999").is_ok());
    }

    #[test]
    fn business_code_excluded_from_checksum() {
        // EPC262-08: the check digits cover the national identifier only, so
        // changing the business code must NOT invalidate the identifier.
        for bc in ["ZZZ", "ABC", "001"] {
            let ci = format!("DE98{bc}09999999999");
            assert!(
                validate_creditor_id(&ci).is_ok(),
                "{ci} must stay valid — business code is not part of the checksum"
            );
        }
    }

    #[test]
    fn iban_style_checksum_is_rejected() {
        // Regression: applying the ISO 13616 (IBAN) rule to a CI accepts DE74
        // and rejects the canonical DE98. Neither may happen.
        // "DE74…" is what the old IBAN-style checksum wrongly accepted.
        assert!(validate_creditor_id(concat!("DE", "74", "ZZZ09999999999")).is_err());
        assert!(validate_creditor_id("DE98ZZZ09999999999").is_ok());
    }

    #[test]
    fn check_digits_roundtrip() {
        for (nid, cc) in [
            ("09999999999", "DE"),
            ("123456", "FR"),
            ("ZZZ00000000", "NL"),
            ("A1B2C3", "AT"),
        ] {
            let cd = creditor_id_check_digits(nid, cc);
            let ci = format!("{cc}{cd}ZZZ{nid}");
            assert!(
                validate_creditor_id(&ci).is_ok(),
                "generated {ci} must validate"
            );
        }
    }

    #[test]
    fn lowercase_normalised() {
        let ci = validate_creditor_id("de98zzz09999999999").unwrap();
        assert_eq!(ci.as_str(), "DE98ZZZ09999999999");
    }

    #[test]
    fn parts() {
        let ci = validate_creditor_id("DE98ZZZ09999999999").unwrap();
        assert_eq!(ci.country_code(), "DE");
        assert_eq!(ci.check_digits(), "98");
        assert_eq!(ci.business_code(), "ZZZ");
        assert_eq!(ci.national_id(), "09999999999");
    }

    #[test]
    fn too_short() {
        assert!(matches!(
            validate_creditor_id("DE98ZZ").unwrap_err(),
            CreditorIdError::InvalidLength { len: 6 }
        ));
    }

    #[test]
    fn the_country_must_be_a_real_country() {
        // Regression: the check digits are computed over the country code, so
        // a `ZZ` identifier is internally consistent and used to validate. The
        // registry is the only thing that rules it out.
        let forged = format!(
            "ZZ{}ZZZ09999999999",
            creditor_id_check_digits("09999999999", "ZZ")
        );
        assert!(matches!(
            validate_creditor_id(&forged),
            Err(CreditorIdError::InvalidCountryCode { .. })
        ));
        assert!(matches!(
            validate_creditor_id("1298ZZZ09999999999"),
            Err(CreditorIdError::InvalidCountryCode { .. })
        ));
        // Every SEPA country still works, including the 2025 joiners.
        for cc in ["DE", "FR", "NL", "AT", "RS", "MD", "AL", "MK", "ME"] {
            let cd = creditor_id_check_digits("0123456789", cc);
            assert!(
                validate_creditor_id(&format!("{cc}{cd}ZZZ0123456789")).is_ok(),
                "{cc} must be accepted"
            );
        }
    }

    #[test]
    fn invalid_check_digit() {
        // DE00 has wrong check digits (correct is DE98)
        assert!(matches!(
            validate_creditor_id("DE00ZZZ09999999999").unwrap_err(),
            CreditorIdError::InvalidChecksum { .. }
        ));
    }

    #[test]
    fn invalid_character_in_national_id() {
        // Contains a dash which is not alphanumeric
        let result = validate_creditor_id("DE98ZZZ0999-999");
        assert!(result.is_err());
    }

    #[test]
    fn from_str() {
        let ci: CreditorId = "DE98ZZZ09999999999".parse().unwrap();
        assert_eq!(ci.country_code(), "DE");
    }

    #[test]
    fn into_string() {
        let ci = validate_creditor_id("DE98ZZZ09999999999").unwrap();
        let s: String = ci.into();
        assert_eq!(s, "DE98ZZZ09999999999");
    }

    #[test]
    fn conversions_match_iban_and_bic() {
        // The three identifier types are used interchangeably in call sites, so
        // an inconsistent set of impls is a papercut in every one of them.
        let ci = CreditorId::try_from("DE98ZZZ09999999999".to_owned()).unwrap();
        assert_eq!(ci.len(), 18); // Deref to str

        let mut by_id = std::collections::HashMap::new();
        by_id.insert(ci.clone(), "Stadtwerke");
        // Borrow<str>: lookup without allocating a CreditorId.
        assert_eq!(by_id.get("DE98ZZZ09999999999"), Some(&"Stadtwerke"));

        let other = validate_creditor_id("AT61ZZZ01234567890").unwrap();
        assert!(other < ci); // Ord
    }

    #[cfg(feature = "serde")]
    #[test]
    fn serde_roundtrip() {
        let ci = validate_creditor_id("DE98ZZZ09999999999").unwrap();
        let json = serde_json::to_string(&ci).unwrap();
        assert_eq!(json, r#""DE98ZZZ09999999999""#);
        let back: CreditorId = serde_json::from_str(&json).unwrap();
        assert_eq!(back, ci);
    }

    #[cfg(feature = "serde")]
    #[test]
    fn serde_invalid_rejected() {
        let result: Result<CreditorId, _> = serde_json::from_str(r#""INVALID""#);
        assert!(result.is_err());
    }
}