kontochronik 0.4.0

Long-Term archive for account transactions
Documentation
use anyhow::bail;
use rust_decimal::Decimal;

pub(crate) fn canonical_iban(s: &str) -> String {
    s.chars()
        .filter(|c| !c.is_whitespace())
        .map(|c| c.to_ascii_uppercase())
        .collect()
}

pub(crate) fn canonical_amount(amount: Decimal) -> String {
    let mut normalized = amount.round_dp(2);
    if normalized.is_zero() {
        normalized = Decimal::ZERO;
    }
    normalized.rescale(2);
    normalized.to_string()
}

/// An all-zero value (e.g. "0" or "0000000000") in an account column
/// is a placeholder for "no counterparty account", not an account:
/// banks render cash withdrawals and similar bookings this way.
/// Zero-padded real account numbers ("0123...") contain other digits
/// and are left alone.
pub(crate) fn is_zero_placeholder(s: &str) -> bool {
    !s.is_empty() && s.chars().all(|c| c == '0')
}

pub(crate) fn canonical_text(s: &str) -> String {
    s.replace(['\r', '\n'], " ").trim().to_owned()
}

pub(crate) fn clean_optional(value: Option<String>) -> Option<String> {
    value.map(|s| canonical_text(&s)).filter(|s| !s.is_empty())
}

/// UTF-8 (with or without BOM) with a Windows-1252 fallback,
/// the traditional encoding of German bank exports.
pub(crate) fn read_to_utf8(bytes: &[u8]) -> anyhow::Result<String> {
    if bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]) {
        bail!("UTF-16 encoded files are not supported");
    }
    let bytes = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
    Ok(match std::str::from_utf8(bytes) {
        Ok(s) => s.to_owned(),
        Err(_) => decode_windows_1252(bytes),
    })
}

fn decode_windows_1252(bytes: &[u8]) -> String {
    bytes.iter().map(|&byte| windows_1252_char(byte)).collect()
}

fn windows_1252_char(byte: u8) -> char {
    // Only 0x80..=0x9F differ from Latin-1; every other byte maps to U+00XX.
    // The five undefined code points keep their Latin-1 interpretation.
    match byte {
        0x80 => '',
        0x82 => '',
        0x83 => 'ƒ',
        0x84 => '',
        0x85 => '',
        0x86 => '',
        0x87 => '',
        0x88 => 'ˆ',
        0x89 => '',
        0x8A => 'Š',
        0x8B => '',
        0x8C => 'Œ',
        0x8E => 'Ž',
        0x91 => '',
        0x92 => '',
        0x93 => '',
        0x94 => '',
        0x95 => '',
        0x96 => '',
        0x97 => '',
        0x98 => '˜',
        0x99 => '',
        0x9A => 'š',
        0x9B => '',
        0x9C => 'œ',
        0x9E => 'ž',
        0x9F => 'Ÿ',
        _ => char::from(byte),
    }
}

pub(crate) mod german_number {
    //! Serde helper for amounts as the banks export them, e.g. `-1.234,56`.

    use std::str::FromStr as _;

    use rust_decimal::Decimal;
    use serde::{Deserialize, Deserializer};

    /// Liberal on purpose: strips thousands separators and blanks,
    /// so it copes with whatever number formatting the bank uses.
    fn normalize_german_number(s: &str) -> String {
        s.trim().replace([' ', '.'], "").replace(',', ".")
    }

    pub fn deserialize<'de, D>(deserializer: D) -> Result<Decimal, D::Error>
    where
        D: Deserializer<'de>,
    {
        let raw = String::deserialize(deserializer)?;
        let norm = normalize_german_number(&raw);
        Decimal::from_str(&norm).map_err(serde::de::Error::custom)
    }
}

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

    #[test]
    fn only_all_zero_values_are_placeholders() {
        assert!(is_zero_placeholder("0"));
        assert!(is_zero_placeholder("0000000000"));
        assert!(!is_zero_placeholder(""));
        assert!(!is_zero_placeholder("0123456789"));
        assert!(!is_zero_placeholder("DE44500105175407324931"));
    }

    #[test]
    fn zero_amount_is_canonicalized() {
        assert_eq!(canonical_amount(Decimal::ZERO), "0.00");
        assert_eq!(canonical_amount("-0.00".parse().unwrap()), "0.00");
        assert_eq!(canonical_amount(Decimal::new(-350, 2)), "-3.50");
    }

    #[test]
    fn utf8_passes_through_and_bom_is_stripped() {
        assert_eq!(read_to_utf8("Bäckerei".as_bytes()).unwrap(), "Bäckerei");
        assert_eq!(
            read_to_utf8(b"\xEF\xBB\xBFB\xC3\xA4ckerei").unwrap(),
            "Bäckerei"
        );
    }

    #[test]
    fn windows_1252_is_decoded() {
        assert_eq!(
            read_to_utf8(b"B\xE4ckerei M\xFCller").unwrap(),
            "Bäckerei Müller"
        );
        assert_eq!(read_to_utf8(b"5 \x80").unwrap(), "5 €");
    }

    #[test]
    fn utf16_is_rejected() {
        assert!(read_to_utf8(b"\xFF\xFEB\x00").is_err());
    }
}