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::Commodity;
use beancount_types::CostBasis;
use beancount_types::Directive;
use beancount_types::Link;
use beancount_types::Price;
use beancount_types::PriceSpec;
use beancount_types::Seg;
use beancount_types::Transaction;
use derive_builder::Builder;
use isin::ISIN;
use miette::Diagnostic;
use miette::IntoDiagnostic;
use rust_decimal::Decimal;
use serde::Deserialize;
use snafu::Backtrace;
use snafu::OptionExt as _;
use snafu::Snafu;
use tap::Tap as _;
use time::Date;
#[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_template: AccountTemplate<TemplateSelector>,
pub payee: String,
pub reference_account: AccountTemplate<TemplateSelector>,
}
#[derive(Debug, Diagnostic, Snafu)]
pub enum Error {}
#[derive(Debug, Deserialize)]
pub struct Importer {
#[serde(flatten)]
config: Config,
#[serde(default = "csv::Importer::semicolon_delimited", skip_deserializing)]
importer: csv::Importer,
}
impl Importer {
const NAME: &str = "bw-bank/portfolio-transactions";
pub fn builder() -> ImporterBuilder {
ImporterBuilder::default()
}
pub fn new(config: Config) -> Self {
let importer = csv::Importer::semicolon_delimited();
Self { config, importer }
}
}
impl beancount_importers_framework::ImporterProtocol for Importer {
type Error = miette::Report;
fn account(&self, _buffer: &[u8]) -> Result<Account, Self::Error> {
Ok(self.config.account_template.base().to_owned())
}
fn date(&self, buffer: &[u8]) -> Option<Result<Date, Self::Error>> {
self.importer
.date(buffer, self)
.map(|result| result.map_err(Self::Error::from))
}
fn extract(
&self,
buffer: &[u8],
existing: &[Directive],
) -> Result<Vec<Directive>, Self::Error> {
self.importer
.extract(buffer, existing, self)
.map_err(Self::Error::from)
}
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] = &[
"Isin",
"Wertpapierbezeichnung",
"Datum",
"Ordernummer",
"Geschäftsart",
"Stück",
"Einheit",
"Kurs",
"Devisenkurs",
"Kurswert",
];
self.importer
.identify(buffer, EXPECTED_HEADERS)
.into_diagnostic()
}
fn name(&self) -> &'static str {
Self::NAME
}
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> {
let isin =
Commodity::try_from(record.isin.to_string()).expect("ISINs are valid commodities");
let transaction_kind = record.transaction_kind;
let security_name = record.security_name;
let order_id = record.order_id;
let (amount, cost_basis, price, total) = match transaction_kind {
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,
),
};
let transaction = Transaction::on(record.date).tap_mut(|transaction| {
let context = {
let isin: &str = ∈
let isin = <&Seg>::try_from(isin).expect("commodities are valid segments");
TemplateContext { isin }
};
transaction
.set_payee(&self.config.payee)
.set_narration(format!("{transaction_kind:?} {security_name}"))
.add_link(Link::try_from(format!("^bw-bank.{order_id}")).unwrap())
.add_meta(common_keys::TRANSACTION_ID, record.order_id)
.build_posting(self.config.account_template.render(&context), |posting| {
posting.set_amount(amount).set_cost(cost_basis);
posting.price = price.map(PriceSpec::from);
})
.build_posting(self.config.reference_account.render(&context), |posting| {
posting.set_amount(total);
});
if let TransactionKind::Sell = transaction_kind {
transaction
.build_posting(self.config.capital_gains_template.render(&context), |_| {});
}
});
let price = Price::new(record.date, isin, record.unit_cost);
Ok(vec![Directive::from(transaction), Directive::from(price)])
}
}
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_template: self.capital_gains_template.clone().context(
UninitializedFieldSnafu {
field: "capital_gains_template",
importer: Importer::NAME,
},
)?,
payee: self.payee.clone().context(UninitializedFieldSnafu {
field: "payee",
importer: Importer::NAME,
})?,
reference_account: self.reference_account.clone().context(
UninitializedFieldSnafu {
field: "reference_account",
importer: Importer::NAME,
},
)?,
};
Ok(Importer::new(config))
}
}
#[derive(Clone, Debug, Deserialize)]
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,
}
#[derive(Clone, Copy, Debug)]
pub enum TemplateSelector {
Isin,
}
impl FromStr for TemplateSelector {
type Err = TemplateSelectorError;
fn from_str(selector: &str) -> Result<Self, Self::Err> {
match selector {
"isin" => Ok(Self::Isin),
_ => TemplateSelectorSnafu { selector }.fail(),
}
}
}
#[derive(Debug, Diagnostic, Snafu)]
#[snafu(display("unsupported context selector: {selector:?}"))]
pub struct TemplateSelectorError {
selector: String,
backtrace: Backtrace,
}
#[derive(Debug)]
struct TemplateContext<'c> {
isin: &'c Seg,
}
impl Index<&TemplateSelector> for TemplateContext<'_> {
type Output = Seg;
fn index(&self, index: &TemplateSelector) -> &Self::Output {
match index {
TemplateSelector::Isin => self.isin,
}
}
}
#[derive(Clone, Copy, Debug, Deserialize)]
enum TransactionKind {
#[serde(rename = "Kauf")]
Buy,
#[serde(rename = "Verkauf")]
Sell,
}
impl FromStr for TransactionKind {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"Kauf" => Ok(Self::Buy),
"Verkauf" => Ok(Self::Sell),
_ => 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")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
isin::parse(v).map_err(E::custom)
}
}
deserializer.deserialize_str(Visitor)
}
fn deserialize_german_amount<'de, D>(deserializer: D) -> Result<Amount, D::Error>
where
D: serde::Deserializer<'de>,
{
struct Visitor;
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"));
};
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))
}
}
deserializer.deserialize_str(Visitor)
}