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},
iban::Iban,
util::canonical_iban,
};
mod gls;
mod sparkasse;
pub use self::{gls::parse_gls, sparkasse::parse_sparkasse};
#[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 missing_balances: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GapPolicy {
Reject,
Accept,
}
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);
warn_about_implausible_ibans(&records);
let missing_fingerprints = records
.iter()
.filter(|record| record.fingerprint.is_none())
.count();
let missing_balances = records
.iter()
.filter(|record| record.balance_after_booking.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())?;
ensure_balance_chain(&records)?;
Ok(VerifySummary {
total: records.len(),
missing_fingerprints,
missing_balances,
})
}
fn ensure_balance_chain(records: &[ArchiveRecord]) -> anyhow::Result<()> {
let mut days: Vec<&[ArchiveRecord]> = records
.chunk_by(|a, b| a.booking_date == b.booking_date)
.collect();
days.reverse();
let mut closing: Option<Decimal> = None;
for day in days {
let Some(previous) = closing else {
closing = day.iter().find_map(|record| record.balance_after_booking);
continue;
};
let amounts: Decimal = day.iter().map(|record| record.amount).sum();
let expected = previous + amounts;
let stored: Vec<Decimal> = day
.iter()
.filter_map(|record| record.balance_after_booking)
.collect();
if !stored.is_empty() && !stored.contains(&expected) {
bail!(
"Balance chain broken on {}: the previous closing balance {} \
plus the day's amounts {} gives {}, but no booking on that \
day has this balance",
day[0].booking_date,
previous,
amounts,
expected
);
}
closing = Some(expected);
}
Ok(())
}
pub fn import<P, D, A>(
parse: P,
data: D,
archive_path: A,
gap_policy: GapPolicy,
) -> anyhow::Result<ImportSummary>
where
P: FnOnce(&[u8]) -> anyhow::Result<Vec<ArchiveRecord>>,
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 =
parse(&bytes).with_context(|| format!("Failed to import {}", data.display()))?;
normalize_direction(&mut incoming);
warn_about_implausible_ibans(&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 archive_was_empty = records.is_empty();
let incoming_total = incoming.len();
let incoming_carries_balances = incoming
.iter()
.any(|record| record.balance_after_booking.is_some());
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;
}
}
if !archive_was_empty && incoming_total > 0 && !incoming_carries_balances && duplicates == 0 {
match gap_policy {
GapPolicy::Reject => bail!(
"The file does not overlap the archive: none of its {incoming_total} \
entries is already archived, and without balances a gapless \
continuation cannot be proven. Export a longer range that reaches \
back to an archived entry, or, if the missing range can no longer \
be exported, explicitly accept a permanent gap."
),
GapPolicy::Accept => {
log::warn!("Accepted a possible gap: the file does not overlap the archive");
}
}
}
records.sort_by_key(|record| Reverse(record.booking_date));
ensure_balance_chain(&records)?;
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 {
let participant_iban = record
.participant_iban
.as_deref()
.and_then(|raw| raw.parse().ok());
FingerprintInput {
account_iban: record.account_iban.clone(),
participant_iban,
amount: record.amount,
booking_date: record.booking_date,
value_date: record.value_date,
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 warn_about_implausible_ibans(records: &[ArchiveRecord]) {
let mut seen = HashSet::new();
for record in records {
let Some(value) = record.participant_iban.as_deref() else {
continue;
};
let canonical = canonical_iban(value);
if implausible_iban(&canonical) && seen.insert(canonical.clone()) {
log::warn!("IBAN {canonical} fails its checksum - possibly corrupted data");
}
}
}
fn implausible_iban(canonical: &str) -> bool {
let mut chars = canonical.chars();
let country = chars.by_ref().take(2).all(|c| c.is_ascii_alphabetic());
let check = chars.by_ref().take(2).all(|c| c.is_ascii_digit());
canonical.len() >= 4 && country && check && canonical.parse::<Iban>().is_err()
}
fn ensure_single_account<'a>(
records: impl Iterator<Item = &'a ArchiveRecord>,
) -> anyhow::Result<()> {
let ibans: HashSet<&str> = records.map(|record| record.account_iban.as_str()).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)
|| r.balance_after_booking
.is_some_and(|balance| !fits(balance))
}) {
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
.map_or_else(|| "(none)".to_owned(), |balance| balance.to_string())
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::fs;
use time::{Date, Month};
use super::*;
fn record(day: u8, amount: Decimal, balance_after_booking: Option<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(),
participant_name: None,
purpose: None,
booking_text: "Gutschrift".to_owned(),
participant_iban: None,
participant_bic: None,
creditor_id: None,
mandate_reference: None,
balance_after_booking,
account_iban: "DE44500105175407324931".parse().unwrap(),
account_bic: Some("GENODEM1GLS".to_owned()),
account_bank_name: Some("Testbank".to_owned()),
fingerprint: None,
}
}
fn static_importer(
records: Vec<ArchiveRecord>,
) -> impl Fn(&[u8]) -> anyhow::Result<Vec<ArchiveRecord>> {
move |_bytes| Ok(records.clone())
}
#[test]
fn only_valid_participant_ibans_reach_the_fingerprint() {
let mut entry = record(1, Decimal::new(100, 2), None);
for junk in ["0", "0000000000", "0100433182", "DE70000000000000000099"] {
entry.participant_iban = Some(junk.to_owned());
assert_eq!(fingerprint_input(&entry, 0).participant_iban, None);
}
entry.participant_iban = Some("DE89 3704 0044 0532 0130 00".to_owned());
assert!(fingerprint_input(&entry, 0).participant_iban.is_some());
}
#[test]
fn iban_plausibility_is_a_warning_heuristic() {
assert!(!implausible_iban("DE89370400440532013000"));
assert!(!implausible_iban("106531065")); assert!(!implausible_iban(""));
assert!(implausible_iban("DE70000000000000000099"));
assert!(implausible_iban("DE89370400440532013001"));
}
#[test]
fn import_is_independent_of_the_source_format() {
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 = static_importer(vec![
record(2, Decimal::new(250, 2), Some(Decimal::new(350, 2))),
record(1, Decimal::new(100, 2), Some(Decimal::new(100, 2))),
]);
let summary = import(&importer, &export, &archive_path, GapPolicy::Reject).unwrap();
assert_eq!(
summary,
ImportSummary {
imported: 2,
duplicates: 0,
total: 2
}
);
let summary = import(&importer, &export, &archive_path, GapPolicy::Reject).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,
missing_balances: 0
}
);
}
#[test]
fn balance_less_imports_must_overlap_the_archive() {
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 first = static_importer(vec![
record(2, Decimal::new(250, 2), None),
record(1, Decimal::new(100, 2), None),
]);
import(&first, &export, &archive_path, GapPolicy::Reject).unwrap();
let before = fs::read(&archive_path).unwrap();
let disconnected = static_importer(vec![record(4, Decimal::new(-100, 2), None)]);
let error = import(&disconnected, &export, &archive_path, GapPolicy::Reject).unwrap_err();
assert!(format!("{error:#}").contains("does not overlap"));
assert_eq!(fs::read(&archive_path).unwrap(), before);
let summary = import(&disconnected, &export, &archive_path, GapPolicy::Accept).unwrap();
assert_eq!(summary.imported, 1);
let overlapping = static_importer(vec![
record(5, Decimal::new(-50, 2), None),
record(4, Decimal::new(-100, 2), None),
]);
let summary = import(&overlapping, &export, &archive_path, GapPolicy::Reject).unwrap();
assert_eq!(summary.imported, 1);
let summary = verify_archive(&archive_path).unwrap();
assert_eq!(
summary,
VerifySummary {
total: 4,
missing_fingerprints: 0,
missing_balances: 4
}
);
}
#[test]
fn import_rejects_a_broken_balance_chain() {
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 = static_importer(vec![
record(3, Decimal::new(-100, 2), Some(Decimal::new(500, 2))),
record(1, Decimal::new(-100, 2), Some(Decimal::new(900, 2))),
]);
let error = import(&importer, &export, &archive_path, GapPolicy::Reject).unwrap_err();
assert!(format!("{error:#}").contains("Balance chain broken on 2025-02-03"));
assert!(!archive_path.exists());
}
#[test]
fn verify_counts_missing_fingerprints_without_modifying_the_file() {
let dir = tempfile::tempdir().unwrap();
let archive_path = dir.path().join("archive.csv");
let records = [
record(2, Decimal::new(-100, 2), Some(Decimal::new(800, 2))),
record(1, Decimal::new(-100, 2), Some(Decimal::new(900, 2))),
];
archive::write_archive(&archive_path, &records).unwrap();
let before = fs::read(&archive_path).unwrap();
let summary = verify_archive(&archive_path).unwrap();
assert_eq!(
summary,
VerifySummary {
total: 2,
missing_fingerprints: 2,
missing_balances: 0
}
);
assert_eq!(fs::read(&archive_path).unwrap(), before);
}
#[test]
fn verify_detects_a_broken_balance_chain() {
let dir = tempfile::tempdir().unwrap();
let archive_path = dir.path().join("archive.csv");
let records = [
record(3, Decimal::new(-100, 2), Some(Decimal::new(500, 2))),
record(1, Decimal::new(-100, 2), Some(Decimal::new(900, 2))),
];
archive::write_archive(&archive_path, &records).unwrap();
let error = verify_archive(&archive_path).unwrap_err();
assert!(format!("{error:#}").contains("Balance chain broken on 2025-02-03"));
}
#[test]
fn verify_accepts_days_without_bookings() {
let dir = tempfile::tempdir().unwrap();
let archive_path = dir.path().join("archive.csv");
let records = [
record(3, Decimal::new(-100, 2), Some(Decimal::new(800, 2))),
record(1, Decimal::new(-100, 2), Some(Decimal::new(900, 2))),
];
archive::write_archive(&archive_path, &records).unwrap();
assert!(verify_archive(&archive_path).is_ok());
}
#[test]
fn verify_tolerates_reordered_bookings_within_a_day() {
let dir = tempfile::tempdir().unwrap();
let archive_path = dir.path().join("archive.csv");
let records = [
record(2, Decimal::new(-100, 2), Some(Decimal::new(800, 2))),
record(2, Decimal::new(-100, 2), Some(Decimal::new(700, 2))),
record(1, Decimal::new(-100, 2), Some(Decimal::new(900, 2))),
];
archive::write_archive(&archive_path, &records).unwrap();
let summary = verify_archive(&archive_path).unwrap();
assert_eq!(summary.total, 3);
}
#[test]
fn verify_accepts_an_archive_without_balances() {
let dir = tempfile::tempdir().unwrap();
let archive_path = dir.path().join("archive.csv");
let records = [
record(3, Decimal::new(-100, 2), None),
record(1, Decimal::new(-100, 2), None),
];
archive::write_archive(&archive_path, &records).unwrap();
let summary = verify_archive(&archive_path).unwrap();
assert_eq!(summary.missing_balances, 2);
}
#[test]
fn chain_bridges_balance_less_rows() {
let dir = tempfile::tempdir().unwrap();
let archive_path = dir.path().join("archive.csv");
let consistent = [
record(3, Decimal::new(-100, 2), Some(Decimal::new(700, 2))),
record(2, Decimal::new(-100, 2), None),
record(1, Decimal::new(-100, 2), Some(Decimal::new(900, 2))),
];
archive::write_archive(&archive_path, &consistent).unwrap();
assert!(verify_archive(&archive_path).is_ok());
let broken = [
record(3, Decimal::new(-100, 2), Some(Decimal::new(500, 2))),
record(2, Decimal::new(-100, 2), None),
record(1, Decimal::new(-100, 2), Some(Decimal::new(900, 2))),
];
archive::write_archive(&archive_path, &broken).unwrap();
let error = verify_archive(&archive_path).unwrap_err();
assert!(format!("{error:#}").contains("Balance chain broken on 2025-02-03"));
}
}