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 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::Date;
use time::PrimitiveDateTime;
use time_tz::PrimitiveDateTimeExt;
#[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, _buffer: &[u8]) -> Result<Account, Self::Error> {
Ok(self.config.base_account.clone())
}
fn date(&self, buffer: &[u8]) -> Option<Result<Date, Self::Error>> {
let ecus_transactions: Vec<EcusTransaction> = match serde_json::from_slice(buffer) {
Ok(data) => data,
Err(error) => return Some(Err(error).into_diagnostic()),
};
ecus_transactions
.into_iter()
.map(|EcusTransaction { date, .. }| date.date())
.max()
.map(Ok)
}
fn extract(
&self,
buffer: &[u8],
_existing: &[Directive],
) -> Result<Vec<Directive>, Self::Error> {
let ecus_transactions: Vec<EcusTransaction> =
serde_json::from_slice(buffer).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 {
tracing::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 filename(&self, _buffer: &[u8]) -> Option<Result<String, Self::Error>> {
Some(Ok(String::from("ecus-transactions.json")))
}
fn identify(&self, buffer: &[u8]) -> Result<bool, Self::Error> {
match serde_json::from_slice::<Vec<EcusTransaction>>(buffer) {
Ok(_) => Ok(true),
Err(_) => 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
})
}