+ 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()
+ }