sepa 0.1.0

SEPA payment utilities: IBAN validation (ISO 13616), BIC validation (ISO 9362), pain.008 direct-debit XML builder, pain.001 credit-transfer XML builder, and CAMT.054 notification types (ISO 20022). Zero I/O. No async. No domain assumptions.
Documentation
//! IBAN validation — ISO 13616-1:2007 mod-97 algorithm.
//!
//! Validates IBANs from any country following the ISO 13616 standard.
//! Commonly used in SEPA payments (eurozone) and internationally.
//!
//! ## Algorithm (ISO 13616-1 §5.3)
//!
//! 1. Remove whitespace, convert to uppercase.
//! 2. Check length: 15–34 characters.
//! 3. Move first 4 characters to the end.
//! 4. Replace each letter with its numeric value: A=10, B=11, …, Z=35.
//! 5. Compute the resulting large integer modulo 97.
//! 6. Valid if result == 1.
//!
//! ## Examples
//!
//! ```
//! use sepa::iban::{validate_iban, IbanError};
//!
//! assert!(validate_iban("DE89 3704 0044 0532 0130 00").is_ok());
//! assert!(validate_iban("NL91ABNA0417164300").is_ok());
//!
//! let err = validate_iban("DE89370400440532013001").unwrap_err();
//! assert!(matches!(err, IbanError::InvalidChecksum { .. }));
//!
//! // FromStr / parse
//! let iban: sepa::Iban = "DE89370400440532013000".parse().unwrap();
//! assert_eq!(iban.country_code(), "DE");
//! ```

use std::str::FromStr;

/// A validated IBAN.  Created only via [`validate_iban`] or [`Iban::from_str`].
///
/// The inner value is normalised: whitespace removed, uppercased.
/// Cannot be forged — all constructors validate the mod-97 checksum.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Iban(String);

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

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

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

impl std::fmt::Display for Iban {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        // Display in groups of 4: "DE89 3704 0044 ..."
        let s = &self.0;
        let mut first = true;
        for chunk in s.as_bytes().chunks(4) {
            if !first {
                write!(f, " ")?;
            }
            first = false;
            f.write_str(std::str::from_utf8(chunk).unwrap_or(""))?;
        }
        Ok(())
    }
}

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

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

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

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

impl FromStr for Iban {
    type Err = IbanError;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        validate_iban(s)
    }
}

impl TryFrom<&str> for Iban {
    type Error = IbanError;
    fn try_from(s: &str) -> Result<Self, Self::Error> {
        validate_iban(s)
    }
}

impl TryFrom<String> for Iban {
    type Error = IbanError;
    fn try_from(s: String) -> Result<Self, Self::Error> {
        validate_iban(&s)
    }
}

#[cfg(feature = "serde")]
impl serde::Serialize for Iban {
    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 Iban {
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
        let s = String::deserialize(d)?;
        validate_iban(&s).map_err(serde::de::Error::custom)
    }
}

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

/// Error returned when IBAN validation fails.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum IbanError {
    /// IBAN length is outside the valid range 15–34.
    #[error("IBAN length {len} is outside the valid range 15–34")]
    InvalidLength {
        /// The actual length that was rejected.
        len: usize,
    },

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

    /// Mod-97 checksum did not produce the expected remainder of 1.
    #[error("IBAN checksum mismatch (mod97 = {remainder}, expected 1)")]
    InvalidChecksum {
        /// The actual mod-97 remainder.
        remainder: u64,
    },
}

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

/// Validate an IBAN using the ISO 13616 mod-97 algorithm.
///
/// Accepts IBANs with or without spaces.  Input is uppercased before validation.
/// Returns the normalised [`Iban`] on success.
///
/// # Errors
///
/// Returns [`IbanError::InvalidLength`] when the input is not 15–34 characters,
/// [`IbanError::InvalidCharacter`] when a non-alphanumeric character is found,
/// or [`IbanError::InvalidChecksum`] when the mod-97 check fails.
///
/// # Examples
///
/// ```
/// use sepa::validate_iban;
///
/// let iban = validate_iban("DE89 3704 0044 0532 0130 00").unwrap();
/// assert_eq!(iban.as_str(), "DE89370400440532013000");
/// assert_eq!(iban.country_code(), "DE");
/// assert!(validate_iban("INVALID").is_err());
/// ```
#[must_use = "ignoring a validated IBAN loses the result"]
pub fn validate_iban(raw: &str) -> Result<Iban, IbanError> {
    let normalised: String = raw
        .chars()
        .filter(|c| !c.is_whitespace())
        .map(|c| c.to_ascii_uppercase())
        .collect();

    let len = normalised.len();
    if !(15..=34).contains(&len) {
        return Err(IbanError::InvalidLength { len });
    }

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

    let rearranged = format!("{}{}", &normalised[4..], &normalised[..4]);

    let numeric: String = rearranged
        .chars()
        .flat_map(|c| {
            if c.is_ascii_alphabetic() {
                let n = (c as u8 - b'A' + 10).to_string();
                n.chars().collect::<Vec<_>>()
            } else {
                vec![c]
            }
        })
        .collect();

    // Rolling mod-97 using groups of digits to avoid u64 overflow
    let mut remainder: u64 = 0;
    for ch in numeric.chars() {
        let digit = ch.to_digit(10).ok_or(IbanError::InvalidCharacter { ch })?;
        remainder = (remainder * 10 + u64::from(digit)) % 97;
    }

    if remainder == 1 {
        Ok(Iban(normalised))
    } else {
        Err(IbanError::InvalidChecksum { remainder })
    }
}

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

    #[test]
    fn de_iban_with_spaces() {
        let iban = validate_iban("DE89 3704 0044 0532 0130 00").unwrap();
        assert_eq!(iban.as_str(), "DE89370400440532013000");
        assert_eq!(iban.country_code(), "DE");
        assert_eq!(iban.check_digits(), "89");
    }

    #[test]
    fn de_iban_no_spaces() {
        assert!(validate_iban("DE89370400440532013000").is_ok());
    }

    #[test]
    fn de_iban_sparkasse() {
        assert!(validate_iban("DE29100500005001065004").is_ok());
    }

    #[test]
    fn nl_iban() {
        assert!(validate_iban("NL91ABNA0417164300").is_ok());
    }

    #[test]
    fn gb_iban() {
        assert!(validate_iban("GB29 NWBK 6016 1331 9268 19").is_ok());
    }

    #[test]
    fn at_iban() {
        assert!(validate_iban("AT611904300234573201").is_ok());
    }

    #[test]
    fn ch_iban() {
        assert!(validate_iban("CH5604835012345678009").is_ok());
    }

    #[test]
    fn lowercase_normalised() {
        assert!(validate_iban("de89370400440532013000").is_ok());
    }

    #[test]
    fn wrong_checksum() {
        let err = validate_iban("DE89370400440532013001").unwrap_err();
        assert!(matches!(err, IbanError::InvalidChecksum { .. }));
    }

    #[test]
    fn too_short() {
        assert!(matches!(
            validate_iban("DE89").unwrap_err(),
            IbanError::InvalidLength { len: 4 }
        ));
    }

    #[test]
    fn too_long() {
        let long = "DE".to_string() + &"1".repeat(33);
        assert!(matches!(
            validate_iban(&long).unwrap_err(),
            IbanError::InvalidLength { len: 35 }
        ));
    }

    #[test]
    fn empty() {
        assert!(matches!(
            validate_iban("").unwrap_err(),
            IbanError::InvalidLength { len: 0 }
        ));
    }

    #[test]
    fn special_chars_rejected() {
        assert!(matches!(
            validate_iban("DE89@70400440532013000").unwrap_err(),
            IbanError::InvalidCharacter { ch: '@' }
        ));
    }

    #[test]
    fn display_groups_of_four() {
        let iban = validate_iban("DE89370400440532013000").unwrap();
        assert_eq!(iban.to_string(), "DE89 3704 0044 0532 0130 00");
    }

    #[test]
    fn only_whitespace_rejected() {
        assert!(matches!(
            validate_iban("   ").unwrap_err(),
            IbanError::InvalidLength { len: 0 }
        ));
    }

    #[test]
    fn minimum_valid_length_norway() {
        assert!(validate_iban("NO9386011117947").is_ok());
    }

    #[test]
    fn from_str() {
        let iban: Iban = "DE89370400440532013000".parse().unwrap();
        assert_eq!(iban.as_str(), "DE89370400440532013000");
    }

    #[test]
    fn try_from_str() {
        let iban = Iban::try_from("DE89370400440532013000").unwrap();
        assert_eq!(iban.country_code(), "DE");
    }

    #[test]
    fn try_from_string() {
        let iban = Iban::try_from("DE89370400440532013000".to_owned()).unwrap();
        assert_eq!(iban.as_str(), "DE89370400440532013000");
    }

    #[test]
    fn into_string() {
        let iban = validate_iban("DE89370400440532013000").unwrap();
        let s: String = iban.into();
        assert_eq!(s, "DE89370400440532013000");
    }

    #[test]
    fn ord() {
        let a = validate_iban("AT611904300234573201").unwrap();
        let b = validate_iban("DE89370400440532013000").unwrap();
        assert!(a < b); // "AT..." < "DE..."
    }

    #[test]
    fn deref_to_str() {
        let iban = validate_iban("DE89370400440532013000").unwrap();
        assert_eq!(iban.len(), 22); // Deref to str
    }

    #[cfg(feature = "serde")]
    #[test]
    fn serde_roundtrip() {
        let iban = validate_iban("DE89370400440532013000").unwrap();
        let json = serde_json::to_string(&iban).unwrap();
        assert_eq!(json, r#""DE89370400440532013000""#);
        let back: Iban = serde_json::from_str(&json).unwrap();
        assert_eq!(back, iban);
    }

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