+ #![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::common_keys;
+ use beancount_types::Account;
+ use beancount_types::AccountTemplate;
+ use beancount_types::Amount;
+ use beancount_types::Commodity;
+ use beancount_types::CostBasis;
+ use beancount_types::CostSpec;
+ use beancount_types::Directive;
+ use beancount_types::Seg;
+ use beancount_types::Transaction;
+ use derive_builder::Builder;
+ use hashbrown::HashMap;
+ use miette::Diagnostic;
+ use miette::IntoDiagnostic as _;
+ use rust_decimal::Decimal;
+ use serde::Deserialize;
+ use snafu::Backtrace;
+ use snafu::OptionExt as _;
+ use snafu::Snafu;
+ use time::macros::format_description;
+ 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 commodity: Commodity,
+
+ #[builder(setter(into), try_setter)]
+ pub express_trade_recipient: AccountTemplate<TemplateSelector>,
+
+ #[builder(setter(into), try_setter)]
+ pub express_trade_fees: AccountTemplate<TemplateSelector>,
+
+ #[builder(setter(into), try_setter)]
+ pub fallback_account: Account,
+
+ #[builder(setter(into), try_setter)]
+ pub fee_account: AccountTemplate<TemplateSelector>,
+
+ #[builder(setter(into, strip_option))]
+ pub fee_payee: Option<String>,
+
+ #[builder(field(type = "HashMap<String, Account>"))]
+ pub known_ibans: HashMap<String, Account>,
+
+ #[builder(setter(into), try_setter)]
+ pub savings_bonds_account: AccountTemplate<TemplateSelector>,
+ #[builder(setter(into), try_setter)]
+ pub savings_bonds_commodity: Commodity,
+ }
+
+ #[derive(Debug, Diagnostic, Snafu)]
+ pub enum Error {}
+
+ #[derive(Debug, Deserialize)]
+ pub struct Importer {
+ #[serde(flatten)]
+ config: Config,
+
+ #[serde(default = "csv::Importer::semicolon_delimited", skip_deserializing)]
+ importer: csv::Importer,
+ }
+
+ impl Importer {
+ const NAME: &str = "fidor/transactions";
+
+ #[must_use]
+ pub fn builder() -> ImporterBuilder {
+ ImporterBuilder::default()
+ }
+
+ #[must_use]
+ pub fn new(config: Config) -> Self {
+ let importer = csv::Importer::semicolon_delimited();
+ Self { config, importer }
+ }
+ }
+
+ impl Importer {
+ fn build_transfer_transaction(
+ &self,
+ record: &csv::Record,
+ transaction: &mut Transaction,
+ prefix: &str,
+ ) {
+ let (payee, iban) = record[2]
+ .strip_prefix(prefix)
+ .and_then(|rest| {
+ let (recipient_name, rest) = rest.split_once(", IBAN ")?;
+ let recipient_iban = rest.split_once(", ").map_or(rest, |(iban, _)| iban);
+
+ Some((recipient_name.trim(), recipient_iban.trim()))
+ })
+ .expect("structured narration");
+
+ let opposite_account = self
+ .config
+ .known_ibans
+ .get(iban)
+ .unwrap_or(&self.config.fallback_account);
+
+ transaction
+ .set_payee(payee)
+ .build_posting(opposite_account, |_posting| {});
+ }
+ }
+
+ impl beancount_importers_framework::ImporterProtocol for Importer {
+ type Error = miette::Report;
+
+ fn account(&self, _file: &camino::Utf8Path) -> Result<Account, Self::Error> {
+ Ok(self.config.account.base().to_owned())
+ }
+
+ fn date(&self, file: &camino::Utf8Path) -> Option<Result<Date, Self::Error>> {
+ self.importer
+ .date(file, self)
+ .map(|result| result.map_err(Self::Error::from))
+ }
+
+ fn extract(
+ &self,
+ file: &camino::Utf8Path,
+ existing: &[Directive],
+ ) -> Result<Vec<Directive>, Self::Error> {
+ self.importer
+ .extract(file, existing, self)
+ .map_err(Self::Error::from)
+ }
+
+ fn filename(&self, _file: &camino::Utf8Path) -> Option<Result<String, Self::Error>> {
+ Some(Ok(String::from("transactions.csv")))
+ }
+
+ fn identify(&self, file: &camino::Utf8Path) -> Result<bool, Self::Error> {
+ const EXPECTED_HEADERS: &[&str] = &["Datum", "Beschreibung", "Beschreibung2", "Wert"];
+
+ self.importer
+ .identify(file, EXPECTED_HEADERS)
+ .into_diagnostic()
+ }
+
+ fn name(&self) -> &'static str {
+ Self::NAME
+ }
+
+ fn typetag_deserialize(&self) {}
+ }
+
+ impl csv::RecordImporter for Importer {
+ type Error = Error;
+
+ fn date(&self, record: &csv::Record) -> Option<Result<Date, Self::Error>> {
+ Some(parse_transaction_date(record))
+ }
+
+ fn extract(
+ &self,
+ _existing: &[Directive],
+ record: &csv::Record,
+ ) -> Result<Vec<Directive>, Self::Error> {
+ let date = parse_transaction_date(record)?;
+
+ let commodity = self.config.commodity;
+
+ let mut transaction = Transaction::on(date);
+
+ let context = TemplateContext {};
+ let account = self.config.account.render(&context);
+
+ let transaction_kind = TransactionKind::try_from(&record[1])?;
+
+ let amount = {
+ let amount = &record[3];
+ let amount = german_decimal::parse(amount).map_err(|_| -> Error { todo!() })?;
+
+ Amount::new(amount, commodity)
+ };
+
+ transaction
+ .set_narration(format!("{transaction_kind}"))
+ .build_posting(account, |posting| {
+ posting.set_amount(amount);
+ });
+
+ match transaction_kind {
+ TransactionKind::AccountManagementCharge | TransactionKind::ActivityBonus => {
+ if let Some(ref payee) = self.config.fee_payee {
+ transaction.set_payee(payee);
+ }
+
+ transaction.build_posting(self.config.fee_account.render(&context), |_posting| {});
+ }
+
+ TransactionKind::Credit => {
+ self.build_transfer_transaction(record, &mut transaction, "Absender ");
+ }
+ TransactionKind::Remittance => {
+ self.build_transfer_transaction(record, &mut transaction, "Empfaenger ");
+ }
+
+ TransactionKind::PurchaseFee { .. } => {
+ if let Some(payee) = &self.config.fee_payee {
+ transaction.set_payee(payee);
+ }
+
+ transaction.build_posting(
+ self.config.express_trade_fees.render(&context),
+ |_posting| {},
+ );
+ }
+ TransactionKind::SendFriendsMoney {
+ purchase_id,
+ recipient,
+ } => {
+ transaction
+ .set_payee(recipient)
+ .add_meta(common_keys::TRANSACTION_ID, purchase_id)
+ .build_posting(
+ self.config.express_trade_recipient.render(&context),
+ |_posting| {},
+ );
+ }
+
+ TransactionKind::SavingsBondCreation { id } => {
+ transaction.build_posting(
+ self.config.savings_bonds_account.render(&context),
+ |posting| {
+ let mut cost = CostSpec::from(CostBasis::PerUnit(-amount));
+ cost.set_label(id);
+ posting
+ .set_amount(Amount::new(
+ Decimal::ONE,
+ self.config.savings_bonds_commodity,
+ ))
+ .set_cost(cost);
+ },
+ );
+ }
+ TransactionKind::SavingsBondPayout { id } => {
+ transaction.build_posting(
+ self.config.savings_bonds_account.render(&context),
+ |posting| {
+ let amount =
+ Amount::new(-Decimal::ONE, self.config.savings_bonds_commodity);
+ posting.set_amount(amount).set_cost(CostSpec::labelled(id));
+ },
+ );
+ }
+ }
+
+ Ok(vec![Directive::from(transaction)])
+ }
+ }
+
+ impl ImporterBuilder {
+ pub fn build(&mut self) -> Result<Importer, ImporterBuilderError> {
+ let config = Config {
+ account: self.account.clone().context(UninitializedFieldSnafu {
+ field: "account",
+ importer: Importer::NAME,
+ })?,
+
+ commodity: self.commodity.context(UninitializedFieldSnafu {
+ field: "commodity",
+ importer: Importer::NAME,
+ })?,
+
+ express_trade_recipient: self.express_trade_recipient.clone().context(
+ UninitializedFieldSnafu {
+ field: "express_trade_recipient",
+ importer: Importer::NAME,
+ },
+ )?,
+
+ express_trade_fees: self.express_trade_fees.clone().context(
+ UninitializedFieldSnafu {
+ field: "express_trade_fees",
+ importer: Importer::NAME,
+ },
+ )?,
+
+ fallback_account: self
+ .fallback_account
+ .clone()
+ .context(UninitializedFieldSnafu {
+ field: "fallback_account",
+ importer: Importer::NAME,
+ })?,
+
+ fee_account: self.fee_account.clone().context(UninitializedFieldSnafu {
+ field: "fee_account",
+ importer: Importer::NAME,
+ })?,
+
+ fee_payee: self.fee_payee.clone().context(UninitializedFieldSnafu {
+ field: "fee_payee",
+ importer: Importer::NAME,
+ })?,
+
+ known_ibans: self.known_ibans.clone(),
+
+ savings_bonds_account: self.savings_bonds_account.clone().context(
+ UninitializedFieldSnafu {
+ field: "savings_bonds_account",
+ importer: Importer::NAME,
+ },
+ )?,
+
+ savings_bonds_commodity: self.savings_bonds_commodity.context(
+ UninitializedFieldSnafu {
+ field: "savings_bonds_commodity",
+ importer: Importer::NAME,
+ },
+ )?,
+ };
+
+ Ok(Importer::new(config))
+ }
+
+ pub fn clear_known_ibans(&mut self) -> &mut Self {
+ self.known_ibans.clear();
+ self
+ }
+
+ pub fn try_add_known_iban<A>(
+ &mut self,
+ iban: impl Into<String>,
+ account: A,
+ ) -> Result<&mut Self, A::Error>
+ where
+ A: TryInto<Account>,
+ {
+ self.known_ibans.insert(iban.into(), account.try_into()?);
+ Ok(self)
+ }
+ }
+
+ #[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)]
+ 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)]
+ enum TransactionKind<'i> {
+ AccountManagementCharge,
+ ActivityBonus,
+ Credit,
+ Remittance,
+
+ PurchaseFee {
+ purchase_id: &'i str,
+ },
+ SendFriendsMoney {
+ purchase_id: &'i str,
+ recipient: &'i str,
+ },
+
+ SavingsBondCreation {
+ id: &'i str,
+ },
+ SavingsBondPayout {
+ id: &'i str,
+ },
+ }
+
+ impl Display for TransactionKind<'_> {
+ fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+ match self {
+ TransactionKind::AccountManagementCharge => f.write_str("Account Management Charge"),
+ TransactionKind::ActivityBonus => f.write_str("Activity Bonus"),
+
+ TransactionKind::Credit => f.write_str("Incoming transfer"),
+ TransactionKind::Remittance => f.write_str("Outgoing transfer"),
+
+ TransactionKind::PurchaseFee { purchase_id } => {
+ write!(f, "Express trade fee for {purchase_id}")
+ }
+ TransactionKind::SendFriendsMoney { purchase_id, .. } => {
+ write!(f, "Express trade purchase {purchase_id}")
+ }
+
+ TransactionKind::SavingsBondCreation { id } => {
+ write!(f, "Savings bond purchase {id}")
+ }
+ TransactionKind::SavingsBondPayout { id } => write!(f, "Savings bond payout {id}"),
+ }
+ }
+ }
+
+ impl<'i> TryFrom<&'i str> for TransactionKind<'i> {
+ type Error = Error;
+
+ fn try_from(s: &'i str) -> Result<Self, Self::Error> {
+ match s {
+ "Aktivitaetsbonus" => return Ok(Self::ActivityBonus),
+ "Kontofuehrung" => return Ok(Self::AccountManagementCharge),
+ "Ueberweisung" => return Ok(Self::Remittance),
+ _ => {}
+ }
+
+ if s.starts_with("Ueberweisung") {
+ return Ok(Self::Remittance);
+ } else if s.starts_with("Gutschrift") {
+ return Ok(Self::Credit);
+ } else if let Some(rest) = s.strip_prefix("Sparbrief ") {
+ let (id, _) = rest.split_once(", ").expect("structured narration format");
+ return Ok(Self::SavingsBondCreation { id });
+ } else if let Some(rest) = s.strip_prefix("Auszahlung Sparbrief ") {
+ let (id, _) = rest.split_once(", ").expect("structured narration format");
+ return Ok(Self::SavingsBondPayout { id });
+ } else if let Some(purchase_id) = s.strip_prefix("Kaeufergebuehr ") {
+ return Ok(Self::PurchaseFee { purchase_id });
+ } else if let Some(rest) = s.strip_prefix(r#""Freunden Geld senden" an Benutzer "#) {
+ let (recipient, purchase_id) = rest.split_once(" ").expect("structured narration");
+ return Ok(Self::SendFriendsMoney {
+ purchase_id,
+ recipient,
+ });
+ }
+
+ todo!("unsupported transaction kind {s:?}")
+ }
+ }
+
+ fn parse_transaction_date(record: &csv::Record) -> Result<Date, Error> {
+ use time::format_description::FormatItem;
+
+ const DATE_COLUMN_INDEX: usize = 0;
+ const DATE_FORMAT: &[FormatItem] = format_description!("[day].[month].[year]");
+
+ Date::parse(&record[DATE_COLUMN_INDEX], DATE_FORMAT).map_err(|_| todo!())
+ }