+ 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())
+ }