KB7Y4PJIETGK43QVN5JTCQ2UD3JCXKYDULKTNQVR7WMH2JIODS7AC 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 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 tap::Tap as _;use time::format_description::well_known::Rfc3339;use time::macros::format_description;use time::OffsetDateTime;use time::PrimitiveDateTime;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>,}#[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/order-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("orders.csv")))}fn identify(&self, file: &Utf8Path) -> Result<bool, Self::Error> {const EXPECTED_HEADERS: &[&str] = &["Website","Order ID","Order Date","Purchase Order Number","Currency","Unit Price","Unit Price Tax","Shipping Charge","Total Discounts","Total Owed","Shipment Item Subtotal","Shipment Item Subtotal Tax","ASIN","Product Condition","Quantity","Payment Instrument Type","Order Status","Shipment Status","Ship Date","Shipping Option","Shipping Address","Billing Address","Carrier Name & Tracking Number","Product Name","Gift Message","Gift Sender Name","Gift Recipient Contact Details",];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 ASIN_COLUMN_INDEX: usize = 12;const CURRENCY_COLUMN_INDEX: usize = 4;const ORDER_ID_COLUMN_INDEX: usize = 1;const PRODUCT_NAME_COLUMN_INDEX: usize = 23;const TOTAL_OWED_COLUMN_INDEX: usize = 9;let order_id = &record[ORDER_ID_COLUMN_INDEX];let timestamp = parse_order_timestamp(record)?;let commodity = Commodity::try_from(&record[CURRENCY_COLUMN_INDEX]).unwrap();let amount = {let amount = Decimal::from_str(&record[TOTAL_OWED_COLUMN_INDEX]).unwrap().tap_mut(|amount| amount.rescale(2));Amount::new(amount, commodity)};let asin = &record[ASIN_COLUMN_INDEX];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_typeslet currency = <&Seg>::try_from(currency).expect("commodities are valid segments");TemplateContext { currency }};transaction.set_narration(product_name).add_link(Link::try_from(format!("^amazon.{order_id}")).expect("valid link")).add_meta(MetadataKey::try_from("order-id").unwrap(), order_id).add_meta(MetadataKey::try_from("asin").unwrap(), asin).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,})?,};Ok(Importer::new(config))}}#[derive(Clone, Copy, Debug)]pub struct TemplateContext<'c> {pub(crate) 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(crate) selector: String,pub(crate) backtrace: Backtrace,}fn parse_order_timestamp(record: &csv::Record) -> Result<OffsetDateTime, Error> {const ORDER_DATE_COLUMN_INDEX: usize = 2;parse_timestamp(&record[ORDER_DATE_COLUMN_INDEX])}fn parse_timestamp(input: &str) -> Result<OffsetDateTime, Error> {const ORDER_DATE_FORMAT: &[time::format_description::FormatItem] =format_description!("[month]/[day]/[year] [hour]:[minute]:[second] UTC");let utc_timestamp = PrimitiveDateTime::parse(input, ORDER_DATE_FORMAT).unwrap().assume_utc();let timezone = time_tz::system::get_timezone().expect("system timezone");let timestamp = utc_timestamp.to_timezone(timezone);Ok(timestamp)}
pub mod digital_items;pub mod gift_card_balance;pub mod order_history;
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 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;use snafu::Snafu;use tap::Tap as _;use time::macros::format_description;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_typeslet 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_typeslet 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, _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("gift-card-transactions.csv")))}fn identify(&self, file: &Utf8Path) -> Result<bool, Self::Error> {const EXPECTED_HEADERS: &[&str] = &["Date", "Description", "Amount"];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_date(record))}fn extract(&self,_existing: &[Directive],record: &csv::Record,) -> Result<Vec<Directive>, Self::Error> {let date = parse_record_date(record)?;let kind = TransactionKind::try_from(&record[1]).unwrap();let amount = {let amount = &record[2];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| match 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))}}fn parse_record_date(record: &csv::Record) -> Result<Date, Error> {const DATE_COLUMN_INDEX: usize = 0;const DATE_FORMAT: &[time::format_description::FormatItem] =format_description!("[day] [month repr:long] [year]");Date::parse(&record[DATE_COLUMN_INDEX], DATE_FORMAT).map_err(|_| todo!())}#[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<'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 { .. } => f.write_str("Gift Card Claim"),TransactionKind::Payment { .. } => f.write_str("Payment"),TransactionKind::TopUp { .. } => f.write_str("Balance Top Up"),}}}impl<'d> TryFrom<&'d str> for TransactionKind<'d> {type Error = (); // TODOfn 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:?}")}}}
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 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 tap::Tap as _;use time::macros::format_description;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 base_account: AccountTemplate<TemplateSelector>,pub expense_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/digital-items";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("digital-items.csv")))}fn identify(&self, file: &Utf8Path) -> Result<bool, Self::Error> {const EXPECTED_HEADERS: &[&str] = &["ASIN","Title","OrderId","DigitalOrderItemId","DeclaredCountryCode","BaseCurrencyCode","FulfilledDate","IsFulfilled","Marketplace","OrderDate","OriginalQuantity","OurPrice","OurPriceCurrencyCode","OurPriceTax","OurPriceTaxCurrencyCode","SellerOfRecord","Publisher","ThirdPartyDisplayPrice","ThirdPartyDisplayCurrencyCode","ListPriceAmount","ListPriceCurrencyCode","ListPriceTaxAmount","ListPriceTaxCurrencyCode","GiftItem","OrderingCustomerNickname","GiftCustomerNickname","GiftMessage","GiftEmail","RecipientEmail","GiftRedemption","ItemMergedFromAnotherOrder","QuantityOrdered","ItemFulfilled","ShipFrom","ShipTo","IsOrderEligibleForPrimeBenefit","OfferingSKU","FulfillmentMobileNumber","RechargeAmount","RechargeAmountCurrencyCode","SubscriptionOrderInfoList","PreviouslyPaidDigitalOrderItemId","PreviouslyPaidOrderId","InstallmentOurPrice","InstallmentOurPricePlusTax","DigitalOrderItemAttributes","InstallmentOurPriceCurrencyCode","InstallmentOurPricePlusTaxCurrencyCode",];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_date(record))}fn extract(&self,_existing: &[Directive],record: &csv::Record,) -> Result<Vec<Directive>, Self::Error> {const ASIN_COLUMN_INDEX: usize = 0;const CURRENCY_COLUMN_INDEX: usize = 14;const ORDER_ID_COLUMN_INDEX: usize = 2;const PRODUCT_NAME_COLUMN_INDEX: usize = 1;const TOTAL_OWED_COLUMN_INDEX: usize = 13;let date = parse_order_date(record)?;let asin = &record[ASIN_COLUMN_INDEX];let product_name = &record[PRODUCT_NAME_COLUMN_INDEX];let order_id = &record[ORDER_ID_COLUMN_INDEX];let commodity = Commodity::try_from(&record[CURRENCY_COLUMN_INDEX]).unwrap();let amount = {let amount = Decimal::from_str(&record[TOTAL_OWED_COLUMN_INDEX]).unwrap().tap_mut(|amount| amount.rescale(2));Amount::new(amount, commodity)};let transaction = Transaction::on(date).tap_mut(|transaction| {let context = {let currency: &str = &commodity;// TODO make this conversion in beancount_typeslet currency = <&Seg>::try_from(currency).expect("commodities are valid segments");TemplateContext { currency }};transaction.set_narration(product_name).add_link(Link::try_from(format!("^amazon.{order_id}")).expect("valid link")).add_meta(MetadataKey::try_from("order-id").unwrap(), order_id).add_meta(MetadataKey::try_from("asin").unwrap(), asin).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,})?,};Ok(Importer::new(config))}}#[derive(Clone, Copy, Debug)]pub struct TemplateContext<'c> {pub(crate) 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(crate) selector: String,pub(crate) backtrace: Backtrace,}fn parse_order_date(record: &csv::Record) -> Result<Date, Error> {const ORDER_DATE_COLUMN_INDEX: usize = 9;parse_date(&record[ORDER_DATE_COLUMN_INDEX])}fn parse_date(input: &str) -> Result<Date, Error> {use time::format_description::FormatItem;const ORDER_DATE_FORMAT: &[FormatItem] = format_description!("[year]-[month]-[day]");Date::parse(input, ORDER_DATE_FORMAT).map_err(|_| todo!())}
[package]name = "amazon"authors.workspace = trueedition.workspace = truepublish.workspace = truerust-version.workspace = trueversion.workspace = true[dependencies]# Workspace dependenciesbeancount-importers-framework.path = "../../framework"beancount-types.path = "../../common/beancount-types"csv.path = "../csv"german-decimal.path = "../../common/german-decimal"# Inherited dependenciescamino.workspace = truederive_builder.workspace = truemiette.workspace = truerust_decimal.workspace = trueserde.workspace = truesnafu.workspace = truetap.workspace = truetime-tz.workspace = truetime.workspace = true
use miette::Diagnostic;use snafu::Backtrace;use snafu::Snafu;#[derive(Debug, Diagnostic, Snafu)]#[snafu(visibility(pub))]pub enum ImporterBuilderError {#[snafu(display("importer `{importer}`: `{field}` must be initialized"))]UninitializedField {backtrace: Backtrace,field: &'static str,importer: &'static str,},}