extern crate alloc;
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::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 derive_builder::Builder;
use hashbrown::HashMap;
use miette::Diagnostic;
use miette::IntoDiagnostic as _;
use miette::Report;
use miette::SourceSpan;
use rust_decimal::Decimal;
use serde::Deserialize;
use snafu::Backtrace;
use snafu::OptionExt;
use snafu::Snafu;
use time::format_description::well_known::Rfc3339;
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: &'static 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 tz = time_tz::timezones::get_by_name(record.timezone).unwrap();
let timestamp = record
.date
.with_time(record.time)
.assume_timezone(tz)
.unwrap();
let context = {
let currency: &str = &record.currency;
let currency = <&Seg>::try_from(currency).expect("commodities are valid segments");
TemplateContext { currency }
};
let transaction = {
let mut transaction = Transaction::on(record.date);
let (payee, opposite_account) = match record.description {
TransactionKind::Chargeback => (
None,
self.config
.known_recipients
.get(record.sender_email)
.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.bank_account].clone(),
),
TransactionKind::Payment => (
non_empty_field(record.name),
self.config
.known_recipients
.get(record.sender_email)
.unwrap_or(&self.config.default_payment_account)
.clone(),
),
};
if let Some(payee) = payee {
transaction.set_payee(payee);
}
if matches!(record.description, TransactionKind::Deposit) {
transaction.add_link(
Link::try_from(format!("^paypal.{}", record.transaction_id)).unwrap(),
);
}
transaction
.add_meta(common_keys::TIMESTAMP, timestamp.format(&Rfc3339).unwrap())
.add_meta(common_keys::TRANSACTION_ID, record.transaction_id);
if let Some(invoice_id) = record.invoice_id {
transaction.add_meta(MetadataKey::from_str("invoice-id").unwrap(), invoice_id);
}
if let Some(related_transaction_id) = record.related_transaction_id {
transaction.add_meta(
MetadataKey::from_str("related-transaction-id").unwrap(),
related_transaction_id,
);
}
let amount = Amount::new(record.gross, record.currency);
transaction
.build_posting(&self.config.balance_account.render(&context), |posting| {
posting.set_amount(amount);
})
.add_posting(Posting::on(opposite_account));
transaction
};
let balance = {
let date = record.date.next_day().unwrap();
let account = self.config.balance_account.render(&context);
let amount = Amount::new(record.balance, record.currency);
let mut balance = Balance::new(date, account, amount);
balance.add_meta(common_keys::TIMESTAMP, timestamp.format(&Rfc3339).unwrap());
balance
};
Ok((transaction, balance))
}
}
impl beancount_importers_framework::ImporterProtocol for Importer {
type Error = Report;
fn account(&self, _file: &[u8]) -> Result<Account, Self::Error> {
Ok(self.config.balance_account.base().to_owned())
}
fn date(&self, file: &[u8]) -> Option<Result<Date, Self::Error>> {
self.importer
.date(file, self)
.map(|result| result.map_err(Self::Error::from))
}
fn extract(&self, file: &[u8], existing: &[Directive]) -> Result<Vec<Directive>, Self::Error> {
self.importer
.extract(file, existing, self)
.map_err(Self::Error::from)
}
fn filename(&self, _file: &[u8]) -> Option<Result<String, Self::Error>> {
Some(Ok(String::from("transactions.csv")))
}
fn identify(&self, file: &[u8]) -> 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;
type Record<'de> = Record<'de>;
fn date(&self, record: Record) -> Date {
record.date
}
fn extract(
&self,
_existing: &[Directive],
record: 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, Deserialize)]
pub struct Record<'r> {
#[serde(rename = "Datum", with = "dmy")]
date: Date,
#[serde(rename = "Uhrzeit", with = "hms")]
time: Time,
#[serde(rename = "Zeitzone")]
timezone: &'r str,
#[serde(rename = "Beschreibung")]
description: TransactionKind,
#[serde(rename = "Währung")]
currency: Commodity,
#[serde(rename = "Brutto", with = "german_decimal::serde")]
gross: Decimal,
#[serde(rename = "Guthaben", with = "german_decimal::serde")]
balance: Decimal,
#[serde(rename = "Transaktionscode")]
transaction_id: &'r str,
#[serde(rename = "Absender E-Mail-Adresse")]
sender_email: &'r str,
#[serde(rename = "Name")]
name: &'r str,
#[serde(rename = "Bankkonto")]
bank_account: &'r str,
#[serde(rename = "Rechnungsnummer")]
invoice_id: Option<&'r str>,
#[serde(borrow, rename = "Zugehöriger Transaktionscode")]
related_transaction_id: Option<&'r str>,
}
#[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, Deserialize, Eq, PartialEq)]
enum TransactionKind {
#[serde(rename = "Rückbuchung")]
Chargeback,
#[serde(rename = "Allgemeine Währungsumrechnung")]
CurrencyConversion,
#[serde(
alias = "Allgemeine Abbuchung – Bankkonto",
rename = "Bankgutschrift auf PayPal-Konto"
)]
Deposit,
#[serde(
alias = "Allgemeine Zahlung",
alias = "Handyzahlung",
alias = "PayPal Express-Zahlung",
alias = "Rückzahlung",
alias = "Spendenzahlung",
alias = "Website-Zahlung",
rename = "Zahlung im Einzugsverfahren mit Zahlungsrechnung"
)]
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)
}
}
time::serde::format_description!(dmy, Date, "[day].[month].[year]");
time::serde::format_description!(hms, Time, "[hour]:[minute]:[second]");
fn non_empty_field(field: &str) -> Option<&str> {
Some(field).filter(|value| !value.is_empty())
}