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]");
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Fingerprint(u128);
#[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,
pub occurrence: u32,
}
impl FingerprintInput {
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")
}
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 {
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() {
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"
);
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() {
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)
);
}
}