use anyhow::{Context as _, bail};
use csv::ReaderBuilder;
use rust_decimal::Decimal;
use serde::Deserialize;
use time::Date;
use crate::{
archive::ArchiveRecord,
util::{canonical_text, clean_optional, is_zero_placeholder, read_to_utf8},
};
const CSV_DELIMITER: u8 = b';';
const INFO_BOOKED: &str = "Umsatz gebucht";
const INFO_PENDING: &str = "Umsatz vorgemerkt";
#[derive(Debug, Deserialize)]
struct CamtRecord {
#[serde(rename = "Auftragskonto")]
account_iban: String,
#[serde(rename = "Buchungstag", with = "short_german_date")]
booking_date: Date,
#[serde(rename = "Valutadatum", with = "short_german_date")]
value_date: Date,
#[serde(rename = "Buchungstext")]
booking_text: String,
#[serde(rename = "Verwendungszweck")]
purpose: Option<String>,
#[serde(rename = "Glaeubiger ID")]
creditor_id: Option<String>,
#[serde(rename = "Mandatsreferenz")]
mandate_reference: Option<String>,
#[serde(rename = "Beguenstigter/Zahlungspflichtiger")]
participant_name: Option<String>,
#[serde(rename = "Kontonummer/IBAN")]
participant_iban: Option<String>,
#[serde(rename = "BIC (SWIFT-Code)")]
participant_bic: Option<String>,
#[serde(rename = "Betrag", with = "crate::util::german_number")]
amount: Decimal,
#[serde(rename = "Waehrung")]
currency: String,
#[serde(rename = "Info")]
info: String,
#[serde(rename = "Kategorie", default)]
category: Option<String>,
}
impl TryFrom<CamtRecord> for ArchiveRecord {
type Error = anyhow::Error;
fn try_from(row: CamtRecord) -> Result<Self, Self::Error> {
let CamtRecord {
account_iban,
booking_date,
value_date,
booking_text,
purpose,
creditor_id,
mandate_reference,
participant_name,
participant_iban,
participant_bic,
amount,
currency,
info: _,
category,
} = row;
if let Some(category) = clean_optional(category) {
bail!(
"\"Kategorie\" contains data ({category:?}) - portal annotations are not archived"
);
}
Ok(Self {
booking_date,
value_date,
amount,
currency: canonical_text(¤cy),
participant_name: clean_optional(participant_name),
purpose: clean_optional(purpose),
booking_text: canonical_text(&booking_text),
participant_iban: clean_optional(participant_iban)
.filter(|iban| !is_zero_placeholder(iban)),
participant_bic: clean_optional(participant_bic),
creditor_id: clean_optional(creditor_id),
mandate_reference: clean_optional(mandate_reference),
balance_after_booking: None,
account_iban: account_iban
.parse()
.with_context(|| format!("Invalid \"Auftragskonto\" {account_iban:?}"))?,
account_bic: None,
account_bank_name: None,
fingerprint: None,
})
}
}
const CAMT_HEADERS: [&str; 18] = [
"Auftragskonto",
"Buchungstag",
"Valutadatum",
"Buchungstext",
"Verwendungszweck",
"Glaeubiger ID",
"Mandatsreferenz",
"Kundenreferenz (End-to-End)",
"Sammlerreferenz",
"Lastschrift Ursprungsbetrag",
"Auslagenersatz Ruecklastschrift",
"Beguenstigter/Zahlungspflichtiger",
"Kontonummer/IBAN",
"BIC (SWIFT-Code)",
"Betrag",
"Waehrung",
"Info",
"Kategorie",
];
#[derive(Debug, Deserialize)]
struct Mt940Record {
#[serde(rename = "Auftragskonto")]
account_iban: String,
#[serde(rename = "Buchungstag")]
booking_date: String,
#[serde(rename = "Valutadatum")]
value_date: String,
#[serde(rename = "Buchungstext")]
booking_text: String,
#[serde(rename = "Verwendungszweck")]
purpose: Option<String>,
#[serde(rename = "Beguenstigter/Zahlungspflichtiger")]
participant_name: Option<String>,
#[serde(rename = "Kontonummer")]
participant_iban: Option<String>,
#[serde(rename = "BLZ")]
participant_bic: Option<String>,
#[serde(rename = "Betrag", with = "crate::util::german_number")]
amount: Decimal,
#[serde(rename = "Waehrung")]
currency: String,
#[serde(rename = "Info")]
info: String,
}
impl TryFrom<Mt940Record> for ArchiveRecord {
type Error = anyhow::Error;
fn try_from(row: Mt940Record) -> Result<Self, Self::Error> {
let Mt940Record {
account_iban,
booking_date,
value_date,
booking_text,
purpose,
participant_name,
participant_iban,
participant_bic,
amount,
currency,
info: _,
} = row;
let booking_date = short_german_date::parse(&booking_date)
.with_context(|| format!("Invalid \"Buchungstag\" {booking_date:?}"))?;
let value_date = short_german_date::parse(&value_date)
.with_context(|| format!("Invalid \"Valutadatum\" {value_date:?}"))?;
Ok(Self {
booking_date,
value_date,
amount,
currency: canonical_text(¤cy),
participant_name: clean_optional(participant_name),
purpose: clean_optional(purpose),
booking_text: canonical_text(&booking_text),
participant_iban: clean_optional(participant_iban),
participant_bic: clean_optional(participant_bic),
creditor_id: None,
mandate_reference: None,
balance_after_booking: None,
account_iban: account_iban
.parse()
.with_context(|| format!("Invalid \"Auftragskonto\" {account_iban:?}"))?,
account_bic: None,
account_bank_name: None,
fingerprint: None,
})
}
}
const MT940_HEADERS: [&str; 11] = [
"Auftragskonto",
"Buchungstag",
"Valutadatum",
"Buchungstext",
"Verwendungszweck",
"Beguenstigter/Zahlungspflichtiger",
"Kontonummer",
"BLZ",
"Betrag",
"Waehrung",
"Info",
];
enum Variant {
Camt,
Mt940,
}
pub fn parse_sparkasse(bytes: &[u8]) -> anyhow::Result<Vec<ArchiveRecord>> {
let text = read_to_utf8(bytes)?;
let mut rdr = ReaderBuilder::new()
.delimiter(CSV_DELIMITER)
.from_reader(text.as_bytes());
let headers = rdr.headers()?.clone();
match detect_variant(&headers)? {
Variant::Camt => booked_records(rdr.deserialize(), |row: &CamtRecord| row.info.as_str()),
Variant::Mt940 => {
log::info!(
"Detected the CSV-MT940 variant - prefer the CSV-CAMT export \
when available, it carries more detail"
);
booked_records(rdr.deserialize(), |row: &Mt940Record| row.info.as_str())
}
}
}
fn detect_variant(headers: &csv::StringRecord) -> anyhow::Result<Variant> {
let headers: Vec<&str> = headers.iter().map(str::trim).collect();
let (variant, known): (Variant, &[&str]) = if headers.contains(&"Kontonummer/IBAN") {
(Variant::Camt, &CAMT_HEADERS)
} else {
(Variant::Mt940, &MT940_HEADERS)
};
for header in headers {
if !known.contains(&header) {
bail!(
"Sparkasse export contains unknown column {header:?} - \
the import adapter must be updated before this data can be archived"
);
}
}
Ok(variant)
}
fn booked_records<R>(
rows: impl Iterator<Item = csv::Result<R>>,
info: impl Fn(&R) -> &str,
) -> anyhow::Result<Vec<ArchiveRecord>>
where
ArchiveRecord: TryFrom<R, Error = anyhow::Error>,
{
let mut pending = 0;
let mut records = vec![];
for row in rows {
let row = row?;
match info(&row).trim() {
INFO_BOOKED => records.push(ArchiveRecord::try_from(row)?),
INFO_PENDING => pending += 1,
other => bail!(
"Sparkasse export contains unknown \"Info\" value {other:?} - \
the import adapter must be updated before this data can be archived"
),
}
}
if pending > 0 {
log::info!("Skipped {pending} pending entries (\"{INFO_PENDING}\")");
}
Ok(records)
}
mod short_german_date {
use serde::{Deserialize, Deserializer};
use time::{Date, format_description::FormatItem, macros::format_description};
const DATE_FMT: &[FormatItem<'static>] = format_description!("[day].[month].[year]");
pub fn parse(s: &str) -> Result<Date, time::error::Parse> {
let s = s.trim();
let expanded = match s.rsplit_once('.') {
Some((day_month, year)) if year.len() == 2 => format!("{day_month}.20{year}"),
_ => s.to_owned(),
};
Date::parse(&expanded, DATE_FMT)
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Date, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
parse(&s).map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use std::{
fs,
path::{Path, PathBuf},
};
use super::*;
use crate::{GapPolicy, ImportSummary, VerifySummary, import, read_archive, verify_archive};
const CAMT_HEADER: &str = "Auftragskonto;Buchungstag;Valutadatum;\
Buchungstext;Verwendungszweck;Glaeubiger ID;Mandatsreferenz;\
Kundenreferenz (End-to-End);Sammlerreferenz;\
Lastschrift Ursprungsbetrag;Auslagenersatz Ruecklastschrift;\
Beguenstigter/Zahlungspflichtiger;Kontonummer/IBAN;BIC (SWIFT-Code);\
Betrag;Waehrung;Info;Kategorie";
const SALARY: &str = "DE44500105175407324931;01.01.26;01.01.26;\
GUTSCHR. UEBERWEISUNG;Gehalt Januar;;;NOTPROVIDED;;;;ACME GmbH;\
DE02300209000106531065;CMCIDEDD;1000,00;EUR;Umsatz gebucht;";
const BAKERY: &str = "DE44500105175407324931;02.01.26;02.01.26;\
KARTENZAHLUNG;2026-01-01T10:52 Debitk.1 2029-12 ;;;68000123();;;;\
Bäckerei Müller;DE02120300000000202051;BYLADEM1001;-3,50;EUR;\
Umsatz gebucht;";
const POWER: &str = "DE44500105175407324931;03.01.26;03.01.26;\
FOLGELASTSCHRIFT;Strom Abschlag;DE98ZZZ09999999999;M-001;;;;;\
Stadtwerke;DE02500105170137075030;INGDDEFF;-45,00;EUR;\
Umsatz gebucht;";
const PENDING: &str = "DE44500105175407324931;04.01.26;05.01.26;\
SEPA-ELV-LASTSCHRIFT;Tanken;DE98ZZZ09999999999;M-002;;;;;\
Tankstelle;DE02100100100006820101;PBNKDEFF;-50,00;EUR;\
Umsatz vorgemerkt;";
const MT940_HEADER: &str = "Auftragskonto;Buchungstag;Valutadatum;\
Buchungstext;Verwendungszweck;Beguenstigter/Zahlungspflichtiger;\
Kontonummer;BLZ;Betrag;Waehrung;Info";
const MT940_SALARY: &str = "DE44500105175407324931;01.01.26;01.01.26;\
GUTSCHR. UEBERWEISUNG;Gehalt Januar;ACME GmbH;\
DE02300209000106531065;CMCIDEDD;1000,00;EUR;Umsatz gebucht";
const MT940_PENDING: &str = "DE44500105175407324931;;;\
SEPA-ELV-LASTSCHRIFT;Tanken;Tankstelle;\
DE02100100100006820101;PBNKDEFF;-50,00;EUR;Umsatz vorgemerkt";
fn export_file(dir: &Path, name: &str, header: &str, rows: &[&str]) -> PathBuf {
let mut content = header.to_owned();
for row in rows {
content.push('\n');
content.push_str(row);
}
content.push('\n');
let path = dir.join(name);
fs::write(&path, content).unwrap();
path
}
fn camt_file(dir: &Path, name: &str, rows: &[&str]) -> PathBuf {
export_file(dir, name, CAMT_HEADER, rows)
}
fn mt940_file(dir: &Path, name: &str, rows: &[&str]) -> PathBuf {
export_file(dir, name, MT940_HEADER, rows)
}
#[test]
fn archives_only_what_the_bank_delivered() {
let dir = tempfile::tempdir().unwrap();
let archive_path = dir.path().join("archive.csv");
let export = camt_file(dir.path(), "export.csv", &[POWER, BAKERY, SALARY]);
let summary = import(parse_sparkasse, &export, &archive_path, GapPolicy::Reject).unwrap();
assert_eq!(
summary,
ImportSummary {
imported: 3,
duplicates: 0,
total: 3
}
);
let records = read_archive(&archive_path).unwrap();
let dates: Vec<String> = records
.iter()
.map(|record| record.booking_date.to_string())
.collect();
assert_eq!(dates, ["2026-01-03", "2026-01-02", "2026-01-01"]);
assert!(
records
.iter()
.all(|record| record.balance_after_booking.is_none())
);
assert!(
records.iter().all(|record| {
record.account_bic.is_none() && record.account_bank_name.is_none()
})
);
assert_eq!(
records[0].creditor_id.as_deref(),
Some("DE98ZZZ09999999999")
);
assert_eq!(
records[1].participant_name.as_deref(),
Some("Bäckerei Müller")
);
assert_eq!(records[2].amount.to_string(), "1000.00");
let summary = verify_archive(&archive_path).unwrap();
assert_eq!(
summary,
VerifySummary {
total: 3,
missing_fingerprints: 0,
missing_balances: 3
}
);
}
#[test]
fn pending_entries_are_skipped() {
let dir = tempfile::tempdir().unwrap();
let archive_path = dir.path().join("archive.csv");
let export = camt_file(dir.path(), "export.csv", &[PENDING, POWER, BAKERY, SALARY]);
let summary = import(parse_sparkasse, &export, &archive_path, GapPolicy::Reject).unwrap();
assert_eq!(summary.imported, 3);
let records = read_archive(&archive_path).unwrap();
assert!(
records
.iter()
.all(|record| record.purpose.as_deref() != Some("Tanken"))
);
}
#[test]
fn merges_overlapping_exports() {
let dir = tempfile::tempdir().unwrap();
let archive_path = dir.path().join("archive.csv");
let first = camt_file(dir.path(), "export1.csv", &[BAKERY, SALARY]);
let second = camt_file(dir.path(), "export2.csv", &[POWER, BAKERY]);
import(parse_sparkasse, &first, &archive_path, GapPolicy::Reject).unwrap();
let summary = import(parse_sparkasse, &second, &archive_path, GapPolicy::Reject).unwrap();
assert_eq!(
summary,
ImportSummary {
imported: 1,
duplicates: 1,
total: 3
}
);
verify_archive(&archive_path).unwrap();
}
#[test]
fn a_file_without_overlap_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let archive_path = dir.path().join("archive.csv");
let first = camt_file(dir.path(), "export1.csv", &[SALARY]);
let second = camt_file(dir.path(), "export2.csv", &[POWER]);
import(parse_sparkasse, &first, &archive_path, GapPolicy::Reject).unwrap();
let before = fs::read(&archive_path).unwrap();
let error = import(parse_sparkasse, &second, &archive_path, GapPolicy::Reject).unwrap_err();
assert!(format!("{error:#}").contains("does not overlap"));
assert_eq!(fs::read(&archive_path).unwrap(), before);
let summary = import(parse_sparkasse, &second, &archive_path, GapPolicy::Accept).unwrap();
assert_eq!(summary.imported, 1);
}
#[test]
fn oldest_first_files_are_flipped() {
let dir = tempfile::tempdir().unwrap();
let archive_path = dir.path().join("archive.csv");
let export = camt_file(dir.path(), "export.csv", &[SALARY, BAKERY, POWER]);
import(parse_sparkasse, &export, &archive_path, GapPolicy::Reject).unwrap();
let records = read_archive(&archive_path).unwrap();
let dates: Vec<String> = records
.iter()
.map(|record| record.booking_date.to_string())
.collect();
assert_eq!(dates, ["2026-01-03", "2026-01-02", "2026-01-01"]);
}
#[test]
fn four_digit_years_are_accepted() {
let with_full_year: &str = &SALARY.replace("01.01.26", "01.01.2026");
assert_ne!(with_full_year, SALARY);
let dir = tempfile::tempdir().unwrap();
let archive_path = dir.path().join("archive.csv");
let export = camt_file(dir.path(), "export.csv", &[with_full_year]);
import(parse_sparkasse, &export, &archive_path, GapPolicy::Reject).unwrap();
let records = read_archive(&archive_path).unwrap();
assert_eq!(records[0].booking_date.to_string(), "2026-01-01");
}
#[test]
fn rejects_unknown_info_values() {
let cancelled: &str = &SALARY.replace("Umsatz gebucht", "Umsatz storniert");
assert_ne!(cancelled, SALARY);
let dir = tempfile::tempdir().unwrap();
let archive_path = dir.path().join("archive.csv");
let export = camt_file(dir.path(), "export.csv", &[cancelled]);
let error = import(parse_sparkasse, &export, &archive_path, GapPolicy::Reject).unwrap_err();
assert!(format!("{error:#}").contains("Umsatz storniert"));
}
#[test]
fn rejects_filled_portal_annotations() {
let categorized: &str = &SALARY.replace("Umsatz gebucht;", "Umsatz gebucht;Einkommen");
assert_ne!(categorized, SALARY);
let dir = tempfile::tempdir().unwrap();
let archive_path = dir.path().join("archive.csv");
let export = camt_file(dir.path(), "export.csv", &[categorized]);
let error = import(parse_sparkasse, &export, &archive_path, GapPolicy::Reject).unwrap_err();
assert!(format!("{error:#}").contains("Kategorie"));
}
#[test]
fn rejects_unknown_export_columns() {
let dir = tempfile::tempdir().unwrap();
let archive_path = dir.path().join("archive.csv");
let path = dir.path().join("export.csv");
fs::write(&path, format!("{CAMT_HEADER};Neue Spalte\n{SALARY};x\n")).unwrap();
let error = import(parse_sparkasse, &path, &archive_path, GapPolicy::Reject).unwrap_err();
assert!(format!("{error:#}").contains("Neue Spalte"));
}
#[test]
fn mt940_variant_is_detected_and_imported() {
let dir = tempfile::tempdir().unwrap();
let archive_path = dir.path().join("archive.csv");
let export = mt940_file(dir.path(), "export.csv", &[MT940_SALARY]);
let summary = import(parse_sparkasse, &export, &archive_path, GapPolicy::Reject).unwrap();
assert_eq!(summary.imported, 1);
let records = read_archive(&archive_path).unwrap();
assert_eq!(records[0].booking_date.to_string(), "2026-01-01");
assert_eq!(
records[0].participant_iban.as_deref(),
Some("DE02300209000106531065")
);
assert_eq!(records[0].participant_bic.as_deref(), Some("CMCIDEDD"));
assert_eq!(records[0].creditor_id, None);
assert!(records[0].balance_after_booking.is_none());
}
#[test]
fn mt940_pending_entries_without_dates_are_skipped() {
let dir = tempfile::tempdir().unwrap();
let archive_path = dir.path().join("archive.csv");
let export = mt940_file(dir.path(), "export.csv", &[MT940_PENDING, MT940_SALARY]);
let summary = import(parse_sparkasse, &export, &archive_path, GapPolicy::Reject).unwrap();
assert_eq!(summary.imported, 1);
}
#[test]
fn the_variants_deduplicate_each_others_bookings() {
let dir = tempfile::tempdir().unwrap();
let archive_path = dir.path().join("archive.csv");
let camt = camt_file(dir.path(), "camt.csv", &[SALARY]);
let mt940 = mt940_file(dir.path(), "mt940.csv", &[MT940_SALARY]);
import(parse_sparkasse, &camt, &archive_path, GapPolicy::Reject).unwrap();
let summary = import(parse_sparkasse, &mt940, &archive_path, GapPolicy::Reject).unwrap();
assert_eq!(
summary,
ImportSummary {
imported: 0,
duplicates: 1,
total: 1
}
);
}
#[test]
fn camt_v8_without_kategorie_column_is_imported() {
let v8_header = CAMT_HEADER.replace(";Kategorie", "");
assert_ne!(v8_header, CAMT_HEADER);
let v8_row = SALARY.strip_suffix(';').unwrap();
let dir = tempfile::tempdir().unwrap();
let archive_path = dir.path().join("archive.csv");
let path = dir.path().join("export.csv");
fs::write(&path, format!("{v8_header}\n{v8_row}\n")).unwrap();
let summary = import(parse_sparkasse, &path, &archive_path, GapPolicy::Reject).unwrap();
assert_eq!(summary.imported, 1);
}
#[test]
fn differently_rendered_purposes_deduplicate() {
let tagged: &str = &MT940_SALARY.replace("Gehalt Januar", "SVWZ+Gehalt Januar");
assert_ne!(tagged, MT940_SALARY);
let dir = tempfile::tempdir().unwrap();
let archive_path = dir.path().join("archive.csv");
let camt = camt_file(dir.path(), "camt.csv", &[SALARY]);
let mt940 = mt940_file(dir.path(), "mt940.csv", &[tagged]);
import(parse_sparkasse, &camt, &archive_path, GapPolicy::Reject).unwrap();
let summary = import(parse_sparkasse, &mt940, &archive_path, GapPolicy::Reject).unwrap();
assert_eq!(
summary,
ImportSummary {
imported: 0,
duplicates: 1,
total: 1
}
);
}
#[test]
fn missing_counterparty_renderings_deduplicate() {
let mt940_row = "DE44500105175407324931;28.04.26;28.04.26;\
BARGELDAUSZAHLUNG;28.04;GA NR00002253;;60050020;-30;EUR;Umsatz gebucht";
let camt_row = "DE44500105175407324931;28.04.26;28.04.26;\
BARGELDAUSZAHLUNG;Baz;;;;;;;GA NR00002253;0000000000;60050020;-30;EUR;Umsatz gebucht;";
let dir = tempfile::tempdir().unwrap();
let archive_path = dir.path().join("archive.csv");
let camt = camt_file(dir.path(), "camt.csv", &[camt_row]);
let mt940 = mt940_file(dir.path(), "mt940.csv", &[mt940_row]);
import(parse_sparkasse, &camt, &archive_path, GapPolicy::Reject).unwrap();
let records = read_archive(&archive_path).unwrap();
assert_eq!(records[0].participant_iban, None);
let summary = import(parse_sparkasse, &mt940, &archive_path, GapPolicy::Reject).unwrap();
assert_eq!(
summary,
ImportSummary {
imported: 0,
duplicates: 1,
total: 1
}
);
}
#[test]
fn bank_internal_bookings_deduplicate() {
let mt940_row = "DE44500105175407324931;01.07.26;30.06.26;\
ABSCHLUSS;Abrechnung 30.06.2026siehe Anlage;;;60050010;0,00;EUR;Umsatz gebucht";
let camt_row = "DE44500105175407324931;01.07.26;30.06.26;\
ABSCHLUSS;Abrechnung 30.06.2026 siehe Anlage ;;;;;;;;0100433182;60050010;0,00;EUR;\
Umsatz gebucht;";
let dir = tempfile::tempdir().unwrap();
let archive_path = dir.path().join("archive.csv");
let camt = camt_file(dir.path(), "camt.csv", &[camt_row]);
let mt940 = mt940_file(dir.path(), "mt940.csv", &[mt940_row]);
import(parse_sparkasse, &camt, &archive_path, GapPolicy::Reject).unwrap();
let summary = import(parse_sparkasse, &mt940, &archive_path, GapPolicy::Reject).unwrap();
assert_eq!(
summary,
ImportSummary {
imported: 0,
duplicates: 1,
total: 1
}
);
}
#[test]
fn mt940_rejects_unknown_export_columns() {
let dir = tempfile::tempdir().unwrap();
let archive_path = dir.path().join("archive.csv");
let path = dir.path().join("export.csv");
fs::write(
&path,
format!("{MT940_HEADER};Neue Spalte\n{MT940_SALARY};x\n"),
)
.unwrap();
let error = import(parse_sparkasse, &path, &archive_path, GapPolicy::Reject).unwrap_err();
assert!(format!("{error:#}").contains("Neue Spalte"));
}
}