Fork channel

Create a new channel as a copy of main.

Rename channel

Rename main to:

Delete channel

Delete main? This cannot be undone.

lib.rs
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::common_keys;
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::format_description::well_known::Iso8601;
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 {
    #[tracing::instrument(fields(header), skip(self, buffer))]
    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());

        tracing::Span::current().record("header", tracing::field::debug(bstr::BStr::new(header)));

        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:" {
            tracing::debug!(?record, "unexpected header format: no account number line");
            return None;
        }

        let Some((account_number, _)) = record[1].split_once(" / ") else {
            tracing::debug!(
                account_number = &record[1],
                "unexpected account number format: no separator"
            );
            return None;
        };
        let account_number = match Iban::from_str(account_number) {
            Ok(account_number) => account_number,
            Err(error) => {
                tracing::debug!(
                    account_number,
                    %error,
                    "unexpected account number format: invalid IBAN"
                );
                return None;
            }
        };

        if !reader.read_record(&mut record).unwrap_or(false) || &record[0] != "Von:" {
            tracing::debug!(?record, "unexpected header format: no from date line");
            return None;
        }

        let from_date = match Date::parse(&record[1], DATE_FORMAT) {
            Ok(from_date) => from_date,
            Err(error) => {
                tracing::debug!(
                    %error,
                    from_date = &record[1],
                    "unexpected date format for from date"
                );
                return None;
            }
        };

        if !reader.read_record(&mut record).unwrap_or(false) || &record[0] != "Bis:" {
            tracing::debug!(?record, "unexpected header format: no to date line");
            return None;
        }

        let to_date = match Date::parse(&record[1], DATE_FORMAT) {
            Ok(to_date) => to_date,
            Err(error) => {
                tracing::debug!(
                    %error,
                    to_date = &record[1],
                    "unexpected date format for to date"
                );
                return None;
            }
        };

        if !reader.read_record(&mut record).unwrap_or(false) {
            tracing::debug!(?record, "unexpected header format: no balance line");
            return None;
        }

        let balance_date = match Date::parse(
            &record[0],
            time::macros::format_description!("Kontostand vom [day].[month].[year]:"),
        ) {
            Ok(balance_date) => balance_date,
            Err(error) => {
                tracing::debug!(
                    %error,
                    field = &record[0],
                    "unexpected header format: unexpected field header for balance",
                );
                return None;
            }
        };

        let final_balance = match parse_german_amount(&record[1]) {
            Ok(final_balance) => final_balance,
            Err(error) => {
                tracing::debug!(
                    %error,
                    balance = &record[1],
                    "unexpected format for closing balance",
                );
                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.balance_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}"))
                    .build_posting(account, |posting| {
                        posting.set_amount(amount);

                        if let Some(info) = record.exchange_information {
                            posting.set_price(info.exchange_rate);
                        }
                    })
                    .build_posting(opposite_account, |posting| {
                        if let Some(payment_date) = record.payment_date {
                            posting.add_meta(common_keys::EFFECTIVE_DATE, payment_date);
                        }

                        if let Some(info) = record.exchange_information {
                            posting.set_amount(info.foreign_total);
                        }
                    });

                if matches!(
                    transaction_kind,
                    TransactionKind::Credit
                        | TransactionKind::Remittance
                        | TransactionKind::StandingOrder
                ) {
                    // Only include this, if the corresponding key is valid
                    transaction.add_meta(
                        MetadataKey::try_from("other-iban").expect("valid key"),
                        record.opposite_iban.electronic_str(),
                    );
                }

                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,

    payment_date: Option<Date>,

    exchange_information: Option<CurrencyExchangeInformation>,

    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,
}

#[derive(Clone, Copy, Debug)]
struct CurrencyExchangeInformation {
    exchange_rate: Amount,
    foreign_total: Amount,
}

impl CurrencyExchangeInformation {
    fn extract_from_description(description: &str) -> Result<Option<Self>, Error> {
        description
            .find("Original ")
            .map(|index| {
                let (foreign_total, rest) = split_off_amount(&description[index + 9..])?;

                let exchange_rate = if let Some(rest) = rest.strip_prefix("1 Euro=") {
                    split_off_amount(rest)?.0
                } else if let Some(rest) = rest.strip_prefix("Kurs ") {
                    let (amount, _rest) = rest.split_once(' ').unwrap_or((rest, ""));

                    Amount::new(
                        Decimal::from_str_exact(amount).map_err(|_| todo!())?,
                        foreign_total.commodity,
                    )
                } else {
                    todo!("handle {rest:?}")
                };

                Ok(Self {
                    exchange_rate,
                    foreign_total,
                })
            })
            .transpose()
    }
}

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"))?;

        let payment_date: Option<Date> = matches!(transaction_kind, TransactionKind::CardPayment)
            .then(|| {
                let (date, _rest) = description.split_once(' ').unwrap();
                Date::parse(date, &Iso8601::PARSING)
            })
            .transpose()
            .map_err(Self::Error::custom)?;

        let exchange_information =
            CurrencyExchangeInformation::extract_from_description(description)
                .map_err(Self::Error::custom)?;

        Ok(Self {
            booking_date,
            value_date,
            partner,
            transaction_kind,
            description,
            payment_date,
            exchange_information,
            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", alias = "Echtzeit 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))
}

fn split_off_amount(v: &str) -> Result<(Amount, &str), Error> {
    let index = v.find(' ').ok_or_else(|| todo!())?;
    let index = v[index + 1..]
        .find(' ')
        .map_or(v.len(), |new| new + index + 1);

    let (amount, rest) = v.split_at(index);
    let amount = parse_german_amount(amount)?;

    // Remove whitespace from the beginning
    let rest = rest.trim_start();

    Ok((amount, rest))
}