Add an importer for PayPal account statements

korrat
Jun 21, 2023, 7:03 PM
RCS5VP3AIMPZ5FQ2QWOQGZET726R2O2GPPEJMHLPXQG65KG7KFFQC

Dependencies

  • [2] SLTVZLYX Upgrade dependencies
  • [*] KB7Y4PJI Implement importers for Amazon accounts
  • [*] I2P2FTLE add basic parser for german decimals
  • [*] RI7HQBYA Add generator and parser for ISO20022 messages
  • [*] 4W4CDACX Upgrade dependencies
  • [*] 5S4MZHL5 pretty print decimals using icu

Change contents

  • file addition: paypal (d--r------)
    [4.1]
  • file addition: src (d--r------)
    [0.1]
  • file addition: lib.rs (---r------)
    [0.21]
    extern crate alloc;
    use core::ops::Index;
    use core::str::FromStr;
    use std::collections::HashMap;
    use alloc::collections::BTreeMap;
    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::Balance;
    use beancount_types::Commodity;
    use beancount_types::Directive;
    use beancount_types::Link;
    use beancount_types::MetadataKey;
    use beancount_types::Posting;
    use beancount_types::Seg;
    use beancount_types::Transaction;
    use camino::Utf8Path;
    use csv::Record;
    use derive_builder::Builder;
    use miette::Diagnostic;
    use miette::IntoDiagnostic as _;
    use miette::Report;
    use miette::SourceSpan;
    use serde::Deserialize;
    use snafu::Backtrace;
    use snafu::OptionExt;
    use snafu::Snafu;
    use time::format_description::well_known::Rfc3339;
    use time::macros::format_description;
    use time::Date;
    use time::Time;
    use time_tz::PrimitiveDateTimeExt;
    #[derive(Builder, Clone, Debug, Deserialize)]
    #[builder(
    build_fn(error = "ImporterBuilderError", skip),
    name = "ImporterBuilder"
    )]
    pub struct Config {
    #[builder(setter(into), try_setter)]
    pub balance_account: AccountTemplate<TemplateSelector>,
    #[builder(setter(into), try_setter)]
    pub currency_account: AccountTemplate<TemplateSelector>,
    #[builder(setter(into), try_setter)]
    pub default_payment_account: Account,
    #[builder(field(type = "Option<String>"), setter(into, strip_option))]
    pub deposit_payee: Option<String>,
    #[serde(default)]
    #[builder(field(type = "HashMap<String, Account>"))]
    pub known_recipients: HashMap<String, Account>,
    #[serde(default)]
    #[builder(field(type = "HashMap<String, Account>"))]
    pub reference_accounts: HashMap<String, Account>,
    }
    #[derive(Debug, Diagnostic, Snafu)]
    pub enum Error {
    #[snafu(display("error while parsing date"))]
    DateFormat { source: time::Error },
    #[snafu(display("unknown currency account for commodity: {commodity:?}"))]
    UnknownCurrencyAccount { commodity: Commodity },
    #[snafu(display("unsupported transaction kind: {value:?}"))]
    UnsupportedTransactionKind { value: String },
    }
    impl From<time::error::TryFromParsed> for Error {
    fn from(source: time::error::TryFromParsed) -> Self {
    let source = source.into();
    Self::DateFormat { source }
    }
    }
    #[derive(Debug, Deserialize)]
    pub struct Importer {
    config: Config,
    #[serde(skip_deserializing)]
    importer: csv::Importer,
    }
    impl Importer {
    pub const NAME: &str = "paypal";
    pub fn builder() -> ImporterBuilder {
    ImporterBuilder::default()
    }
    pub fn new(config: Config) -> Self {
    let importer = csv::Importer::default();
    Self { config, importer }
    }
    }
    impl Importer {
    fn extract_record(&self, record: &Record) -> Result<(Transaction, Balance), Error> {
    let date = parse_date(record).unwrap();
    let time =
    Time::parse(&record[1], format_description!("[hour]:[minute]:[second]")).unwrap();
    let tz = time_tz::timezones::get_by_name(&record[2]).unwrap();
    let timestamp = date.with_time(time).assume_timezone(tz).unwrap();
    let commodity = Commodity::try_from(&record[4]).unwrap();
    let context = {
    let currency: &str = &commodity;
    let currency = <&Seg>::try_from(currency).expect("commodities are valid segments");
    TemplateContext { currency }
    };
    let transaction = {
    let mut transaction = Transaction::on(date);
    let kind = TransactionKind::try_from(&record[3])?;
    let (payee, opposite_account) = match kind {
    TransactionKind::Chargeback => (
    None,
    self.config
    .known_recipients
    .get(&record[10])
    .unwrap_or(&self.config.default_payment_account)
    .clone(),
    ),
    TransactionKind::CurrencyConversion => {
    (None, self.config.currency_account.render(&context))
    }
    TransactionKind::Deposit => (
    self.config.deposit_payee.as_deref(),
    self.config.reference_accounts[&record[13]].clone(),
    ),
    TransactionKind::Payment => (
    non_empty_field(&record[11]),
    self.config
    .known_recipients
    .get(&record[10])
    .unwrap_or(&self.config.default_payment_account)
    .clone(),
    ),
    };
    if let Some(payee) = payee {
    transaction.set_payee(payee);
    }
    let transaction_id = &record[9];
    if matches!(kind, TransactionKind::Deposit) {
    transaction.add_link(Link::try_from(format!("^paypal.{transaction_id}")).unwrap());
    }
    transaction
    .add_meta(common_keys::TIMESTAMP, timestamp.format(&Rfc3339).unwrap())
    .add_meta(common_keys::TRANSACTION_ID, transaction_id);
    if let Some(invoice_id) = non_empty_field(&record[16]) {
    transaction.add_meta(MetadataKey::from_str("invoice-id").unwrap(), invoice_id);
    }
    if let Some(related_transaction_id) = non_empty_field(&record[17]) {
    transaction.add_meta(
    MetadataKey::from_str("related-transaction-id").unwrap(),
    related_transaction_id,
    );
    }
    let amount = Amount::new(german_decimal::parse(&record[5]).unwrap(), commodity);
    transaction
    .build_posting(&self.config.balance_account.render(&context), |posting| {
    posting.set_amount(amount);
    })
    .add_posting(Posting::on(opposite_account));
    transaction
    };
    let balance = {
    let account = self.config.balance_account.render(&context);
    let amount = german_decimal::parse(&record[8]).unwrap();
    let amount = Amount::new(amount, commodity);
    let date = date.next_day().unwrap();
    Balance::new(date, account, amount)
    };
    Ok((transaction, balance))
    }
    }
    impl beancount_importers_framework::ImporterProtocol for Importer {
    type Error = 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> {
    // TODO post-process results to eliminate duplicate balance statements
    let directives = self
    .importer
    .extract(file, existing, self)
    .map_err(Self::Error::from)?;
    let mut balances = BTreeMap::new();
    let mut directives: Vec<_> = directives
    .into_iter()
    .filter_map(|directive| {
    if let Directive::Balance(balance) = directive {
    use alloc::collections::btree_map::Entry;
    match balances.entry((
    balance.date,
    balance.account.clone(),
    balance.amount.commodity,
    )) {
    Entry::Vacant(entry) => {
    entry.insert(balance);
    }
    Entry::Occupied(mut entry) => {
    let other = entry.get();
    if other.meta["lineno"] < balance.meta["lineno"] {
    *entry.get_mut() = balance;
    }
    }
    }
    None
    } else {
    Some(directive)
    }
    })
    .collect();
    directives.extend(balances.into_values().map(Directive::from));
    Ok(directives)
    }
    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; 18] = [
    "Datum",
    "Uhrzeit",
    "Zeitzone",
    "Beschreibung",
    "Währung",
    "Brutto",
    "Entgelt",
    "Netto",
    "Guthaben",
    "Transaktionscode",
    "Absender E-Mail-Adresse",
    "Name",
    "Name der Bank",
    "Bankkonto",
    "Versand- und Bearbeitungsgebühr",
    "Umsatzsteuer",
    "Rechnungsnummer",
    "Zugehöriger Transaktionscode",
    ];
    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_date(record))
    }
    fn extract(
    &self,
    _existing: &[Directive],
    record: &csv::Record,
    ) -> Result<Vec<Directive>, Self::Error> {
    self.extract_record(record).map(|(transaction, balance)| {
    vec![Directive::from(transaction), Directive::from(balance)]
    })
    }
    }
    impl ImporterBuilder {
    pub fn clear_known_recipients(&mut self) -> &mut Self {
    self.known_recipients.clear();
    self
    }
    pub fn clear_referenece_accounts(&mut self) -> &mut Self {
    self.reference_accounts.clear();
    self
    }
    pub fn try_add_known_recipient<A>(
    &mut self,
    name: impl Into<String>,
    account: A,
    ) -> Result<&mut Self, A::Error>
    where
    A: TryInto<Account>,
    {
    self.known_recipients
    .insert(name.into(), account.try_into()?);
    Ok(self)
    }
    pub fn try_add_reference_account<A>(
    &mut self,
    id: impl Into<String>,
    account: A,
    ) -> Result<&mut Self, A::Error>
    where
    A: TryInto<Account>,
    {
    self.reference_accounts
    .insert(id.into(), account.try_into()?);
    Ok(self)
    }
    }
    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,
    })?,
    currency_account: self
    .currency_account
    .clone()
    .context(UninitializedFieldSnafu {
    field: "currency_account",
    importer: Importer::NAME,
    })?,
    default_payment_account: self.default_payment_account.clone().context(
    UninitializedFieldSnafu {
    field: "default_payment_account",
    importer: Importer::NAME,
    },
    )?,
    deposit_payee: self.deposit_payee.clone(),
    known_recipients: self.known_recipients.clone(),
    reference_accounts: self.reference_accounts.clone(),
    };
    Ok(Importer::new(config))
    }
    }
    #[derive(Debug, Diagnostic, Snafu)]
    #[snafu(display("encountered error(s) while extracting transactions"))]
    pub struct MultiError {
    #[related]
    errors: Vec<RecordError>,
    #[source_code]
    contents: String,
    }
    #[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(Debug, Diagnostic, Snafu)]
    #[snafu(display("encountered error while extracting record"))]
    struct RecordError {
    #[diagnostic_source]
    source: Error,
    #[label("in this record")]
    span: SourceSpan,
    }
    type Result<T, E = Error> = core::result::Result<T, E>;
    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
    enum TransactionKind {
    Chargeback,
    CurrencyConversion,
    Deposit,
    Payment,
    }
    impl TryFrom<&str> for TransactionKind {
    type Error = Error;
    fn try_from(value: &str) -> Result<Self, Self::Error> {
    let kind = match value {
    "Rückbuchung" => Self::Chargeback,
    "Allgemeine Währungsumrechnung" => Self::CurrencyConversion,
    "Allgemeine Abbuchung – Bankkonto" | "Bankgutschrift auf PayPal-Konto" => {
    Self::Deposit
    }
    "Allgemeine Zahlung"
    | "Handyzahlung"
    | "PayPal Express-Zahlung"
    | "Rückzahlung"
    | "Spendenzahlung"
    | "Website-Zahlung"
    | "Zahlung im Einzugsverfahren mit Zahlungsrechnung" => Self::Payment,
    _ => return UnsupportedTransactionKindSnafu { value }.fail(),
    };
    Ok(kind)
    }
    }
    fn parse_date(record: &Record) -> Result<Date, Error> {
    use time::format_description::FormatItem;
    const DATE_COLUMN_INDEX: usize = 0;
    const DATE_FORMAT: &[FormatItem] = format_description!("[day].[month].[year]");
    Date::parse(&record[DATE_COLUMN_INDEX], DATE_FORMAT).map_err(|_| todo!())
    }
    fn non_empty_field(field: &str) -> Option<&str> {
    Some(field).filter(|value| !value.is_empty())
    }
  • file addition: Cargo.toml (---r------)
    [0.1]
    [package]
    edition = "2021"
    name = "paypal"
    version = "0.1.0"
    [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
    inventory.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
  • edit in Cargo.toml at line 14
    [2.241]
    [6.91793541]
    "importers/paypal",
  • edit in Cargo.lock at line 2831
    [7.10296]
    [8.36723]
    ]
    [[package]]
    name = "paypal"
    version = "0.1.0"
    dependencies = [
    "beancount-importers-framework",
    "beancount-types",
    "camino",
    "csv 0.0.0-dev.0",
    "derive_builder",
    "german-decimal",
    "inventory",
    "miette",
    "rust_decimal",
    "serde",
    "snafu",
    "tap",
    "time 0.3.22",
    "time-tz",