kontochronik 0.2.0

Long-Term archive for account transactions
Documentation
//! The archive file

use std::path::Path;

use anyhow::Context as _;
use csv::{ReaderBuilder, WriterBuilder};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use tempfile::NamedTempFile;
use time::Date;

const CSV_DELIMITER: u8 = b';';

/// One archived booking (one line in the archive file).
///
/// Field order defines the column order of the file
/// and is frozen per format version.
/// Structural changes are a deliberate version bump
/// with a one-time file migration.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ArchiveRecord {
    #[serde(rename = "Buchungstag", with = "german_date")]
    pub booking_date: Date,

    #[serde(rename = "Betrag", with = "german_amount")]
    pub amount: Decimal,

    #[serde(rename = "Name Zahlungsbeteiligter")]
    pub participant_name: Option<String>,

    #[serde(rename = "Verwendungszweck")]
    pub purpose: Option<String>,

    #[serde(rename = "Saldo nach Buchung", with = "german_amount")]
    pub balance_after_booking: Decimal,

    #[serde(rename = "Buchungstext")]
    pub booking_text: String,

    #[serde(rename = "IBAN Zahlungsbeteiligter")]
    pub participant_iban: Option<String>,

    #[serde(rename = "BIC Zahlungsbeteiligter")]
    pub participant_bic: Option<String>,

    #[serde(rename = "Valutadatum", with = "german_date")]
    pub value_date: Date,

    #[serde(rename = "Waehrung")]
    pub currency: String,

    #[serde(rename = "Glaeubiger ID")]
    pub creditor_id: Option<String>,

    #[serde(rename = "Mandatsreferenz")]
    pub mandate_reference: Option<String>,

    #[serde(rename = "Bezeichnung Auftragskonto")]
    pub account_description: String,

    #[serde(rename = "IBAN Auftragskonto")]
    pub account_iban: String,

    #[serde(rename = "BIC Auftragskonto")]
    pub account_bic: String,

    #[serde(rename = "Bankname Auftragskonto")]
    pub account_bank_name: String,

    #[serde(rename = "Fingerprint")]
    pub fingerprint: Option<String>,
}

/// Reads all records of an archive file. The file must be UTF-8.
pub fn read_archive<P: AsRef<Path>>(path: P) -> anyhow::Result<Vec<ArchiveRecord>> {
    let path = path.as_ref();
    log::debug!("Read archive {}", path.display());
    let mut rdr = ReaderBuilder::new()
        .delimiter(CSV_DELIMITER)
        .from_path(path)?;
    let mut records = vec![];
    for result in rdr.deserialize() {
        let record: ArchiveRecord =
            result.with_context(|| format!("Invalid archive file {}", path.display()))?;
        records.push(record);
    }
    Ok(records)
}

/// Writes the archive atomically: into a temporary file in the target
/// directory first, fsynced, then renamed over the destination.
pub fn write_archive<P: AsRef<Path>>(path: P, records: &[ArchiveRecord]) -> anyhow::Result<()> {
    let path = path.as_ref();
    log::debug!("Write archive {}", path.display());
    let dir = match path.parent() {
        Some(parent) if !parent.as_os_str().is_empty() => parent,
        _ => Path::new("."),
    };
    let tempfile = NamedTempFile::new_in(dir)?;
    let mut wtr = WriterBuilder::new()
        .delimiter(CSV_DELIMITER)
        .from_writer(tempfile.reopen()?);
    for record in records {
        wtr.serialize(record)?;
    }
    wtr.flush()?;
    drop(wtr);
    tempfile.as_file().sync_all()?;
    tempfile.persist(path)?;
    Ok(())
}

mod german_date {
    //! `TT.MM.JJJJ`

    use serde::{Deserialize, Deserializer, Serializer};
    use time::{Date, format_description::FormatItem, macros::format_description};

    const DATE_FMT: &[FormatItem<'static>] = format_description!("[day].[month].[year]");

    #[allow(clippy::trivially_copy_pass_by_ref)]
    pub fn serialize<S>(date: &Date, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        let s = date.format(DATE_FMT).map_err(serde::ser::Error::custom)?;
        serializer.serialize_str(&s)
    }

    pub fn deserialize<'de, D>(deserializer: D) -> Result<Date, D::Error>
    where
        D: Deserializer<'de>,
    {
        let s = String::deserialize(deserializer)?;
        Date::parse(&s, DATE_FMT).map_err(serde::de::Error::custom)
    }
}

mod german_amount {
    //! `-1234,56`

    use std::str::FromStr as _;

    use rust_decimal::Decimal;
    use serde::{Deserialize, Deserializer, Serializer};

    use crate::util::canonical_amount;

    pub fn serialize<S>(decimal: &Decimal, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        serializer.serialize_str(&canonical_amount(*decimal).replace('.', ","))
    }

    pub fn deserialize<'de, D>(deserializer: D) -> Result<Decimal, D::Error>
    where
        D: Deserializer<'de>,
    {
        let s = String::deserialize(deserializer)?;
        Decimal::from_str(&s.trim().replace(',', ".")).map_err(serde::de::Error::custom)
    }
}

#[cfg(test)]
mod tests {
    use time::Month;

    use super::*;

    fn example_record() -> ArchiveRecord {
        ArchiveRecord {
            booking_date: Date::from_calendar_date(2025, Month::January, 2).unwrap(),
            value_date: Date::from_calendar_date(2025, Month::January, 3).unwrap(),
            amount: Decimal::new(-123_456, 2),
            currency: "EUR".to_owned(),
            balance_after_booking: Decimal::new(100_000, 2),
            participant_name: Some("Bäckerei Müller".to_owned()),
            participant_iban: Some("DE02120300000000202051".to_owned()),
            participant_bic: None,
            booking_text: "Kartenzahlung".to_owned(),
            purpose: Some("Miete; Januar".to_owned()),
            creditor_id: None,
            mandate_reference: None,
            account_description: "Girokonto".to_owned(),
            account_iban: "DE44500105175407324931".to_owned(),
            account_bic: "GENODEM1GLS".to_owned(),
            account_bank_name: "GLS Gemeinschaftsbank eG".to_owned(),
            fingerprint: Some("00112233445566778899aabbccddeeff".to_owned()),
        }
    }

    #[test]
    fn roundtrip() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("archive.csv");
        let records = vec![example_record()];

        write_archive(&path, &records).unwrap();
        let read_back = read_archive(&path).unwrap();

        assert_eq!(read_back, records);
    }

    #[test]
    fn file_format_is_spec_conform() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("archive.csv");
        write_archive(&path, &[example_record()]).unwrap();

        let content = std::fs::read_to_string(&path).unwrap();
        let mut lines = content.lines();
        assert_eq!(
            lines.next().unwrap(),
            "Buchungstag;Betrag;Name Zahlungsbeteiligter;Verwendungszweck;\
                 Saldo nach Buchung;Buchungstext;\
                 IBAN Zahlungsbeteiligter;BIC Zahlungsbeteiligter;\
                 Valutadatum;Waehrung;Glaeubiger ID;Mandatsreferenz;\
                 Bezeichnung Auftragskonto;IBAN Auftragskonto;BIC Auftragskonto;\
                 Bankname Auftragskonto;Fingerprint"
        );
        let row = lines.next().unwrap();
        // The semicolon in the purpose forces quoting.
        assert!(row.starts_with(
            "02.01.2025;-1234,56;Bäckerei Müller;\"Miete; Januar\";1000,00;\
                 Kartenzahlung;DE02120300000000202051;;03.01.2025;EUR;"
        ));
    }
}