+ 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::Directive;
+ use beancount_types::Link;
+ use beancount_types::Seg;
+ use beancount_types::Transaction;
+ use camino::Utf8Path;
+ use derive_builder::Builder;
+ use miette::Diagnostic;
+ use miette::IntoDiagnostic as _;
+ use rust_decimal::Decimal;
+ use serde::Deserialize;
+ use snafu::Backtrace;
+ use snafu::OptionExt as _;
+ use snafu::Snafu;
+ use time::format_description::well_known::Iso8601;
+ use time::format_description::well_known::Rfc3339;
+ use time::Date;
+ use time::OffsetDateTime;
+ use time::PrimitiveDateTime;
+ use time_tz::system;
+ use time_tz::PrimitiveDateTimeExt;
+
+ #[derive(Debug, Diagnostic, Snafu)]
+ pub enum Error {}
+
+ #[derive(Builder, Clone, Debug, Deserialize)]
+ #[builder(
+ build_fn(error = "ImporterBuilderError", skip),
+ name = "ImporterBuilder",
+ setter(into),
+ try_setter
+ )]
+ pub struct Config {
+ pub balance_account: AccountTemplate<TemplateSelector>,
+
+ pub claim_account: AccountTemplate<TemplateSelector>,
+
+ pub payee: String,
+
+ pub payment_account: AccountTemplate<TemplateSelector>,
+ }
+
+ #[derive(Debug, Deserialize)]
+ pub struct Importer {
+ #[serde(flatten)]
+ pub config: Config,
+
+ #[serde(skip_deserializing)]
+ pub importer: csv::Importer,
+ }
+
+ impl Importer {
+ pub const NAME: &str = "apple/store-balance";
+
+ pub fn new(config: Config) -> Self {
+ let importer = csv::Importer::default();
+ Self { config, importer }
+ }
+
+ 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> {
+ Ok(self.config.balance_account.base().to_owned())
+ }
+
+ fn date(&self, file: &Utf8Path) -> Option<Result<Date, Self::Error>> {
+ self.importer
+ .date(file, self)
+ .map(|result| result.map_err(Self::Error::from))
+ }
+
+ fn extract(
+ &self,
+ file: &Utf8Path,
+ _existing: &[Directive],
+ ) -> Result<Vec<Directive>, Self::Error> {
+ self.importer
+ .extract(file, _existing, self)
+ .map_err(miette::Report::from)
+ }
+
+ fn filename(&self, _file: &Utf8Path) -> Option<Result<String, Self::Error>> {
+ Some(Ok(String::from("store-balance.csv")))
+ }
+
+ fn identify(&self, file: &Utf8Path) -> Result<bool, Self::Error> {
+ const EXPECTED_HEADERS: &[&str] = &[
+ "Transaction date",
+ "Country",
+ "Order number",
+ "Amount",
+ "Transaction type",
+ ];
+
+ self.importer
+ .identify(file, EXPECTED_HEADERS)
+ .into_diagnostic()
+ }
+
+ fn name(&self) -> &'static str {
+ Self::NAME
+ }
+
+ #[doc(hidden)]
+ 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_record_timestamp(record).map(OffsetDateTime::date))
+ }
+
+ fn extract(
+ &self,
+ _existing: &[Directive],
+ record: &csv::Record,
+ ) -> Result<Vec<Directive>, Self::Error> {
+ const AMOUNT_COLUMN_INDEX: usize = 3;
+ const COUNTRY_COLUMN_INDEX: usize = 1;
+ const ORDER_ID_COLUMN_INDEX: usize = 2;
+ const TRANSACTION_KIND_COLUMN_INDEX: usize = 4;
+
+ let timestamp = parse_record_timestamp(record)?;
+
+ let country = &record[COUNTRY_COLUMN_INDEX];
+ let commodity = match country {
+ "DE" => Commodity::try_from("EUR").unwrap(),
+ _ => todo!(),
+ };
+
+ let order_id = &record[ORDER_ID_COLUMN_INDEX];
+
+ let kind = TransactionKind::try_from(&record[TRANSACTION_KIND_COLUMN_INDEX])?;
+ let amount = {
+ let amount = &record[AMOUNT_COLUMN_INDEX];
+ let amount = Decimal::from_str(amount).map_err(|_| todo!())?;
+ Amount::new(amount, commodity)
+ };
+
+ let context = {
+ let currency: &str = &amount.commodity;
+ // TODO make this conversion in beancount_types
+ let currency = <&Seg>::try_from(currency).expect("commodities are valid segments");
+ TemplateContext { currency }
+ };
+
+ let transaction = match kind {
+ TransactionKind::Debit => {
+ let order_id = {
+ let (order_id, _) = order_id.rsplit_once('-').ok_or_else(|| todo!())?;
+ order_id
+ };
+
+ let mut transaction = Transaction::on(timestamp.date());
+ transaction
+ .set_payee(&self.config.payee)
+ .set_narration(format!("Payment towards order {order_id}"))
+ .add_link(Link::try_from(format!("^apple.{order_id}")).map_err(|_| todo!())?);
+
+ transaction
+ .add_meta(
+ common_keys::TIMESTAMP,
+ timestamp.format(&Rfc3339).map_err(|_| todo!())?,
+ )
+ .add_meta(common_keys::TRANSACTION_ID, order_id)
+ .build_posting(self.config.balance_account.render(&context), |posting| {
+ posting.set_amount(-amount);
+ })
+ .build_posting(self.config.payment_account.render(&context), |_| {});
+
+ transaction
+ }
+
+ TransactionKind::Redeem => {
+ let mut transaction = Transaction::on(timestamp.date());
+
+ transaction
+ .set_payee(&self.config.payee)
+ .set_narration(String::from("Gift card redemption"))
+ .add_meta(
+ common_keys::TIMESTAMP,
+ timestamp.format(&Rfc3339).map_err(|_| todo!())?,
+ )
+ .add_meta(common_keys::TRANSACTION_ID, order_id)
+ .build_posting(self.config.balance_account.render(&context), |posting| {
+ posting.set_amount(amount);
+ })
+ .build_posting(self.config.claim_account.render(&context), |_| {});
+
+ transaction
+ }
+ };
+
+ Ok(vec![Directive::from(transaction)])
+ }
+ }
+
+ impl ImporterBuilder {
+ pub fn build(&self) -> Result<Importer, ImporterBuilderError> {
+ let config = Config {
+ balance_account: self
+ .balance_account
+ .clone()
+ .context(UninitializedFieldSnafu {
+ field: "balance_account",
+ importer: Importer::NAME,
+ })?,
+
+ claim_account: self
+ .claim_account
+ .clone()
+ .context(UninitializedFieldSnafu {
+ field: "claim_account",
+ importer: Importer::NAME,
+ })?,
+
+ payee: self.payee.clone().context(UninitializedFieldSnafu {
+ field: "payee",
+ importer: Importer::NAME,
+ })?,
+
+ payment_account: self
+ .payment_account
+ .clone()
+ .context(UninitializedFieldSnafu {
+ field: "payment_account",
+ importer: Importer::NAME,
+ })?,
+ };
+
+ Ok(Importer::new(config))
+ }
+ }
+
+ #[derive(Clone, Copy, Debug)]
+ pub struct TemplateContext<'c> {
+ currency: &'c Seg,
+ }
+
+ impl<'c> Index<&TemplateSelector> for TemplateContext<'c> {
+ type Output = Seg;
+
+ fn index(&self, selector: &TemplateSelector) -> &Self::Output {
+ match selector {
+ TemplateSelector::Currency => self.currency,
+ }
+ }
+ }
+
+ #[derive(Clone, Copy, Debug)]
+ pub enum TemplateSelector {
+ Currency,
+ }
+
+ impl FromStr for TemplateSelector {
+ type Err = TemplateSelectorError;
+
+ fn from_str(selector: &str) -> Result<Self, Self::Err> {
+ let selector = match selector {
+ "currency" => Self::Currency,
+ selector => return TemplateSelectorSnafu { selector }.fail(),
+ };
+
+ Ok(selector)
+ }
+ }
+
+ #[derive(Debug, Diagnostic, Snafu)]
+ #[snafu(display("unsupported context selector: {selector:?}"))]
+ pub struct TemplateSelectorError {
+ selector: String,
+
+ backtrace: Backtrace,
+ }
+
+ #[derive(Clone, Copy, Debug)]
+ enum TransactionKind {
+ Debit,
+ Redeem,
+ }
+
+ impl<'d> TryFrom<&'d str> for TransactionKind {
+ type Error = Error;
+
+ fn try_from(kind: &'d str) -> Result<Self, Self::Error> {
+ match kind {
+ "DEBIT" => Ok(Self::Debit),
+ "REDEEM" => Ok(Self::Redeem),
+ _ => todo!("unsupported description {kind:?}"),
+ }
+ }
+ }
+
+ fn parse_record_timestamp(record: &csv::Record) -> Result<OffsetDateTime, Error> {
+ const DATE_COLUMN_INDEX: usize = 0;
+
+ let timestamp = PrimitiveDateTime::parse(&record[DATE_COLUMN_INDEX], &Iso8601::PARSING)
+ .map_err(|error| {
+ dbg!(error);
+ todo!()
+ })?;
+
+ let tz = system::get_timezone().map_err(|_| todo!())?;
+ timestamp
+ .assume_timezone(tz)
+ .take_first()
+ .ok_or_else(|| todo!())
+ }