kontochronik 0.2.0

Long-Term archive for account transactions
Documentation
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 {
    /// Decodes the raw bytes of a file into archive records.
    fn parse(&self, bytes: &[u8]) -> anyhow::Result<Vec<ArchiveRecord>>;
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ImportSummary {
    /// Entries newly added to the archive.
    pub imported: usize,
    /// Entries of the source file that were already archived.
    pub duplicates: usize,
    /// Entries in the archive after the import.
    pub total: usize,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VerifySummary {
    /// Entries in the archive.
    pub total: usize,
    /// Entries without a stored fingerprint (hand-added rows);
    /// the next import will fill them in.
    pub missing_fingerprints: usize,
}

/// Verifies the archive at `archive_path` without modifying it:
/// every stored fingerprint must match its entry's content, and the
/// archive must hold a single account and currency with amounts
/// representable in two decimal places.
/// Entries without a fingerprint are legal and only counted.
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,
    })
}

/// Imports a file into the archive at `archive_path`, creating
/// the archive if it doesn't exist yet.
/// The `importer` decodes the data format.
/// Entries whose fingerprint is already archived are skipped,
/// so overlapping export date ranges are safe.
#[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;
        }
    }

    // Stable, newest first:
    // existing rows and, within a day, the bank's order stay untouched.
    // A fresh import forms one contiguous block right below the header.
    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)
}

/// The archive is ordered newest first, like the online-banking export;
/// oldest-first files are flipped. Detected via first vs. last row.
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;
    }
}

/// Ensures every archive row carries the fingerprint for its content.
/// Missing fingerprints (hand-added rows) are filled in;
/// mismatches abort, since they indicate edited data or algorithm drift.
/// Groups of identical-content rows are checked set-wise against the
/// occurrence counters 0..n.
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; // 1-based, after the header line
            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(())
}

// The archive format fixes amounts to exactly two decimal places.
// Currencies with three decimal places would silently lose data.
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::*;

    /// The merge only ever sees [`ArchiveRecord`]s
    /// Any [`Importer`] implementation can feed the archive.
    #[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);
    }
}