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;
use crate::iban::Iban;
const CSV_DELIMITER: u8 = b';';
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ArchiveRecord {
#[serde(rename = "Buchungstag", with = "german_date")]
pub booking_date: Date,
#[serde(rename = "Valutadatum", with = "german_date")]
pub value_date: Date,
#[serde(rename = "Betrag", with = "german_amount")]
pub amount: Decimal,
#[serde(rename = "Waehrung")]
pub currency: String,
#[serde(rename = "Name Zahlungsbeteiligter")]
pub participant_name: Option<String>,
#[serde(rename = "Verwendungszweck")]
pub purpose: Option<String>,
#[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 = "Glaeubiger ID")]
pub creditor_id: Option<String>,
#[serde(rename = "Mandatsreferenz")]
pub mandate_reference: Option<String>,
#[serde(rename = "Saldo nach Buchung", with = "optional_german_amount")]
pub balance_after_booking: Option<Decimal>,
#[serde(rename = "IBAN Auftragskonto")]
pub account_iban: Iban,
#[serde(rename = "BIC Auftragskonto")]
pub account_bic: Option<String>,
#[serde(rename = "Bankname Auftragskonto")]
pub account_bank_name: Option<String>,
#[serde(rename = "Fingerprint")]
pub fingerprint: Option<String>,
}
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)
}
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 {
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 {
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)
}
}
mod optional_german_amount {
use std::str::FromStr as _;
use rust_decimal::Decimal;
use serde::{Deserialize, Deserializer, Serializer};
use super::german_amount;
#[allow(clippy::ref_option)]
pub fn serialize<S>(decimal: &Option<Decimal>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match decimal {
Some(decimal) => german_amount::serialize(decimal, serializer),
None => serializer.serialize_str(""),
}
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Decimal>, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
let s = s.trim();
if s.is_empty() {
return Ok(None);
}
Decimal::from_str(&s.replace(',', "."))
.map(Some)
.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(),
participant_name: Some("Bäckerei Müller".to_owned()),
purpose: Some("Miete; Januar".to_owned()),
booking_text: "Kartenzahlung".to_owned(),
participant_iban: Some("DE02120300000000202051".to_owned()),
participant_bic: None,
creditor_id: None,
mandate_reference: None,
balance_after_booking: Some(Decimal::new(100_000, 2)),
account_iban: "DE44500105175407324931".parse().unwrap(),
account_bic: Some("GENODEM1GLS".to_owned()),
account_bank_name: Some("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 roundtrip_without_balance_and_account_metadata() {
let record = ArchiveRecord {
balance_after_booking: None,
account_bic: None,
account_bank_name: None,
..example_record()
};
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("archive.csv");
let records = vec![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;Valutadatum;Betrag;Waehrung;\
Name Zahlungsbeteiligter;Verwendungszweck;Buchungstext;\
IBAN Zahlungsbeteiligter;BIC Zahlungsbeteiligter;\
Glaeubiger ID;Mandatsreferenz;Saldo nach Buchung;\
IBAN Auftragskonto;BIC Auftragskonto;\
Bankname Auftragskonto;Fingerprint"
);
let row = lines.next().unwrap();
assert!(row.starts_with(
"02.01.2025;03.01.2025;-1234,56;EUR;Bäckerei Müller;\
\"Miete; Januar\";Kartenzahlung;DE02120300000000202051;;;;1000,00;"
));
}
}