use std::{
cmp::Reverse,
collections::{HashMap, HashSet},
fs,
path::Path,
};
use anyhow::{Context as _, bail};
use rust_decimal::Decimal;
use crate::{
archive::{self, ArchiveRecord},
fingerprint::{Fingerprint, FingerprintInput},
util::canonical_iban,
};
pub mod gls;
pub trait Importer {
fn parse(&self, bytes: &[u8]) -> anyhow::Result<Vec<ArchiveRecord>>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ImportSummary {
pub imported: usize,
pub duplicates: usize,
pub total: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VerifySummary {
pub total: usize,
pub missing_fingerprints: usize,
}
pub fn verify_archive<A: AsRef<Path>>(archive_path: A) -> anyhow::Result<VerifySummary> {
let archive_path = archive_path.as_ref();
let mut records = archive::read_archive(archive_path)?;
normalize_direction(&mut records);
let missing_fingerprints = records
.iter()
.filter(|record| record.fingerprint.is_none())
.count();
complete_and_verify_fingerprints(&mut records)
.with_context(|| format!("Archive {} is inconsistent", archive_path.display()))?;
ensure_single_account(records.iter())?;
ensure_single_currency(records.iter())?;
ensure_representable_amounts(records.iter())?;
Ok(VerifySummary {
total: records.len(),
missing_fingerprints,
})
}
#[allow(clippy::similar_names)]
pub fn import<I, D, A>(importer: &I, data: D, archive_path: A) -> anyhow::Result<ImportSummary>
where
I: Importer + ?Sized,
D: AsRef<Path>,
A: AsRef<Path>,
{
let data = data.as_ref();
let archive_path = archive_path.as_ref();
let mut records = if archive_path.exists() {
archive::read_archive(archive_path)?
} else {
vec![]
};
normalize_direction(&mut records);
complete_and_verify_fingerprints(&mut records)
.with_context(|| format!("Archive {} is inconsistent", archive_path.display()))?;
log::debug!("Read data {}", data.display());
let bytes =
fs::read(data).with_context(|| format!("Failed to read data {}", data.display()))?;
let mut incoming = importer
.parse(&bytes)
.with_context(|| format!("Failed to import {}", data.display()))?;
normalize_direction(&mut incoming);
ensure_single_account(records.iter().chain(incoming.iter()))?;
ensure_single_currency(records.iter().chain(incoming.iter()))?;
ensure_representable_amounts(records.iter().chain(incoming.iter()))?;
assign_fingerprints(&mut incoming);
let mut known: HashSet<_> = records
.iter()
.map(stored_fingerprint)
.collect::<anyhow::Result<_>>()?;
let mut imported = 0;
let mut duplicates = 0;
for record in incoming {
let fingerprint = stored_fingerprint(&record)?;
if known.insert(fingerprint) {
records.push(record);
imported += 1;
} else {
duplicates += 1;
}
}
records.sort_by_key(|record| Reverse(record.booking_date));
archive::write_archive(archive_path, &records)?;
let summary = ImportSummary {
imported,
duplicates,
total: records.len(),
};
log::debug!(
"Imported {} new entries ({} duplicates skipped), archive now holds {}",
summary.imported,
summary.duplicates,
summary.total
);
Ok(summary)
}
fn normalize_direction(records: &mut [ArchiveRecord]) {
let is_ascending = records
.first()
.zip(records.last())
.is_some_and(|(first, last)| first.booking_date < last.booking_date);
if is_ascending {
records.reverse();
}
}
fn fingerprint_input(record: &ArchiveRecord, occurrence: u32) -> FingerprintInput<'_> {
FingerprintInput {
account_iban: &record.account_iban,
participant_iban: record.participant_iban.as_deref(),
amount: record.amount,
booking_date: record.booking_date,
value_date: record.value_date,
purpose: record.purpose.as_deref().unwrap_or_default(),
occurrence,
}
}
fn assign_fingerprints(records: &mut [ArchiveRecord]) {
let mut occurrences: HashMap<String, u32> = HashMap::new();
for record in records.iter_mut() {
let key = fingerprint_input(record, 0).content_key();
let counter = occurrences.entry(key).or_insert(0);
let fingerprint = Fingerprint::new(&fingerprint_input(record, *counter));
record.fingerprint = Some(fingerprint.to_string());
*counter += 1;
}
}
fn complete_and_verify_fingerprints(records: &mut [ArchiveRecord]) -> anyhow::Result<()> {
let mut groups: HashMap<String, Vec<usize>> = HashMap::new();
for (index, record) in records.iter().enumerate() {
groups
.entry(fingerprint_input(record, 0).content_key())
.or_default()
.push(index);
}
for indices in groups.values() {
let mut unassigned = (0..u32::try_from(indices.len())?)
.map(|occurrence| {
Fingerprint::new(&fingerprint_input(&records[indices[0]], occurrence))
})
.collect::<Vec<_>>();
let mut missing = vec![];
for &index in indices {
let line = index + 2; match &records[index].fingerprint {
Some(stored) => {
let stored: Fingerprint = stored
.parse()
.with_context(|| format!("Line {line}: invalid fingerprint"))?;
let Some(position) = unassigned.iter().position(|fp| *fp == stored) else {
bail!(
"Line {line}: stored fingerprint {stored} does not match the entry's content"
);
};
unassigned.remove(position);
}
None => missing.push(index),
}
}
for (&index, fingerprint) in missing.iter().zip(&unassigned) {
records[index].fingerprint = Some(fingerprint.to_string());
}
}
Ok(())
}
fn stored_fingerprint(record: &ArchiveRecord) -> anyhow::Result<Fingerprint> {
let stored = record
.fingerprint
.as_deref()
.context("Entry without fingerprint")?;
Ok(stored.parse()?)
}
fn ensure_single_account<'a>(
records: impl Iterator<Item = &'a ArchiveRecord>,
) -> anyhow::Result<()> {
let ibans: HashSet<String> = records
.map(|record| canonical_iban(&record.account_iban))
.collect();
if ibans.len() > 1 {
bail!("An archive holds entries of a single bank account, found: {ibans:?}");
}
Ok(())
}
fn ensure_single_currency<'a>(
records: impl Iterator<Item = &'a ArchiveRecord>,
) -> anyhow::Result<()> {
let currencies: HashSet<&str> = records.map(|record| record.currency.as_str()).collect();
if currencies.len() > 1 {
bail!("An archive holds entries of a single currency, found: {currencies:?}");
}
Ok(())
}
fn ensure_representable_amounts<'a>(
mut records: impl Iterator<Item = &'a ArchiveRecord>,
) -> anyhow::Result<()> {
fn fits(amount: Decimal) -> bool {
amount.round_dp(2) == amount
}
if let Some(record) = records.find(|r| !fits(r.amount) || !fits(r.balance_after_booking)) {
bail!(
"Amounts must be exactly representable with two decimal places, \
found booking on {} with amount {} and balance {}",
record.booking_date,
record.amount,
record.balance_after_booking
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::fs;
use time::{Date, Month};
use super::*;
#[test]
fn import_is_independent_of_the_source_format() {
struct StaticImporter(Vec<ArchiveRecord>);
impl Importer for StaticImporter {
fn parse(&self, _bytes: &[u8]) -> anyhow::Result<Vec<ArchiveRecord>> {
Ok(self.0.clone())
}
}
fn record(day: u8, amount: Decimal) -> ArchiveRecord {
let date = Date::from_calendar_date(2025, Month::February, day).unwrap();
ArchiveRecord {
booking_date: date,
value_date: date,
amount,
currency: "EUR".to_owned(),
balance_after_booking: amount,
participant_name: None,
participant_iban: None,
participant_bic: None,
booking_text: "Gutschrift".to_owned(),
purpose: None,
creditor_id: None,
mandate_reference: None,
account_description: "Girokonto".to_owned(),
account_iban: "DE44500105175407324931".to_owned(),
account_bic: "GENODEM1GLS".to_owned(),
account_bank_name: "Testbank".to_owned(),
fingerprint: None,
}
}
let dir = tempfile::tempdir().unwrap();
let archive_path = dir.path().join("archive.csv");
let export = dir.path().join("export.anything");
fs::write(&export, b"opaque bytes").unwrap();
let importer = StaticImporter(vec![
record(2, Decimal::new(250, 2)),
record(1, Decimal::new(100, 2)),
]);
let summary = import(&importer, &export, &archive_path).unwrap();
assert_eq!(
summary,
ImportSummary {
imported: 2,
duplicates: 0,
total: 2
}
);
let summary = import(&importer, &export, &archive_path).unwrap();
assert_eq!(
summary,
ImportSummary {
imported: 0,
duplicates: 2,
total: 2
}
);
let summary = verify_archive(&archive_path).unwrap();
assert_eq!(
summary,
VerifySummary {
total: 2,
missing_fingerprints: 0
}
);
}
#[test]
fn verify_counts_missing_fingerprints_without_modifying_the_file() {
fn record(day: u8) -> ArchiveRecord {
let date = Date::from_calendar_date(2025, Month::March, day).unwrap();
ArchiveRecord {
booking_date: date,
value_date: date,
amount: Decimal::new(-100, 2),
currency: "EUR".to_owned(),
balance_after_booking: Decimal::new(900, 2),
participant_name: None,
participant_iban: None,
participant_bic: None,
booking_text: "Kartenzahlung".to_owned(),
purpose: None,
creditor_id: None,
mandate_reference: None,
account_description: "Girokonto".to_owned(),
account_iban: "DE44500105175407324931".to_owned(),
account_bic: "GENODEM1GLS".to_owned(),
account_bank_name: "Testbank".to_owned(),
fingerprint: None,
}
}
let dir = tempfile::tempdir().unwrap();
let archive_path = dir.path().join("archive.csv");
archive::write_archive(&archive_path, &[record(2), record(1)]).unwrap();
let before = fs::read(&archive_path).unwrap();
let summary = verify_archive(&archive_path).unwrap();
assert_eq!(
summary,
VerifySummary {
total: 2,
missing_fingerprints: 2
}
);
assert_eq!(fs::read(&archive_path).unwrap(), before);
}
}