+ #![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))
+ }