Add importer for ECUS transactions

korrat
Jun 16, 2023, 4:02 PM
PUJBHX7CH4GQS5EQLQSGUG46QGB3TIUXIOBTKNPZISMNJXY6TMAAC

Dependencies

  • [2] KB7Y4PJI Implement importers for Amazon accounts
  • [3] R7S2CWF7 Add type for account segments
  • [4] YDK6X6PP add a library of important types for beancount
  • [5] 2JBFREZG enable additional warnings

Change contents

  • file addition: ecus (d--r------)
    [2.1]
  • file addition: src (d--r------)
    [0.1]
  • file addition: lib.rs (---r------)
    [0.19]
    use core::str::FromStr as _;
    use std::collections::HashMap;
    use beancount_importers_framework::error::ImporterBuilderError;
    use beancount_importers_framework::error::UninitializedFieldSnafu;
    use beancount_types::Account;
    use beancount_types::Amount;
    use beancount_types::Commodity;
    use beancount_types::Directive;
    use beancount_types::MetadataKey;
    use beancount_types::Transaction;
    use camino::Utf8Path;
    use derive_builder::Builder;
    use miette::IntoDiagnostic;
    use rust_decimal::Decimal;
    use serde::Deserialize;
    use serde::Deserializer;
    use snafu::OptionExt as _;
    use time::format_description::well_known::Rfc3339;
    use time::macros::format_description;
    use time::PrimitiveDateTime;
    use time_tz::PrimitiveDateTimeExt;
    use tracing::warn;
    #[derive(Builder, Clone, Debug, Deserialize)]
    #[builder(
    build_fn(error = "ImporterBuilderError", skip),
    name = "ImporterBuilder"
    )]
    pub struct Config {
    #[builder(setter(into), try_setter)]
    pub base_account: Account,
    #[builder(field(type = "HashMap<String, LocationInformation>"))]
    pub locations: HashMap<String, LocationInformation>,
    #[builder(field(type = "Option<String>"), setter(into, strip_option))]
    pub payee: Option<String>,
    #[builder(setter(into), try_setter)]
    pub reference_account: Account,
    }
    #[derive(Debug, Deserialize)]
    pub struct Importer {
    config: Config,
    }
    impl Importer {
    pub const NAME: &str = "ecus/transactions";
    pub fn new(config: Config) -> Self {
    Self { config }
    }
    pub fn builder() -> ImporterBuilder {
    ImporterBuilder::default()
    }
    }
    impl beancount_importers_framework::ImporterProtocol for Importer {
    type Error = miette::Report;
    fn account(&self, _file: &Utf8Path) -> Result<Account, Self::Error> {
    todo!()
    }
    fn extract(
    &self,
    file: &Utf8Path,
    _existing: &[Directive],
    ) -> Result<Vec<Directive>, Self::Error> {
    let ecus_transactions: Vec<EcusTransaction> = {
    let data = std::fs::read_to_string(file).into_diagnostic()?;
    serde_json::from_str(&data).into_diagnostic()?
    };
    let transactions = ecus_transactions
    .into_iter()
    .filter_map(|ecus_transaction| {
    let EcusTransaction {
    id,
    date,
    location,
    checkout_counter,
    typ,
    mut amount,
    } = ecus_transaction;
    let mut transaction = Transaction::on(date.date());
    let timestamp = date.assume_timezone(time_tz::timezones::db::CET).unwrap();
    if let Some(payee) = &self.config.payee {
    transaction.set_payee(payee);
    }
    let (narration, target_account) = match typ {
    TransactionType::Sale => {
    if let Some(LocationInformation { narration, account }) =
    self.config.locations.get(&location)
    {
    (narration.as_deref(), account)
    } else {
    warn!(%location, "ignoring transaction at unknown location");
    return None;
    }
    }
    TransactionType::Card => (Some("ECUS Charge"), &self.config.reference_account),
    };
    let commodity = Commodity::try_from("EUR").unwrap();
    amount.rescale(2);
    let amount = Amount::new(amount, commodity);
    if let Some(narration) = narration {
    transaction.set_narration(narration);
    }
    transaction
    .add_meta(MetadataKey::from_str("transaction-id").unwrap(), id)
    .add_meta(MetadataKey::from_str("counter").unwrap(), checkout_counter)
    .add_meta(
    MetadataKey::from_str("timestamp").unwrap(),
    timestamp.format(&Rfc3339).unwrap(),
    )
    .build_posting(&self.config.base_account, |posting| {
    posting.set_amount(amount);
    })
    .build_posting(target_account, |_| {});
    Some(transaction)
    })
    .map(Directive::from)
    .collect();
    Ok(transactions)
    }
    fn identify(&self, _file: &Utf8Path) -> Result<bool, Self::Error> {
    Ok(false)
    }
    fn name(&self) -> &'static str {
    Self::NAME
    }
    #[doc(hidden)]
    fn typetag_deserialize(&self) {}
    }
    impl ImporterBuilder {
    pub fn clear_locations(&mut self) -> &mut Self {
    self.locations.clear();
    self
    }
    pub fn add_location(
    &mut self,
    name: impl Into<String>,
    info: LocationInformation,
    ) -> &mut Self {
    self.locations.insert(name.into(), info);
    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,
    })?,
    locations: self.locations.clone(),
    payee: self.payee.clone(),
    reference_account: self
    .reference_account
    .clone()
    .context(UninitializedFieldSnafu {
    field: "reference_account",
    importer: Importer::NAME,
    })?,
    };
    Ok(Importer::new(config))
    }
    }
    #[derive(Clone, Debug, Deserialize)]
    pub struct LocationInformation {
    pub account: Account,
    pub narration: Option<String>,
    }
    #[derive(Debug, Deserialize)]
    struct EcusTransaction {
    #[serde(rename = "transFullId")]
    id: String,
    #[serde(rename = "datum", deserialize_with = "german_date_time")]
    date: PrimitiveDateTime,
    #[serde(rename = "ortName")]
    location: String,
    #[serde(rename = "kaName")]
    checkout_counter: String,
    #[serde(rename = "typName")]
    typ: TransactionType,
    #[serde(rename = "zahlBetrag")]
    amount: Decimal,
    }
    #[derive(Debug, Deserialize)]
    enum TransactionType {
    #[serde(rename = "Karte")]
    Card,
    #[serde(rename = "Verkauf")]
    Sale,
    }
    fn german_date_time<'de, D>(deserializer: D) -> Result<PrimitiveDateTime, D::Error>
    where
    D: Deserializer<'de>,
    {
    deserializer.deserialize_str({
    struct Visitor;
    impl serde::de::Visitor<'_> for Visitor {
    type Value = PrimitiveDateTime;
    fn expecting(&self, _formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
    todo!()
    }
    fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
    where
    E: serde::de::Error,
    {
    PrimitiveDateTime::parse(
    v,
    format_description!("[day].[month].[year] [hour]:[minute]"),
    )
    .map_err(E::custom)
    }
    }
    Visitor
    })
    }
  • file addition: Cargo.toml (---r------)
    [0.1]
    [package]
    name = "ecus"
    edition.workspace = true
    publish.workspace = true
    rust-version.workspace = true
    version.workspace = true
    [dependencies]
    # Workspace dependencies
    beancount-importers-framework.path = "../../framework"
    beancount-pretty-printer.path = "../../common/beancount-pretty-printer"
    beancount-tree-writer.path = "../../common/beancount-tree-writer"
    beancount-types.path = "../../common/beancount-types"
    # Inherited dependencies
    camino.workspace = true
    color-eyre.workspace = true
    derive_builder.workspace = true
    itertools.workspace = true
    miette.workspace = true
    rust_decimal.workspace = true
    serde.workspace = true
    serde_json.workspace = true
    snafu.workspace = true
    time-tz.workspace = true
    time.workspace = true
    tracing-error.workspace = true
    tracing-subscriber.workspace = true
    tracing.workspace = true
    futures = "0.3.28"
    [dependencies.reqwest]
    features = ["json"]
    version = "0.11.17"
    [dependencies.tokio]
    features = ["full"]
    version = "1.28.1"
  • replacement in beancount/account/src/lib.rs at line 676
    [3.24516][3.24516:24540]()
    #[derive(Debug, Snafu)]
    [3.24516]
    [3.24540]
    #[derive(Debug, Diagnostic, Snafu)]