kontochronik 0.4.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::{iban::Iban, util::canonical_amount};

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.
#[derive(Debug, Clone)]
pub struct FingerprintInput {
    pub account_iban: Iban,
    pub participant_iban: Option<Iban>,
    pub amount: Decimal,
    pub booking_date: Date,
    pub value_date: Date,
    /// 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,
            occurrence: _,
        } = self;
        [
            account_iban.as_str().to_owned(),
            participant_iban
                .as_ref()
                .map(|iban| iban.as_str().to_owned())
                .unwrap_or_default(),
            canonical_amount(*amount),
            booking_date.format(ISO_DATE_FMT).expect("ISO date"),
            value_date.format(ISO_DATE_FMT).expect("ISO date"),
        ]
        .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)))
    }
}

/// The string is not the hex form of a [`Fingerprint`].
#[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 {
        FingerprintInput {
            account_iban: "DE44 5001 0517 5407 3249 31".parse().unwrap(),
            participant_iban: None,
            amount: Decimal::new(-123_456, 2),
            booking_date: example_date(),
            value_date: example_date(),
            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\n0"
        );
        assert_eq!(
            Fingerprint::new(&example_input()).to_string(),
            "c9b67edb3728cb6c62d68c569cc76d0a"
        );

        // The counterparty enters the preimage in electronic form.
        let with_participant = FingerprintInput {
            participant_iban: Some("DE89370400440532013000".parse().unwrap()),
            ..example_input()
        };
        assert_eq!(
            with_participant.preimage(),
            "DE44500105175407324931\nDE89370400440532013000\n-1234.56\n2025-12-31\n2025-12-31\n0"
        );
    }

    #[test]
    fn valid_participant_ibans_distinguish_same_day_transfers() {
        // Ten identical contributions on the same day stay coupled to
        // the accounts they came from.
        let a = FingerprintInput {
            participant_iban: Some("DE89370400440532013000".parse().unwrap()),
            ..example_input()
        };
        let b = FingerprintInput {
            participant_iban: Some("DE75512108001245126199".parse().unwrap()),
            ..example_input()
        };
        assert_ne!(Fingerprint::new(&a), Fingerprint::new(&example_input()));
        assert_ne!(Fingerprint::new(&a), Fingerprint::new(&b));
    }

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

    #[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 occurrence_distinguishes_identical_entries() {
        let second = FingerprintInput {
            occurrence: 1,
            ..example_input()
        };
        assert_ne!(
            Fingerprint::new(&example_input()),
            Fingerprint::new(&second)
        );
    }
}