kontochronik 0.2.0

Long-Term archive for account transactions
Documentation
use std::{fmt, str::FromStr};

use rust_decimal::Decimal;
use thiserror::Error;
use time::{Date, format_description::FormatItem, macros::format_description};

use crate::util::{canonical_amount, canonical_iban, canonical_text};

const ISO_DATE_FMT: &[FormatItem<'static>] = format_description!("[year]-[month]-[day]");

/// Content-derived identity of a ledger entry.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Fingerprint(u128);

/// The entry fields a [`Fingerprint`] is derived from.
///
/// IBANs and purpose are taken as raw strings and canonicalized internally,
/// so callers don't have to validate them first.
#[derive(Debug, Clone, Copy)]
pub struct FingerprintInput<'a> {
    pub account_iban: &'a str,
    /// `None` and `Some("")` are equivalent.
    pub participant_iban: Option<&'a str>,
    pub amount: Decimal,
    pub booking_date: Date,
    pub value_date: Date,
    pub purpose: &'a str,
    /// 0-based index of this entry among the entries of the same source file
    /// whose content is identical.
    /// Distinguishes otherwise identical bookings,
    /// e.g. two equal card payments on the same day.
    pub occurrence: u32,
}

impl FingerprintInput<'_> {
    // The canonical form of all fields except `occurrence`, one per line.
    // Entries with the same content key are indistinguishable by content
    // and are only told apart by their occurrence counter.
    pub(crate) fn content_key(&self) -> String {
        let Self {
            account_iban,
            participant_iban,
            amount,
            booking_date,
            value_date,
            purpose,
            occurrence: _,
        } = self;
        [
            canonical_iban(account_iban),
            canonical_iban(participant_iban.unwrap_or_default()),
            canonical_amount(*amount),
            booking_date.format(ISO_DATE_FMT).expect("ISO date"),
            value_date.format(ISO_DATE_FMT).expect("ISO date"),
            canonical_text(purpose),
        ]
        .join("\n")
    }

    /// The exact UTF-8 text that gets hashed.
    fn preimage(&self) -> String {
        format!("{}\n{}", self.content_key(), self.occurrence)
    }
}

impl Fingerprint {
    #[must_use]
    pub fn new(input: &FingerprintInput<'_>) -> Self {
        let hash = blake3::hash(input.preimage().as_bytes());
        let mut bytes = [0u8; 16];
        bytes.copy_from_slice(&hash.as_bytes()[..16]);
        Self(u128::from_be_bytes(bytes))
    }
}

impl fmt::Display for Fingerprint {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{:032x}", self.0)
    }
}

impl FromStr for Fingerprint {
    type Err = ParseFingerprintError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let bytes = hex::decode(s)?;
        let arr: [u8; 16] = bytes.try_into().map_err(|_| Self::Err::Length)?;
        Ok(Self(u128::from_be_bytes(arr)))
    }
}

#[derive(Debug, Error)]
pub enum ParseFingerprintError {
    #[error("Hex string must be 16 bytes")]
    Length,
    #[error(transparent)]
    Hex(#[from] hex::FromHexError),
}

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

    fn example_date() -> Date {
        Date::from_ordinal_date(2025, 365).unwrap()
    }

    fn example_input() -> FingerprintInput<'static> {
        FingerprintInput {
            account_iban: "DE44 5001 0517 5407 3249 31",
            participant_iban: None,
            amount: Decimal::new(-123_456, 2),
            booking_date: example_date(),
            value_date: example_date(),
            purpose: "Rechnung 42",
            occurrence: 0,
        }
    }

    #[test]
    fn pinned_test_vector() {
        // The normative test vector.
        // If this test breaks, the algorithm changed — which breaks every existing archive.
        assert_eq!(
            example_input().preimage(),
            "DE44500105175407324931\n\n-1234.56\n2025-12-31\n2025-12-31\nRechnung 42\n0"
        );
        assert_eq!(
            Fingerprint::new(&example_input()).to_string(),
            "c957c585ea60aeb60eca163dca367a5a"
        );
    }

    #[test]
    fn parse_display_roundtrip() {
        let fingerprint = Fingerprint::new(&example_input());
        assert_eq!(
            fingerprint.to_string().parse::<Fingerprint>().unwrap(),
            fingerprint
        );
    }

    #[test]
    fn iban_is_canonicalized() {
        let compact = FingerprintInput {
            account_iban: "de44500105175407324931",
            ..example_input()
        };
        assert_eq!(
            Fingerprint::new(&compact),
            Fingerprint::new(&example_input())
        );
    }

    #[test]
    fn missing_and_empty_participant_iban_are_equivalent() {
        let empty = FingerprintInput {
            participant_iban: Some(""),
            ..example_input()
        };
        assert_eq!(Fingerprint::new(&empty), Fingerprint::new(&example_input()));
    }

    #[test]
    fn amount_scale_is_irrelevant() {
        let a = FingerprintInput {
            amount: Decimal::new(15, 1),
            ..example_input()
        };
        let b = FingerprintInput {
            amount: Decimal::new(1_500, 3),
            ..example_input()
        };
        assert_eq!(Fingerprint::new(&a), Fingerprint::new(&b));
    }

    #[test]
    fn purpose_distinguishes_entries() {
        // Two same-day transfers that differ only in their purpose text
        // must get different fingerprints.
        let a = FingerprintInput {
            purpose: "A",
            ..example_input()
        };
        let b = FingerprintInput {
            purpose: "B",
            ..example_input()
        };
        assert_ne!(Fingerprint::new(&a), Fingerprint::new(&b));
    }

    #[test]
    fn occurrence_distinguishes_identical_entries() {
        let second = FingerprintInput {
            occurrence: 1,
            ..example_input()
        };
        assert_ne!(
            Fingerprint::new(&example_input()),
            Fingerprint::new(&second)
        );
    }

    #[test]
    fn purpose_is_trimmed_and_newlines_normalized() {
        let messy = FingerprintInput {
            purpose: " Rechnung 42\r\n",
            ..example_input()
        };
        assert_eq!(Fingerprint::new(&messy), Fingerprint::new(&example_input()));
    }
}