+ 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
+ })
+ }