#![warn(clippy::all, clippy::nursery, clippy::pedantic)]
extern crate alloc;
use core::fmt;
use core::fmt::Display;
use core::fmt::Formatter;
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::Balance;
use beancount_types::Commodity;
use beancount_types::Directive;
use beancount_types::MetadataKey;
use beancount_types::Seg;
use beancount_types::Transaction;
use bstr::ByteSlice;
use derive_builder::Builder;
use hashbrown::HashMap;
use iban::Iban;
use iban::IbanLike;
use miette::Diagnostic;
use miette::IntoDiagnostic as _;
use rust_decimal::Decimal;
use serde::de::IntoDeserializer as _;
use serde::Deserialize;
use snafu::Backtrace;
use snafu::OptionExt as _;
use snafu::Snafu;
use tap::Pipe as _;
use tap::Tap as _;
use time::Date;
// TODO documentation
// TODO balance assertions
// TODO metadata + linking
// TODO adjust accounts
#[derive(Builder, Clone, Debug, Deserialize)]
#[builder(
build_fn(error = "ImporterBuilderError", skip),
name = "ImporterBuilder"
)]
pub struct Config {
#[builder(setter(into), try_setter)]
pub account: AccountTemplate<TemplateSelector>,
#[builder(setter(into), try_setter)]
pub fallback_account: Account,
#[builder(field(type = "HashMap<Iban, Account>"))]
pub known_ibans: HashMap<Iban, Account>,
}
#[derive(Debug, Diagnostic, Snafu)]
pub enum Error {}
#[derive(Debug, Deserialize)]
pub struct Importer {
#[serde(flatten)]
config: Config,
#[serde(
default = "csv_importer::Importer::semicolon_delimited",
skip_deserializing
)]
importer: csv_importer::Importer,
}
impl Importer {
const NAME: &'static str = "dkb/transactions";
#[must_use]
pub fn builder() -> ImporterBuilder {
ImporterBuilder::default()
}
#[must_use]
pub fn new(config: Config) -> Self {
let importer = csv::ReaderBuilder::new()
.tap_mut(|builder| {
builder.flexible(true).delimiter(b';');
})
.pipe(csv_importer::Importer::new)
.tap_mut(|importer| {
importer.pad_records(true);
});
Self { config, importer }
}
}
impl Importer {
fn parse_header<'b>(&self, buffer: &'b [u8]) -> Option<(FileHeader, &'b [u8])> {
let mut iter = buffer.lines();
iter.by_ref().take(6).for_each(|_| {});
let rest = iter.as_bytes();
let (header, rest) = buffer.split_at(buffer.len() - rest.len());
let mut reader = csv::ReaderBuilder::new()
.has_headers(false)
.delimiter(b';')
.from_reader(header);
let mut record = csv::StringRecord::new();
if !reader.read_record(&mut record).unwrap_or(false) || &record[0] != "Kontonummer:" {
return None;
}
let (account_number, _) = record[1].split_once(" / ")?;
let Ok(account_number) = Iban::from_str(account_number) else {
return None;
};
if !reader.read_record(&mut record).unwrap_or(false) || &record[0] != "Von:" {
return None;
}
let Ok(from_date) = Date::parse(&record[1], DATE_FORMAT) else {
return None;
};
if !reader.read_record(&mut record).unwrap_or(false) || &record[0] != "Bis:" {
return None;
}
let Ok(to_date) = Date::parse(&record[1], DATE_FORMAT) else {
return None;
};
if !reader.read_record(&mut record).unwrap_or(false) {
return None;
}
let Ok(balance_date) = Date::parse(
&record[0],
time::macros::format_description!("Kontostand vom [day].[month].[year]:"),
) else {
return None;
};
let Ok(final_balance) = parse_german_amount(&record[1]) else {
return None;
};
Some((
FileHeader {
account_number,
from_date,
to_date,
balance_date,
final_balance,
},
rest,
))
}
}
impl beancount_importers_framework::ImporterProtocol for Importer {
type Error = miette::Report;
fn account(&self, _buffer: &[u8]) -> Result<Account, Self::Error> {
Ok(self.config.account.base().to_owned())
}
fn date(&self, buffer: &[u8]) -> Option<Result<Date, Self::Error>> {
let Some((header, _rest)) = self.parse_header(buffer) else {
todo!()
};
Some(Ok(header.to_date))
}
fn extract(
&self,
buffer: &[u8],
existing: &[Directive],
) -> Result<Vec<Directive>, Self::Error> {
let Some((header, rest)) = self.parse_header(buffer) else {
todo!()
};
let mut extracted = self
.importer
.extract(rest, existing, self)
.map_err(Self::Error::from)?;
extracted.push(Directive::from(Balance::new(
header.to_date.next_day().unwrap(),
self.config.account.base(),
header.final_balance,
)));
Ok(extracted)
}
fn filename(&self, _buffer: &[u8]) -> Option<Result<String, Self::Error>> {
Some(Ok(String::from("transactions.csv")))
}
fn identify(&self, buffer: &[u8]) -> Result<bool, Self::Error> {
const EXPECTED_HEADERS: &[&str] = &[
"Buchungstag",
"Wertstellung",
"Buchungstext",
"Auftraggeber / Begünstigter",
"Verwendungszweck",
"Kontonummer",
"BLZ",
"Betrag (EUR)",
"Gläubiger-ID",
"Mandatsreferenz",
"Kundenreferenz",
"",
];
let Some((_header, rest)) = self.parse_header(buffer) else {
return Ok(false);
};
self.importer
.identify(rest, EXPECTED_HEADERS)
.into_diagnostic()
}
fn name(&self) -> &'static str {
Self::NAME
}
fn typetag_deserialize(&self) {}
}
impl csv_importer::RecordImporter for Importer {
type Error = Error;
type Record<'de> = Record<'de>;
fn date(&self, _record: Record) -> Date {
unimplemented!("this importer uses metadata from the file header instead")
}
fn extract(
&self,
_existing: &[Directive],
record: Record,
) -> Result<Vec<Directive>, Self::Error> {
let commodity = Commodity::try_from("EUR").unwrap();
let directives = match record {
Record::Balance(record) => record.amount.map_or_else(Vec::new, |amount| {
vec![Directive::from(Balance::new(
record.booking_date.next_day().unwrap(),
self.config.account.render(&TemplateContext {}),
Amount::new(amount, commodity),
))]
}),
Record::Statement(record) => vec![],
Record::Transaction(record) => {
let mut transaction = Transaction::on(record.value_date);
transaction
.set_payee(record.partner)
.set_narration(record.description);
let context = TemplateContext {};
let account = self.config.account.render(&context);
let transaction_kind = record.transaction_kind;
let amount = Amount::new(record.amount, commodity);
let opposite_account = self
.config
.known_ibans
.get(&record.opposite_iban)
.unwrap_or(&self.config.fallback_account);
transaction
.set_narration(format!("{transaction_kind}"))
.add_meta(
MetadataKey::try_from("other-iban").expect("valid key"),
record.opposite_iban.electronic_str(),
)
.build_posting(account, |posting| {
posting.set_amount(amount);
})
.build_posting(opposite_account, |_posting| {});
vec![Directive::from(transaction)]
}
};
Ok(directives)
}
}
impl ImporterBuilder {
pub fn add_known_iban(&mut self, iban: Iban, account: Account) -> &mut Self {
self.known_ibans.insert(iban, account);
self
}
pub fn build(&mut self) -> Result<Importer, ImporterBuilderError> {
let config = Config {
account: self.account.clone().context(UninitializedFieldSnafu {
field: "account",
importer: Importer::NAME,
})?,
fallback_account: self
.fallback_account
.clone()
.context(UninitializedFieldSnafu {
field: "fallback_account",
importer: Importer::NAME,
})?,
known_ibans: self.known_ibans.clone(),
};
Ok(Importer::new(config))
}
pub fn clear_known_ibans(&mut self) -> &mut Self {
self.known_ibans.clear();
self
}
}
#[derive(Clone, Copy, Debug, Deserialize)]
#[serde(try_from = "RawRecord")]
pub enum Record<'r> {
Balance(BalanceRecord),
Statement(StatementRecord<'r>),
Transaction(#[serde(borrow)] TransactionRecord<'r>),
}
#[derive(Clone, Copy, Debug)]
pub struct BalanceRecord {
booking_date: Date,
amount: Option<Decimal>,
}
#[derive(Clone, Copy, Debug)]
pub struct StatementRecord<'r> {
booking_date: Date,
value_date: Date,
description: &'r str,
amount: Decimal,
}
#[derive(Clone, Copy, Debug)]
pub struct TransactionRecord<'r> {
booking_date: Date,
value_date: Date,
partner: &'r str,
transaction_kind: TransactionKind,
description: &'r str,
opposite_iban: Iban,
amount: Decimal,
}
#[derive(Clone, Copy, Debug)]
pub enum TemplateSelector {}
impl FromStr for TemplateSelector {
type Err = TemplateSelectorError;
fn from_str(selector: &str) -> Result<Self, Self::Err> {
TemplateSelectorSnafu { selector }.fail()
}
}
#[derive(Debug, Diagnostic, Snafu)]
pub struct TemplateSelectorError {
selector: String,
backtrace: Backtrace,
}
struct FileHeader {
account_number: Iban,
from_date: Date,
to_date: Date,
balance_date: Date,
final_balance: Amount,
}
#[derive(Clone, Copy, Debug, Deserialize)]
struct RawRecord<'r> {
#[serde(rename = "Buchungstag", with = "dmy")]
booking_date: Date,
#[serde(rename = "Wertstellung", with = "dmy::option")]
value_date: Option<Date>,
#[serde(rename = "Buchungstext")]
transaction_kind: &'r str,
#[serde(rename = "Auftraggeber / Begünstigter")]
partner: &'r str,
#[serde(rename = "Verwendungszweck")]
description: &'r str,
#[serde(rename = "Kontonummer")]
opposite_iban: &'r str,
#[serde(rename = "Betrag (EUR)", with = "german_decimal::serde::opt")]
amount: Option<Decimal>,
}
impl<'r> TryFrom<RawRecord<'r>> for Record<'r> {
type Error = serde::de::value::Error;
fn try_from(value: RawRecord<'r>) -> Result<Self, Self::Error> {
if value.description == "Tagessaldo" {
return Ok(Self::Balance(BalanceRecord::from(value)));
} else if value.transaction_kind == "Abschluss" {
StatementRecord::try_from(value).map(Self::Statement)
} else {
TransactionRecord::try_from(value).map(Self::Transaction)
}
}
}
impl<'r> From<RawRecord<'r>> for BalanceRecord {
fn from(value: RawRecord<'r>) -> Self {
let RawRecord {
booking_date,
amount,
..
} = value;
Self {
booking_date,
amount,
}
}
}
impl<'r> TryFrom<RawRecord<'r>> for StatementRecord<'r> {
type Error = serde::de::value::Error;
fn try_from(value: RawRecord<'r>) -> Result<Self, Self::Error> {
use serde::de::Error as _;
let RawRecord {
booking_date,
value_date,
description,
amount,
..
} = value;
let value_date = value_date.ok_or_else(|| Self::Error::missing_field("value_date"))?;
let amount = amount.ok_or_else(|| Self::Error::missing_field("amount"))?;
Ok(Self {
booking_date,
value_date,
description,
amount,
})
}
}
impl<'r> TryFrom<RawRecord<'r>> for TransactionRecord<'r> {
type Error = serde::de::value::Error;
fn try_from(value: RawRecord<'r>) -> Result<Self, Self::Error> {
use serde::de::Error as _;
let RawRecord {
booking_date,
value_date,
partner,
transaction_kind,
description,
opposite_iban,
amount,
} = value;
let value_date = value_date.ok_or_else(|| Self::Error::missing_field("value_date"))?;
let transaction_kind = TransactionKind::deserialize(transaction_kind.into_deserializer())?;
let opposite_iban = Iban::deserialize(opposite_iban.into_deserializer())?;
let amount = amount.ok_or_else(|| Self::Error::missing_field("amount"))?;
Ok(Self {
booking_date,
value_date,
partner,
transaction_kind,
description,
opposite_iban,
amount,
})
}
}
#[derive(Debug)]
struct TemplateContext {}
impl Index<&TemplateSelector> for TemplateContext {
type Output = Seg;
fn index(&self, selector: &TemplateSelector) -> &Self::Output {
match *selector {}
}
}
#[derive(Clone, Copy, Debug, Deserialize)]
enum TransactionKind {
#[serde(rename = "Kartenzahlung")]
CardPayment,
#[serde(rename = "Gutschrift")]
Credit,
#[serde(rename = "Online-Zahlung")]
OnlinePayment,
#[serde(rename = "Überweisung")]
Remittance,
#[serde(rename = "Dauerauftrag")]
StandingOrder,
}
impl Display for TransactionKind {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self {
Self::CardPayment => f.write_str("Card payment"),
Self::Credit => f.write_str("Credit"),
Self::OnlinePayment => f.write_str("Online payment"),
Self::Remittance => f.write_str("Remittance"),
Self::StandingOrder => f.write_str("Standing order"),
}
}
}
impl FromStr for TransactionKind {
type Err = serde::de::value::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::deserialize(s.into_deserializer())
}
}
impl TryFrom<&'_ str> for TransactionKind {
type Error = <Self as FromStr>::Err;
fn try_from(s: &str) -> Result<Self, Self::Error> {
Self::from_str(s)
}
}
const DATE_FORMAT: &[time::format_description::FormatItem<'_>] =
time::macros::format_description!("[day].[month].[year]");
time::serde::format_description!(dmy, Date, DATE_FORMAT);
fn parse_german_amount(v: &str) -> Result<Amount, Error> {
let Some((amount, currency)) = v.split_once(' ') else {
todo!()
};
let amount = german_decimal::parse(amount).map_err(|_| todo!())?;
let currency = Commodity::try_from(currency).map_err(|_| todo!())?;
Ok(Amount::new(amount, currency))
}