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 derive_builder::Builder;
use hashbrown::HashMap;
use miette::Diagnostic;
use miette::IntoDiagnostic as _;
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 fallback_account: Account,
#[builder(field(type = "HashMap<String, Account>"))]
pub known_ibans: HashMap<String, Account>,
}
#[derive(Debug, Diagnostic, Snafu)]
pub enum Error {}
#[derive(Deserialize)]
pub struct Importer {
#[serde(flatten)]
config: Config,
#[serde(default = "csv::Importer::semicolon_delimited", skip_deserializing)]
importer: csv::Importer,
}
impl fmt::Debug for Importer {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
f.debug_struct("Importer")
.field("config", &self.config)
.field("importer", &self.importer)
.finish_non_exhaustive()
}
}
impl Importer {
const NAME: &str = "vr-bank/transactions";
pub fn builder() -> ImporterBuilder {
ImporterBuilder::default()
}
pub fn new(config: Config) -> Self {
let importer = csv::Importer::semicolon_delimited();
Self { config, importer }
}
}
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] = &[
"Bezeichnung Auftragskonto",
"IBAN Auftragskonto",
"BIC Auftragskonto",
"Bankname Auftragskonto",
"Buchungstag",
"Valutadatum",
"Name Zahlungsbeteiligter",
"IBAN Zahlungsbeteiligter",
"BIC (SWIFT-Code) Zahlungsbeteiligter",
"Buchungstext",
"Verwendungszweck",
"Betrag",
"Waehrung",
"Saldo nach Buchung",
"Bemerkung",
"Kategorie",
"Steuerrelevant",
"Glaeubiger ID",
"Mandatsreferenz",
];
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 = Commodity::try_from(&record[12]).map_err(|_err| -> Error { todo!() })?;
// TODO handle transaction
let transaction = {
let mut transaction = Transaction::on(date);
transaction.set_payee(&record[6]).set_narration(&record[10]);
let context = TemplateContext {};
let account = self.config.account.render(&context);
let amount = {
let amount = &record[11];
let amount = german_decimal::parse(amount).map_err(|_| -> Error { todo!() })?;
Amount::new(amount, commodity)
};
let opposite_iban = &record[7];
let opposite_account = self
.config
.known_ibans
.get(opposite_iban)
.unwrap_or(&self.config.fallback_account);
transaction
.add_meta(
MetadataKey::try_from("other-iban").expect("valid key"),
opposite_iban,
)
.build_posting(account, |posting| {
posting.set_amount(amount);
})
.build_posting(opposite_account, |_posting| {});
transaction
};
// TODO create an interface for matchers that can modify transactions
let balance = {
let amount = {
let amount = &record[13];
let amount = german_decimal::parse(amount).map_err(|_| -> Error { todo!() })?;
Amount::new(amount, commodity)
};
Balance::new(date.next_day().unwrap(), self.config.account.base(), amount)
};
Ok(vec![Directive::from(transaction), Directive::from(balance)])
}
}
impl ImporterBuilder {
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
}
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 {
DepositFee,
Distribution,
ForeignFees,
Purchase,
Reversal,
Sale,
}
impl Display for TransactionKind {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
f.write_str(match self {
TransactionKind::DepositFee => "Sale To Cover Deposit Fees",
TransactionKind::Distribution => "Reinvestment of Distribution",
TransactionKind::ForeignFees => "Sale To Cover Foreign Fees",
TransactionKind::Purchase => "Purchase",
TransactionKind::Reversal => "Reversal of Purchase",
TransactionKind::Sale => "Sale",
})
}
}
impl FromStr for TransactionKind {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let value = match s {
"Ertragsausschüttung" => Self::Distribution,
"Fremde Gebühren" => Self::ForeignFees,
"Kauf" => Self::Purchase,
"Storno wegen Rücklastschrift" => Self::Reversal,
"Verkauf" => Self::Sale,
"Verkauf wg. Depotgebühr UID mit Postbox" => Self::DepositFee,
other => todo!("unsupported transaction kind {other:?}"),
};
Ok(value)
}
}
fn parse_transaction_date(record: &csv::Record) -> Result<Date, Error> {
use time::format_description::FormatItem;
const DATE_COLUMN_INDEX: usize = 5;
const DATE_FORMAT: &[FormatItem] = format_description!("[day].[month].[year]");
Date::parse(&record[DATE_COLUMN_INDEX], DATE_FORMAT).map_err(|_| todo!())
}