Fork channel

Create a new channel as a copy of main.

Rename channel

Rename main to:

Delete channel

Delete main? This cannot be undone.

gift_card_balance.rs
use core::fmt::Display;
use core::ops::Index;
use core::str::FromStr;

use beancount_importers_framework::error::ImporterBuilderError;
use beancount_importers_framework::error::UninitializedFieldSnafu;
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::MetadataKey;
use beancount_types::Seg;
use beancount_types::Transaction;
use derive_builder::Builder;
use miette::Diagnostic;
use miette::IntoDiagnostic as _;
use rust_decimal::Decimal;
use serde::Deserialize;
use snafu::Backtrace;
use snafu::OptionExt;
use snafu::Snafu;
use tap::Tap as _;
use time::Date;

#[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 payment_account: AccountTemplate<TemplateSelector>,
}

#[derive(Debug, Deserialize)]
pub struct Importer {
    #[serde(flatten)]
    pub(crate) config: Config,

    #[serde(skip_deserializing)]
    pub(crate) importer: csv::Importer,
}

impl Importer {
    pub const NAME: &str = "amazon/gift-card-balance";

    pub fn new(config: Config) -> Self {
        let importer = csv::Importer::default();
        Self { config, importer }
    }

    pub fn builder() -> ImporterBuilder {
        ImporterBuilder::default()
    }
}

impl Importer {
    fn build_claim_transaction(
        &self,
        transaction: &mut Transaction,
        claim_code: &str,
        amount: Amount,
    ) {
        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 }
        };

        transaction
            .add_meta(MetadataKey::try_from("claim-code").unwrap(), claim_code)
            .build_posting(self.config.balance_account.render(&context), |posting| {
                posting.set_amount(amount);
            })
            .build_posting(self.config.claim_account.render(&context), |_| {});
    }

    fn build_payment_transaction(
        &self,
        transaction: &mut Transaction,
        order_id: &str,
        amount: Amount,
    ) {
        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 }
        };

        transaction
            .add_link(Link::try_from(format!("^amazon.{order_id}")).expect("valid link"))
            .add_meta(MetadataKey::try_from("order-id").unwrap(), order_id)
            .build_posting(self.config.balance_account.render(&context), |posting| {
                posting.set_amount(amount);
            })
            .build_posting(self.config.payment_account.render(&context), |_posting| {});
    }
}

impl beancount_importers_framework::ImporterProtocol for Importer {
    type Error = miette::Report;

    fn account(&self, _buffer: &[u8]) -> Result<Account, Self::Error> {
        Ok(self.config.balance_account.base().to_owned())
    }

    fn date(&self, buffer: &[u8]) -> Option<Result<Date, Self::Error>> {
        self.importer
            .date(buffer, self)
            .map(|result| result.map_err(Self::Error::from))
    }

    fn extract(
        &self,
        buffer: &[u8],
        _existing: &[Directive],
    ) -> Result<Vec<Directive>, Self::Error> {
        self.importer
            .extract(buffer, _existing, self)
            .map_err(miette::Report::from)
    }

    fn filename(&self, _buffer: &[u8]) -> Option<Result<String, Self::Error>> {
        Some(Ok(String::from("gift-card-transactions.csv")))
    }

    fn identify(&self, buffer: &[u8]) -> Result<bool, Self::Error> {
        const EXPECTED_HEADERS: &[&str] = &["Date", "Description", "Amount"];

        self.importer
            .identify(buffer, 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;
    type Record<'de> = Record<'de>;

    fn date(&self, record: Record) -> Date {
        record.date
    }

    fn extract(
        &self,
        _existing: &[Directive],
        record: Record,
    ) -> Result<Vec<Directive>, Self::Error> {
        let date = record.date;

        let amount = {
            let amount = record.amount;

            let (negative, amount) = amount
                .strip_prefix('-')
                .map_or((false, amount), |amount| (true, amount));
            let amount = amount.strip_prefix('').unwrap();

            let commodity = Commodity::try_from("EUR").unwrap();

            let amount = Decimal::from_str(amount).unwrap().tap_mut(|decimal| {
                decimal.rescale(2);
            });
            let amount = if negative { -amount } else { amount };
            Amount::new(amount, commodity)
        };

        let transaction = Transaction::on(date).tap_mut(|transaction| {
            transaction.set_narration(record.kind.to_string());

            match record.kind {
                TransactionKind::Claim { claim_code } => {
                    self.build_claim_transaction(transaction, claim_code, amount);
                }
                TransactionKind::Payment { order_id } => {
                    self.build_payment_transaction(transaction, order_id, amount);
                }
                TransactionKind::TopUp { order_id } => {
                    self.build_payment_transaction(transaction, order_id, amount);
                }
            }
        });

        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,
                })?,

            payment_account: self
                .payment_account
                .clone()
                .context(UninitializedFieldSnafu {
                    field: "payment_account",
                    importer: Importer::NAME,
                })?,
        };

        Ok(Importer::new(config))
    }
}

#[derive(Clone, Copy, Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct Record<'r> {
    #[serde(with = "dm_long_y")]
    date: Date,

    #[serde(rename = "Description")]
    kind: TransactionKind<'r>,

    amount: &'r str,
}

time::serde::format_description!(dm_long_y, Date, "[day] [month repr:long] [year]");

#[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, Deserialize)]
#[serde(try_from = "&str")]
enum TransactionKind<'d> {
    Claim { claim_code: &'d str },
    Payment { order_id: &'d str },
    TopUp { order_id: &'d str },
}

impl Display for TransactionKind<'_> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            TransactionKind::Claim { claim_code } => write!(f, "Claim gift card {claim_code}"),
            TransactionKind::Payment { order_id } => write!(f, "Payment towards order {order_id}"),
            TransactionKind::TopUp { order_id } => write!(f, "Balance top up in order {order_id}"),
        }
    }
}

impl<'d> TryFrom<&'d str> for TransactionKind<'d> {
    type Error = &'static str; // TODO

    fn try_from(description: &'d str) -> Result<Self, Self::Error> {
        if let Some(rest) = description.strip_prefix("Gift Card claim") {
            let (_, rest) = rest.rsplit_once('(').unwrap();
            let rest = rest.strip_suffix(')').unwrap();
            let claim_code = rest.strip_prefix("claim code ").unwrap();

            Ok(Self::Claim { claim_code })
        } else if let Some(rest) = description.strip_prefix("Balance Top Up") {
            let (_, rest) = rest.rsplit_once('(').unwrap();
            let order_id = rest.strip_suffix(')').unwrap();

            Ok(Self::TopUp { order_id })
        } else if let Some(rest) = description.strip_prefix("Payment") {
            let (_, rest) = rest.rsplit_once('(').unwrap();
            let order_id = rest.strip_suffix(')').unwrap();

            Ok(Self::Payment { order_id })
        } else {
            todo!("unsupported description {description:?}")
        }
    }
}