UO34MAAGCLHTUGNRZQUNIS32ZE354N72IMGZ76THUBKR7TDT6L2QC
D6LJRTWXZZTEJPCUTKTHMIKR5R3UYZF4WKN2GV3P432HNHUJCVVAC
VXPFYRI2BRQGUOIT3KNNSCEEJMCJLMRI4DG647JFLVNN3WQVH5DQC
EYMZ5JWP6L3HVVAWYUGS6M6IWY6LOHIZPKTHYIZVNH7LOURQ3WRAC
JUYXTWXP5Y52UAPROXCHVL4VL5G2DDRWGY4OX7ZXFE6GRMEV4EYQC
4WYI5U7YHCVEXBFCOY4M3JN7IZYSBBK6AOZJMBAQ6XWI2Y26IYXAC
6A5YLGWVK7F6DQWDTSVFRIPFQ5VLIUN3P6DW64QHDDE6RUARDEBQC
XQHYMSDYDG3MFDUEGXADTNXA4B7AQRSYA5JCP3S3KQYL527DORHQC
RCS5VP3AIMPZ5FQ2QWOQGZET726R2O2GPPEJMHLPXQG65KG7KFFQC
NQ455YWRMOM3SUX3A7W2SR4VAI3DYBXBKBMYDGNQIGUICOVC2WOAC
PCHAKXNMERZROCHMO6GWWES5JSN6MK4O2UAABLPETYYSRICWXTHAC
GVEI7KNDSO5KHFV5GIT2FLNIF7I3UQNH6KHUDM4QLUSWBSMDJO6AC
R5K55SCBMHEGYZTMITKMNV7JFM3246SSKVB75HNJEKMLLIMJRGNQC
BDLVPDJZU5BPXRS5DTAUPJ5H6JWURRGS2NKKNCA43HGQGRFIKGIAC
362NCCMXCBQRRWBRE5G6YLINRAF472PJJF3WSF43B2SULDJBWEHQC
KB7Y4PJIETGK43QVN5JTCQ2UD3JCXKYDULKTNQVR7WMH2JIODS7AC
TSSWMQVUBZOA2PG4N2Z4IQRKOEM7BHS2S762HTTWGJGVDKSXQI3AC
I2P2FTLEKLICJKHQ3FHOLRQRQYGZCJTCTU2MWXU2TMIRIKG6YFCQC
2JBFREZGJ2PST2DE3ZVDQADXAOFXBYPMSFTG7C65GDKLOZGETTGAC
UESS5YZE6ZHPMVUL2P2OACW2Y2QLFLLLLC3F3JAENVH6A7REKLBAC
TB2QGHXN2PTACZQG4M53QBG74IDGSDQPAFSTRZ7UNHHCAXRWQEZQC
JQJTN6N53CMWHRUVXJ4K3HDNUYIM23W7GET7XTOBVCS57RCCT6ZQC
ONRIF4V72HMVLO4BWDHI7ZOWYWTLTVYNH5TXUN5HB7BQUMV22NVAC
24CCPM5ORU7ONWBW4OU6BLAW666JMQ5VXWO43VEOD6VSFT6TCX2QC
2Z4EGCWQV2GF3CZC6IBGLTOS6JN3NMNBNQMUOZTIOCRO54VB6DSQC
SLTVZLYXPHYVACXUOKWGVI3MV6IJBT22Y4NYRBQDVDYULCDSJ6SAC
YDK6X6PPD42DMLFGF6OO2O3G7GA4Z2PCIDJIREHX6XNX2NYEBJSQC
RI7HQBYA7OZBTINMECRKAM5JCCMI3HDLD2CBNYT2OVIHKZPZIWDAC
#[serde(rename = "Buchungstag", with = "dmy")]
booking_date: Date,
#[serde(rename = "Waehrung")]
currency: Currency,
#[serde(rename = "Verwendungszweck")]
purpose: &'r str,
#[serde(rename = "IBAN Zahlungsbeteiligter")]
transaction_partner_iban: &'r str,
#[serde(rename = "Name Zahlungsbeteiligter")]
transaction_partner_name: &'r str,
#[serde(rename = "Valutadatum", with = "dmy")]
value_date: Date,
}
const DEPOT_ID_COLUMN_INDEX: usize = 1;
const PRICE_COLUMN_INDEX: usize = 5;
const TOTAL_COLUMN_INDEX: usize = 7;
const TRANSACTION_KIND_COLUMN_INDEX: usize = 3;
const UNIT_COLUMN_INDEX: usize = 8;
const UNITS_COLUMN_INDEX: usize = 6;
let context = TemplateContext::try_from(&record[DEPOT_ID_COLUMN_INDEX])?;
let context = TemplateContext::try_from(record.depot_id)?;
let currency: Commodity = self.lookup_currency(&record[UNIT_COLUMN_INDEX])?;
let units = {
let value: &str = &record[UNITS_COLUMN_INDEX];
german_decimal::parse(value).map_err(|_| todo!())
}?;
let unit_cost = {
let price = &record[PRICE_COLUMN_INDEX];
german_decimal::parse(price).map_err(|_| todo!())
}?;
let currency: Commodity = self.lookup_currency(record.currency)?;
let units = record.shares;
let unit_cost = record.unit_cost;
}
}
#[derive(Clone, Copy, Debug, Deserialize)]
pub struct Record<'r> {
#[serde(rename = "DATUM")]
date: Date,
#[serde(rename = "UNIONDEPOTNUMMER")]
depot_id: DepotId<'r>,
#[serde(rename = "TRANSAKTIONSART")]
transaction_kind: TransactionKind,
#[serde(rename = "FONDSPREIS", with = "german_decimal::serde")]
unit_cost: Decimal,
#[serde(rename = "ANTEILE", with = "german_decimal::serde")]
shares: Decimal,
#[serde(rename = "VOLUMEN", with = "german_decimal::serde")]
total: Decimal,
#[serde(rename = "EINHEIT")]
currency: &'r str,
}
#[derive(Clone, Copy, Debug, Deserialize)]
#[serde(try_from = "&str")]
struct DepotId<'r> {
depot: &'r str,
position: &'r str,
}
impl<'i> TryFrom<&'i str> for DepotId<'i> {
type Error = Error;
fn try_from(value: &'i str) -> Result<Self, Self::Error> {
let (depot, position) = value.split_once('/').ok_or_else(|| todo!())?;
Ok(Self { depot, position })
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();
fn extract_record(&self, record: Record) -> Result<(Transaction, Balance), Error> {
dbg!(record);
let timestamp = date.with_time(time).assume_timezone(tz).unwrap();
let commodity = Commodity::try_from(&record[4]).unwrap();
let tz = time_tz::timezones::get_by_name(record.timezone).unwrap();
let timestamp = record
.date
.with_time(record.time)
.assume_timezone(tz)
.unwrap();
let transaction_id = &record[9];
if matches!(kind, TransactionKind::Deposit) {
transaction.add_link(Link::try_from(format!("^paypal.{transaction_id}")).unwrap());
if matches!(record.description, TransactionKind::Deposit) {
transaction.add_link(
Link::try_from(format!("^paypal.{}", record.transaction_id)).unwrap(),
);
}
#[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>,
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!())
}
time::serde::format_description!(dmy, Date, "[day].[month].[year]");
time::serde::format_description!(hms, Time, "[hour]:[minute]:[second]");
#[derive(Clone, Copy, Debug, Deserialize)]
pub struct Record<'r> {
#[serde(rename = "Datum", with = "dmy")]
date: Date,
#[serde(rename = "Beschreibung")]
transaction_kind: TransactionKind<'r>,
#[serde(rename = "Beschreibung2")]
description: &'r str,
#[serde(rename = "Wert", with = "german_decimal::serde")]
amount: Decimal,
}
fn parse_transaction_date(record: &csv::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!())
}
time::serde::format_description!(dmy, Date, "[day].[month].[year]");
mod dmy_short {
pub fn deserialize<'de, D>(deserializer: D) -> Result<time::Date, D::Error>
where
D: serde::Deserializer<'de>,
{
struct Visitor;
impl serde::de::Visitor<'_> for Visitor {
type Value = time::Date;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a date in dd.mm.yy format")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
const BASE_YEAR: i32 = 2000;
const DATE_FORMAT: &[time::format_description::FormatItem] =
time::macros::format_description!("[day].[month].[year repr:last_two]");
let mut parsed = time::parsing::Parsed::new();
parsed.parse_items(v.as_bytes(), DATE_FORMAT).unwrap();
let year_last_two = i32::from(parsed.year_last_two().unwrap());
parsed.set_year(BASE_YEAR + year_last_two);
parsed.try_into().map_err(E::custom)
}
}
const DEPOT_ID_COLUMN_INDEX: usize = 0;
const DEPOT_POSITION_COLUMN_INDEX: usize = 1;
const FUND_CURRENCY_COLUMN_INDEX: usize = 12;
const FUND_NAME_COLUMN_INDEX: usize = 6;
const ISIN_COLUMN_INDEX: usize = 7;
const REFERENCE_COLUMN_INDEX: usize = 2;
const SHARES_COLUMN_INDEX: usize = 10;
const PRICE_DATE_COLUMN_INDEX: usize = 13;
const PAYMENT_AMOUNT_COLUMN_INDEX: usize = 8;
const PAYMENT_CURRENY_COLUMN_INDEX: usize = 9;
const TRANSACTION_KIND_COLUMN_INDEX: usize = 4;
const SHARE_PRICE_COLUMN_INDEX: usize = 11;
const EXCHANGE_RATE_COLUMN_INDEX: usize = 14;
let kind = TransactionKind::try_from(&record[TRANSACTION_KIND_COLUMN_INDEX])?;
let transaction_id = &record[REFERENCE_COLUMN_INDEX];
let (reference, ..) = transaction_id.split_once('/').unwrap();
let reference = record.reference.transaction_id;
let fund_commodity = Commodity::try_from(&record[ISIN_COLUMN_INDEX]).unwrap();
let depot_id = &record[DEPOT_ID_COLUMN_INDEX];
let position = &record[DEPOT_POSITION_COLUMN_INDEX];
let depot_id = record.depot_id;
let position = record.depot_position;
};
let fund_currency = Commodity::try_from(&record[FUND_CURRENCY_COLUMN_INDEX]).unwrap();
let share_price = Amount::new(
german_decimal::parse(&record[SHARE_PRICE_COLUMN_INDEX]).unwrap(),
fund_currency,
);
let shares_amount = Amount::new(
german_decimal::parse(&record[SHARES_COLUMN_INDEX])
.map_err(|_| -> Error { todo!() })?,
fund_commodity,
);
let payment_currency = Commodity::try_from(&record[PAYMENT_CURRENY_COLUMN_INDEX])
.map_err(|_| -> Error { todo!() })?;
let payment_amount = {
let amount = parse_decimal_with_precision(&record[PAYMENT_AMOUNT_COLUMN_INDEX], 2)
.map_err(|_| -> Error { todo!() })?;
Amount::new(amount, payment_currency)
let investment_amount_payment = {
let investment_amount = &record[15];
let investment_amount = parse_decimal_with_precision(investment_amount, 2)
.map_err(|_| -> Error { todo!() })?;
let fund_currency = record.fund_currency;
let share_price = Amount::new(record.share_price, fund_currency);
let investment_amount_fund = {
let exchange_rate = Some(&record[EXCHANGE_RATE_COLUMN_INDEX])
.filter(|value| !value.is_empty())
.map(german_decimal::parse)
.transpose()
.map_err(|_| -> Error { todo!() })?;
let payment_amount = Amount::new(record.payment_amount, record.payment_curreny);
let fees = german_decimal::parse(&record[26])
.unwrap()
.tap_mut(|amount| {
amount.rescale(2);
});
let investment_amount_fund = record.exchange_rate.map(|rate| {
let mut amount = investment_amount_payment.amount * rate;
amount.rescale(2);
Amount::new(amount, fund_currency)
});
.set_narration(kind.format_narration(&record[FUND_NAME_COLUMN_INDEX]))
.add_meta(common_keys::TRANSACTION_ID, transaction_id);
.set_narration(record.transaction_kind.format_narration(record.fund_name))
.add_meta(common_keys::TRANSACTION_ID, record.reference.to_string());
#[derive(Clone, Copy, Debug, Deserialize)]
pub struct Record<'r> {
#[serde(rename = "Buchungsdatum", with = "dmy_short")]
booking_date: Date,
#[serde(rename = "Depotnummer")]
depot_id: &'r str,
#[serde(rename = "Depotposition")]
depot_position: &'r str,
#[serde(rename = "Devisenkurs (ZW/FW)", with = "german_decimal::serde::opt")]
exchange_rate: Option<Decimal>,
#[serde(
rename = "Entgelt in ZW",
with = "german_decimal::serde::with_precision::<2>"
)]
fees: Decimal,
#[serde(rename = "Fondswährung (FW)")]
fund_currency: Commodity,
#[serde(rename = "Fonds")]
fund_name: &'r str,
#[serde(
rename = "Anlagebetrag in ZW",
with = "german_decimal::serde::with_precision::<2>"
)]
investment_amount: Decimal,
#[serde(rename = "ISIN")]
isin: Commodity,
#[serde(
rename = "Zahlungsbetrag in ZW",
with = "german_decimal::serde::with_precision::<2>"
)]
payment_amount: Decimal,
#[serde(rename = "Zahlungswährung (ZW)")]
payment_curreny: Commodity,
#[serde(rename = "Kursdatum", with = "dmy_short")]
price_date: Date,
#[serde(rename = "Ref. Nr.")]
reference: TransactionReference<'r>,
#[serde(rename = "Abrechnungskurs in FW", with = "german_decimal::serde")]
share_price: Decimal,
#[serde(rename = "Anteile", with = "german_decimal::serde")]
shares: Decimal,
#[serde(rename = "Umsatzart")]
transaction_kind: TransactionKind,
}
pub fn parse_transaction_date(record: &Record) -> Result<Date, Error> {
const TRANSACTION_DATE_COLUMN_INDEX: usize = 3;
parse_date(&record[TRANSACTION_DATE_COLUMN_INDEX])
#[derive(Clone, Copy, Debug)]
struct TransactionReference<'r> {
order_date: Date,
transaction_id: &'r str,
pub fn parse_date(date: &str) -> Result<Date, Error> {
const BASE_YEAR: i32 = 2000;
const DATE_FORMAT: &[time::format_description::FormatItem] =
format_description!("[day].[month].[year repr:last_two]");
impl<'de, 'r> serde::Deserialize<'de> for TransactionReference<'r>
where
'de: 'r,
{
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = TransactionReference<'de>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("an ebase transaction reference number")
}
fn visit_borrowed_str<E>(self, v: &'de str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
let (transaction_id, order_date) = v
.split_once('/')
.ok_or(E::custom("unexpected format for transaction reference"))?;
fn parse_decimal_with_precision(
decimal: &str,
precision: u32,
) -> Result<Decimal, rust_decimal::Error> {
german_decimal::parse(decimal).tap_ok_mut(|amount| {
amount.rescale(precision);
})
impl std::fmt::Display for TransactionReference<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut buffer: &mut [u8] = &mut [0u8; 8];
f.write_str(self.transaction_id)?;
f.write_char('/')?;
self.order_date
.format_into(&mut buffer, format_description!("[day][month][year]"))
.expect("valid formatting");
f.write_str(core::str::from_utf8(buffer).expect("valid UTF-8"))
}
const CURRENCY_COLUMN_INDEX: usize = 7;
const DEPOT_ID_COLUMN_INDEX: usize = 0;
const DEPOT_POSITION_COLUMN_INDEX: usize = 1;
const ISIN_COLUMN_INDEX: usize = 3;
const SHARE_PRICE_COLUMN_INDEX: usize = 6;
const SHARES_COLUMN_INDEX: usize = 5;
let date = parse_price_date(record)?;
let depot_id = &record[DEPOT_ID_COLUMN_INDEX];
let position = {
let position = &record[DEPOT_POSITION_COLUMN_INDEX];
let position = usize::from_str(position).context(PositionSnafu { position })?;
format!("{:02}", position)
};
let position = format!("{:02}", record.depot_position);
let share_price = {
let share_price = &record[SHARE_PRICE_COLUMN_INDEX];
let amount =
german_decimal::parse(share_price).context(SharePriceSnafu { share_price })?;
let currency = &record[CURRENCY_COLUMN_INDEX];
let commodity = Commodity::try_from(currency).context(CurrencySnafu { currency })?;
let share_price = Amount::new(record.share_price, record.share_price_currency);
let price = Price::new(record.share_price_date, isin, share_price);
#[derive(Clone, Copy, Debug, Deserialize)]
pub struct Record<'r> {
#[serde(rename = "Depotnummer")]
depot_id: &'r str,
#[serde(rename = "Position")]
depot_position: usize,
#[serde(rename = "ISIN")]
isin: Commodity,
#[serde(rename = "Anteile", with = "german_decimal::serde")]
shares: Decimal,
#[serde(rename = "Anteilswert", with = "german_decimal::serde")]
share_price: Decimal,
#[serde(rename = "Währung (Anteilswert)")]
share_price_currency: Commodity,
#[serde(rename = "Kursdatum", with = "dmy")]
share_price_date: Date,
}
self.fold(
file,
None,
|record| inner.date(record).transpose(),
|date, extracted| {
if let Some(new_date) = extracted {
let date = date.get_or_insert(new_date);
*date = Date::max(*date, new_date);
};
},
)
(|| {
let mut date = None;
let data = std::fs::read_to_string(file).context(ReadingSnafu { file })?;
let mut reader = self.builder.from_reader(data.as_bytes());
let headers = reader.headers().context(ParsingSnafu {})?.clone();
let mut errors = Vec::new();
let mut record = Record::new();
while reader.read_record(&mut record).context(ParsingSnafu {})? {
let position = record.position().expect("position is set by csv::Reader");
let offset = SourceOffset::from((position.byte() + 1) as usize);
let new_date = record
.deserialize(Some(&headers))
.map(|record| inner.date(record))
.context(DeserializationSnafu { offset });
match new_date {
Ok(new_date) if errors.is_empty() => {
let date = date.get_or_insert(new_date);
*date = Date::max(*date, new_date);
}
// we can skip updating our state when we already have at least one error
Ok(_) => {}
Err(error) => {
errors.push(error);
}
}
}
if errors.is_empty() {
Ok(date)
} else {
ProcessingSnafu { errors, data }.fail()
}
})()
self.fold(
file,
Vec::new(),
|record| {
let position = record.position().expect("position is set by csv::Reader");
inner.extract(existing, record).tap_ok_mut(|records| {
records.iter_mut().for_each(|directive| {
directive.add_meta(common_keys::IMPORTED_RECORD, position.record());
});
})
},
|directives, extracted| {
directives.extend(extracted);
},
)
}
pub fn identify(&self, file: &Utf8Path, expected_headers: &[&str]) -> Result<bool, csv::Error> {
if !matches!(file.extension(), Some(ext) if ext.eq_ignore_ascii_case("csv")) {
return Ok(false);
}
let mut reader = self.builder.from_path(file)?;
let headers = reader.headers()?;
Ok(headers == expected_headers)
}
}
impl Importer {
fn fold<State, Err>(
&self,
file: &Utf8Path,
mut state: State,
extractor: impl Fn(&Record) -> Result<State, Err>,
folder: impl Fn(&mut State, State),
) -> Result<State, Error<Err>>
where
Err: Diagnostic + Send + Sync,
{
let mut directives = Vec::new();
{
let mut record = Record::new();
while reader.read_record(&mut record).context(ParsingSnafu {})? {
let position = record.position().expect("position is set by csv::Reader");
let offset = SourceOffset::from((position.byte() + 1) as usize);
let mut record = Record::new();
while reader.read_record(&mut record).context(ParsingSnafu {})? {
let position = record.position().expect("position is set by csv::Reader");
let offset = SourceOffset::from((position.byte() + 1) as usize);
match extractor(&record).context(RecordSnafu { offset }) {
Ok(new_state) if errors.is_empty() => {
folder(&mut state, new_state);
}
let extracted = record
.deserialize(Some(&headers))
.context(DeserializationSnafu { offset })
.and_then(|record| {
inner
.extract(existing, record)
.context(ExtractionSnafu { offset })
});
match extracted {
Ok(extracted) if errors.is_empty() => {
directives.extend(extracted.into_iter().map(|mut directive| {
directive.add_meta(common_keys::IMPORTED_RECORD, position.record());
directive
}));
}
ExtractingSnafu { errors, data }.fail()
ProcessingSnafu { errors, data }.fail()
}
}
pub fn identify(&self, file: &Utf8Path, expected_headers: &[&str]) -> Result<bool, csv::Error> {
if !matches!(file.extension(), Some(ext) if ext.eq_ignore_ascii_case("csv")) {
return Ok(false);
#[diagnostic_source]
source: E,
#[snafu(display("error while deserializing CSV record"))]
Deserialization {
source: csv::Error,
#[label("in this record")]
offset: SourceSpan,
},
#[snafu(display("error while extracting record"))]
Extraction {
#[diagnostic_source]
source: E,
const ORDER_ID_COLUMN_INDEX: usize = 3;
let isin = Commodity::try_from(&record[0]).map_err(|_| todo!())?;
let security_name = &record[1];
let date = parse_order_date(record)?;
let order_id = &record[ORDER_ID_COLUMN_INDEX];
let transaction_kind = TransactionKind::from_str(&record[4])?;
let units = german_decimal::parse(&record[5]).map_err(|_| todo!())?;
let amount = Amount::new(units, isin);
let security_price = parse_amount(&record[7])?;
let isin =
Commodity::try_from(record.isin.to_string()).expect("ISINs are valid commodities");
TransactionKind::Buy => (amount, CostBasis::PerUnit(security_price), None, -total),
TransactionKind::Sell => (-amount, CostBasis::Empty, Some(security_price), total),
TransactionKind::Buy => (
Amount::new(record.shares, isin),
CostBasis::PerUnit(record.unit_cost),
None,
-record.total_cost,
),
TransactionKind::Sell => (
-Amount::new(record.shares, isin),
CostBasis::Empty,
Some(record.unit_cost),
record.total_cost,
),
}
#[derive(Clone, Debug, Deserialize)]
// TODO derive Copy once ISIN changes to support that
pub struct Record<'r> {
#[serde(rename = "Datum", with = "dmy")]
date: Date,
#[serde(deserialize_with = "deserialize_isin", rename = "Isin")]
isin: ISIN,
#[serde(rename = "Ordernummer")]
order_id: &'r str,
#[serde(rename = "Wertpapierbezeichnung")]
security_name: &'r str,
#[serde(rename = "Stück", with = "german_decimal::serde")]
shares: Decimal,
#[serde(deserialize_with = "deserialize_german_amount", rename = "Kurswert")]
total_cost: Amount,
#[serde(rename = "Geschäftsart")]
transaction_kind: TransactionKind,
#[serde(deserialize_with = "deserialize_german_amount", rename = "Kurs")]
unit_cost: Amount,
fn parse_amount(cost: &str) -> Result<Amount, Error> {
let (amount, commodity) = cost.split_once(' ').ok_or_else(|| todo!())?;
time::serde::format_description!(dmy, Date, "[day].[month].[year]");
fn deserialize_isin<'de, D>(deserializer: D) -> Result<ISIN, D::Error>
where
D: serde::Deserializer<'de>,
{
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = ISIN;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("an ISIN")
}
let amount = german_decimal::parse(amount).map_err(|_| todo!())?;
let commodity = Commodity::try_from(commodity).map_err(|_| todo!())?;
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
isin::parse(v).map_err(E::custom)
}
}
fn parse_order_date(record: &csv::Record) -> Result<Date, Error> {
use time::format_description::FormatItem;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = Amount;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("an ISIN")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
let Some((amount, currency)) = v.split_once(' ') else {
return Err(E::custom("unexpected format"));
};
const DATE_COLUMN_INDEX: usize = 2;
const DATE_FORMAT: &[FormatItem] = format_description!("[day].[month].[year]");
let amount = german_decimal::parse(amount).map_err(E::custom)?;
let currency = Commodity::try_from(currency).map_err(E::custom)?;
Ok(Amount::new(amount, currency))
}
}
fn date(&self, record: &csv::Record) -> Option<Result<time::Date, Self::Error>> {
Some(parse_order_timestamp(record).map(OffsetDateTime::date))
fn date(&self, record: Record) -> Date {
Date::max(
record.order_time.date(),
record.invoice_date.unwrap_or(Date::MIN),
)
const CURRENCY_COLUMN_INDEX: usize = 22;
const ORDER_ID_COLUMN_INDEX: usize = 19;
const PRODUCT_NAME_COLUMN_INDEX: usize = 4;
const ITEM_PRICE_COLUMN_INDEX: usize = 8;
let order_id = Some(&record[ORDER_ID_COLUMN_INDEX]).filter(|order_id| !order_id.is_empty());
let timestamp = parse_order_timestamp(record)?;
let commodity = Commodity::try_from(&record[CURRENCY_COLUMN_INDEX]).unwrap();
let amount = {
let amount = &record[ITEM_PRICE_COLUMN_INDEX];
let (amount, _) = amount.split_once(' ').ok_or_else(|| todo!())?;
let amount = german_decimal::parse(amount).map_err(|_| todo!())?;
Amount::new(-amount, commodity)
let Some(order_id) = record.order_id else {
return Ok(vec![]);
.set_payee(&self.config.payee)
.set_narration(product_name);
.set_payee(record.seller)
.set_narration(record.item_description)
.add_link(Link::try_from(format!("^apple.{order_id}")).expect("valid link"))
.add_meta(common_keys::TRANSACTION_ID, order_id);
.add_link(Link::try_from(format!("^apple.{order_id}")).expect("valid link"))
.add_meta(common_keys::TRANSACTION_ID, order_id);
};
.add_meta(
MetadataKey::try_from("invoice-date").expect("valid key"),
invoice_date.to_string(),
)
.add_meta(
MetadataKey::try_from("invoice-id").expect("valid key"),
invoice_id,
);
}
#[derive(Clone, Copy, Debug, Deserialize)]
pub struct Record<'r> {
#[serde(rename = "Currency")]
currency: Currency,
#[serde(rename = "Document Number")]
document_number: Option<&'r str>,
#[serde(rename = "Invoice Date", with = "mdy::option")]
invoice_date: Option<Date>,
#[serde(rename = "Item Description")]
item_description: &'r str,
#[serde(
deserialize_with = "deserialize_german_euro_amount",
rename = "Invoice Item Total"
)]
item_total: Decimal,
#[serde(rename = "Order Number")]
order_id: Option<&'r str>,
#[serde(rename = "Item Purchased Date", with = "time::serde::iso8601")]
order_time: OffsetDateTime,
#[serde(rename = "Seller")]
seller: &'r str,
}
fn parse_order_timestamp(record: &csv::Record) -> Result<OffsetDateTime, Error> {
const ORDER_TIMESTAMP_COLUMN_INDEX: usize = 1;
fn deserialize_german_euro_amount<'de, D>(deserializer: D) -> Result<Decimal, D::Error>
where
D: serde::Deserializer<'de>,
{
struct Visitor;
fn parse_timestamp(input: &str) -> Result<OffsetDateTime, Error> {
let utc_timestamp = OffsetDateTime::parse(input, &Iso8601::PARSING).map_err(|_| todo!())?;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("an amount with trailing € symbol")
}
let timezone = time_tz::system::get_timezone().expect("system timezone");
let timestamp = utc_timestamp.to_timezone(timezone);
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
let v = v
.strip_suffix(" €")
.ok_or_else(|| E::custom("expected symbol suffix not found"))?
.trim();
const AMOUNT_COLUMN_INDEX: usize = 3;
const COUNTRY_COLUMN_INDEX: usize = 1;
const ORDER_ID_COLUMN_INDEX: usize = 2;
const TRANSACTION_KIND_COLUMN_INDEX: usize = 4;
let timestamp = parse_record_timestamp(record)?;
let country = &record[COUNTRY_COLUMN_INDEX];
let commodity = match country {
"DE" => Commodity::try_from("EUR").unwrap(),
let commodity = match record.country {
Country::DE => Commodity::try_from("EUR").unwrap(),
let kind = TransactionKind::try_from(&record[TRANSACTION_KIND_COLUMN_INDEX])?;
let amount = {
let amount = &record[AMOUNT_COLUMN_INDEX];
let amount = Decimal::from_str(amount).map_err(|_| todo!())?;
Amount::new(amount, commodity)
};
let kind = TransactionKind::try_from(record.transaction_type)?;
let amount = Amount::new(record.amount, commodity);
}
#[derive(Clone, Copy, Debug, Deserialize)]
pub struct Record<'r> {
#[serde(rename = "Amount")]
amount: Decimal,
#[serde(rename = "Country")]
country: Country,
#[serde(rename = "Order number")]
order_number: &'r str,
#[serde(
rename = "Transaction date",
deserialize_with = "deserialize_local_offset_date_time"
)]
transaction_date: OffsetDateTime,
#[serde(rename = "Transaction type")]
transaction_type: &'r str,
fn parse_record_timestamp(record: &csv::Record) -> Result<OffsetDateTime, Error> {
const DATE_COLUMN_INDEX: usize = 0;
fn deserialize_local_offset_date_time<'de, D>(deserializer: D) -> Result<OffsetDateTime, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error as _;
use time_tz::system;
use time_tz::PrimitiveDateTimeExt as _;
use time_tz::TimeZone as _;
let tz = system::get_timezone().map_err(|_| todo!())?;
timestamp
.assume_timezone(tz)
.take_first()
.ok_or_else(|| todo!())
let tz = system::get_timezone().map_err(D::Error::custom)?;
timestamp.assume_timezone(tz).take_first().ok_or_else(|| {
D::Error::custom(format!(
"invalid datetime {timestamp} in timezone {}",
tz.name()
))
})
const PRIMITIVE_DATE_TIME_FORMAT: Iso8601<0> =
time::format_description::well_known::Iso8601::PARSING;
time::serde::format_description!(
iso_primitivedatetime,
PrimitiveDateTime,
PRIMITIVE_DATE_TIME_FORMAT
);
#[derive(Clone, Copy, Debug, Deserialize)]
pub struct Record<'r> {
#[serde(rename = "Order ID")]
order_id: &'r str,
#[serde(rename = "Order Date", with = "time::serde::iso8601")]
order_timestamp: OffsetDateTime,
#[serde(rename = "Currency")]
currency: Commodity,
#[serde(rename = "Total Owed")]
total_owed: Decimal,
fn parse_timestamp(input: &str) -> Result<OffsetDateTime, Error> {
let utc_timestamp = PrimitiveDateTime::parse(input, &Rfc3339)
.unwrap()
.assume_utc();
let timezone = time_tz::system::get_timezone().expect("system timezone");
let timestamp = utc_timestamp.to_timezone(timezone);
Ok(timestamp)
}
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]");
#[derive(Clone, Copy, Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct Record<'r> {
#[serde(with = "dm_long_y")]
date: Date,
#[serde(rename = "Description")]
kind: TransactionKind<'r>,
TransactionKind::Claim { .. } => f.write_str("Gift Card Claim"),
TransactionKind::Payment { .. } => f.write_str("Payment"),
TransactionKind::TopUp { .. } => f.write_str("Balance Top Up"),
TransactionKind::Claim { claim_code } => write!(f, "Claim gift card {claim_code}"),
TransactionKind::Payment { order_id } => write!(f, "Payment towards order {order_id}"),
TransactionKind::TopUp { order_id } => write!(f, "Balance top up in order {order_id}"),
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 date = record.order_date;
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 amount = Amount::new(
record.our_price_tax.tap_mut(|amount| amount.rescale(2)),
record.our_price_tax_currency_code,
);
use rust_decimal::Decimal;
pub mod opt {
use rust_decimal::Decimal;
#[allow(non_camel_case_types)]
pub struct with_precision<const PRECISION: u32>;
impl<const PRECISION: u32> with_precision<PRECISION> {
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Decimal>, D::Error>
where
D: serde::Deserializer<'de>,
{
struct Visitor<const PRECISION: u32>;
impl<'de, const PRECISION: u32> serde::de::Visitor<'de> for Visitor<PRECISION> {
type Value = Option<Decimal>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("an optional decimal number in german format")
}
fn visit_none<E>(self) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(None)
}
fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
where
D: serde::Deserializer<'de>,
{
super::with_precision::<PRECISION>::deserialize(deserializer).map(Some)
}
}
deserializer.deserialize_option(Visitor::<PRECISION>)
}
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Decimal>, D::Error>
where
D: serde::Deserializer<'de>,
{
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = Option<Decimal>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("an optional decimal number in german format")
}
fn visit_none<E>(self) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(None)
}
fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
where
D: serde::Deserializer<'de>,
{
super::deserialize(deserializer).map(Some)
}
}
deserializer.deserialize_option(Visitor)
}
}
#[allow(non_camel_case_types)]
pub struct with_precision<const PRECISION: u32>;
impl<const PRECISION: u32> with_precision<PRECISION> {
pub fn deserialize<'de, D>(deserializer: D) -> Result<Decimal, D::Error>
where
D: serde::Deserializer<'de>,
{
struct Visitor<const PRECISION: u32>;
impl<'de, const PRECISION: u32> serde::de::Visitor<'de> for Visitor<PRECISION> {
type Value = Decimal;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a decimal number in german format")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
let mut amount = crate::parse(v).map_err(E::custom)?;
amount.rescale(PRECISION);
Ok(amount)
}
}
deserializer.deserialize_str(Visitor::<PRECISION>)
}
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Decimal, D::Error>
where
D: serde::Deserializer<'de>,
{
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = Decimal;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a decimal number in german format")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
crate::parse(v).map_err(E::custom)
}
}
deserializer.deserialize_str(Visitor)
}