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 derive_builder::Builder;
use iso_currency::Country;
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;
#[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, _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("store-balance.csv")))
}
fn identify(&self, buffer: &[u8]) -> Result<bool, Self::Error> {
const EXPECTED_HEADERS: &[&str] = &[
"Transaction date",
"Country",
"Order number",
"Amount",
"Transaction type",
];
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.transaction_date.date()
}
fn extract(
&self,
_existing: &[Directive],
record: Record,
) -> Result<Vec<Directive>, Self::Error> {
let commodity = match record.country {
Country::DE => Commodity::try_from("EUR").unwrap(),
_ => todo!(),
};
let transaction_id = record.order_number;
let kind = TransactionKind::try_from(record.transaction_type)?;
let amount = Amount::new(record.amount, commodity);
let context = {
let currency: &str = &amount.commodity;
let currency = <&Seg>::try_from(currency).expect("commodities are valid segments");
TemplateContext { currency }
};
let transaction = match kind {
TransactionKind::Debit => {
let mut transaction = Transaction::on(record.transaction_date.date());
transaction
.set_payee(&self.config.payee)
.set_narration(format!("Payment {transaction_id}"))
.add_link(
Link::try_from({
let order_id =
transaction_id.rsplit_once('-').ok_or_else(|| todo!())?.0;
format!("^apple.{order_id}")
})
.map_err(|_| todo!())?,
);
transaction
.add_meta(
common_keys::TIMESTAMP,
record
.transaction_date
.format(&Rfc3339)
.map_err(|_| todo!())?,
)
.add_meta(common_keys::TRANSACTION_ID, transaction_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(record.transaction_date.date());
transaction
.set_payee(&self.config.payee)
.set_narration(format!("Gift card redemption {transaction_id}"))
.add_meta(
common_keys::TIMESTAMP,
record
.transaction_date
.format(&Rfc3339)
.map_err(|_| todo!())?,
)
.add_meta(common_keys::TRANSACTION_ID, transaction_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, Deserialize)]
pub struct Record<'r> {
#[serde(rename = "Amount")]
amount: Decimal,
#[serde(rename = "Country")]
country: Country,
#[serde(rename = "Order number")]
order_number: &'r str,
#[serde(
rename = "Transaction date",
deserialize_with = "deserialize_local_offset_date_time"
)]
transaction_date: OffsetDateTime,
#[serde(rename = "Transaction type")]
transaction_type: &'r str,
}
#[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 deserialize_local_offset_date_time<'de, D>(deserializer: D) -> Result<OffsetDateTime, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error as _;
use time_tz::system;
use time_tz::PrimitiveDateTimeExt as _;
use time_tz::TimeZone as _;
let timestamp = iso_primitivedatetime::deserialize(deserializer)?;
let tz = system::get_timezone().map_err(D::Error::custom)?;
timestamp.assume_timezone(tz).take_first().ok_or_else(|| {
D::Error::custom(format!(
"invalid datetime {timestamp} in timezone {}",
tz.name()
))
})
}
const PRIMITIVE_DATE_TIME_FORMAT: Iso8601<0> =
time::format_description::well_known::Iso8601::PARSING;
time::serde::format_description!(
iso_primitivedatetime,
PrimitiveDateTime,
PRIMITIVE_DATE_TIME_FORMAT
);