Add a importer account for BW bank portfolios

korrat
Jun 21, 2023, 6:43 PM
BDLVPDJZU5BPXRS5DTAUPJ5H6JWURRGS2NKKNCA43HGQGRFIKGIAC

Dependencies

  • [2] SLTVZLYX Upgrade dependencies
  • [*] KB7Y4PJI Implement importers for Amazon accounts
  • [*] I2P2FTLE add basic parser for german decimals
  • [*] TB2QGHXN Upgrade dependencies
  • [*] 5S4MZHL5 pretty print decimals using icu

Change contents

  • file addition: bw-bank (d--r------)
    [4.1]
  • file addition: src (d--r------)
    [0.1]
  • file addition: portfolio.rs (---r------)
    [0.22]
    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::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::Price;
    use beancount_types::Seg;
    use beancount_types::Transaction;
    use derive_builder::Builder;
    use miette::Diagnostic;
    use miette::IntoDiagnostic;
    use serde::Deserialize;
    use snafu::Backtrace;
    use snafu::OptionExt as _;
    use snafu::Snafu;
    use tap::Tap as _;
    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 account_template: AccountTemplate<TemplateSelector>,
    pub capital_gains_template: AccountTemplate<TemplateSelector>,
    pub payee: String,
    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 = "bw-bank/portfolio-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_template.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] = &[
    "Isin",
    "Wertpapierbezeichnung",
    "Datum",
    "Ordernummer",
    "Geschäftsart",
    "Stück",
    "Einheit",
    "Kurs",
    "Devisenkurs",
    "Kurswert",
    ];
    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 ORDER_ID_COLUMN_INDEX: usize = 3;
    let isin = Commodity::try_from(&record[0]).map_err(|_| todo!())?;
    let security_name = &record[1];
    let date = parse_order_date(record)?;
    let order_id = &record[ORDER_ID_COLUMN_INDEX];
    let transaction_kind = TransactionKind::from_str(&record[4])?;
    let units = german_decimal::parse(&record[5]).map_err(|_| todo!())?;
    let amount = Amount::new(units, isin);
    let security_price = parse_amount(&record[7])?;
    let total = parse_amount(&record[9])?;
    let (amount, cost_basis, price, total) = match transaction_kind {
    TransactionKind::Buy => (amount, CostBasis::PerUnit(security_price), None, -total),
    TransactionKind::Sell => (-amount, CostBasis::Empty, Some(security_price), total),
    };
    let transaction = Transaction::on(date).tap_mut(|transaction| {
    let context = {
    let isin: &str = &isin;
    // TODO make this conversion in beancount_types
    let isin = <&Seg>::try_from(isin).expect("commodities are valid segments");
    TemplateContext { isin }
    };
    transaction
    .set_payee(&self.config.payee)
    .set_narration(format!("{transaction_kind:?} {security_name}"))
    .add_link(Link::try_from(format!("^bw-bank.{order_id}")).unwrap())
    .add_meta(common_keys::TRANSACTION_ID, order_id)
    .build_posting(self.config.account_template.render(&context), |posting| {
    posting.set_amount(amount).set_cost(cost_basis);
    posting.price = price;
    })
    .build_posting(self.config.reference_account.render(&context), |posting| {
    posting.set_amount(total);
    });
    if let TransactionKind::Sell = transaction_kind {
    transaction
    .build_posting(self.config.capital_gains_template.render(&context), |_| {});
    }
    });
    let price = Price::new(date, isin, security_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_template: self.capital_gains_template.clone().context(
    UninitializedFieldSnafu {
    field: "capital_gains_template",
    importer: Importer::NAME,
    },
    )?,
    payee: self.payee.clone().context(UninitializedFieldSnafu {
    field: "payee",
    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 {
    Isin,
    }
    impl FromStr for TemplateSelector {
    type Err = TemplateSelectorError;
    fn from_str(selector: &str) -> Result<Self, Self::Err> {
    match selector {
    "isin" => Ok(Self::Isin),
    _ => TemplateSelectorSnafu { selector }.fail(),
    }
    }
    }
    #[derive(Debug, Diagnostic, Snafu)]
    #[snafu(display("unsupported context selector: {selector:?}"))]
    pub struct TemplateSelectorError {
    selector: String,
    backtrace: Backtrace,
    }
    #[derive(Debug)]
    struct TemplateContext<'c> {
    isin: &'c Seg,
    }
    impl Index<&TemplateSelector> for TemplateContext<'_> {
    type Output = Seg;
    fn index(&self, index: &TemplateSelector) -> &Self::Output {
    match index {
    TemplateSelector::Isin => self.isin,
    }
    }
    }
    #[derive(Clone, Copy, Debug)]
    enum TransactionKind {
    Buy,
    Sell,
    }
    impl FromStr for TransactionKind {
    type Err = Error;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
    match s {
    "Kauf" => Ok(Self::Buy),
    "Verkauf" => Ok(Self::Sell),
    _ => todo!(),
    }
    }
    }
    fn parse_amount(cost: &str) -> Result<Amount, Error> {
    let (amount, commodity) = cost.split_once(' ').ok_or_else(|| todo!())?;
    let amount = german_decimal::parse(amount).map_err(|_| todo!())?;
    let commodity = Commodity::try_from(commodity).map_err(|_| todo!())?;
    Ok(Amount::new(amount, commodity))
    }
    fn parse_order_date(record: &csv::Record) -> Result<Date, Error> {
    use time::format_description::FormatItem;
    const DATE_COLUMN_INDEX: usize = 2;
    const DATE_FORMAT: &[FormatItem] = format_description!("[day].[month].[year]");
    Date::parse(&record[DATE_COLUMN_INDEX], DATE_FORMAT).map_err(|_| todo!())
    }
  • file addition: lib.rs (---r------)
    [0.22]
    pub mod portfolio;
  • file addition: Cargo.toml (---r------)
    [0.1]
    [package]
    name = "bw-bank"
    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
  • edit in Cargo.toml at line 11
    [2.196]
    [2.196]
    "importers/bw-bank",
  • edit in Cargo.lock at line 449
    [6.1601]
    [7.5325]
    [[package]]
    name = "bw-bank"
    version = "0.0.0-dev.0"
    dependencies = [
    "beancount-importers-framework",
    "beancount-types",
    "camino",
    "csv 0.0.0-dev.0",
    "derive_builder",
    "german-decimal",
    "inventory",
    "itertools",
    "miette",
    "rust_decimal",
    "serde",
    "snafu",
    "tap",
    "time 0.3.22",
    ]