use crate::EuroAmount;
use crate::rates::RoundMoney;
use rust_decimal::Decimal;
use rust_decimal::dec;
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct PositionTrace {
pub formula: String,
pub input_quantity: Decimal,
pub input_unit_price_eur: Decimal,
pub gross_eur: Decimal,
pub regulatory_basis: Vec<String>,
pub tariff_source: Option<String>,
pub pro_rata_fraction: Option<Decimal>,
pub rounding_note: Option<String>,
}
impl PositionTrace {
#[must_use]
pub fn commodity(
quantity: Decimal,
unit: &str,
unit_price_eur: Decimal,
regulatory_basis: impl Into<String>,
) -> Self {
let gross = quantity * unit_price_eur;
Self {
formula: format!(
"{quantity:.3} {unit} × {unit_price_eur:.5} EUR/{unit} = {:.5} EUR",
gross.round_kfm(5)
),
input_quantity: quantity,
input_unit_price_eur: unit_price_eur,
gross_eur: gross,
regulatory_basis: vec![regulatory_basis.into()],
tariff_source: None,
pro_rata_fraction: None,
rounding_note: None,
}
}
#[must_use]
pub fn tax(
rate: Decimal,
netto_base_eur: Decimal,
regulatory_basis: impl Into<String>,
) -> Self {
let gross = netto_base_eur * rate;
Self {
formula: format!(
"{rate:.4} × {netto_base_eur:.5} EUR = {:.5} EUR",
gross.round_kfm(5)
),
input_quantity: netto_base_eur,
input_unit_price_eur: rate,
gross_eur: gross,
regulatory_basis: vec![regulatory_basis.into()],
tariff_source: None,
pro_rata_fraction: None,
rounding_note: None,
}
}
#[must_use]
pub fn with_tariff_source(mut self, source: impl Into<String>) -> Self {
self.tariff_source = Some(source.into());
self
}
#[must_use]
pub fn with_pro_rata(mut self, fraction: Decimal) -> Self {
self.pro_rata_fraction = Some(fraction);
self
}
#[must_use]
pub fn with_basis(mut self, basis: impl Into<String>) -> Self {
self.regulatory_basis.push(basis.into());
self
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct BillingWarning {
pub code: &'static str,
pub severity: WarningSeverity,
pub message: String,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
)]
pub enum WarningSeverity {
Info,
Warning,
Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum PositionCategory {
Commodity,
GridCharge,
Levy,
Tax,
Credit,
Discount,
Fee,
Info,
Abschlag,
Bonus,
EnergyShare,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct BillingPosition {
pub description: String,
pub legal_basis: Option<String>,
pub quantity: Decimal,
pub unit: String,
pub unit_price_eur: Decimal,
pub net_eur: Decimal,
pub category: PositionCategory,
pub tags: Vec<String>,
#[serde(default)]
pub applicable_tax_rate: Option<Decimal>,
#[serde(default)]
pub trace: PositionTrace,
}
pub const REVERSE_CHARGE_TAG: &str = "reverse-charge";
pub const OUT_OF_SCOPE_TAG: &str = "nicht-steuerbar";
impl BillingPosition {
#[must_use]
pub const fn is_rechnungsposition(&self) -> bool {
!matches!(
self.category,
PositionCategory::Tax | PositionCategory::Abschlag
)
}
#[must_use]
pub fn debit(
description: impl Into<String>,
quantity: Decimal,
unit: impl Into<String>,
unit_price_eur: Decimal,
category: PositionCategory,
) -> Self {
let net_eur = validated_eur(quantity * unit_price_eur);
let unit_str = unit.into();
Self {
description: description.into(),
legal_basis: None,
quantity,
unit: unit_str,
unit_price_eur,
net_eur,
category,
tags: Vec::new(),
applicable_tax_rate: None,
trace: PositionTrace::default(),
}
}
#[must_use]
pub fn credit(
description: impl Into<String>,
quantity: Decimal,
unit: impl Into<String>,
abs_rate_eur: Decimal,
category: PositionCategory,
) -> Self {
let net_eur = -validated_eur(quantity * abs_rate_eur);
Self {
description: description.into(),
legal_basis: None,
quantity,
unit: unit.into(),
unit_price_eur: -abs_rate_eur,
net_eur,
category,
tags: Vec::new(),
applicable_tax_rate: None,
trace: PositionTrace::default(),
}
}
#[must_use]
pub fn with_legal_basis(mut self, basis: impl Into<String>) -> Self {
self.legal_basis = Some(basis.into());
self
}
#[must_use]
pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
self.tags.push(tag.into());
self
}
#[must_use]
pub fn with_tax_rate(mut self, rate: Decimal) -> Self {
self.applicable_tax_rate = Some(rate);
self
}
#[must_use]
pub fn with_reverse_charge(mut self) -> Self {
if !self.has_tag(REVERSE_CHARGE_TAG) {
self.tags.push(REVERSE_CHARGE_TAG.to_owned());
}
self.applicable_tax_rate = Some(Decimal::ZERO);
self
}
#[must_use]
pub fn is_reverse_charge(&self) -> bool {
self.has_tag(REVERSE_CHARGE_TAG)
}
#[must_use]
pub fn with_out_of_scope(mut self) -> Self {
if !self.has_tag(OUT_OF_SCOPE_TAG) {
self.tags.push(OUT_OF_SCOPE_TAG.to_owned());
}
self.applicable_tax_rate = Some(Decimal::ZERO);
self
}
#[must_use]
pub fn is_out_of_scope(&self) -> bool {
self.has_tag(OUT_OF_SCOPE_TAG)
}
#[must_use]
pub fn has_tag(&self, tag: &str) -> bool {
self.tags.iter().any(|t| t == tag)
}
#[must_use]
pub fn net_total(positions: &[BillingPosition]) -> Decimal {
positions.iter().map(|p| p.net_eur).sum()
}
#[must_use]
pub fn total_by_tag(positions: &[BillingPosition], tag: &str) -> Decimal {
positions
.iter()
.filter(|p| p.has_tag(tag))
.map(|p| p.net_eur)
.sum()
}
}
pub(crate) fn validated_eur(amount: Decimal) -> Decimal {
EuroAmount::checked_from_decimal(amount)
.map(EuroAmount::into_decimal)
.unwrap_or_else(|_| amount.round_kfm(5))
}
pub(crate) fn grundpreis_position(
label: impl Into<String>,
daily_rate_eur: Decimal,
days: i64,
legal_basis: &'static str,
tags: &[&'static str],
) -> BillingPosition {
let mut p = BillingPosition::debit(
label,
Decimal::from(days),
"Tage",
daily_rate_eur,
PositionCategory::Commodity,
)
.with_legal_basis(legal_basis)
.with_tag("commodity")
.with_tag("grundpreis");
p.trace = PositionTrace::commodity(Decimal::from(days), "Tage", daily_rate_eur, legal_basis);
for tag in tags {
p = p.with_tag(*tag);
}
p
}
pub(crate) fn arbeitspreis_position(
label: impl Into<String>,
kwh: Decimal,
rate_ct_kwh: Decimal,
unit: &'static str,
legal_basis: &'static str,
tags: &[&'static str],
) -> BillingPosition {
let mut p = BillingPosition::debit(
label,
kwh,
unit,
rate_ct_kwh / dec!(100),
PositionCategory::Commodity,
)
.with_legal_basis(legal_basis)
.with_tag("commodity")
.with_tag("arbeitspreis");
p.trace = PositionTrace::commodity(kwh, unit, rate_ct_kwh / dec!(100), legal_basis);
for tag in tags {
p = p.with_tag(*tag);
}
p
}
pub(crate) fn co2kostaufg_disclosures(
menge_kwh: Decimal,
unit: &'static str,
emissionsfaktor_kg_per_kwh: Decimal,
tag: &'static str,
) -> Vec<BillingPosition> {
let emissionen_kg = (menge_kwh * emissionsfaktor_kg_per_kwh).round_kfm(2);
let info = |description: String, legal: &'static str| BillingPosition {
description,
legal_basis: Some(legal.to_owned()),
quantity: menge_kwh,
unit: unit.to_owned(),
unit_price_eur: Decimal::ZERO,
net_eur: Decimal::ZERO,
category: PositionCategory::Info,
tags: vec!["co2kostaufg".to_owned(), tag.to_owned()],
applicable_tax_rate: None,
trace: PositionTrace::default(),
};
vec![
info(
format!("Brennstoffemissionen der Lieferung: {emissionen_kg} kg CO₂"),
"CO2KostAufG § 3 Abs. 1 Nr. 1",
),
info(
format!("Heizwertbezogener Emissionsfaktor: {emissionsfaktor_kg_per_kwh} kg CO₂/kWh"),
"CO2KostAufG § 3 Abs. 1 Nr. 3",
),
info(
format!("Energiegehalt der Lieferung: {menge_kwh} {unit}"),
"CO2KostAufG § 3 Abs. 1 Nr. 4",
),
info(
"Erstattungsansprüche nach § 6 Absatz 2 und § 8 Absatz 2 CO2KostAufG \
können gegenüber dem Vermieter bzw. Verpächter geltend gemacht werden."
.to_owned(),
"CO2KostAufG § 3 Abs. 1 Nr. 5",
),
]
}
pub(crate) fn levy_position(
label: impl Into<String>,
quantity: Decimal,
unit: &'static str,
rate_ct: Decimal,
legal_basis: &'static str,
tag: &'static str,
) -> BillingPosition {
let mut p = BillingPosition::debit(
label,
quantity,
unit,
rate_ct / dec!(100),
PositionCategory::Levy,
)
.with_legal_basis(legal_basis)
.with_tag("levy")
.with_tag(tag);
p.trace = PositionTrace::commodity(quantity, unit, rate_ct / dec!(100), legal_basis);
p
}