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