+ 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::AccountTemplate;
+ use beancount_types::Amount;
+ use beancount_types::Commodity;
+ use beancount_types::CostBasis;
+ use beancount_types::Directive;
+ use beancount_types::Link;
+ use beancount_types::Price;
+ use beancount_types::Seg;
+ use beancount_types::Transaction;
+ use camino::Utf8Path;
+ use csv::Record;
+ use derive_builder::Builder;
+ use miette::Diagnostic;
+ use miette::IntoDiagnostic;
+ use miette::Report;
+ use rust_decimal::Decimal;
+ use serde::Deserialize;
+ use snafu::Backtrace;
+ use snafu::OptionExt;
+ use snafu::Snafu;
+ use tap::Tap as _;
+ use tap::TapFallible;
+ use time::macros::format_description;
+ use time::parsing::Parsed;
+ use time::Date;
+
+ #[derive(Builder, Clone, Debug, Deserialize)]
+ #[builder(
+ build_fn(error = "ImporterBuilderError", skip),
+ name = "ImporterBuilder",
+ setter(into),
+ try_setter
+ )]
+ pub struct Config {
+ pub account_template: AccountTemplate<TemplateSelector>,
+
+ pub capital_gains_account: AccountTemplate<TemplateSelector>,
+
+ pub currency_account: AccountTemplate<CurrencyTemplateSelector>,
+
+ pub distributions_template: AccountTemplate<TemplateSelector>,
+
+ pub fee_template: AccountTemplate<TemplateSelector>,
+
+ pub payee: String,
+
+ pub portfolio_fee_template: AccountTemplate<TemplateSelector>,
+
+ pub reference_account: AccountTemplate<TemplateSelector>,
+ }
+
+ #[derive(Clone, Copy, Debug)]
+ pub enum CurrencyTemplateSelector {
+ Currency,
+ }
+
+ impl FromStr for CurrencyTemplateSelector {
+ type Err = TemplateSelectorError;
+
+ fn from_str(selector: &str) -> Result<Self, Self::Err> {
+ let selector = match selector {
+ "currency" => Self::Currency,
+ _ => return TemplateSelectorSnafu { selector }.fail(),
+ };
+
+ Ok(selector)
+ }
+ }
+
+ #[derive(Debug, Diagnostic, Snafu)]
+ pub enum Error {
+ #[snafu(display("error while parsing date"))]
+ DateFormat { source: time::Error },
+
+ #[snafu(display("unsupported transaction kind: {value:?}"))]
+ UnsupportedTransactionKind { value: String, backtrace: Backtrace },
+ }
+
+ impl From<time::error::TryFromParsed> for Error {
+ fn from(source: time::error::TryFromParsed) -> Self {
+ let source = source.into();
+ Self::DateFormat { source }
+ }
+ }
+
+ #[derive(Debug, Deserialize)]
+ pub struct Importer {
+ #[serde(flatten)]
+ pub config: Config,
+
+ #[serde(default = "csv::Importer::semicolon_delimited", skip_deserializing)]
+ pub importer: csv::Importer,
+ }
+
+ impl Importer {
+ pub const NAME: &str = "ebase/transactions";
+
+ pub fn new(config: Config) -> Self {
+ let importer = csv::Importer::semicolon_delimited();
+ Self { config, importer }
+ }
+
+ pub fn builder() -> ImporterBuilder {
+ ImporterBuilder::default()
+ }
+ }
+
+ impl beancount_importers_framework::ImporterProtocol for Importer {
+ type Error = Report;
+
+ fn account(&self, _file: &Utf8Path) -> Result<beancount_types::Account, Self::Error> {
+ Ok(self.config.account_template.base().to_owned())
+ }
+
+ fn date(&self, file: &Utf8Path) -> Option<Result<Date, Self::Error>> {
+ self.importer
+ .date(file, self)
+ .map(|result| result.map_err(Self::Error::from))
+ }
+
+ fn extract(
+ &self,
+ file: &Utf8Path,
+ existing: &[Directive],
+ ) -> Result<Vec<Directive>, Self::Error> {
+ self.importer
+ .extract(file, existing, self)
+ .map_err(Report::from)
+ }
+
+ fn filename(&self, _file: &Utf8Path) -> Option<Result<String, Self::Error>> {
+ Some(Ok(String::from("transactions.csv")))
+ }
+
+ fn identify(&self, file: &Utf8Path) -> Result<bool, Self::Error> {
+ const EXPECTED_HEADERS: [&str; 33] = [
+ "Depotnummer",
+ "Depotposition",
+ "Ref. Nr.",
+ "Buchungsdatum",
+ "Umsatzart",
+ "Teilumsatz",
+ "Fonds",
+ "ISIN",
+ "Zahlungsbetrag in ZW",
+ "Zahlungswährung (ZW)",
+ "Anteile",
+ "Abrechnungskurs in FW",
+ "Fondswährung (FW)",
+ "Kursdatum",
+ "Devisenkurs (ZW/FW)",
+ "Anlagebetrag in ZW",
+ "Vertriebsprovision in ZW (im Abrechnungskurs enthalten)",
+ "KVG Einbehalt in ZW (im Abrechnungskurs enthalten)",
+ "Gegenwert der Anteile in ZW",
+ "Anteile zum Bestandsdatum",
+ "Barausschüttung/Steuerliquidität je Anteil in EW",
+ "Ertragswährung (EW)",
+ "Bestandsdatum",
+ "Devisenkurs (ZW/EW)",
+ "Barausschüttung/Steuerliquidität in ZW",
+ "Bruttobetrag VAP je Anteil in EUR",
+ "Entgelt in ZW",
+ "Entgelt in EUR",
+ "Steuern in ZW",
+ "Steuern in EUR",
+ "Devisenkurs (EUR/ZW)",
+ "Art des Steuereinbehalts",
+ "Steuereinbehalt in EUR",
+ ];
+
+ self.importer
+ .identify(file, &EXPECTED_HEADERS)
+ .into_diagnostic()
+ }
+
+ fn name(&self) -> &'static str {
+ Self::NAME
+ }
+
+ #[doc(hidden)]
+ 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: &Record,
+ ) -> Result<Vec<Directive>, Self::Error> {
+ const DEPOT_ID_COLUMN_INDEX: usize = 0;
+ const DEPOT_POSITION_COLUMN_INDEX: usize = 1;
+ const FUND_CURRENCY_COLUMN_INDEX: usize = 12;
+ const FUND_NAME_COLUMN_INDEX: usize = 6;
+ const ISIN_COLUMN_INDEX: usize = 7;
+ const REFERENCE_COLUMN_INDEX: usize = 2;
+ const SHARES_COLUMN_INDEX: usize = 10;
+ const PRICE_DATE_COLUMN_INDEX: usize = 13;
+ const PAYMENT_AMOUNT_COLUMN_INDEX: usize = 8;
+ const PAYMENT_CURRENY_COLUMN_INDEX: usize = 9;
+ const TRANSACTION_KIND_COLUMN_INDEX: usize = 4;
+ const SHARE_PRICE_COLUMN_INDEX: usize = 11;
+ const EXCHANGE_RATE_COLUMN_INDEX: usize = 14;
+
+ // TODO improve error handling
+
+ let kind = TransactionKind::try_from(&record[TRANSACTION_KIND_COLUMN_INDEX])?;
+
+ let transaction_id = &record[REFERENCE_COLUMN_INDEX];
+ let (reference, ..) = transaction_id.split_once('/').unwrap();
+
+ let fund_commodity = Commodity::try_from(&record[ISIN_COLUMN_INDEX]).unwrap();
+
+ let depot_id = &record[DEPOT_ID_COLUMN_INDEX];
+ let position = &record[DEPOT_POSITION_COLUMN_INDEX];
+ let context = TemplateContext {
+ depot_id: <&Seg>::try_from(depot_id).unwrap(),
+ position: <&Seg>::try_from(position).unwrap(),
+ };
+
+ let fund_currency = Commodity::try_from(&record[FUND_CURRENCY_COLUMN_INDEX]).unwrap();
+ let share_price = Amount::new(
+ german_decimal::parse(&record[SHARE_PRICE_COLUMN_INDEX]).unwrap(),
+ fund_currency,
+ );
+
+ let shares_amount = Amount::new(
+ german_decimal::parse(&record[SHARES_COLUMN_INDEX])
+ .map_err(|_| -> Error { todo!() })?,
+ fund_commodity,
+ );
+
+ let payment_currency = Commodity::try_from(&record[PAYMENT_CURRENY_COLUMN_INDEX])
+ .map_err(|_| -> Error { todo!() })?;
+
+ let payment_amount = {
+ let amount = parse_decimal_with_precision(&record[PAYMENT_AMOUNT_COLUMN_INDEX], 2)
+ .map_err(|_| -> Error { todo!() })?;
+
+ Amount::new(amount, payment_currency)
+ };
+
+ let investment_amount_payment = {
+ let investment_amount = &record[15];
+ let investment_amount = parse_decimal_with_precision(investment_amount, 2)
+ .map_err(|_| -> Error { todo!() })?;
+
+ Amount::new(investment_amount, payment_currency)
+ };
+
+ let investment_amount_fund = {
+ let exchange_rate = Some(&record[EXCHANGE_RATE_COLUMN_INDEX])
+ .filter(|value| !value.is_empty())
+ .map(german_decimal::parse)
+ .transpose()
+ .map_err(|_| -> Error { todo!() })?;
+
+ exchange_rate.map(|rate| {
+ let mut amount = investment_amount_payment.amount * rate;
+ amount.rescale(2);
+ Amount::new(amount, fund_currency)
+ })
+ };
+
+ let fees = german_decimal::parse(&record[26])
+ .unwrap()
+ .tap_mut(|amount| {
+ amount.rescale(2);
+ });
+
+ let mut transaction = Transaction::on(parse_transaction_date(record)?);
+ transaction
+ .set_payee(&self.config.payee)
+ .set_narration(kind.format_narration(&record[FUND_NAME_COLUMN_INDEX]))
+ .add_meta(common_keys::TRANSACTION_ID, transaction_id);
+
+ match kind {
+ TransactionKind::Purchase | TransactionKind::SavingsPlan => {
+ transaction
+ .add_link(Link::try_from(format!("^ebase.{reference}")).unwrap())
+ .build_posting(self.config.account_template.render(&context), |posting| {
+ posting
+ .set_amount(shares_amount)
+ .set_cost(CostBasis::PerUnit(share_price));
+ });
+
+ if !fees.is_zero() {
+ let fees = Amount::new(fees, payment_currency);
+
+ transaction.build_posting(
+ self.config.fee_template.render(&context),
+ |posting| {
+ posting.set_amount(fees);
+ },
+ );
+ }
+
+ transaction.build_posting(
+ self.config.reference_account.render(&context),
+ |posting| {
+ posting.set_amount(-payment_amount);
+ },
+ );
+
+ if let Some(investment_amount_fund) = investment_amount_fund {
+ transaction
+ .build_posting(
+ self.config
+ .currency_account
+ .render(&CurrencyTemplateContext {
+ currency: {
+ let currency: &str = &payment_currency;
+ <&Seg>::try_from(currency)
+ .expect("commodities are valid segments")
+ },
+ }),
+ |posting| {
+ posting.set_amount(investment_amount_payment);
+ },
+ )
+ .build_posting(
+ self.config
+ .currency_account
+ .render(&CurrencyTemplateContext {
+ currency: {
+ let currency: &str = &fund_currency;
+ <&Seg>::try_from(currency)
+ .expect("commodities are valid segments")
+ },
+ }),
+ |posting| {
+ posting.set_amount(-investment_amount_fund);
+ },
+ );
+ }
+ }
+
+ TransactionKind::PortfolioFee => {
+ // TODO we may have to handle currency accounts here
+ transaction
+ .build_posting(self.config.account_template.render(&context), |posting| {
+ posting
+ .set_amount(shares_amount)
+ .set_cost(CostBasis::Empty)
+ .set_price(share_price);
+ })
+ .build_posting(
+ self.config.portfolio_fee_template.render(&context),
+ |posting| {
+ posting.set_amount(Amount::new(fees, payment_currency));
+ },
+ )
+ .build_posting(
+ self.config.capital_gains_account.render(&context),
+ |_posting| {},
+ );
+ }
+
+ TransactionKind::Reinvest => {
+ transaction.build_posting(
+ self.config.account_template.render(&context),
+ |posting| {
+ posting
+ .set_amount(shares_amount)
+ .set_cost(CostBasis::PerUnit(share_price));
+ },
+ );
+
+ if !fees.is_zero() {
+ let fees = Amount::new(fees, payment_currency);
+
+ transaction
+ .build_posting(self.config.fee_template.render(&context), |posting| {
+ posting.set_amount(fees);
+ })
+ .build_posting(self.config.reference_account.render(&context), |posting| {
+ posting.set_amount(-fees);
+ });
+ }
+
+ transaction.build_posting(
+ self.config.distributions_template.render(&context),
+ |posting| {
+ posting.set_amount(-investment_amount_payment);
+
+ if payment_currency != fund_currency {
+ // TODO instead of exchange rates, this could be tracked using Currency accounts
+ let exchange_rate = german_decimal::parse(&record[14]).unwrap();
+ posting.set_price(Amount::new(exchange_rate, fund_currency));
+ }
+ },
+ );
+ }
+
+ TransactionKind::SavingsPlanRebooking => {
+ transaction.build_posting(
+ self.config.account_template.render(&context),
+ |posting| {
+ posting
+ .set_amount(shares_amount)
+ .set_cost(CostBasis::PerUnit(share_price));
+ },
+ );
+
+ if !fees.is_zero() {
+ let fees = Amount::new(fees, payment_currency);
+
+ transaction
+ .build_posting(self.config.fee_template.render(&context), |posting| {
+ posting.set_amount(fees);
+ })
+ .build_posting(self.config.reference_account.render(&context), |posting| {
+ posting.set_amount(-fees);
+ });
+ }
+
+ transaction.build_posting(
+ self.config.reference_account.render(&context),
+ |posting| {
+ posting.set_amount(-investment_amount_payment);
+
+ if payment_currency != fund_currency {
+ // TODO instead of exchange rates, this could be tracked using Currency accounts
+ let exchange_rate = german_decimal::parse(&record[14]).unwrap();
+ posting.set_price(Amount::new(exchange_rate, fund_currency));
+ }
+ },
+ );
+ }
+
+ TransactionKind::SavingsPlanReversal => {
+ transaction.build_posting(
+ self.config.account_template.render(&context),
+ |posting| {
+ posting
+ .set_amount(shares_amount)
+ .set_cost(CostBasis::PerUnit(share_price));
+ },
+ );
+
+ if !fees.is_zero() {
+ let fees = Amount::new(fees, payment_currency);
+
+ transaction.build_posting(
+ self.config.fee_template.render(&context),
+ |posting| {
+ posting.set_amount(-fees);
+ },
+ );
+ }
+
+ transaction.build_posting(
+ self.config.reference_account.render(&context),
+ |posting| {
+ posting.set_amount(payment_amount);
+ },
+ );
+
+ if let Some(investment_amount_fund) = investment_amount_fund {
+ transaction
+ .build_posting(
+ self.config
+ .currency_account
+ .render(&CurrencyTemplateContext {
+ currency: {
+ let currency: &str = &payment_currency;
+ <&Seg>::try_from(currency)
+ .expect("commodities are valid segments")
+ },
+ }),
+ |posting| {
+ posting.set_amount(-investment_amount_payment);
+ },
+ )
+ .build_posting(
+ self.config
+ .currency_account
+ .render(&CurrencyTemplateContext {
+ currency: {
+ let currency: &str = &fund_currency;
+ <&Seg>::try_from(currency)
+ .expect("commodities are valid segments")
+ },
+ }),
+ |posting| {
+ posting.set_amount(investment_amount_fund);
+ },
+ );
+ }
+ }
+ };
+
+ let price = {
+ let date = parse_date(&record[PRICE_DATE_COLUMN_INDEX])?;
+ Price::new(date, fund_commodity, share_price)
+ };
+
+ Ok(vec![Directive::from(transaction), Directive::from(price)])
+ }
+ }
+
+ impl ImporterBuilder {
+ pub fn build(&self) -> Result<Importer, ImporterBuilderError> {
+ let config =
+ Config {
+ account_template: self.account_template.clone().context(
+ UninitializedFieldSnafu {
+ field: "account_template",
+ importer: Importer::NAME,
+ },
+ )?,
+
+ capital_gains_account: self.capital_gains_account.clone().context(
+ UninitializedFieldSnafu {
+ field: "capital_gains_account",
+ importer: Importer::NAME,
+ },
+ )?,
+
+ currency_account: self.currency_account.clone().context(
+ UninitializedFieldSnafu {
+ field: "currency_account",
+ importer: Importer::NAME,
+ },
+ )?,
+
+ distributions_template: self.distributions_template.clone().context(
+ UninitializedFieldSnafu {
+ field: "distributions_template",
+ importer: Importer::NAME,
+ },
+ )?,
+
+ fee_template: self.fee_template.clone().context(UninitializedFieldSnafu {
+ field: "fee_template",
+ importer: Importer::NAME,
+ })?,
+
+ payee: self.payee.clone().context(UninitializedFieldSnafu {
+ field: "payee",
+ importer: Importer::NAME,
+ })?,
+
+ portfolio_fee_template: self.portfolio_fee_template.clone().context(
+ UninitializedFieldSnafu {
+ field: "portfolio_fee_template",
+ importer: Importer::NAME,
+ },
+ )?,
+
+ reference_account: self.reference_account.clone().context(
+ UninitializedFieldSnafu {
+ field: "reference_account",
+ importer: Importer::NAME,
+ },
+ )?,
+ };
+
+ Ok(Importer::new(config))
+ }
+ }
+
+ #[derive(Clone, Copy, Debug)]
+ pub enum TemplateSelector {
+ DepotId,
+ Position,
+ }
+
+ impl FromStr for TemplateSelector {
+ type Err = TemplateSelectorError;
+
+ fn from_str(selector: &str) -> Result<Self, Self::Err> {
+ let selector = match selector {
+ "depot_id" => Self::DepotId,
+ "position" => Self::Position,
+ selector => return TemplateSelectorSnafu { selector }.fail(),
+ };
+
+ Ok(selector)
+ }
+ }
+
+ #[derive(Debug, Diagnostic, Snafu)]
+ #[snafu(display("unsupported context selector: {selector:?}"))]
+ pub struct TemplateSelectorError {
+ pub selector: String,
+
+ pub backtrace: Backtrace,
+ }
+
+ #[derive(Clone, Copy, Debug)]
+ struct CurrencyTemplateContext<'c> {
+ pub currency: &'c Seg,
+ }
+
+ impl Index<&CurrencyTemplateSelector> for CurrencyTemplateContext<'_> {
+ type Output = Seg;
+
+ fn index(&self, index: &CurrencyTemplateSelector) -> &Self::Output {
+ match index {
+ CurrencyTemplateSelector::Currency => self.currency,
+ }
+ }
+ }
+
+ #[derive(Clone, Copy, Debug)]
+ struct TemplateContext<'c> {
+ pub depot_id: &'c Seg,
+ pub position: &'c Seg,
+ }
+
+ impl Index<&TemplateSelector> for TemplateContext<'_> {
+ type Output = Seg;
+
+ fn index(&self, index: &TemplateSelector) -> &Self::Output {
+ match index {
+ TemplateSelector::DepotId => self.depot_id,
+ TemplateSelector::Position => self.position,
+ }
+ }
+ }
+
+ #[derive(Debug)]
+ enum TransactionKind {
+ PortfolioFee,
+ Purchase,
+ Reinvest,
+ SavingsPlan,
+ SavingsPlanRebooking,
+ SavingsPlanReversal,
+ }
+
+ impl TransactionKind {
+ fn format_narration(&self, fund: &str) -> String {
+ match self {
+ TransactionKind::PortfolioFee => format!("Sell {fund} to cover portfolio fees"),
+ TransactionKind::Purchase => format!("Buy {fund}"),
+ TransactionKind::Reinvest => format!("Reinvest distribution of {fund}"),
+ TransactionKind::SavingsPlan => format!("Savings plan for {fund}"),
+ TransactionKind::SavingsPlanRebooking => format!("Rebook savings plan for {fund}"),
+ TransactionKind::SavingsPlanReversal => format!("Reverse savings plan for {fund}"),
+ }
+ }
+ }
+
+ impl TryFrom<&str> for TransactionKind {
+ type Error = Error;
+
+ fn try_from(value: &str) -> Result<Self, Self::Error> {
+ let kind = match value {
+ "Ansparplan" => Self::SavingsPlan,
+ "Entgeltbelastung Verkauf" => Self::PortfolioFee,
+ "Kauf" => Self::Purchase,
+ "Neuabrechnung Ansparplan" => Self::SavingsPlanRebooking,
+ "Stornierung Ansparplan" => Self::SavingsPlanReversal,
+ "Wiederanlage Fondsertrag" => Self::Reinvest,
+
+ _ => return UnsupportedTransactionKindSnafu { value }.fail(),
+ };
+
+ Ok(kind)
+ }
+ }
+
+ pub fn parse_transaction_date(record: &Record) -> Result<Date, Error> {
+ const TRANSACTION_DATE_COLUMN_INDEX: usize = 3;
+
+ parse_date(&record[TRANSACTION_DATE_COLUMN_INDEX])
+ }
+
+ pub fn parse_date(date: &str) -> Result<Date, Error> {
+ const BASE_YEAR: i32 = 2000;
+ const DATE_FORMAT: &[time::format_description::FormatItem] =
+ format_description!("[day].[month].[year repr:last_two]");
+
+ let mut parsed = Parsed::new();
+ parsed.parse_items(date.as_bytes(), DATE_FORMAT).unwrap();
+
+ let year_last_two = i32::from(parsed.year_last_two().unwrap());
+ parsed.set_year(BASE_YEAR + year_last_two);
+
+ parsed.try_into().map_err(Error::from)
+ }
+
+ fn parse_decimal_with_precision(
+ decimal: &str,
+ precision: u32,
+ ) -> Result<Decimal, rust_decimal::Error> {
+ german_decimal::parse(decimal).tap_ok_mut(|amount| {
+ amount.rescale(precision);
+ })
+ }