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]");
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Fingerprint(u128);
#[derive(Debug, Clone, Copy)]
pub struct FingerprintInput<'a> {
pub account_iban: &'a str,
pub participant_iban: Option<&'a str>,
pub amount: Decimal,
pub booking_date: Date,
pub value_date: Date,
pub purpose: &'a str,
pub occurrence: u32,
}
impl FingerprintInput<'_> {
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")
}
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() {
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() {
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()));
}
}