+ 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 = ∈
+ // 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!())
+ }