use core::fmt::Write;
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::AccountTemplate;
use beancount_types::Amount;
use beancount_types::Balance;
use beancount_types::Commodity;
use beancount_types::CostBasis;
use beancount_types::Directive;
use beancount_types::Link;
use beancount_types::Price;
use beancount_types::Seg;
use beancount_types::Transaction;
use derive_builder::Builder;
use miette::Diagnostic;
use miette::IntoDiagnostic;
use miette::Report;
use rust_decimal::Decimal;
use serde::Deserialize;
use snafu::Backtrace;
use snafu::OptionExt;
use snafu::Snafu;
use tap::prelude::Tap;
use time::macros::format_description;
use time::Date;
mod dmy_short {
pub mod opt {
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<time::Date>, D::Error>
where
D: serde::Deserializer<'de>,
{
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = Option<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_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)
}
}
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)
.map_err(E::custom)?;
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)
}
}
deserializer.deserialize_str(Visitor)
}
}
#[derive(Builder, Clone, Debug, Deserialize)]
#[builder(
build_fn(error = "ImporterBuilderError", skip),
name = "ImporterBuilder",
setter(into),
try_setter
)]
pub struct Config {
pub account_template: AccountTemplate<TemplateSelector>,
pub capital_gains_account: AccountTemplate<TemplateSelector>,
pub currency_account: AccountTemplate<CurrencyTemplateSelector>,
pub distributions_template: AccountTemplate<TemplateSelector>,
pub fee_template: AccountTemplate<TemplateSelector>,
pub payee: String,
pub portfolio_fee_template: AccountTemplate<TemplateSelector>,
pub reference_account: AccountTemplate<TemplateSelector>,
}
#[derive(Clone, Copy, Debug)]
pub enum CurrencyTemplateSelector {
Currency,
}
impl FromStr for CurrencyTemplateSelector {
type Err = TemplateSelectorError;
fn from_str(selector: &str) -> Result<Self, Self::Err> {
let selector = match selector {
"currency" => Self::Currency,
_ => return TemplateSelectorSnafu { selector }.fail(),
};
Ok(selector)
}
}
#[derive(Debug, Diagnostic, Snafu)]
pub enum Error {
#[snafu(display("error while parsing date"))]
DateFormat { source: time::Error },
#[snafu(display("unsupported transaction kind: {value:?}"))]
UnsupportedTransactionKind { value: String, backtrace: Backtrace },
}
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 {
#[serde(flatten)]
pub config: Config,
#[serde(default = "csv::Importer::semicolon_delimited", skip_deserializing)]
pub importer: csv::Importer,
}
impl Importer {
pub const NAME: &str = "ebase/transactions";
pub fn new(config: Config) -> Self {
let importer = csv::Importer::semicolon_delimited();
Self { config, importer }
}
pub fn builder() -> ImporterBuilder {
ImporterBuilder::default()
}
}
impl beancount_importers_framework::ImporterProtocol for Importer {
type Error = Report;
fn account(&self, _file: &[u8]) -> Result<beancount_types::Account, Self::Error> {
Ok(self.config.account_template.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(Report::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; 33] = [
"Depotnummer",
"Depotposition",
"Ref. Nr.",
"Buchungsdatum",
"Umsatzart",
"Teilumsatz",
"Fonds",
"ISIN",
"Zahlungsbetrag in ZW",
"Zahlungswährung (ZW)",
"Anteile",
"Abrechnungskurs in FW",
"Fondswährung (FW)",
"Kursdatum",
"Devisenkurs (ZW/FW)",
"Anlagebetrag in ZW",
"Vertriebsprovision in ZW (im Abrechnungskurs enthalten)",
"KVG Einbehalt in ZW (im Abrechnungskurs enthalten)",
"Gegenwert der Anteile in ZW",
"Anteile zum Bestandsdatum",
"Barausschüttung/Steuerliquidität je Anteil in EW",
"Ertragswährung (EW)",
"Bestandsdatum",
"Devisenkurs (ZW/EW)",
"Barausschüttung/Steuerliquidität in ZW",
"Bruttobetrag VAP je Anteil in EUR",
"Entgelt in ZW",
"Entgelt in EUR",
"Steuern in ZW",
"Steuern in EUR",
"Devisenkurs (EUR/ZW)",
"Art des Steuereinbehalts",
"Steuereinbehalt in EUR",
];
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.booking_date
}
fn extract(
&self,
_existing: &[Directive],
record: Record,
) -> Result<Vec<Directive>, Self::Error> {
let reference = record.reference.transaction_id;
let depot_id = record.depot_id;
let position = record.depot_position;
let context = TemplateContext {
depot_id: <&Seg>::try_from(depot_id).unwrap(),
position: <&Seg>::try_from(position).unwrap(),
};
let mut directives = Vec::new();
let mut transaction = Transaction::on(record.booking_date);
transaction
.set_payee(&self.config.payee)
.set_narration(record.transaction_kind.format_narration(record.fund_name))
.add_meta(common_keys::TRANSACTION_ID, record.reference.to_string());
match record.transaction_kind {
TransactionKind::Distribution => {
let distribution_amount =
Amount::new(record.distribution_total, record.payment_currency);
let balance_date = record.balance_date.unwrap();
let main_account = self.config.account_template.render(&context);
let balance_amount = Amount::new(record.balance_shares, record.isin);
directives.push(Directive::from(Balance::new(
balance_date,
main_account,
balance_amount,
)));
transaction.build_posting(
self.config.reference_account.render(&context),
|posting| {
if record.payment_currency.as_ref() == "EUR" {
posting.set_amount(distribution_amount);
} else {
let rate = record.exchange_rate_eur_payment.unwrap();
let distribution_amount_eur = {
let amount_eur =
(distribution_amount.amount / rate).tap_mut(|amount| {
amount.rescale(2);
});
Amount::new(amount_eur, Commodity::try_from("EUR").unwrap())
};
posting
.set_amount(distribution_amount_eur)
.set_price(Amount::new(rate, record.payment_currency));
}
},
);
transaction.build_posting(
self.config.distributions_template.render(&context),
|posting| {
posting.set_amount(-distribution_amount);
},
);
}
TransactionKind::LumpSumTaxAdvance => {
return Ok(vec![]);
}
TransactionKind::Purchase | TransactionKind::SavingsPlan => {
let fund_currency = record.fund_currency.unwrap();
let share_price = Amount::new(record.share_price, fund_currency);
let shares_amount = Amount::new(record.shares, record.isin);
let payment_currency = record.payment_currency;
let payment_amount = Amount::new(record.payment_amount, payment_currency);
let investment_amount_payment =
Amount::new(record.investment_amount, payment_currency);
let investment_amount_fund = record.exchange_rate_payment_fund.map(|rate| {
let mut amount = investment_amount_payment.amount * rate;
amount.rescale(2);
Amount::new(amount, fund_currency)
});
transaction
.add_link(Link::try_from(format!("^ebase.{reference}")).unwrap())
.build_posting(self.config.account_template.render(&context), |posting| {
posting
.set_amount(shares_amount)
.set_cost(CostBasis::PerUnit(share_price));
});
if !record.fees.is_zero() {
let fees = Amount::new(record.fees, payment_currency);
transaction.build_posting(
self.config.fee_template.render(&context),
|posting| {
posting.set_amount(fees);
},
);
}
transaction.build_posting(
self.config.reference_account.render(&context),
|posting| {
posting.set_amount(-payment_amount);
},
);
if let Some(investment_amount_fund) = investment_amount_fund {
transaction
.build_posting(
self.config
.currency_account
.render(&CurrencyTemplateContext {
currency: {
let currency: &str = &payment_currency;
<&Seg>::try_from(currency)
.expect("commodities are valid segments")
},
}),
|posting| {
posting.set_amount(investment_amount_payment);
},
)
.build_posting(
self.config
.currency_account
.render(&CurrencyTemplateContext {
currency: {
let currency: &str = &fund_currency;
<&Seg>::try_from(currency)
.expect("commodities are valid segments")
},
}),
|posting| {
posting.set_amount(-investment_amount_fund);
},
);
}
}
TransactionKind::PortfolioFee => {
let fund_currency = record.fund_currency.unwrap();
let share_price = Amount::new(record.share_price, fund_currency);
let shares_amount = Amount::new(record.shares, record.isin);
let payment_currency = record.payment_currency;
let sale_amount_payment = Amount::new(record.fees, payment_currency);
let sale_amount_fund = record.exchange_rate_payment_fund.map(|rate| {
let mut amount = sale_amount_payment.amount * rate;
amount.rescale(2);
Amount::new(amount, fund_currency)
});
transaction
.build_posting(self.config.account_template.render(&context), |posting| {
posting
.set_amount(shares_amount)
.set_cost(CostBasis::Empty)
.set_price(share_price);
})
.build_posting(
self.config.portfolio_fee_template.render(&context),
|posting| {
posting.set_amount(sale_amount_payment);
},
)
.build_posting(
self.config.capital_gains_account.render(&context),
|_posting| {},
);
if let Some(sale_amount_fund) = sale_amount_fund {
transaction
.build_posting(
self.config
.currency_account
.render(&CurrencyTemplateContext {
currency: {
let currency: &str = &payment_currency;
<&Seg>::try_from(currency)
.expect("commodities are valid segments")
},
}),
|posting| {
posting.set_amount(-sale_amount_payment);
},
)
.build_posting(
self.config
.currency_account
.render(&CurrencyTemplateContext {
currency: {
let currency: &str = &fund_currency;
<&Seg>::try_from(currency)
.expect("commodities are valid segments")
},
}),
|posting| {
posting.set_amount(sale_amount_fund);
},
);
}
}
TransactionKind::Reinvest => {
let fund_currency = record.fund_currency.unwrap();
let share_price = Amount::new(record.share_price, fund_currency);
let shares_amount = Amount::new(record.shares, record.isin);
let payment_currency = record.payment_currency;
let investment_amount_payment =
Amount::new(record.investment_amount, payment_currency);
transaction.build_posting(
self.config.account_template.render(&context),
|posting| {
posting
.set_amount(shares_amount)
.set_cost(CostBasis::PerUnit(share_price));
},
);
if !record.fees.is_zero() {
let fees = Amount::new(record.fees, payment_currency);
transaction
.build_posting(self.config.fee_template.render(&context), |posting| {
posting.set_amount(fees);
})
.build_posting(self.config.reference_account.render(&context), |posting| {
posting.set_amount(-fees);
});
}
transaction.build_posting(
self.config.reference_account.render(&context),
|posting| {
posting.set_amount(-investment_amount_payment);
if payment_currency != fund_currency {
let exchange_rate = record
.exchange_rate_payment_fund
.expect("exchange rate is set when currencies differ");
posting.set_price(Amount::new(exchange_rate, fund_currency));
}
},
);
}
TransactionKind::SavingsPlanRebooking => {
let fund_currency = record.fund_currency.unwrap();
let share_price = Amount::new(record.share_price, fund_currency);
let shares_amount = Amount::new(record.shares, record.isin);
let payment_currency = record.payment_currency;
let investment_amount_payment =
Amount::new(record.investment_amount, payment_currency);
transaction.build_posting(
self.config.account_template.render(&context),
|posting| {
posting
.set_amount(shares_amount)
.set_cost(CostBasis::PerUnit(share_price));
},
);
if !record.fees.is_zero() {
let fees = Amount::new(record.fees, payment_currency);
transaction
.build_posting(self.config.fee_template.render(&context), |posting| {
posting.set_amount(fees);
})
.build_posting(self.config.reference_account.render(&context), |posting| {
posting.set_amount(-fees);
});
}
transaction.build_posting(
self.config.reference_account.render(&context),
|posting| {
posting.set_amount(-investment_amount_payment);
if payment_currency != fund_currency {
let exchange_rate = record
.exchange_rate_payment_fund
.expect("exchange rate is set when currencies differ");
posting.set_price(Amount::new(exchange_rate, fund_currency));
}
},
);
}
TransactionKind::SavingsPlanReversal => {
let fund_currency = record.fund_currency.unwrap();
let share_price = Amount::new(record.share_price, fund_currency);
let shares_amount = Amount::new(record.shares, record.isin);
let payment_currency = record.payment_currency;
let payment_amount = Amount::new(record.payment_amount, payment_currency);
let investment_amount_payment =
Amount::new(record.investment_amount, payment_currency);
let investment_amount_fund = record.exchange_rate_payment_fund.map(|rate| {
let mut amount = investment_amount_payment.amount * rate;
amount.rescale(2);
Amount::new(amount, fund_currency)
});
transaction.build_posting(
self.config.account_template.render(&context),
|posting| {
posting
.set_amount(shares_amount)
.set_cost(CostBasis::PerUnit(share_price));
},
);
if !record.fees.is_zero() {
let fees = Amount::new(record.fees, payment_currency);
transaction.build_posting(
self.config.fee_template.render(&context),
|posting| {
posting.set_amount(-fees);
},
);
}
transaction.build_posting(
self.config.reference_account.render(&context),
|posting| {
posting.set_amount(payment_amount);
},
);
if let Some(investment_amount_fund) = investment_amount_fund {
transaction
.build_posting(
self.config
.currency_account
.render(&CurrencyTemplateContext {
currency: {
let currency: &str = &payment_currency;
<&Seg>::try_from(currency)
.expect("commodities are valid segments")
},
}),
|posting| {
posting.set_amount(-investment_amount_payment);
},
)
.build_posting(
self.config
.currency_account
.render(&CurrencyTemplateContext {
currency: {
let currency: &str = &fund_currency;
<&Seg>::try_from(currency)
.expect("commodities are valid segments")
},
}),
|posting| {
posting.set_amount(investment_amount_fund);
},
);
}
}
};
directives.push(Directive::from(transaction));
if let Some(date) = record.price_date {
let share_price = Amount::new(record.share_price, record.fund_currency.unwrap());
directives.push(Directive::from(Price::new(date, record.isin, share_price)));
}
Ok(directives)
}
}
impl ImporterBuilder {
pub fn build(&self) -> Result<Importer, ImporterBuilderError> {
let config =
Config {
account_template: self.account_template.clone().context(
UninitializedFieldSnafu {
field: "account_template",
importer: Importer::NAME,
},
)?,
capital_gains_account: self.capital_gains_account.clone().context(
UninitializedFieldSnafu {
field: "capital_gains_account",
importer: Importer::NAME,
},
)?,
currency_account: self.currency_account.clone().context(
UninitializedFieldSnafu {
field: "currency_account",
importer: Importer::NAME,
},
)?,
distributions_template: self.distributions_template.clone().context(
UninitializedFieldSnafu {
field: "distributions_template",
importer: Importer::NAME,
},
)?,
fee_template: self.fee_template.clone().context(UninitializedFieldSnafu {
field: "fee_template",
importer: Importer::NAME,
})?,
payee: self.payee.clone().context(UninitializedFieldSnafu {
field: "payee",
importer: Importer::NAME,
})?,
portfolio_fee_template: self.portfolio_fee_template.clone().context(
UninitializedFieldSnafu {
field: "portfolio_fee_template",
importer: Importer::NAME,
},
)?,
reference_account: self.reference_account.clone().context(
UninitializedFieldSnafu {
field: "reference_account",
importer: Importer::NAME,
},
)?,
};
Ok(Importer::new(config))
}
}
#[derive(Clone, Copy, Debug, Deserialize)]
pub struct Record<'r> {
#[serde(rename = "Bestandsdatum", with = "dmy_short::opt")]
balance_date: Option<Date>,
#[serde(
rename = "Anteile zum Bestandsdatum",
with = "decimal_parsers::german::serde::with_precision::<6>"
)]
balance_shares: Decimal,
#[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 = "Ertragswährung (EW)")]
distribution_currency: Option<Commodity>,
#[serde(
rename = "Barausschüttung/Steuerliquidität in ZW",
with = "decimal_parsers::german::serde::with_precision::<2>"
)]
distribution_total: Decimal,
#[serde(
rename = "Devisenkurs (ZW/FW)",
with = "german_decimal::serde::opt::with_precision::<6>"
)]
exchange_rate_payment_fund: Option<Decimal>,
#[serde(
rename = "Devisenkurs (EUR/ZW)",
with = "decimal_parsers::german::serde::opt::with_precision::<6>"
)]
exchange_rate_eur_payment: Option<Decimal>,
#[serde(
rename = "Entgelt in ZW",
with = "german_decimal::serde::with_precision::<2>"
)]
fees: Decimal,
#[serde(rename = "Fondswährung (FW)")]
fund_currency: Option<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_currency: Commodity,
#[serde(rename = "Kursdatum", with = "dmy_short::opt")]
price_date: Option<Date>,
#[serde(rename = "Ref. Nr.")]
reference: TransactionReference<'r>,
#[serde(
rename = "Abrechnungskurs in FW",
with = "german_decimal::serde::with_precision::<6>"
)]
share_price: Decimal,
#[serde(
rename = "Anteile",
with = "german_decimal::serde::with_precision::<6>"
)]
shares: Decimal,
#[serde(rename = "Umsatzart")]
transaction_kind: TransactionKind,
}
#[derive(Clone, Copy, Debug)]
pub enum TemplateSelector {
DepotId,
Position,
}
impl FromStr for TemplateSelector {
type Err = TemplateSelectorError;
fn from_str(selector: &str) -> Result<Self, Self::Err> {
let selector = match selector {
"depot_id" => Self::DepotId,
"position" => Self::Position,
selector => return TemplateSelectorSnafu { selector }.fail(),
};
Ok(selector)
}
}
#[derive(Debug, Diagnostic, Snafu)]
#[snafu(display("unsupported context selector: {selector:?}"))]
pub struct TemplateSelectorError {
pub selector: String,
pub backtrace: Backtrace,
}
#[derive(Clone, Copy, Debug)]
struct CurrencyTemplateContext<'c> {
pub currency: &'c Seg,
}
impl Index<&CurrencyTemplateSelector> for CurrencyTemplateContext<'_> {
type Output = Seg;
fn index(&self, index: &CurrencyTemplateSelector) -> &Self::Output {
match index {
CurrencyTemplateSelector::Currency => self.currency,
}
}
}
#[derive(Clone, Copy, Debug)]
struct TemplateContext<'c> {
pub depot_id: &'c Seg,
pub position: &'c Seg,
}
impl Index<&TemplateSelector> for TemplateContext<'_> {
type Output = Seg;
fn index(&self, index: &TemplateSelector) -> &Self::Output {
match index {
TemplateSelector::DepotId => self.depot_id,
TemplateSelector::Position => self.position,
}
}
}
#[derive(Clone, Copy, Debug, Deserialize)]
enum TransactionKind {
#[serde(rename = "Fondsertrag (Ausschüttung)")]
Distribution,
#[serde(rename = "Entgeltbelastung Verkauf")]
PortfolioFee,
#[serde(rename = "Vorabpauschale")]
LumpSumTaxAdvance,
#[serde(rename = "Kauf")]
Purchase,
#[serde(rename = "Wiederanlage Fondsertrag")]
Reinvest,
#[serde(rename = "Ansparplan")]
SavingsPlan,
#[serde(rename = "Neuabrechnung Ansparplan")]
SavingsPlanRebooking,
#[serde(rename = "Stornierung Ansparplan")]
SavingsPlanReversal,
}
impl TransactionKind {
fn format_narration(&self, fund: &str) -> String {
match self {
Self::Distribution => format!("Distribution of {fund}"),
Self::PortfolioFee => format!("Sell {fund} to cover portfolio fees"),
Self::LumpSumTaxAdvance => format!("Lump-sum tax advance for {fund}"),
Self::Purchase => format!("Buy {fund}"),
Self::Reinvest => format!("Reinvest distribution of {fund}"),
Self::SavingsPlan => format!("Savings plan for {fund}"),
Self::SavingsPlanRebooking => format!("Rebook savings plan for {fund}"),
Self::SavingsPlanReversal => format!("Reverse savings plan for {fund}"),
}
}
}
impl TryFrom<&str> for TransactionKind {
type Error = Error;
fn try_from(value: &str) -> Result<Self, Self::Error> {
let kind = match value {
"Ansparplan" => Self::SavingsPlan,
"Entgeltbelastung Verkauf" => Self::PortfolioFee,
"Fondsertrag (Ausschüttung)" => Self::Distribution,
"Kauf" => Self::Purchase,
"Neuabrechnung Ansparplan" => Self::SavingsPlanRebooking,
"Stornierung Ansparplan" => Self::SavingsPlanReversal,
"Vorabpauschale" => Self::LumpSumTaxAdvance,
"Wiederanlage Fondsertrag" => Self::Reinvest,
_ => return UnsupportedTransactionKindSnafu { value }.fail(),
};
Ok(kind)
}
}
#[derive(Clone, Copy, Debug)]
struct TransactionReference<'r> {
order_date: Date,
transaction_id: &'r str,
}
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"))?;
let order_date = Date::parse(
order_date,
time::macros::format_description!("[day][month][year]"),
)
.map_err(E::custom)?;
Ok(TransactionReference {
order_date,
transaction_id,
})
}
}
deserializer.deserialize_str(Visitor)
}
}
impl std::fmt::Display for TransactionReference<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut buffer = [0u8; 8];
f.write_str(self.transaction_id)?;
f.write_char('/')?;
self.order_date
.format_into(
&mut buffer.as_mut_slice(),
format_description!("[day][month][year]"),
)
.expect("valid formatting");
f.write_str(core::str::from_utf8(&buffer).expect("valid UTF-8"))
}
}