Add an importer for DKB account statements

korrat
Dec 17, 2023, 8:00 AM
7URE4HPLC2YYQP3WCKISVA77WLIB4TKMZRN4LQHSSGBYXKAIK5QAC

Dependencies

  • [2] MSGK44CS Refactor ImporterProtocol to pass buffers
  • [3] S2677K7B Trim record fields when importing CSV
  • [4] M55GNGVC Switch to stable toolchain in flake
  • [5] GVEI7KND Add a importer component for CSV files
  • [6] KWZJZO3C Set up a basic flake for Rust workspace
  • [*] KB7Y4PJI Implement importers for Amazon accounts
  • [*] I2P2FTLE add basic parser for german decimals
  • [*] BDLVPDJZ Add a importer account for BW bank portfolios
  • [*] SLTVZLYX Upgrade dependencies
  • [*] PCHAKXNM Add an importer for Fidor account statements

Change contents

  • file addition: dkb (d--r------)
    [8.1]
  • file addition: src (d--r------)
    [0.15]
  • file addition: lib.rs (----------)
    [0.32]
    #![warn(clippy::all, clippy::nursery, clippy::pedantic)]
    extern crate alloc;
    use core::fmt;
    use core::fmt::Display;
    use core::fmt::Formatter;
    use core::ops::Index;
    use core::str::FromStr;
    use beancount_importers_framework::error::ImporterBuilderError;
    use beancount_importers_framework::error::UninitializedFieldSnafu;
    use beancount_types::Account;
    use beancount_types::AccountTemplate;
    use beancount_types::Amount;
    use beancount_types::Balance;
    use beancount_types::Commodity;
    use beancount_types::Directive;
    use beancount_types::MetadataKey;
    use beancount_types::Seg;
    use beancount_types::Transaction;
    use bstr::ByteSlice;
    use derive_builder::Builder;
    use hashbrown::HashMap;
    use iban::Iban;
    use iban::IbanLike;
    use miette::Diagnostic;
    use miette::IntoDiagnostic as _;
    use rust_decimal::Decimal;
    use serde::de::IntoDeserializer as _;
    use serde::Deserialize;
    use snafu::Backtrace;
    use snafu::OptionExt as _;
    use snafu::Snafu;
    use tap::Pipe as _;
    use tap::Tap as _;
    use time::Date;
    // TODO documentation
    // TODO balance assertions
    // TODO metadata + linking
    // TODO adjust accounts
    #[derive(Builder, Clone, Debug, Deserialize)]
    #[builder(
    build_fn(error = "ImporterBuilderError", skip),
    name = "ImporterBuilder"
    )]
    pub struct Config {
    #[builder(setter(into), try_setter)]
    pub account: AccountTemplate<TemplateSelector>,
    #[builder(setter(into), try_setter)]
    pub fallback_account: Account,
    #[builder(field(type = "HashMap<Iban, Account>"))]
    pub known_ibans: HashMap<Iban, Account>,
    }
    #[derive(Debug, Diagnostic, Snafu)]
    pub enum Error {}
    #[derive(Debug, Deserialize)]
    pub struct Importer {
    #[serde(flatten)]
    config: Config,
    #[serde(
    default = "csv_importer::Importer::semicolon_delimited",
    skip_deserializing
    )]
    importer: csv_importer::Importer,
    }
    impl Importer {
    const NAME: &'static str = "dkb/transactions";
    #[must_use]
    pub fn builder() -> ImporterBuilder {
    ImporterBuilder::default()
    }
    #[must_use]
    pub fn new(config: Config) -> Self {
    let importer = csv::ReaderBuilder::new()
    .tap_mut(|builder| {
    builder.flexible(true).delimiter(b';');
    })
    .pipe(csv_importer::Importer::new)
    .tap_mut(|importer| {
    importer.pad_records(true);
    });
    Self { config, importer }
    }
    }
    impl Importer {
    fn parse_header<'b>(&self, buffer: &'b [u8]) -> Option<(FileHeader, &'b [u8])> {
    let mut iter = buffer.lines();
    iter.by_ref().take(6).for_each(|_| {});
    let rest = iter.as_bytes();
    let (header, rest) = buffer.split_at(buffer.len() - rest.len());
    let mut reader = csv::ReaderBuilder::new()
    .has_headers(false)
    .delimiter(b';')
    .from_reader(header);
    let mut record = csv::StringRecord::new();
    if !reader.read_record(&mut record).unwrap_or(false) || &record[0] != "Kontonummer:" {
    return None;
    }
    let (account_number, _) = record[1].split_once(" / ")?;
    let Ok(account_number) = Iban::from_str(account_number) else {
    return None;
    };
    if !reader.read_record(&mut record).unwrap_or(false) || &record[0] != "Von:" {
    return None;
    }
    let Ok(from_date) = Date::parse(&record[1], DATE_FORMAT) else {
    return None;
    };
    if !reader.read_record(&mut record).unwrap_or(false) || &record[0] != "Bis:" {
    return None;
    }
    let Ok(to_date) = Date::parse(&record[1], DATE_FORMAT) else {
    return None;
    };
    if !reader.read_record(&mut record).unwrap_or(false) {
    return None;
    }
    let Ok(balance_date) = Date::parse(
    &record[0],
    time::macros::format_description!("Kontostand vom [day].[month].[year]:"),
    ) else {
    return None;
    };
    let Ok(final_balance) = parse_german_amount(&record[1]) else {
    return None;
    };
    Some((
    FileHeader {
    account_number,
    from_date,
    to_date,
    balance_date,
    final_balance,
    },
    rest,
    ))
    }
    }
    impl beancount_importers_framework::ImporterProtocol for Importer {
    type Error = miette::Report;
    fn account(&self, _buffer: &[u8]) -> Result<Account, Self::Error> {
    Ok(self.config.account.base().to_owned())
    }
    fn date(&self, buffer: &[u8]) -> Option<Result<Date, Self::Error>> {
    let Some((header, _rest)) = self.parse_header(buffer) else {
    todo!()
    };
    Some(Ok(header.to_date))
    }
    fn extract(
    &self,
    buffer: &[u8],
    existing: &[Directive],
    ) -> Result<Vec<Directive>, Self::Error> {
    let Some((header, rest)) = self.parse_header(buffer) else {
    todo!()
    };
    let mut extracted = self
    .importer
    .extract(rest, existing, self)
    .map_err(Self::Error::from)?;
    extracted.push(Directive::from(Balance::new(
    header.to_date.next_day().unwrap(),
    self.config.account.base(),
    header.final_balance,
    )));
    Ok(extracted)
    }
    fn filename(&self, _buffer: &[u8]) -> Option<Result<String, Self::Error>> {
    Some(Ok(String::from("transactions.csv")))
    }
    fn identify(&self, buffer: &[u8]) -> Result<bool, Self::Error> {
    const EXPECTED_HEADERS: &[&str] = &[
    "Buchungstag",
    "Wertstellung",
    "Buchungstext",
    "Auftraggeber / Begünstigter",
    "Verwendungszweck",
    "Kontonummer",
    "BLZ",
    "Betrag (EUR)",
    "Gläubiger-ID",
    "Mandatsreferenz",
    "Kundenreferenz",
    "",
    ];
    let Some((_header, rest)) = self.parse_header(buffer) else {
    return Ok(false);
    };
    self.importer
    .identify(rest, EXPECTED_HEADERS)
    .into_diagnostic()
    }
    fn name(&self) -> &'static str {
    Self::NAME
    }
    fn typetag_deserialize(&self) {}
    }
    impl csv_importer::RecordImporter for Importer {
    type Error = Error;
    type Record<'de> = Record<'de>;
    fn date(&self, _record: Record) -> Date {
    unimplemented!("this importer uses metadata from the file header instead")
    }
    fn extract(
    &self,
    _existing: &[Directive],
    record: Record,
    ) -> Result<Vec<Directive>, Self::Error> {
    let commodity = Commodity::try_from("EUR").unwrap();
    let directives = match record {
    Record::Balance(record) => record.amount.map_or_else(Vec::new, |amount| {
    vec![Directive::from(Balance::new(
    record.booking_date.next_day().unwrap(),
    self.config.account.render(&TemplateContext {}),
    Amount::new(amount, commodity),
    ))]
    }),
    Record::Statement(record) => vec![],
    Record::Transaction(record) => {
    let mut transaction = Transaction::on(record.value_date);
    transaction
    .set_payee(record.partner)
    .set_narration(record.description);
    let context = TemplateContext {};
    let account = self.config.account.render(&context);
    let transaction_kind = record.transaction_kind;
    let amount = Amount::new(record.amount, commodity);
    let opposite_account = self
    .config
    .known_ibans
    .get(&record.opposite_iban)
    .unwrap_or(&self.config.fallback_account);
    transaction
    .set_narration(format!("{transaction_kind}"))
    .add_meta(
    MetadataKey::try_from("other-iban").expect("valid key"),
    record.opposite_iban.electronic_str(),
    )
    .build_posting(account, |posting| {
    posting.set_amount(amount);
    })
    .build_posting(opposite_account, |_posting| {});
    vec![Directive::from(transaction)]
    }
    };
    Ok(directives)
    }
    }
    impl ImporterBuilder {
    pub fn add_known_iban(&mut self, iban: Iban, account: Account) -> &mut Self {
    self.known_ibans.insert(iban, account);
    self
    }
    pub fn build(&mut self) -> Result<Importer, ImporterBuilderError> {
    let config = Config {
    account: self.account.clone().context(UninitializedFieldSnafu {
    field: "account",
    importer: Importer::NAME,
    })?,
    fallback_account: self
    .fallback_account
    .clone()
    .context(UninitializedFieldSnafu {
    field: "fallback_account",
    importer: Importer::NAME,
    })?,
    known_ibans: self.known_ibans.clone(),
    };
    Ok(Importer::new(config))
    }
    pub fn clear_known_ibans(&mut self) -> &mut Self {
    self.known_ibans.clear();
    self
    }
    }
    #[derive(Clone, Copy, Debug, Deserialize)]
    #[serde(try_from = "RawRecord")]
    pub enum Record<'r> {
    Balance(BalanceRecord),
    Statement(StatementRecord<'r>),
    Transaction(#[serde(borrow)] TransactionRecord<'r>),
    }
    #[derive(Clone, Copy, Debug)]
    pub struct BalanceRecord {
    booking_date: Date,
    amount: Option<Decimal>,
    }
    #[derive(Clone, Copy, Debug)]
    pub struct StatementRecord<'r> {
    booking_date: Date,
    value_date: Date,
    description: &'r str,
    amount: Decimal,
    }
    #[derive(Clone, Copy, Debug)]
    pub struct TransactionRecord<'r> {
    booking_date: Date,
    value_date: Date,
    partner: &'r str,
    transaction_kind: TransactionKind,
    description: &'r str,
    opposite_iban: Iban,
    amount: Decimal,
    }
    #[derive(Clone, Copy, Debug)]
    pub enum TemplateSelector {}
    impl FromStr for TemplateSelector {
    type Err = TemplateSelectorError;
    fn from_str(selector: &str) -> Result<Self, Self::Err> {
    TemplateSelectorSnafu { selector }.fail()
    }
    }
    #[derive(Debug, Diagnostic, Snafu)]
    pub struct TemplateSelectorError {
    selector: String,
    backtrace: Backtrace,
    }
    struct FileHeader {
    account_number: Iban,
    from_date: Date,
    to_date: Date,
    balance_date: Date,
    final_balance: Amount,
    }
    #[derive(Clone, Copy, Debug, Deserialize)]
    struct RawRecord<'r> {
    #[serde(rename = "Buchungstag", with = "dmy")]
    booking_date: Date,
    #[serde(rename = "Wertstellung", with = "dmy::option")]
    value_date: Option<Date>,
    #[serde(rename = "Buchungstext")]
    transaction_kind: &'r str,
    #[serde(rename = "Auftraggeber / Begünstigter")]
    partner: &'r str,
    #[serde(rename = "Verwendungszweck")]
    description: &'r str,
    #[serde(rename = "Kontonummer")]
    opposite_iban: &'r str,
    #[serde(rename = "Betrag (EUR)", with = "german_decimal::serde::opt")]
    amount: Option<Decimal>,
    }
    impl<'r> TryFrom<RawRecord<'r>> for Record<'r> {
    type Error = serde::de::value::Error;
    fn try_from(value: RawRecord<'r>) -> Result<Self, Self::Error> {
    if value.description == "Tagessaldo" {
    return Ok(Self::Balance(BalanceRecord::from(value)));
    } else if value.transaction_kind == "Abschluss" {
    StatementRecord::try_from(value).map(Self::Statement)
    } else {
    TransactionRecord::try_from(value).map(Self::Transaction)
    }
    }
    }
    impl<'r> From<RawRecord<'r>> for BalanceRecord {
    fn from(value: RawRecord<'r>) -> Self {
    let RawRecord {
    booking_date,
    amount,
    ..
    } = value;
    Self {
    booking_date,
    amount,
    }
    }
    }
    impl<'r> TryFrom<RawRecord<'r>> for StatementRecord<'r> {
    type Error = serde::de::value::Error;
    fn try_from(value: RawRecord<'r>) -> Result<Self, Self::Error> {
    use serde::de::Error as _;
    let RawRecord {
    booking_date,
    value_date,
    description,
    amount,
    ..
    } = value;
    let value_date = value_date.ok_or_else(|| Self::Error::missing_field("value_date"))?;
    let amount = amount.ok_or_else(|| Self::Error::missing_field("amount"))?;
    Ok(Self {
    booking_date,
    value_date,
    description,
    amount,
    })
    }
    }
    impl<'r> TryFrom<RawRecord<'r>> for TransactionRecord<'r> {
    type Error = serde::de::value::Error;
    fn try_from(value: RawRecord<'r>) -> Result<Self, Self::Error> {
    use serde::de::Error as _;
    let RawRecord {
    booking_date,
    value_date,
    partner,
    transaction_kind,
    description,
    opposite_iban,
    amount,
    } = value;
    let value_date = value_date.ok_or_else(|| Self::Error::missing_field("value_date"))?;
    let transaction_kind = TransactionKind::deserialize(transaction_kind.into_deserializer())?;
    let opposite_iban = Iban::deserialize(opposite_iban.into_deserializer())?;
    let amount = amount.ok_or_else(|| Self::Error::missing_field("amount"))?;
    Ok(Self {
    booking_date,
    value_date,
    partner,
    transaction_kind,
    description,
    opposite_iban,
    amount,
    })
    }
    }
    #[derive(Debug)]
    struct TemplateContext {}
    impl Index<&TemplateSelector> for TemplateContext {
    type Output = Seg;
    fn index(&self, selector: &TemplateSelector) -> &Self::Output {
    match *selector {}
    }
    }
    #[derive(Clone, Copy, Debug, Deserialize)]
    enum TransactionKind {
    #[serde(rename = "Kartenzahlung")]
    CardPayment,
    #[serde(rename = "Gutschrift")]
    Credit,
    #[serde(rename = "Online-Zahlung")]
    OnlinePayment,
    #[serde(rename = "Überweisung")]
    Remittance,
    #[serde(rename = "Dauerauftrag")]
    StandingOrder,
    }
    impl Display for TransactionKind {
    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
    match self {
    Self::CardPayment => f.write_str("Card payment"),
    Self::Credit => f.write_str("Credit"),
    Self::OnlinePayment => f.write_str("Online payment"),
    Self::Remittance => f.write_str("Remittance"),
    Self::StandingOrder => f.write_str("Standing order"),
    }
    }
    }
    impl FromStr for TransactionKind {
    type Err = serde::de::value::Error;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
    Self::deserialize(s.into_deserializer())
    }
    }
    impl TryFrom<&'_ str> for TransactionKind {
    type Error = <Self as FromStr>::Err;
    fn try_from(s: &str) -> Result<Self, Self::Error> {
    Self::from_str(s)
    }
    }
    const DATE_FORMAT: &[time::format_description::FormatItem<'_>] =
    time::macros::format_description!("[day].[month].[year]");
    time::serde::format_description!(dmy, Date, DATE_FORMAT);
    fn parse_german_amount(v: &str) -> Result<Amount, Error> {
    let Some((amount, currency)) = v.split_once(' ') else {
    todo!()
    };
    let amount = german_decimal::parse(amount).map_err(|_| todo!())?;
    let currency = Commodity::try_from(currency).map_err(|_| todo!())?;
    Ok(Amount::new(amount, currency))
    }
  • file addition: Cargo.toml (----------)
    [0.15]
    [package]
    name = "beancount-importer-dkb"
    edition.workspace = true
    publish.workspace = true
    rust-version.workspace = true
    version.workspace = true
    [dependencies]
    # Workspace dependencies
    beancount-importers-framework.path = "../../framework"
    beancount-types.path = "../../common/beancount-types"
    german-decimal.path = "../../common/german-decimal"
    # Inherited dependencies
    bstr.workspace = true
    csv.workspace = true
    derive_builder.workspace = true
    hashbrown.workspace = true
    iban_validate.workspace = true
    miette.workspace = true
    rust_decimal.workspace = true
    serde.workspace = true
    snafu.workspace = true
    tap.workspace = true
    time.workspace = true
    [dependencies.csv-importer]
    package = "csv"
    path = "../csv"
  • edit in importers/csv/src/lib.rs at line 38
    [5.1049]
    [3.18]
    pad_records: bool,
  • replacement in importers/csv/src/lib.rs at line 54
    [5.1117][5.1117:1142]()
    Self { builder }
    [5.1117]
    [5.1142]
    Self {
    builder,
    pad_records: false,
    }
  • edit in importers/csv/src/lib.rs at line 70
    [5.1364]
    [2.3439]
    pub fn pad_records(&mut self, yes: bool) -> &mut Self {
    self.pad_records = yes;
    self
    }
    }
    impl Importer {
  • edit in importers/csv/src/lib.rs at line 195
    [3.937]
    [3.937]
    record.extend(core::iter::repeat("").take(headers.len() - record.len()));
  • replacement in flake.lock at line 6
    [5.4327][5.4327:4437]()
    "lastModified": 1699714741,
    "narHash": "sha256-7cRZc3RoBv4n9GgMun+OoTK5ssI2fzqkDAZsp37uhTs=",
    [5.4327]
    [5.4437]
    "lastModified": 1702652226,
    "narHash": "sha256-nBq7EmP7E42XRLkMArk4aSjoclBxFT2gxgLmS2xF1EY=",
  • replacement in flake.lock at line 10
    [5.4496][5.4496:4555]()
    "rev": "3338fcfb59cea5fcd7d2a4e7fe24cbc7cb778003",
    [5.4496]
    [5.4555]
    "rev": "fd71859263a51bf69da4ad9f692d6ebfe7db525b",
  • replacement in flake.lock at line 26
    [5.4819][5.4819:4929]()
    "lastModified": 1699548976,
    "narHash": "sha256-xnpxms0koM8mQpxIup9JnT0F7GrKdvv0QvtxvRuOYR4=",
    [5.4819]
    [5.4929]
    "lastModified": 1702749801,
    "narHash": "sha256-frIhfv0h4RAobzQ/vp7C7a2bEbz2gcZc2qVSc2CElxw=",
  • replacement in flake.lock at line 30
    [5.4982][5.4982:5041]()
    "rev": "6849911446e18e520970cc6b7a691e64ee90d649",
    [5.4982]
    [5.5041]
    "rev": "3330c0de31e8729bb5d01820e59ceb1640e128dc",
  • replacement in flake.lock at line 47
    [5.5332][4.229:339]()
    "lastModified": 1700202172,
    "narHash": "sha256-njBw+2qexaXbLXk9bKU8T3R50squCgdQm8OtDoAVMY0=",
    [5.5332]
    [5.5442]
    "lastModified": 1702794080,
    "narHash": "sha256-lh9IAeo7gq0Z1X+AtgPsO2r+e4uFqhIVE+ZEVH55Dk0=",
  • replacement in flake.lock at line 51
    [5.5501][4.340:399]()
    "rev": "166ea2849ed268fca9bfe64fe6d14225c9534845",
    [5.5501]
    [5.5560]
    "rev": "07eda01af85c16be6de78c6e35e9cc970721a5c1",
  • replacement in flake.lock at line 65
    [5.5808][5.5808:5918]()
    "lastModified": 1694529238,
    "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
    [5.5808]
    [5.5918]
    "lastModified": 1701680307,
    "narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=",
  • replacement in flake.lock at line 69
    [5.5977][5.5977:6036]()
    "rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
    [5.5977]
    [5.6036]
    "rev": "4022d587cbbfd70fe950c1e2083a02621806a725",
  • replacement in flake.lock at line 80
    [5.6224][4.400:510]()
    "lastModified": 1700108881,
    "narHash": "sha256-+Lqybl8kj0+nD/IlAWPPG/RDTa47gff9nbei0u7BntE=",
    [5.6224]
    [5.6334]
    "lastModified": 1702539185,
    "narHash": "sha256-KnIRG5NMdLIpEkZTnN5zovNYc0hhXjAgv6pfd5Z4c7U=",
  • replacement in flake.lock at line 84
    [5.6387][4.511:570]()
    "rev": "7414e9ee0b3e9903c24d3379f577a417f0aae5f1",
    [5.6387]
    [5.6446]
    "rev": "aa9d4729cbc99dabacb50e3994dcefb3ea0f7447",
  • edit in Cargo.toml at line 12
    [10.9878]
    [11.196]
    "importers/dkb",
  • edit in Cargo.lock at line 236
    [12.21210]
    [12.21210]
    name = "beancount-importer-dkb"
    version = "0.0.0-dev.0"
    dependencies = [
    "beancount-importers-framework",
    "beancount-types",
    "bstr",
    "csv 0.0.0-dev.0",
    "csv 1.3.0",
    "derive_builder",
    "german-decimal",
    "hashbrown 0.14.2",
    "iban_validate",
    "miette",
    "rust_decimal",
    "serde",
    "snafu",
    "tap",
    "time 0.3.30",
    ]
    [[package]]