362NCCMXCBQRRWBRE5G6YLINRAF472PJJF3WSF43B2SULDJBWEHQC
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 serde::Deserialize;
use snafu::Backtrace;
use snafu::OptionExt;
use snafu::Snafu;
use tap::Tap as _;
use time::format_description::well_known::Iso8601;
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
use time_tz::OffsetDateTimeExt as _;
#[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>,
pub expense_account: AccountTemplate<TemplateSelector>,
pub payee: String,
}
#[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/transaction-history";
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.base_account.base().to_owned())
}
fn date(&self, file: &Utf8Path) -> Option<Result<time::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(Self::Error::from)
}
fn filename(&self, _file: &Utf8Path) -> Option<Result<String, Self::Error>> {
Some(Ok(String::from("transactions.csv")))
}
fn identify(&self, file: &Utf8Path) -> 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(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<time::Date, Self::Error>> {
Some(parse_order_timestamp(record).map(OffsetDateTime::date))
}
fn extract(
&self,
_existing: &[Directive],
record: &csv::Record,
) -> Result<Vec<Directive>, Self::Error> {
const CURRENCY_COLUMN_INDEX: usize = 22;
const ORDER_ID_COLUMN_INDEX: usize = 19;
const PRODUCT_NAME_COLUMN_INDEX: usize = 4;
const ITEM_PRICE_COLUMN_INDEX: usize = 8;
let order_id = Some(&record[ORDER_ID_COLUMN_INDEX]).filter(|order_id| !order_id.is_empty());
let timestamp = parse_order_timestamp(record)?;
let commodity = Commodity::try_from(&record[CURRENCY_COLUMN_INDEX]).unwrap();
let amount = {
let amount = &record[ITEM_PRICE_COLUMN_INDEX];
let (amount, _) = amount.split_once(' ').ok_or_else(|| todo!())?;
let amount = german_decimal::parse(amount).map_err(|_| todo!())?;
Amount::new(-amount, commodity)
};
let product_name = &record[PRODUCT_NAME_COLUMN_INDEX];
let transaction = Transaction::on(timestamp.date()).tap_mut(|transaction| {
let context = {
let currency: &str = &commodity;
// TODO make this conversion in beancount_types
let currency = <&Seg>::try_from(currency).expect("commodities are valid segments");
TemplateContext { currency }
};
transaction
.set_payee(&self.config.payee)
.set_narration(product_name);
if let Some(order_id) = order_id {
transaction
.add_link(Link::try_from(format!("^apple.{order_id}")).expect("valid link"))
.add_meta(common_keys::TRANSACTION_ID, order_id);
};
transaction
.add_meta(common_keys::TIMESTAMP, timestamp.format(&Rfc3339).unwrap())
.build_posting(self.config.base_account.render(&context), |posting| {
posting.set_amount(amount);
})
// TODO handle auto matching
.build_posting(self.config.expense_account.render(&context), |_posting| {});
});
Ok(vec![Directive::from(transaction)])
}
}
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,
})?,
expense_account: self
.expense_account
.clone()
.context(UninitializedFieldSnafu {
field: "expense_account",
importer: Importer::NAME,
})?,
payee: self.payee.clone().context(UninitializedFieldSnafu {
field: "payee",
importer: Importer::NAME,
})?,
};
Ok(Importer::new(config))
}
}
#[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,
}
fn parse_order_timestamp(record: &csv::Record) -> Result<OffsetDateTime, Error> {
const ORDER_TIMESTAMP_COLUMN_INDEX: usize = 1;
parse_timestamp(&record[ORDER_TIMESTAMP_COLUMN_INDEX])
}
fn parse_timestamp(input: &str) -> Result<OffsetDateTime, Error> {
let utc_timestamp = OffsetDateTime::parse(input, &Iso8601::PARSING).map_err(|_| todo!())?;
let timezone = time_tz::system::get_timezone().expect("system timezone");
let timestamp = utc_timestamp.to_timezone(timezone);
Ok(timestamp)
}
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!())
}
pub mod store_balance;
pub mod transaction_history;
[package]
name = "apple"
authors.workspace = true
edition.workspace = true
publish.workspace = true
rust-version.workspace = true
version.workspace = true
[dependencies]
# Workspace dependencies
beancount-importers-framework.path = "../../framework"
beancount-types.path = "../../common/beancount-types"
csv.path = "../csv"
german-decimal.path = "../../common/german-decimal"
# Inherited dependencies
camino.workspace = true
derive_builder.workspace = true
miette.workspace = true
rust_decimal.workspace = true
serde.workspace = true
snafu.workspace = true
tap.workspace = true
time-tz.workspace = true
time.workspace = true