use core::ops::Index;
use core::str::FromStr;
use beancount_importers_framework::error::ImporterBuilderError;
use beancount_importers_framework::error::UninitializedFieldSnafu;
use beancount_types::AccountTemplate;
use beancount_types::Amount;
use beancount_types::Balance;
use beancount_types::Commodity;
use beancount_types::Directive;
use beancount_types::Price;
use beancount_types::Seg;
use derive_builder::Builder;
use miette::Diagnostic;
use miette::IntoDiagnostic as _;
use miette::Report;
use rust_decimal::Decimal;
use serde::Deserialize;
use snafu::Backtrace;
use snafu::OptionExt as _;
use snafu::Snafu;
use time::Date;
#[derive(Builder, Clone, Debug, Deserialize)]
#[builder(
build_fn(error = "ImporterBuilderError", skip),
name = "ImporterBuilder",
setter(into),
try_setter
)]
pub struct Config {
pub balance_account: AccountTemplate<TemplateSelector>,
}
#[derive(Debug, Diagnostic, Snafu)]
pub enum Error {
#[snafu(display("error while parsing date"))]
DateFormat { source: time::Error },
#[snafu(display("could not parse position {currency:?}"))]
Currency {
backtrace: Backtrace,
currency: String,
source: <Commodity as TryFrom<&'static str>>::Error,
},
#[snafu(display("could not parse position {isin:?}"))]
Isin {
backtrace: Backtrace,
isin: String,
source: <Commodity as TryFrom<&'static str>>::Error,
},
#[snafu(display("could not parse position {position:?}"))]
Position {
backtrace: Backtrace,
source: core::num::ParseIntError,
position: String,
},
#[snafu(display("could not parse share price {share_price:?}"))]
SharePrice {
backtrace: Backtrace,
source: rust_decimal::Error,
share_price: String,
},
#[snafu(display("could not parse shares {shares:?}"))]
Shares {
backtrace: Backtrace,
source: rust_decimal::Error,
shares: 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 {
#[serde(flatten)]
pub config: Config,
#[serde(default = "csv::Importer::semicolon_delimited", skip_deserializing)]
pub importer: csv::Importer,
}
impl Importer {
pub const NAME: &str = "ebase/balances";
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.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(Report::from)
}
fn filename(&self, _file: &[u8]) -> Option<Result<String, Self::Error>> {
Some(Ok(String::from("balances.csv")))
}
fn identify(&self, file: &[u8]) -> Result<bool, Self::Error> {
const EXPECTED_HEADERS: &[&str] = &[
"Depotnummer",
"Position",
"Fondsname / Produkt",
"ISIN",
"WKN",
"Anteile",
"Anteilswert",
"Währung (Anteilswert)",
"Kursdatum",
"Devisenkurs",
"+/- Vortag (absolut)",
"+/- Vortag (relativ)",
"Gewinn und Verlust seit Monatsanfang",
"Gewinn und Verlust seit Jahresanfang",
"Gewinn und Verlust seit Eröffnung",
"Bestand in Euro",
];
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.share_price_date
}
fn extract(
&self,
_existing: &[Directive],
record: Record,
) -> Result<Vec<Directive>, Self::Error> {
let position = format!("{:02}", record.depot_position);
let context = TemplateContext {
depot_id: <&Seg>::try_from(record.depot_id).unwrap(),
position: <&Seg>::try_from(&*position).unwrap(),
};
let isin = record.isin;
let total_shares = Amount::new(record.shares, isin);
let balance = Balance::new(
record.share_price_date,
self.config.balance_account.render(&context),
total_shares,
);
let share_price = Amount::new(record.share_price, record.share_price_currency);
let price = Price::new(record.share_price_date, isin, share_price);
Ok(vec![Directive::from(balance), Directive::from(price)])
}
}
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,
})?,
};
Ok(Importer::new(config))
}
}
#[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,
}
#[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(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,
}
}
}
time::serde::format_description!(dmy, Date, "[day].[month].[year]");