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::MetadataKey;
use beancount_types::Seg;
use beancount_types::Transaction;
use derive_builder::Builder;
use hashbrown::HashMap;
use iso_currency::Currency;
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::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 base_account: AccountTemplate<TemplateSelector>,
#[serde(default)]
#[builder(field(type = "HashMap<String, Account>"))]
pub content_type_expense_accounts: HashMap<String, Account>,
pub expense_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: &'static str = "apple/transaction-history";
pub fn new(config: Config) -> Self {
let importer = csv::Importer::default();
Self { config, importer }
}
pub fn builder() -> ImporterBuilder {
ImporterBuilder::default()
}
fn lookup_expense_account(&self, content_type: &str, context: &TemplateContext<'_>) -> Account {
self.config
.content_type_expense_accounts
.get(content_type)
.cloned()
.unwrap_or_else(|| self.config.expense_account.render(context))
}
}
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.base().to_owned())
}
fn date(&self, buffer: &[u8]) -> Option<Result<time::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(Self::Error::from)
}
fn filename(&self, _buffer: &[u8]) -> Option<Result<String, Self::Error>> {
Some(Ok(String::from("transactions.csv")))
}
fn identify(&self, buffer: &[u8]) -> Result<bool, Self::Error> {
const EXPECTED_HEADERS: &[&str] = &[
"Apple ID Number",
"Item Purchased Date",
"Content Type",
"Item Reference Number",
"Item Description",
"Seller",
"Container Reference Number",
"Container Description",
"Invoice Item Total",
"Invoice Item Amount",
"Device Identifier",
"Device Details",
"Device IP Address",
"Refund Amount",
"Document Number",
"Invoice Date",
"Invoice Tax Amount",
"Invoice Total Amount",
"Purchase Created Date",
"Order Number",
"Billing Information ID",
"Payment Type",
"Currency",
"Purchase Chargeback?",
"iCloud Family Purchase?",
"UUID",
"Free product Code Redemption?",
];
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 {
Date::max(
record.order_time.date(),
record.invoice_date.unwrap_or(Date::MIN),
)
}
fn extract(
&self,
_existing: &[Directive],
record: Record,
) -> Result<Vec<Directive>, Self::Error> {
let Some(order_id) = record.order_id else {
return Ok(vec![]);
};
let commodity = Commodity::try_from(record.currency.code()).unwrap();
let amount = Amount::new(-record.item_total, commodity);
let transaction = Transaction::on(record.order_time.date()).tap_mut(|transaction| {
let context = {
let currency: &str = &commodity;
let currency = <&Seg>::try_from(currency).expect("commodities are valid segments");
TemplateContext { currency }
};
transaction
.set_payee(record.seller)
.set_narration(record.item_description)
.add_link(Link::try_from(format!("^apple.{order_id}")).expect("valid link"))
.add_meta(common_keys::TRANSACTION_ID, order_id);
if let Some((invoice_date, invoice_id)) =
record.invoice_date.zip(record.document_number)
{
transaction
.add_meta(
MetadataKey::try_from("invoice-date").expect("valid key"),
invoice_date.to_string(),
)
.add_meta(
MetadataKey::try_from("invoice-id").expect("valid key"),
invoice_id,
);
}
transaction
.add_meta(
common_keys::TIMESTAMP,
record.order_time.format(&Rfc3339).unwrap(),
)
.build_posting(self.config.base_account.render(&context), |posting| {
posting.set_amount(amount);
})
.build_posting(
self.lookup_expense_account(record.content_type, &context),
|_posting| {},
);
});
Ok(vec![Directive::from(transaction)])
}
}
impl ImporterBuilder {
pub fn try_add_content_type_expense_account<A>(
&mut self,
name: impl Into<String>,
account: A,
) -> Result<&mut Self, A::Error>
where
A: TryInto<Account>,
{
self.content_type_expense_accounts
.insert(name.into(), account.try_into()?);
Ok(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,
})?,
content_type_expense_accounts: self.content_type_expense_accounts.clone(),
expense_account: self
.expense_account
.clone()
.context(UninitializedFieldSnafu {
field: "expense_account",
importer: Importer::NAME,
})?,
};
Ok(Importer::new(config))
}
}
#[derive(Clone, Copy, Debug, Deserialize)]
pub struct Record<'r> {
#[serde(rename = "Content Type")]
content_type: &'r str,
#[serde(rename = "Currency")]
currency: Currency,
#[serde(rename = "Document Number")]
document_number: Option<&'r str>,
#[serde(rename = "Invoice Date", with = "mdy::option")]
invoice_date: Option<Date>,
#[serde(rename = "Item Description")]
item_description: &'r str,
#[serde(
deserialize_with = "deserialize_german_euro_amount",
rename = "Invoice Item Total"
)]
item_total: Decimal,
#[serde(rename = "Order Number")]
order_id: Option<&'r str>,
#[serde(rename = "Item Purchased Date", with = "time::serde::iso8601")]
order_time: OffsetDateTime,
#[serde(rename = "Seller")]
seller: &'r str,
}
#[derive(Clone, Copy, Debug)]
pub struct TemplateContext<'c> {
pub 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 {
pub selector: String,
pub backtrace: Backtrace,
}
time::serde::format_description!(mdy, Date, "[month]/[day]/[year]");
fn deserialize_german_euro_amount<'de, D>(deserializer: D) -> Result<Decimal, D::Error>
where
D: serde::Deserializer<'de>,
{
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = Decimal;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("an amount with trailing € symbol")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
let v = v
.strip_suffix(" €")
.ok_or_else(|| E::custom("expected symbol suffix not found"))?
.trim();
german_decimal::parse(v).map_err(E::custom)
}
}
deserializer.deserialize_str(Visitor)
}