use core::fmt;
use core::fmt::Display;
use core::fmt::Formatter;
use core::hash::Hash;
use core::ops::Index;
use core::str::FromStr;
use std::collections::HashMap;
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::Commodity;
use beancount_types::CostBasis;
use beancount_types::Directive;
use beancount_types::Link;
use beancount_types::Seg;
use beancount_types::Transaction;
use derive_builder::Builder;
use miette::Diagnostic;
use miette::IntoDiagnostic as _;
use serde::Deserialize;
use snafu::Backtrace;
use snafu::OptionExt as _;
use snafu::Snafu;
use time::format_description::well_known::Iso8601;
use time::Date;
use xxhash_rust::xxh3::Xxh3;
// 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 base_account: AccountTemplate<TemplateSelector>,
#[builder(setter(into), try_setter)]
pub capital_gains_account: AccountTemplate<TemplateSelector>,
#[builder(field(type = "HashMap<String, Commodity>"), setter(into))]
#[serde(default)]
pub currencies: HashMap<String, Commodity>,
#[builder(setter(into), try_setter)]
pub distributions_account: AccountTemplate<TemplateSelector>,
#[builder(setter(into), try_setter)]
pub fee_account: AccountTemplate<TemplateSelector>,
#[builder(setter(into))]
pub payee: String,
#[builder(field(type = "HashMap<String, Commodity>"), setter(into))]
#[serde(default)]
pub positions: HashMap<String, Commodity>,
#[builder(setter(into), try_setter)]
pub reference_account: AccountTemplate<TemplateSelector>,
}
#[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 = "uniondepot/transactions";
pub fn builder() -> ImporterBuilder {
ImporterBuilder::default()
}
pub fn new(config: Config) -> Self {
let importer = csv::Importer::semicolon_delimited();
Self { config, importer }
}
}
impl Importer {
fn lookup_currency(&self, name: &str) -> Result<Commodity, Error> {
self.config
.currencies
.get(name)
.copied()
.ok_or_else(|| todo!())
}
fn lookup_commodity(&self, position: &str) -> Result<Commodity, Error> {
self.config
.positions
.get(position)
.copied()
.ok_or_else(|| todo!())
}
}
impl beancount_importers_framework::ImporterProtocol for Importer {
type Error = miette::Report;
fn account(&self, _file: &camino::Utf8Path) -> Result<Account, Self::Error> {
Ok(self.config.base_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",
"UNIONDEPOTNUMMER",
"FONDS/PRODUKT",
"TRANSAKTIONSART",
"STATUS",
"FONDSPREIS",
"ANTEILE",
"VOLUMEN",
"EINHEIT",
];
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_order_date(record))
}
fn extract(
&self,
_existing: &[Directive],
record: &csv::Record,
) -> Result<Vec<Directive>, Self::Error> {
const DEPOT_ID_COLUMN_INDEX: usize = 1;
const PRICE_COLUMN_INDEX: usize = 5;
const TOTAL_COLUMN_INDEX: usize = 7;
const TRANSACTION_KIND_COLUMN_INDEX: usize = 3;
const UNIT_COLUMN_INDEX: usize = 8;
const UNITS_COLUMN_INDEX: usize = 6;
let context = TemplateContext::try_from(&record[DEPOT_ID_COLUMN_INDEX])?;
let account = self.config.base_account.render(&context);
let stock = self.lookup_commodity(context.position).unwrap();
let date = parse_order_date(record)?;
let currency: Commodity = self.lookup_currency(&record[UNIT_COLUMN_INDEX])?;
let units = {
let value: &str = &record[UNITS_COLUMN_INDEX];
german_decimal::parse(value).map_err(|_| todo!())
}?;
let unit_cost = {
let price = &record[PRICE_COLUMN_INDEX];
german_decimal::parse(price).map_err(|_| todo!())
}?;
let total = {
let value: &str = &record[TOTAL_COLUMN_INDEX];
german_decimal::parse(value).map_err(|_| todo!())
}?;
let total = Amount {
amount: total,
commodity: currency,
};
let transaction_kind = TransactionKind::from_str(&record[TRANSACTION_KIND_COLUMN_INDEX])?;
let opposite_account = match transaction_kind {
TransactionKind::DepositFee | TransactionKind::ForeignFees => {
self.config.fee_account.render(&context)
}
TransactionKind::Distribution => self.config.distributions_account.render(&context),
TransactionKind::Purchase | TransactionKind::Reversal | TransactionKind::Sale => {
self.config.reference_account.render(&context)
}
};
let (cost_basis, price) = match transaction_kind {
TransactionKind::Distribution
| TransactionKind::Purchase
| TransactionKind::Reversal => (
CostBasis::PerUnit(Amount {
amount: unit_cost,
commodity: currency,
}),
None,
),
TransactionKind::DepositFee | TransactionKind::ForeignFees | TransactionKind::Sale => (
CostBasis::Empty,
Some(Amount {
amount: unit_cost,
commodity: currency,
}),
),
};
let mut transaction = Transaction::on(date);
transaction
.set_payee(&self.config.payee)
.set_narration(transaction_kind.to_string())
.build_posting(account, |posting| {
posting
.set_amount(Amount::new(units, stock))
.set_cost(cost_basis);
posting.price = price;
})
.build_posting(opposite_account, |posting| {
posting.set_amount(-total);
});
if matches!(
transaction_kind,
TransactionKind::Purchase | TransactionKind::Sale
) {
transaction.add_link(
Link::try_from(format!("^union-investment.{:X}", hash(date, &context))).unwrap(),
);
}
if matches!(
transaction_kind,
TransactionKind::DepositFee | TransactionKind::ForeignFees | TransactionKind::Sale
) {
// Third leg for capital gains
transaction.build_posting(
self.config.capital_gains_account.render(&context),
|_posting| {},
);
}
Ok(vec![Directive::from(transaction)])
}
}
impl ImporterBuilder {
pub fn clear_currencies(&mut self) -> &mut Self {
self.currencies.clear();
self
}
pub fn clear_positions(&mut self) -> &mut Self {
self.positions.clear();
self
}
pub fn try_add_currency<C>(
&mut self,
name: impl Into<String>,
commodity: C,
) -> Result<&mut Self, C::Error>
where
C: TryInto<Commodity>,
{
self.currencies.insert(name.into(), commodity.try_into()?);
Ok(self)
}
pub fn try_add_position<C>(
&mut self,
position: impl Into<String>,
commodity: C,
) -> Result<&mut Self, C::Error>
where
C: TryInto<Commodity>,
{
self.positions
.insert(position.into(), commodity.try_into()?);
Ok(self)
}
}
impl ImporterBuilder {
pub fn build(&self) -> Result<Importer, ImporterBuilderError> {
let config = Config {
base_account: self.base_account.clone().context(UninitializedFieldSnafu {
field: "base_account",
importer: Importer::NAME,
})?,
capital_gains_account: self.capital_gains_account.clone().context(
UninitializedFieldSnafu {
field: "capital_gains_account",
importer: Importer::NAME,
},
)?,
distributions_account: self.distributions_account.clone().context(
UninitializedFieldSnafu {
field: "distributions_account",
importer: Importer::NAME,
},
)?,
fee_account: self.fee_account.clone().context(UninitializedFieldSnafu {
field: "fee_account",
importer: Importer::NAME,
})?,
payee: self.payee.clone().context(UninitializedFieldSnafu {
field: "payee",
importer: Importer::NAME,
})?,
positions: self.positions.clone(),
reference_account: self
.reference_account
.clone()
.context(UninitializedFieldSnafu {
field: "reference_account",
importer: Importer::NAME,
})?,
currencies: self.currencies.clone(),
};
Ok(Importer::new(config))
}
}
#[derive(Debug)]
struct TemplateContext<'i> {
depot: &'i Seg,
position: &'i Seg,
}
impl Index<&TemplateSelector> for TemplateContext<'_> {
type Output = Seg;
fn index(&self, selector: &TemplateSelector) -> &Self::Output {
match selector {
TemplateSelector::Depot => self.depot,
TemplateSelector::Position => self.position,
}
}
}
impl<'i> TryFrom<&'i str> for TemplateContext<'i> {
type Error = Error;
fn try_from(value: &'i str) -> Result<Self, Self::Error> {
let (depot, position) = value.split_once('/').ok_or_else(|| todo!())?;
let depot = <&Seg>::try_from(depot).map_err(|_| todo!())?;
let position = <&Seg>::try_from(position).map_err(|_| todo!())?;
Ok(Self { depot, position })
}
}
#[derive(Clone, Copy, Debug)]
pub enum TemplateSelector {
Depot,
Position,
}
impl FromStr for TemplateSelector {
type Err = TemplateSelectorError;
fn from_str(selector: &str) -> Result<Self, Self::Err> {
match selector {
"depot" => Ok(Self::Depot),
"position" => Ok(Self::Position),
selector => 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_order_date(record: &csv::Record) -> Result<Date, Error> {
const ORDER_DATE_COLUMN_INDEX: usize = 0;
Date::parse(&record[ORDER_DATE_COLUMN_INDEX], &Iso8601::PARSING).map_err(|_| todo!())
}
fn hash(date: Date, context: &TemplateContext) -> u128 {
let mut hasher = Xxh3::new();
(context.depot, context.position, date.year(), date.month()).hash(&mut hasher);
hasher.digest128()
}