Add importers for ebase accounts

korrat
Jun 16, 2023, 3:21 PM
D6LJRTWXZZTEJPCUTKTHMIKR5R3UYZF4WKN2GV3P432HNHUJCVVAC

Dependencies

  • [2] KB7Y4PJI Implement importers for Amazon accounts

Change contents

  • file addition: ebase (d--r------)
    [2.1]
  • file addition: src (d--r------)
    [0.1]
  • file addition: transactions.rs (---r------)
    [0.20]
    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);
    })
    }
  • file addition: lib.rs (---r------)
    [0.20]
    pub mod balances;
    pub mod transactions;
  • file addition: balances.rs (---r------)
    [0.20]
    use core::ops::Index;
    use core::str::FromStr;
    use beancount_importers_framework::error::ImporterBuilderError;
    use beancount_importers_framework::error::UninitializedFieldSnafu;
    use beancount_types::AccountTemplate;
    use beancount_types::Amount;
    use beancount_types::Balance;
    use beancount_types::Commodity;
    use beancount_types::Directive;
    use beancount_types::Price;
    use beancount_types::Seg;
    use camino::Utf8Path;
    use csv::Record;
    use derive_builder::Builder;
    use miette::Diagnostic;
    use miette::IntoDiagnostic as _;
    use miette::Report;
    use serde::Deserialize;
    use snafu::Backtrace;
    use snafu::OptionExt as _;
    use snafu::ResultExt as _;
    use snafu::Snafu;
    use time::macros::format_description;
    use time::Date;
    #[derive(Builder, Clone, Debug, Deserialize)]
    #[builder(
    build_fn(error = "ImporterBuilderError", skip),
    name = "ImporterBuilder",
    setter(into),
    try_setter
    )]
    pub struct Config {
    pub balance_account: AccountTemplate<TemplateSelector>,
    }
    #[derive(Debug, Diagnostic, Snafu)]
    pub enum Error {
    #[snafu(display("error while parsing date"))]
    DateFormat { source: time::Error },
    #[snafu(display("could not parse position {currency:?}"))]
    Currency {
    backtrace: Backtrace,
    currency: String,
    source: <Commodity as TryFrom<&'static str>>::Error,
    },
    #[snafu(display("could not parse position {isin:?}"))]
    Isin {
    backtrace: Backtrace,
    isin: String,
    source: <Commodity as TryFrom<&'static str>>::Error,
    },
    #[snafu(display("could not parse position {position:?}"))]
    Position {
    backtrace: Backtrace,
    source: core::num::ParseIntError,
    position: String,
    },
    #[snafu(display("could not parse share price {share_price:?}"))]
    SharePrice {
    backtrace: Backtrace,
    source: rust_decimal::Error,
    share_price: String,
    },
    #[snafu(display("could not parse shares {shares:?}"))]
    Shares {
    backtrace: Backtrace,
    source: rust_decimal::Error,
    shares: String,
    },
    }
    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/balances";
    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.balance_account.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("balances.csv")))
    }
    fn identify(&self, file: &Utf8Path) -> Result<bool, Self::Error> {
    const EXPECTED_HEADERS: &[&str] = &[
    "Depotnummer",
    "Position",
    "Fondsname / Produkt",
    "ISIN",
    "WKN",
    "Anteile",
    "Anteilswert",
    "Währung (Anteilswert)",
    "Kursdatum",
    "Devisenkurs",
    "+/- Vortag (absolut)",
    "+/- Vortag (relativ)",
    "Gewinn und Verlust seit Monatsanfang",
    "Gewinn und Verlust seit Jahresanfang",
    "Gewinn und Verlust seit Eröffnung",
    "Bestand in Euro",
    ];
    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_price_date(record))
    }
    fn extract(
    &self,
    _existing: &[Directive],
    record: &Record,
    ) -> Result<Vec<Directive>, Self::Error> {
    const CURRENCY_COLUMN_INDEX: usize = 7;
    const DEPOT_ID_COLUMN_INDEX: usize = 0;
    const DEPOT_POSITION_COLUMN_INDEX: usize = 1;
    const ISIN_COLUMN_INDEX: usize = 3;
    const SHARE_PRICE_COLUMN_INDEX: usize = 6;
    const SHARES_COLUMN_INDEX: usize = 5;
    let date = parse_price_date(record)?;
    let depot_id = &record[DEPOT_ID_COLUMN_INDEX];
    let position = {
    let position = &record[DEPOT_POSITION_COLUMN_INDEX];
    let position = usize::from_str(position).context(PositionSnafu { position })?;
    format!("{:02}", position)
    };
    let context = TemplateContext {
    depot_id: <&Seg>::try_from(depot_id).unwrap(),
    position: <&Seg>::try_from(&*position).unwrap(),
    };
    let isin = {
    let isin = &record[ISIN_COLUMN_INDEX];
    Commodity::try_from(isin).context(IsinSnafu { isin })?
    };
    let total_shares = {
    let shares = &record[SHARES_COLUMN_INDEX];
    let shares = german_decimal::parse(shares).context(SharesSnafu { shares })?;
    Amount::new(shares, isin)
    };
    let balance = Balance::new(
    date,
    self.config.balance_account.render(&context),
    total_shares,
    );
    let share_price = {
    let share_price = &record[SHARE_PRICE_COLUMN_INDEX];
    let amount =
    german_decimal::parse(share_price).context(SharePriceSnafu { share_price })?;
    let currency = &record[CURRENCY_COLUMN_INDEX];
    let commodity = Commodity::try_from(currency).context(CurrencySnafu { currency })?;
    Amount::new(amount, commodity)
    };
    let price = Price::new(date, isin, share_price);
    Ok(vec![Directive::from(balance), Directive::from(price)])
    }
    }
    impl ImporterBuilder {
    pub fn build(&self) -> Result<Importer, ImporterBuilderError> {
    let config = Config {
    balance_account: self
    .balance_account
    .clone()
    .context(UninitializedFieldSnafu {
    field: "balance_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(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,
    }
    }
    }
    pub fn parse_price_date(record: &Record) -> Result<Date, Error> {
    const PRICE_DATE_COLUMN_INDEX: usize = 8;
    parse_date(&record[PRICE_DATE_COLUMN_INDEX])
    }
    pub fn parse_date(date: &str) -> Result<Date, Error> {
    use time::format_description::FormatItem;
    const DATE_FORMAT: &[FormatItem] = format_description!("[day].[month].[year]");
    Date::parse(date, DATE_FORMAT)
    .map_err(time::Error::from)
    .context(DateFormatSnafu {})
    }
  • file addition: Cargo.toml (---r------)
    [0.1]
    [package]
    name = "ebase"
    authors.workspace = true
    edition.workspace = true
    publish.workspace = true
    rust-version.workspace = true
    version.workspace = true
    [dependencies]
    # Workspace dependencies
    beancount-importers-framework.path = "../../framework"
    beancount-types.path = "../../common/beancount-types"
    csv.path = "../csv"
    german-decimal.path = "../../common/german-decimal"
    # Inherited dependencies
    camino.workspace = true
    derive_builder.workspace = true
    inventory.workspace = true
    itertools.workspace = true
    miette.workspace = true
    rust_decimal.workspace = true
    serde.workspace = true
    snafu.workspace = true
    tap.workspace = true
    time.workspace = true