use billing::{
BillingError, DynamicPricing, TariffBand, TariffSchedule, TimeOfUsePricing, TouBand,
};
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use crate::context::BillingContext;
use crate::position::{
BillingPosition, BillingWarning, PositionCategory, WarningSeverity, arbeitspreis_position,
grundpreis_position, levy_position, validated_eur,
};
use crate::provider::BillingProvider;
use crate::quantities::{GridInput, Quantities};
use crate::tariff::{
ControllableLoadProduct, EegProduct, EinspeisungProduct, ElectricityProduct, EmobilityProduct,
GasProduct, HeatProduct, HemsProduct, ServiceProduct, SharingProduct, SolarProduct,
};
pub struct ElectricityProvider {
product: ElectricityProduct,
grid: GridInput,
}
impl ElectricityProvider {
#[must_use]
pub fn new(product: ElectricityProduct, grid: GridInput) -> Self {
Self { product, grid }
}
#[must_use]
pub fn from_product(product: &crate::tariff::Product, grid: GridInput) -> Self {
use crate::tariff::Product;
match product {
Product::Strom(p) => Self::new(p.clone(), grid),
Product::Waermepumpe(c) | Product::Wallbox(c) => Self::new(c.base.clone(), grid),
Product::Sharing(s) => Self::new(s.electricity.clone(), grid),
other => panic!(
"ElectricityProvider::from_product: incompatible product category '{}'",
other.category_str()
),
}
}
}
impl BillingProvider for ElectricityProvider {
fn bill(
&self,
ctx: &BillingContext,
quantities: &Quantities,
_prior: &[BillingPosition],
) -> Result<Vec<BillingPosition>, BillingError> {
let meter = quantities.electricity.as_ref().cloned().unwrap_or_default();
let kwh = meter.arbeitsmenge_kwh;
let days = ctx.days();
let product = &self.product;
let grid = &self.grid;
let rates = &ctx.regulatory_rates;
let mut positions: Vec<BillingPosition> = Vec::new();
let billing_month = ctx.period_from.month() as u8;
let seasonal_arbeitspreis = product.seasonal_prices.as_ref().and_then(|seasons| {
seasons
.iter()
.find(|s| s.contains_month(billing_month))
.and_then(|s| s.arbeitspreis_ct_per_kwh)
});
if let Some(p) = &quantities.prosumer {
return self.bill_prosumer(ctx, p, product, grid, rates, seasonal_arbeitspreis);
}
if let Some(gp_ct_day) = product.grundpreis_ct_per_day {
positions.push(
grundpreis_position(
"Grundpreis",
gp_ct_day / dec!(100),
ctx.prorate_days().0 as i64,
"§41 EnWG",
&["strom"],
)
.with_tag("strom"),
);
}
if kwh > Decimal::ZERO {
if let Some(tiers) = product.block_tiers.as_ref().filter(|t| !t.is_empty()) {
positions.extend(build_block_tariff_positions(tiers, kwh, &[])?);
} else if let (Some(ht), Some(nt)) =
(meter.arbeitsmenge_ht_kwh, meter.arbeitsmenge_nt_kwh)
{
let mut bands = Vec::new();
if let Some(ap_ht) = product.arbeitspreis_ht_ct_per_kwh {
let price = billing::Amount::<5>::try_from((ap_ht / dec!(100)).round_dp(5))
.map_err(|_| BillingError::InvalidInput {
reason: "HT price out of range".into(),
})?;
bands.push(TouBand::new("HT", price));
}
if let Some(ap_nt) = product.arbeitspreis_nt_ct_per_kwh {
let price = billing::Amount::<5>::try_from((ap_nt / dec!(100)).round_dp(5))
.map_err(|_| BillingError::InvalidInput {
reason: "NT price out of range".into(),
})?;
bands.push(TouBand::new("NT", price));
}
if !bands.is_empty() {
let items = TimeOfUsePricing::new(bands)
.with_unit("kWh")
.calculate(&[("HT", ht), ("NT", nt)])?;
for item in items {
let is_ht = item.has_tag("HT");
let label = if is_ht {
"Arbeitspreis Hochtarif (HT)"
} else {
"Arbeitspreis Niedertarif (NT)"
};
let band_tag = if is_ht { "ht" } else { "nt" };
let mut pos = billing_item_to_position(
item,
PositionCategory::Commodity,
"§41 EnWG",
&["strom", "arbeitspreis"],
);
pos.description = label.to_owned();
pos.tags.push(band_tag.to_owned());
positions.push(pos);
}
}
} else if let Some(ap_ct) = seasonal_arbeitspreis.or(product.arbeitspreis_ct_per_kwh) {
let label = if seasonal_arbeitspreis.is_some() {
product
.seasonal_prices
.as_ref()
.and_then(|s| s.iter().find(|p| p.contains_month(billing_month)))
.and_then(|s| s.label.as_deref())
.map(|l| format!("Arbeitspreis Strom ({l})"))
.unwrap_or_else(|| "Arbeitspreis Strom (Saisontarif)".to_owned())
} else {
"Arbeitspreis Strom".to_owned()
};
positions.push(
arbeitspreis_position(label, kwh, ap_ct, "kWh", "§41 EnWG", &["strom"])
.with_tag("strom"),
);
} else if let Some(idx) = &product.indexed_price {
if let Some(effective_ct) = idx.effective_ct_per_kwh() {
positions.push(
arbeitspreis_position(
idx.position_description(),
kwh,
effective_ct,
"kWh",
"§41 Abs. 3 EnWG",
&["strom", "indexed"],
)
.with_tag("strom")
.with_tag("indexed_price"),
);
}
}
}
if let Some(eeg_ct) = quantities.eeg_gutschrift_eur
&& eeg_ct != Decimal::ZERO
{
positions.push(
BillingPosition::credit(
"EEG-Gutschrift (Photovoltaik)",
Decimal::ONE,
"EUR",
eeg_ct.abs(),
PositionCategory::Credit,
)
.with_legal_basis("§38 EEG 2023")
.with_tag("eeg_gutschrift")
.with_tag("solar"),
);
}
let kwh_for_grid = kwh;
if let Some(nne_gp) = grid.nne_grundpreis_eur_per_year {
let daily = nne_gp / dec!(365);
positions.push(
BillingPosition::debit(
"Netznutzungsentgelt Grundpreis",
Decimal::from(days),
"Tage",
daily,
PositionCategory::GridCharge,
)
.with_legal_basis("StromNEV")
.with_tag("nne_grundpreis")
.with_tag("nne"),
);
}
if let Some(nne_ap_ct) = grid.nne_arbeitspreis_ct_per_kwh {
positions.push(
BillingPosition::debit(
"Netznutzungsentgelt Arbeitspreis",
kwh_for_grid,
"kWh",
nne_ap_ct / dec!(100),
PositionCategory::GridCharge,
)
.with_legal_basis("StromNEV")
.with_tag("nne_arbeitspreis")
.with_tag("nne"),
);
}
if let (Some(nne_lp), Some(kw)) = (
grid.nne_leistungspreis_eur_per_kw_year,
meter.spitzenleistung_kw,
) {
let months_frac = Decimal::from(days) / dec!(30.4375);
positions.push(
BillingPosition::debit(
"Netznutzungsentgelt Leistungspreis",
kw,
"kW",
nne_lp / dec!(12) * months_frac,
PositionCategory::GridCharge,
)
.with_legal_basis("StromNEV")
.with_tag("nne_leistungspreis")
.with_tag("nne"),
);
}
if let Some(ka_ct) = grid.ka_ct_per_kwh {
positions.push(
BillingPosition::debit(
"Konzessionsabgabe",
kwh_for_grid,
"kWh",
ka_ct / dec!(100),
PositionCategory::GridCharge,
)
.with_legal_basis("KAV")
.with_tag("konzessionsabgabe")
.with_tag("nne"),
);
}
if let (Some(lp_ct_per_kw_month), Some(kw)) = (
product.leistungspreis_strom_ct_per_kw_month,
meter.spitzenleistung_kw.filter(|kw| *kw > Decimal::ZERO),
) {
positions.push(
BillingPosition::debit(
"Leistungspreis",
kw,
"kW",
lp_ct_per_kw_month / dec!(100),
PositionCategory::Commodity,
)
.with_legal_basis("§41 EnWG")
.with_tag("leistungspreis")
.with_tag("rlm"),
);
}
let st_rate = rates.effective_stromsteuer(product.stromsteuer_ct_per_kwh_override);
let effective_befreiung = if product.stromsteuer_befreiung.is_exempt() {
product.stromsteuer_befreiung
} else if product.industrie_stromsteuer_befreiung {
crate::tariff::StromsteuerBefreiung::IndustrieProduktionesGewerbe
} else {
crate::tariff::StromsteuerBefreiung::Keine
};
if effective_befreiung.is_exempt() {
if kwh > Decimal::ZERO {
positions.push(BillingPosition {
description: effective_befreiung.description().to_owned(),
legal_basis: Some(effective_befreiung.citation().to_owned()),
quantity: kwh,
unit: "kWh".to_owned(),
unit_price_eur: Decimal::ZERO,
net_eur: Decimal::ZERO,
category: PositionCategory::Info,
tags: vec!["stromsteuer_befreiung".to_owned()],
applicable_tax_rate: None,
trace: crate::position::PositionTrace::commodity(
kwh,
"kWh",
Decimal::ZERO,
effective_befreiung.citation(),
),
});
}
} else if st_rate > Decimal::ZERO && kwh > Decimal::ZERO {
positions.push(
levy_position(
"Stromsteuer",
kwh,
"kWh",
st_rate,
"§3 StromStG",
"stromsteuer",
)
.with_tag("strom"),
);
}
if let Some(aa_ct) = product
.auf_abschlag_ct_per_kwh
.filter(|v| *v != Decimal::ZERO)
&& kwh > Decimal::ZERO
{
let (label, cat) = if aa_ct < Decimal::ZERO {
("Rabatt (Arbeitspreis)", PositionCategory::Discount)
} else {
("Aufschlag (Arbeitspreis)", PositionCategory::Levy)
};
positions.push(
BillingPosition::debit(
label,
kwh,
"kWh",
aa_ct / dec!(100), cat,
)
.with_tag("auf_abschlag"),
);
}
if let Some(aa_month) = product
.auf_abschlag_eur_per_month
.filter(|v| *v != Decimal::ZERO)
{
let months_frac = Decimal::from(days) / dec!(30.4375);
let eur = crate::position::validated_eur(aa_month * months_frac);
let (label, cat) = if aa_month < Decimal::ZERO {
(
"Rabatt (monatlicher Festbetrag)",
PositionCategory::Discount,
)
} else {
("Aufschlag (monatlicher Festbetrag)", PositionCategory::Levy)
};
positions.push(BillingPosition {
description: label.to_owned(),
legal_basis: None,
quantity: months_frac,
unit: "Monat".to_owned(),
unit_price_eur: aa_month,
net_eur: eur
* if aa_month < Decimal::ZERO {
Decimal::NEGATIVE_ONE
} else {
Decimal::ONE
},
category: cat,
tags: vec!["auf_abschlag".to_owned()],
applicable_tax_rate: None,
trace: crate::position::PositionTrace::default(),
});
}
if let Some(msb_ct_day) = product
.msb_gebuehr_ct_per_day
.filter(|v| *v > Decimal::ZERO)
{
positions.push(
BillingPosition::debit(
"Messstellenbetrieb Grundgebühr",
Decimal::from(days),
"Tage",
msb_ct_day / dec!(100),
PositionCategory::Fee,
)
.with_legal_basis("MsbG")
.with_tag("msb_gebuehr"),
);
}
if meter.zaehlerstand_von.is_some() || meter.zaehlerstand_bis.is_some() {
let label = format!(
"Zählerstand: {} – {}",
meter
.zaehlerstand_von
.map(|v| v.to_string())
.unwrap_or_else(|| "-".to_owned()),
meter
.zaehlerstand_bis
.map(|v| v.to_string())
.unwrap_or_else(|| "-".to_owned()),
);
let zid = meter
.zaehlernummer
.as_deref()
.or(ctx.zaehler_id.as_deref())
.unwrap_or("-");
positions.push(BillingPosition {
description: label,
legal_basis: Some("§41 EnWG".to_owned()),
quantity: Decimal::ZERO,
unit: "kWh".to_owned(),
unit_price_eur: Decimal::ZERO,
net_eur: Decimal::ZERO,
category: PositionCategory::Info,
tags: vec!["zaehlerstand".to_owned(), zid.to_owned()],
applicable_tax_rate: None,
trace: crate::position::PositionTrace::default(),
});
}
if let Some(vh) = &ctx.verbrauchshistorie {
if let Some(vj_kwh) = vh.vorjahr_kwh {
positions.push(BillingPosition {
description: format!("Verbrauch Vorjahreszeitraum: {vj_kwh:.0} kWh"),
legal_basis: Some("§41 Abs. 1 Nr. 3a EnWG".to_owned()),
quantity: vj_kwh,
unit: "kWh".to_owned(),
unit_price_eur: Decimal::ZERO,
net_eur: Decimal::ZERO,
category: PositionCategory::Info,
tags: vec!["verbrauchshistorie".to_owned(), "vorjahr".to_owned()],
applicable_tax_rate: None,
trace: crate::position::PositionTrace::default(),
});
}
if let Some(avg_kwh) = vh.bundesdurchschnitt_kwh {
let kundengruppe = vh.kundengruppe.as_deref().unwrap_or("Vergleichsgruppe");
positions.push(BillingPosition {
description: format!("Bundesdurchschnitt {kundengruppe}: {avg_kwh:.0} kWh"),
legal_basis: Some("§41 Abs. 1 Nr. 3b EnWG".to_owned()),
quantity: avg_kwh,
unit: "kWh".to_owned(),
unit_price_eur: Decimal::ZERO,
net_eur: Decimal::ZERO,
category: PositionCategory::Info,
tags: vec![
"verbrauchshistorie".to_owned(),
"bundesdurchschnitt".to_owned(),
],
applicable_tax_rate: None,
trace: crate::position::PositionTrace::default(),
});
}
}
if let Some(rate) = product.mwst_rate_override {
for pos in &mut positions {
if pos.applicable_tax_rate.is_none()
&& !matches!(
pos.category,
PositionCategory::Tax | PositionCategory::Abschlag | PositionCategory::Info
)
{
pos.applicable_tax_rate = Some(rate);
}
}
}
if meter.is_estimated {
positions.push(BillingPosition {
description: "Abrechnungswert: Schätzung gemäß §17 Abs. 1 MessZV — Bestätigung innerhalb 8 Wochen"
.to_owned(),
legal_basis: Some("§17 Abs. 1 MessZV".to_owned()),
quantity: Decimal::ZERO,
unit: String::new(),
unit_price_eur: Decimal::ZERO,
net_eur: Decimal::ZERO,
category: PositionCategory::Info,
tags: vec!["schatzwert".to_owned(), "messZV".to_owned()],
applicable_tax_rate: None,
trace: crate::position::PositionTrace::default(),
});
}
if meter.zaehler_replaced {
positions.push(BillingPosition {
description: "Zählerwechsel innerhalb des Abrechnungszeitraums".to_owned(),
legal_basis: Some("§41 EnWG".to_owned()),
quantity: Decimal::ZERO,
unit: String::new(),
unit_price_eur: Decimal::ZERO,
net_eur: Decimal::ZERO,
category: PositionCategory::Info,
tags: vec!["zaehlerwechsel".to_owned()],
applicable_tax_rate: None,
trace: crate::position::PositionTrace::default(),
});
}
if let Some(pg_bis) = product.preisgarantie_bis.filter(|d| *d >= ctx.period_to) {
positions.push(BillingPosition {
description: format!("Preisgarantie gültig bis {pg_bis}"),
legal_basis: Some("§41 Abs. 1 Nr. 4 EnWG".to_owned()),
quantity: Decimal::ZERO,
unit: String::new(),
unit_price_eur: Decimal::ZERO,
net_eur: Decimal::ZERO,
category: PositionCategory::Info,
tags: vec!["preisgarantie".to_owned()],
applicable_tax_rate: None,
trace: crate::position::PositionTrace::default(),
});
}
Ok(positions)
}
}
impl ElectricityProvider {
fn bill_prosumer(
&self,
ctx: &BillingContext,
prosumer: &crate::quantities::ProsumerMeterInput,
product: &ElectricityProduct,
grid: &GridInput,
rates: &crate::rates::RegulatoryRates,
seasonal_arbeitspreis: Option<Decimal>,
) -> Result<Vec<BillingPosition>, BillingError> {
let days = ctx.days();
let mut positions: Vec<BillingPosition> = Vec::new();
let grid_kwh = prosumer.grid_consumption_kwh;
let self_kwh = prosumer.self_consumption_kwh;
if let Some(gp_ct_day) = product.grundpreis_ct_per_day {
positions.push(
grundpreis_position(
"Grundpreis",
gp_ct_day / dec!(100),
days,
"§41 EnWG",
&["strom"],
)
.with_tag("strom"),
);
}
if grid_kwh > Decimal::ZERO {
if let Some(ap_ct) = seasonal_arbeitspreis.or(product.arbeitspreis_ct_per_kwh) {
let label = if seasonal_arbeitspreis.is_some() {
"Arbeitspreis Strom Netzbezug (Saisontarif)".to_owned()
} else {
"Arbeitspreis Strom (Netzbezug)".to_owned()
};
positions.push(
arbeitspreis_position(label, grid_kwh, ap_ct, "kWh", "§41 EnWG", &["strom"])
.with_tag("strom"),
);
}
if let Some(nne_ap_ct) = grid.nne_arbeitspreis_ct_per_kwh {
positions.push(
BillingPosition::debit(
"Netznutzungsentgelt Arbeitspreis (Netzbezug)",
grid_kwh,
"kWh",
nne_ap_ct / dec!(100),
PositionCategory::GridCharge,
)
.with_legal_basis("StromNEV")
.with_tag("nne_arbeitspreis")
.with_tag("nne"),
);
}
let st_rate = rates.effective_stromsteuer(product.stromsteuer_ct_per_kwh_override);
if st_rate > Decimal::ZERO {
positions.push(
levy_position(
"Stromsteuer (Netzbezug)",
grid_kwh,
"kWh",
st_rate,
"§3 StromStG",
"stromsteuer",
)
.with_tag("strom"),
);
}
}
if self_kwh > Decimal::ZERO {
let self_supply_pct = (prosumer.self_supply_ratio() * dec!(100)).round_dp(1);
positions.push(BillingPosition {
description: format!(
"Eigenverbrauch PV: {self_kwh:.3}\u{202f}kWh (Selbstversorgungsgrad {self_supply_pct:.1}\u{202f}%)",
),
legal_basis: Some("\u{a7}9a Nr. 1 StromStG (Stromsteuerfreiheit Eigenverbrauch \u{2264}30\u{202f}kWp)".to_owned()),
quantity: self_kwh,
unit: "kWh".to_owned(),
unit_price_eur: Decimal::ZERO,
net_eur: Decimal::ZERO,
category: PositionCategory::Info,
tags: vec!["eigenverbrauch".to_owned(), "prosumer".to_owned()],
applicable_tax_rate: None,
trace: crate::position::PositionTrace::default(),
});
}
if let Some(export) = prosumer.export_kwh.filter(|&e| e > Decimal::ZERO) {
positions.push(BillingPosition {
description: format!("Netzeinspeisung PV: {export:.3}\u{202f}kWh (Abrechnung via EEG-Vergütung separat)"),
legal_basis: Some("\u{a7}41 EnWG".to_owned()),
quantity: export,
unit: "kWh".to_owned(),
unit_price_eur: Decimal::ZERO,
net_eur: Decimal::ZERO,
category: PositionCategory::Info,
tags: vec!["einspeisung".to_owned(), "prosumer".to_owned()],
applicable_tax_rate: None,
trace: crate::position::PositionTrace::default(),
});
}
if let Some(rate) = product.mwst_rate_override {
for pos in &mut positions {
if pos.applicable_tax_rate.is_none()
&& !matches!(
pos.category,
PositionCategory::Tax | PositionCategory::Abschlag | PositionCategory::Info
)
{
pos.applicable_tax_rate = Some(rate);
}
}
}
Ok(positions)
}
}
pub struct ControllableLoadProvider {
product: ControllableLoadProduct,
grid: GridInput,
}
impl ControllableLoadProvider {
#[must_use]
pub fn new(product: ControllableLoadProduct, grid: GridInput) -> Self {
Self { product, grid }
}
}
impl BillingProvider for ControllableLoadProvider {
fn validate_warnings(
&self,
ctx: &BillingContext,
quantities: &Quantities,
) -> Vec<BillingWarning> {
ElectricityProvider::new(self.product.base.clone(), self.grid.clone())
.validate_warnings(ctx, quantities)
}
fn bill(
&self,
ctx: &BillingContext,
quantities: &Quantities,
prior: &[BillingPosition],
) -> Result<Vec<BillingPosition>, BillingError> {
let ep = ElectricityProvider::new(self.product.base.clone(), self.grid.clone());
let mut positions = ep.bill(ctx, quantities, prior)?;
let meter = quantities.electricity.as_ref().cloned().unwrap_or_default();
let kwh = meter.arbeitsmenge_kwh;
let days = ctx.days();
let p = &self.product;
if let Some(sect14a_m1_ct) = p.sect14a_modul1_nne_reduktion_ct_per_kwh
&& sect14a_m1_ct > Decimal::ZERO
&& kwh > Decimal::ZERO
{
positions.push(
BillingPosition::credit(
"§14a EnWG Modul 1 — NNE Reduktion",
kwh,
"kWh",
sect14a_m1_ct / dec!(100),
PositionCategory::Credit,
)
.with_legal_basis("§14a EnWG")
.with_tag("§14a")
.with_tag("sect14a_modul1"),
);
}
if let (Some(m1_year), Some(kw)) = (
p.steuerungsrabatt_modul1_eur_per_kw_year,
meter.spitzenleistung_kw,
) && m1_year > Decimal::ZERO
&& kw > Decimal::ZERO
{
let months_frac = Decimal::from(days) / dec!(30.4375);
positions.push(
BillingPosition::credit(
"§14a EnWG Modul 1 — Steuerungsrabatt NNE",
kw,
"kW",
(m1_year / dec!(12)) * months_frac,
PositionCategory::Credit,
)
.with_legal_basis("§14a EnWG")
.with_tag("§14a")
.with_tag("sect14a_modul1"),
);
}
if let (Some(m3_year), Some(kw), Some(steuerung_h)) = (
p.steuerungsrabatt_modul3_eur_per_kw_year,
meter.spitzenleistung_kw,
meter.steuerung_stunden,
) && m3_year > Decimal::ZERO
&& kw > Decimal::ZERO
&& steuerung_h > Decimal::ZERO
{
positions.push(
BillingPosition::credit(
"§14a EnWG Modul 3 — Steuerungsentschädigung",
kw,
"kW",
m3_year * (steuerung_h / dec!(8760)),
PositionCategory::Credit,
)
.with_legal_basis("§14a EnWG")
.with_tag("§14a")
.with_tag("sect14a_modul3"),
);
}
if let (Some(modul3_ct), Some(steuerung_h)) = (
p.sect14a_modul3_entschaedigung_ct_per_kwh,
meter.steuerung_stunden,
) {
let kw = meter.spitzenleistung_kw.unwrap_or(Decimal::ZERO);
if modul3_ct > Decimal::ZERO && steuerung_h > Decimal::ZERO && kw > Decimal::ZERO {
let steuerung_kwh = kw * steuerung_h;
positions.push(
BillingPosition::credit(
"§14a EnWG Modul 3 — Steuerungsentschädigung",
steuerung_kwh,
"kWh",
modul3_ct / dec!(100),
PositionCategory::Credit,
)
.with_legal_basis("§14a EnWG")
.with_tag("§14a")
.with_tag("sect14a_modul3"),
);
}
}
Ok(positions)
}
}
pub struct GasProvider {
product: GasProduct,
grid: GridInput,
}
impl GasProvider {
pub fn new(product: GasProduct, grid: GridInput) -> Self {
Self { product, grid }
}
pub fn from_product(product: &crate::tariff::Product, grid: GridInput) -> Self {
match product {
crate::tariff::Product::Gas(p) => Self::new(p.clone(), grid),
other => panic!(
"GasProvider::from_product: got '{}', expected Gas",
other.category_str()
),
}
}
}
impl BillingProvider for GasProvider {
fn bill(
&self,
ctx: &BillingContext,
quantities: &Quantities,
_prior: &[BillingPosition],
) -> Result<Vec<BillingPosition>, BillingError> {
let meter = quantities.gas.as_ref().cloned().unwrap_or_default();
let days = ctx.days();
let product = &self.product;
let grid = &self.grid;
let rates = &ctx.regulatory_rates;
let billing_month = ctx.period_from.month() as u8;
let seasonal_gas_ap = product.seasonal_prices.as_ref().and_then(|seasons| {
seasons
.iter()
.find(|s| s.contains_month(billing_month))
.and_then(|s| s.gas_arbeitspreis_ct_per_kwh_hs)
});
let kwh_hs = if let Some(kwh) = meter.kwh_hs {
kwh
} else {
let hs = meter.brennwert_kwh_per_qm3.unwrap_or(dec!(10.55));
let z = meter.zustandszahl.unwrap_or(dec!(1.0));
(meter.messung_qm3 * hs * z).round_dp(3)
};
let mut positions: Vec<BillingPosition> = Vec::new();
if meter.kwh_hs.is_none() && meter.brennwert_kwh_per_qm3.is_some() {
let hs = meter.brennwert_kwh_per_qm3.unwrap_or(dec!(10.55));
let z = meter.zustandszahl.unwrap_or(dec!(1.0));
positions.push(BillingPosition {
description: format!(
"Brennwertkorrektur: {:.4} kWh/m³ × {:.4} = {:.3} kWh_Hs",
hs, z, kwh_hs
),
legal_basis: Some("§24 GasGVV / DVGW G 685".to_owned()),
quantity: meter.messung_qm3,
unit: "m³".to_owned(),
unit_price_eur: Decimal::ZERO,
net_eur: Decimal::ZERO,
category: PositionCategory::Info,
tags: vec!["brennwertkorrektur".to_owned(), "info".to_owned()],
applicable_tax_rate: None,
trace: crate::position::PositionTrace::default(),
});
}
if let Some(ref gq) = meter.gasqualitaet {
positions.push(BillingPosition {
description: format!("Gasqualität: {gq} (§ DVGW G 260)"),
legal_basis: Some(gq.clone()),
quantity: Decimal::ZERO,
unit: "".to_owned(),
unit_price_eur: Decimal::ZERO,
net_eur: Decimal::ZERO,
category: PositionCategory::Info,
tags: vec!["gasqualitaet".to_owned(), "info".to_owned()],
applicable_tax_rate: None,
trace: crate::position::PositionTrace::default(),
});
}
if let Some(gp_ct_day) = product.gas_grundpreis_ct_per_day {
positions.push(
grundpreis_position(
"Grundpreis Gas",
gp_ct_day / dec!(100),
ctx.prorate_days().0 as i64,
"§41 EnWG",
&["gas"],
)
.with_tag("gas"),
);
}
if kwh_hs > Decimal::ZERO {
let active_indexed = product
.gas_indexed_price
.as_ref()
.or(product.gas_indexed_price.as_ref());
let gas_ap_ct = if let Some(idx) = active_indexed {
idx.effective_ct_per_kwh()
.or(seasonal_gas_ap)
.or(product.gas_arbeitspreis_ct_per_kwh_hs)
} else {
seasonal_gas_ap.or(product.gas_arbeitspreis_ct_per_kwh_hs)
};
if let Some(ap_ct) = gas_ap_ct {
let (label, legal_basis) = if active_indexed.is_some() {
(
active_indexed
.and_then(|idx| {
if idx.index_value.is_some() {
Some(idx.position_description())
} else {
None
}
})
.unwrap_or_else(|| "Arbeitspreis Gas".to_owned()),
"§41 Abs. 3 EnWG",
)
} else if seasonal_gas_ap.is_some() {
let season_label = product
.seasonal_prices
.as_ref()
.and_then(|s| s.iter().find(|p| p.contains_month(billing_month)))
.and_then(|s| s.label.as_deref())
.map(|l| format!("Arbeitspreis Gas ({l})"))
.unwrap_or_else(|| "Arbeitspreis Gas (Saisontarif)".to_owned());
(season_label, "§41 EnWG")
} else {
("Arbeitspreis Gas".to_owned(), "§41 EnWG")
};
positions.push(
arbeitspreis_position(label, kwh_hs, ap_ct, "kWh_Hs", legal_basis, &["gas"])
.with_tag("gas")
.with_tag(if active_indexed.is_some() {
"indexed_price"
} else if seasonal_gas_ap.is_some() {
"seasonal"
} else {
"gas"
}),
);
}
if let (Some(lp_ct_per_kw_month), Some(kw)) = (
product.gas_leistungspreis_ct_per_kw_month,
meter.spitzenleistung_kw.filter(|kw| *kw > Decimal::ZERO),
) {
positions.push(
BillingPosition::debit(
"Leistungspreis Gas",
kw,
"kW",
lp_ct_per_kw_month / dec!(100),
PositionCategory::Commodity,
)
.with_legal_basis("§41 EnWG")
.with_tag("gas_leistungspreis")
.with_tag("gas")
.with_tag("rlm"),
);
}
if let Some(nne_gp) = grid.gas_nne_grundpreis_eur_per_year {
let daily = nne_gp / dec!(365);
positions.push(
BillingPosition::debit(
"Gasnetznutzungsentgelt Grundpreis",
Decimal::from(days),
"Tage",
daily,
PositionCategory::GridCharge,
)
.with_legal_basis("GasNEV")
.with_tag("gas_nne_grundpreis")
.with_tag("nne"),
);
}
if let Some(nne_ap_ct) = grid.gas_nne_arbeitspreis_ct_per_kwh {
positions.push(
BillingPosition::debit(
"Gasnetznutzungsentgelt Arbeitspreis",
kwh_hs,
"kWh_Hs",
nne_ap_ct / dec!(100),
PositionCategory::GridCharge,
)
.with_legal_basis("GasNEV")
.with_tag("gas_nne_arbeitspreis")
.with_tag("nne"),
);
}
if let Some(ka_ct) = grid.gas_ka_ct_per_kwh {
positions.push(
BillingPosition::debit(
"Konzessionsabgabe Gas",
kwh_hs,
"kWh_Hs",
ka_ct / dec!(100),
PositionCategory::GridCharge,
)
.with_legal_basis("KAV")
.with_tag("gas_konzessionsabgabe")
.with_tag("nne"),
);
}
if let Some(bilu_ct) = grid.gas_bilanzierungsumlage_ct_per_kwh {
positions.push(
BillingPosition::debit(
"Bilanzierungsumlage Gas",
kwh_hs,
"kWh_Hs",
bilu_ct / dec!(100),
PositionCategory::GridCharge,
)
.with_legal_basis("GasNZV")
.with_tag("gas_bilanzierungsumlage")
.with_tag("nne"),
);
}
let est_rate =
rates.effective_energiesteuer_gas(product.energiesteuer_gas_ct_per_kwh_override);
if product.gas_energiesteuer_befreiung {
positions.push(BillingPosition {
description: "Energiesteuer Erdgas: befreit gemäß §54 EnergieStG".to_owned(),
legal_basis: Some("§54 EnergieStG".to_owned()),
quantity: kwh_hs,
unit: "kWh_Hs".to_owned(),
unit_price_eur: Decimal::ZERO,
net_eur: Decimal::ZERO,
category: PositionCategory::Info,
tags: vec!["energiesteuer_gas_befreiung".to_owned(), "gas".to_owned()],
applicable_tax_rate: None,
trace: crate::position::PositionTrace::default(),
});
} else if est_rate > Decimal::ZERO {
positions.push(
levy_position(
"Energiesteuer Erdgas",
kwh_hs,
"kWh_Hs",
est_rate,
"§2 Nr. 3 EnergieStG",
"energiesteuer_gas",
)
.with_tag("gas"),
);
}
let behg_rate = rates.effective_behg_gas(product.behg_gas_ct_per_kwh_override);
if behg_rate > Decimal::ZERO {
positions.push(
levy_position(
"CO₂-Abgabe BEHG",
kwh_hs,
"kWh_Hs",
behg_rate,
"BEHG",
"behg",
)
.with_tag("gas"),
);
}
}
if let Some(aa_ct) = product
.auf_abschlag_ct_per_kwh
.filter(|v| *v != Decimal::ZERO)
{
let kwh_total = meter.kwh_hs.unwrap_or_else(|| {
let bw = meter.brennwert_kwh_per_qm3.unwrap_or(dec!(10.0));
let zz = meter.zustandszahl.unwrap_or(dec!(1.0));
meter.messung_qm3 * bw * zz
});
if kwh_total > Decimal::ZERO {
let (label, cat) = if aa_ct < Decimal::ZERO {
("Rabatt Gas (Arbeitspreis)", PositionCategory::Discount)
} else {
("Aufschlag Gas (Arbeitspreis)", PositionCategory::Levy)
};
positions.push(
BillingPosition::debit(label, kwh_total, "kWh", aa_ct / dec!(100), cat)
.with_tag("auf_abschlag")
.with_tag("gas"),
);
}
}
if let Some(aa_month) = product
.auf_abschlag_eur_per_month
.filter(|v| *v != Decimal::ZERO)
{
let days = ctx.days();
let months_frac = Decimal::from(days) / dec!(30.4375);
let (label, cat) = if aa_month < Decimal::ZERO {
("Rabatt Gas (Festbetrag)", PositionCategory::Discount)
} else {
("Aufschlag Gas (Festbetrag)", PositionCategory::Levy)
};
positions.push(BillingPosition {
description: label.to_owned(),
legal_basis: None,
quantity: months_frac,
unit: "Monat".to_owned(),
unit_price_eur: aa_month,
net_eur: crate::position::validated_eur(aa_month * months_frac),
category: cat,
tags: vec!["auf_abschlag".to_owned(), "gas".to_owned()],
applicable_tax_rate: None,
trace: crate::position::PositionTrace::default(),
});
}
if let Some(rate) = product.mwst_rate_override {
for pos in &mut positions {
if pos.applicable_tax_rate.is_none()
&& !matches!(
pos.category,
PositionCategory::Tax | PositionCategory::Abschlag | PositionCategory::Info
)
{
pos.applicable_tax_rate = Some(rate);
}
}
}
Ok(positions)
}
}
pub struct HeatProvider {
product: HeatProduct,
}
impl HeatProvider {
pub fn new(product: HeatProduct) -> Self {
Self { product }
}
pub fn from_product(product: &crate::tariff::Product) -> Self {
match product {
crate::tariff::Product::Waerme(p) => Self::new(p.clone()),
other => panic!(
"HeatProvider::from_product: got '{}', expected Waerme",
other.category_str()
),
}
}
}
impl BillingProvider for HeatProvider {
fn bill(
&self,
ctx: &BillingContext,
quantities: &Quantities,
_prior: &[BillingPosition],
) -> Result<Vec<BillingPosition>, BillingError> {
let meter = quantities.heat.as_ref().cloned().unwrap_or_default();
let days = ctx.days();
let product = &self.product;
let mut positions: Vec<BillingPosition> = Vec::new();
let months = meter.months.unwrap_or(dec!(1));
if let Some(gp) = product.waerme_grundpreis_eur_per_month {
positions.push(
BillingPosition::debit(
"Grundpreis Fernwärme",
months,
"Monate",
gp,
PositionCategory::Commodity,
)
.with_tag("commodity")
.with_tag("waerme"),
);
}
if let (Some(lp), Some(kw)) = (
product.waerme_leistungspreis_eur_per_kw_year.or_else(|| {
product
.waerme_leistungspreis_eur_per_kw_month
.map(|m| m * dec!(12))
}),
meter.spitzenleistung_kw,
) {
let billing_months = meter
.months
.unwrap_or_else(|| Decimal::from(days) / dec!(30.4375));
positions.push(
BillingPosition::debit(
"Leistungspreis Fernwärme",
kw,
"kW",
lp / dec!(12) * billing_months,
PositionCategory::Commodity,
)
.with_tag("commodity")
.with_tag("waerme"),
);
}
if let Some(ap_ct) = product.waerme_arbeitspreis_ct_per_kwh
&& meter.kwh_waerme > Decimal::ZERO
{
positions.push(
arbeitspreis_position(
"Arbeitspreis Fernwärme",
meter.kwh_waerme,
ap_ct,
"kWh_th",
"§41 EnWG",
&["waerme"],
)
.with_tag("waerme"),
);
}
let heat_tax_rate = if product.mwst_rate_override.is_some() {
product.mwst_rate_override
} else if product.waerme_is_renewable {
Some(dec!(0.07))
} else {
None
};
if let Some(rate) = heat_tax_rate {
for pos in &mut positions {
if pos.applicable_tax_rate.is_none()
&& !matches!(
pos.category,
PositionCategory::Tax | PositionCategory::Abschlag | PositionCategory::Info
)
{
pos.applicable_tax_rate = Some(rate);
}
}
}
Ok(positions)
}
}
pub struct SolarProvider {
product: SolarProduct,
}
impl SolarProvider {
pub fn new(product: SolarProduct) -> Self {
Self { product }
}
}
impl BillingProvider for SolarProvider {
fn bill(
&self,
ctx: &BillingContext,
quantities: &Quantities,
_prior: &[BillingPosition],
) -> Result<Vec<BillingPosition>, BillingError> {
let product = &self.product;
let mut positions: Vec<BillingPosition> = Vec::new();
if let Some(ggv) = &quantities.ggv_solar {
let pv_kwh = ggv.pv_delivered_kwh();
let grid_kwh = ggv.grid_kwh();
if pv_kwh > Decimal::ZERO {
if let Some(ap_ct) = product.solar_arbeitspreis_ct_per_kwh {
positions.push(
arbeitspreis_position(
format!("Arbeitspreis Solarstrom GGV ({pv_kwh:.3}\u{202f}kWh)"),
pv_kwh,
ap_ct,
"kWh",
"\u{a7}42b EEG 2023",
&["solar", "ggv_pv"],
)
.with_tag("solar")
.with_tag("ggv_pv"),
);
}
if let Some(rabatt_ct) = product.gemeinschaft_rabatt_ct_per_kwh {
positions.push(
BillingPosition::credit(
"GGV-Rabatt Solarstrom (\u{a7}42b EEG 2023)",
pv_kwh,
"kWh",
rabatt_ct / dec!(100),
PositionCategory::Discount,
)
.with_legal_basis("\u{a7}42b EEG 2023 Abs.\u{202f}3")
.with_tag("gemeinschaft_rabatt")
.with_tag("solar")
.with_tag("ggv_pv"),
);
}
if product.solar_include_stromsteuer {
let st_rate = ctx.regulatory_rates.effective_stromsteuer(None);
if st_rate > Decimal::ZERO {
positions.push(
levy_position(
"Stromsteuer (GGV PV-Anteil)",
pv_kwh,
"kWh",
st_rate,
"\u{a7}3 StromStG",
"stromsteuer",
)
.with_tag("solar")
.with_tag("ggv_pv"),
);
}
}
}
if grid_kwh > Decimal::ZERO {
let grid_rate = product
.arbeitspreis_ct_per_kwh
.or(product.solar_arbeitspreis_ct_per_kwh);
if let Some(ap_ct) = grid_rate {
positions.push(
arbeitspreis_position(
format!("Arbeitspreis Reststrom Netz ({grid_kwh:.3}\u{202f}kWh)"),
grid_kwh,
ap_ct,
"kWh",
"\u{a7}41 EnWG",
&["strom", "ggv_grid"],
)
.with_tag("strom")
.with_tag("ggv_grid"),
);
}
let st_rate = ctx.regulatory_rates.effective_stromsteuer(None);
if st_rate > Decimal::ZERO {
positions.push(
levy_position(
"Stromsteuer (Reststrom Netz)",
grid_kwh,
"kWh",
st_rate,
"\u{a7}3 StromStG",
"stromsteuer",
)
.with_tag("strom")
.with_tag("ggv_grid"),
);
}
}
let ratio_pct = (ggv.pv_coverage_ratio() * dec!(100)).round_dp(1);
positions.push(BillingPosition {
description: format!(
"GGV Solarstromanteil: {ratio_pct}\u{202f}% ({pv_kwh:.3}\u{202f}kWh von {:.3}\u{202f}kWh)",
ggv.actual_consumption_kwh
),
legal_basis: Some("\u{a7}42b EEG 2023 (Solarpaket I)".to_owned()),
quantity: ggv.pv_coverage_ratio(),
unit: "%".to_owned(),
unit_price_eur: Decimal::ZERO,
net_eur: Decimal::ZERO,
category: PositionCategory::Info,
tags: vec!["ggv_coverage".to_owned(), "solar".to_owned()],
applicable_tax_rate: None,
trace: crate::position::PositionTrace::default(),
});
if let Some(rate) = product.mwst_rate_override {
for pos in &mut positions {
if pos.applicable_tax_rate.is_none()
&& !matches!(
pos.category,
PositionCategory::Tax
| PositionCategory::Abschlag
| PositionCategory::Info
)
{
pos.applicable_tax_rate = Some(rate);
}
}
}
return Ok(positions);
}
let meter = quantities.solar.as_ref().cloned().unwrap_or_default();
let kwh = meter.eigenverbrauch_kwh;
if let Some(ap_ct) = product.solar_arbeitspreis_ct_per_kwh {
positions.push(
arbeitspreis_position(
"Arbeitspreis Solarstrom (Eigenverbrauch)",
kwh,
ap_ct,
"kWh",
"\u{a7}42b EEG 2023",
&["solar"],
)
.with_tag("solar"),
);
}
if let Some(ms_ct) = product.mieterstrom_aufschlag_ct_per_kwh {
positions.push(
arbeitspreis_position(
"Mieterstrom-Aufschlag (\u{a7}38a EEG 2023)",
kwh,
ms_ct,
"kWh",
"\u{a7}38a EEG 2023",
&["solar", "mieterstrom"],
)
.with_tag("mieterstrom_aufschlag"),
);
}
if let Some(rabatt_ct) = product.gemeinschaft_rabatt_ct_per_kwh {
positions.push(
BillingPosition::credit(
"Rabatt Gemeinschaftliche Geb\u{e4}udeversorgung (\u{a7}42b EEG)",
kwh,
"kWh",
rabatt_ct / dec!(100),
PositionCategory::Discount,
)
.with_legal_basis("\u{a7}42b EEG 2023")
.with_tag("gemeinschaft_rabatt")
.with_tag("solar"),
);
}
if let Some(rate) = product.mwst_rate_override {
for pos in &mut positions {
if pos.applicable_tax_rate.is_none()
&& !matches!(
pos.category,
PositionCategory::Tax | PositionCategory::Abschlag | PositionCategory::Info
)
{
pos.applicable_tax_rate = Some(rate);
}
}
}
Ok(positions)
}
}
pub struct EegProvider {
product: EegProduct,
}
impl EegProvider {
pub fn new(product: EegProduct) -> Self {
Self { product }
}
}
impl BillingProvider for EegProvider {
fn bill(
&self,
ctx: &BillingContext,
quantities: &Quantities,
_prior: &[BillingPosition],
) -> Result<Vec<BillingPosition>, BillingError> {
#[cfg(feature = "eeg")]
if let Some(eeg_full) = &quantities.eeg_full {
return bill_eeg_full(eeg_full, ctx);
}
let meter = quantities.eeg.as_ref().cloned().unwrap_or_default();
let product = &self.product;
let kwh = meter.einspeisung_kwh;
let billable_kwh = meter
.kwh_during_negative_epex
.map(|neg| (kwh - neg).max(Decimal::ZERO))
.unwrap_or(kwh);
let suspended_kwh = kwh - billable_kwh;
let mut positions: Vec<BillingPosition> = Vec::new();
if suspended_kwh > Decimal::ZERO {
positions.push(BillingPosition {
description: "Keine Vergütung (§51 EEG Negativpreisregel)".to_owned(),
legal_basis: Some("§51 EEG 2023".to_owned()),
quantity: suspended_kwh,
unit: "kWh".to_owned(),
unit_price_eur: Decimal::ZERO,
net_eur: Decimal::ZERO,
category: PositionCategory::Info,
tags: vec!["eeg_negativpreis_suspension".to_owned(), "info".to_owned()],
applicable_tax_rate: None,
trace: crate::position::PositionTrace::default(),
});
}
if let Some(vg_ct) = product.eeg_verguetungssatz_ct_per_kwh {
positions.push(
BillingPosition::debit(
"EEG Einspeisevergütung",
billable_kwh,
"kWh",
vg_ct / dec!(100),
PositionCategory::Credit,
)
.with_legal_basis("§21 EEG 2023")
.with_tag("eeg_verguetung")
.with_tag("eeg"),
);
}
if let Some(mp_ct) = product.eeg_marktpraemie_ct_per_kwh {
positions.push(
BillingPosition::debit(
"EEG Marktprämie",
kwh,
"kWh",
mp_ct / dec!(100),
PositionCategory::Credit,
)
.with_legal_basis("§20 EEG 2023")
.with_tag("eeg_marktpraemie")
.with_tag("eeg"),
);
}
if let Some(mgp_ct) = product.eeg_managementpraemie_ct_per_kwh {
positions.push(
BillingPosition::debit(
"Managementprämie Direktvermarktung",
kwh,
"kWh",
mgp_ct / dec!(100),
PositionCategory::Credit,
)
.with_legal_basis("§20 Abs. 3 EEG 2023")
.with_tag("eeg_managementpraemie")
.with_tag("eeg"),
);
}
if let Some(kwkg_ct) = product.kwkg_zuschlag_ct_per_kwh {
positions.push(
BillingPosition::debit(
"KWKG Zuschlag",
kwh,
"kWh",
kwkg_ct / dec!(100),
PositionCategory::Credit,
)
.with_legal_basis("§7 KWKG 2023")
.with_tag("kwkg_zuschlag")
.with_tag("kwkg"),
);
}
if let Some(rate) = product.mwst_rate_override {
for pos in &mut positions {
if pos.applicable_tax_rate.is_none()
&& !matches!(
pos.category,
PositionCategory::Tax | PositionCategory::Abschlag | PositionCategory::Info
)
{
pos.applicable_tax_rate = Some(rate);
}
}
}
Ok(positions)
}
}
#[cfg(feature = "eeg")]
fn bill_eeg_full(
settle_input: &eeg_billing::SettleInput,
_ctx: &BillingContext,
) -> Result<Vec<BillingPosition>, BillingError> {
let output = eeg_billing::calculate_settlement(settle_input);
let positions = output
.positions
.into_iter()
.map(|p| BillingPosition {
description: p.description,
legal_basis: Some(p.legal_basis),
quantity: p.kwh,
unit: "kWh".to_owned(),
unit_price_eur: p.rate_ct_kwh / dec!(100),
net_eur: validated_eur(p.eur),
category: PositionCategory::Credit,
tags: vec!["eeg".to_owned(), "eeg_full".to_owned()],
applicable_tax_rate: None,
trace: crate::position::PositionTrace::default(),
})
.collect();
Ok(positions)
}
pub struct EinspeisungProvider {
product: EinspeisungProduct,
}
impl EinspeisungProvider {
pub fn new(product: EinspeisungProduct) -> Self {
Self { product }
}
}
impl BillingProvider for EinspeisungProvider {
fn bill(
&self,
_ctx: &BillingContext,
quantities: &Quantities,
_prior: &[BillingPosition],
) -> Result<Vec<BillingPosition>, BillingError> {
let meter = quantities.einspeisung.as_ref().cloned().unwrap_or_default();
let product = &self.product;
let kwh = meter.einspeisung_kwh;
let mut positions: Vec<BillingPosition> = Vec::new();
if let Some(mv_ct) = product.marktwert_ct_per_kwh {
positions.push(
BillingPosition::debit(
"Marktwert Strom (EPEX Spot Monatsmarktwert)",
kwh,
"kWh",
mv_ct / dec!(100),
PositionCategory::Credit,
)
.with_legal_basis("§20 EEG 2023")
.with_tag("marktwert")
.with_tag("einspeisung"),
);
}
if let Some(vm_ct) = product.vermarktungsgebuehr_ct_per_kwh {
positions.push(
BillingPosition::debit(
"Vermarktungsgebühr Direktvermarktung",
kwh,
"kWh",
-(vm_ct / dec!(100)), PositionCategory::Fee,
)
.with_tag("vermarktungsgebuehr")
.with_tag("einspeisung"),
);
}
if let Some(rate) = product.mwst_rate_override {
for pos in &mut positions {
if pos.applicable_tax_rate.is_none()
&& !matches!(
pos.category,
PositionCategory::Tax | PositionCategory::Abschlag | PositionCategory::Info
)
{
pos.applicable_tax_rate = Some(rate);
}
}
}
Ok(positions)
}
}
pub struct HemsProvider {
product: HemsProduct,
}
impl HemsProvider {
pub fn new(product: HemsProduct) -> Self {
Self { product }
}
}
impl BillingProvider for HemsProvider {
fn bill(
&self,
_ctx: &BillingContext,
quantities: &Quantities,
_prior: &[BillingPosition],
) -> Result<Vec<BillingPosition>, BillingError> {
let usage = quantities.hems.as_ref().cloned().unwrap_or_default();
let product = &self.product;
let months = usage.months.unwrap_or(dec!(1));
let mut positions: Vec<BillingPosition> = Vec::new();
let sub_eur = product.hems_subscription_eur_per_month;
if let Some(sub_eur) = sub_eur {
positions.push(
BillingPosition::debit(
"HEMS Grundgebühr",
months,
"Monate",
sub_eur,
PositionCategory::Fee,
)
.with_tag("hems_subscription")
.with_tag("hems"),
);
}
if let (Some(events), Some(event_eur)) = (
usage.optimization_events,
product.hems_optimization_event_eur,
) && events > 0
{
positions.push(
BillingPosition::debit(
"HEMS Optimierungsereignisse",
Decimal::from(events),
"Ereignisse",
event_eur,
PositionCategory::Fee,
)
.with_tag("hems_events")
.with_tag("hems"),
);
}
if let (Some(reads), Some(read_eur)) =
(usage.readout_events, product.hems_readout_event_eur)
&& reads > 0
{
positions.push(
BillingPosition::debit(
"HEMS Smart Meter Ablesungen",
Decimal::from(reads),
"Ablesungen",
read_eur,
PositionCategory::Fee,
)
.with_tag("hems_readouts")
.with_tag("hems"),
);
}
if let Some(rate) = product.mwst_rate_override {
for pos in &mut positions {
if pos.applicable_tax_rate.is_none()
&& !matches!(
pos.category,
PositionCategory::Tax | PositionCategory::Abschlag | PositionCategory::Info
)
{
pos.applicable_tax_rate = Some(rate);
}
}
}
Ok(positions)
}
}
pub struct EmobilityProvider {
product: EmobilityProduct,
}
impl EmobilityProvider {
pub fn new(product: EmobilityProduct) -> Self {
Self { product }
}
}
impl BillingProvider for EmobilityProvider {
fn bill(
&self,
_ctx: &BillingContext,
quantities: &Quantities,
_prior: &[BillingPosition],
) -> Result<Vec<BillingPosition>, BillingError> {
let usage = quantities.emobility.as_ref().cloned().unwrap_or_default();
let product = &self.product;
let months = usage.months.unwrap_or(dec!(1));
let mut positions: Vec<BillingPosition> = Vec::new();
let svc_eur = product.emobility_service_fee_eur;
let kwh_price = product.emobility_kwh_price_ct;
if let Some(svc_eur) = svc_eur {
positions.push(
BillingPosition::debit(
"E-Mobility Servicegebühr",
months,
"Monate",
svc_eur,
PositionCategory::Fee,
)
.with_tag("emobility_service")
.with_tag("emobility"),
);
}
if let (Some(kwh), Some(kwh_price_ct)) = (usage.kwh_charged, kwh_price)
&& kwh > Decimal::ZERO
{
positions.push(
arbeitspreis_position(
"E-Mobility Ladeenergie",
kwh,
kwh_price_ct,
"kWh",
"§41a EnWG",
&["emobility"],
)
.with_tag("emobility"),
);
}
if let (Some(sessions), Some(session_eur)) =
(usage.sessions, product.emobility_session_fee_eur)
&& sessions > 0
{
positions.push(
BillingPosition::debit(
"E-Mobility Ladesessionsgebühr",
Decimal::from(sessions),
"Sessionen",
session_eur,
PositionCategory::Fee,
)
.with_tag("emobility_sessions")
.with_tag("emobility"),
);
}
if let (Some(roaming), Some(roaming_eur)) =
(usage.roaming_sessions, product.emobility_roaming_fee_eur)
&& roaming > 0
{
positions.push(
BillingPosition::debit(
"E-Mobility Roaming-Gebühr",
Decimal::from(roaming),
"Sessionen",
roaming_eur,
PositionCategory::Fee,
)
.with_tag("emobility_roaming")
.with_tag("emobility"),
);
}
if let Some(rate) = product.mwst_rate_override {
for pos in &mut positions {
if pos.applicable_tax_rate.is_none()
&& !matches!(
pos.category,
PositionCategory::Tax | PositionCategory::Abschlag | PositionCategory::Info
)
{
pos.applicable_tax_rate = Some(rate);
}
}
}
Ok(positions)
}
}
pub struct ServiceProvider {
product: ServiceProduct,
}
impl ServiceProvider {
pub fn new(product: ServiceProduct) -> Self {
Self { product }
}
}
impl BillingProvider for ServiceProvider {
fn bill(
&self,
_ctx: &BillingContext,
quantities: &Quantities,
_prior: &[BillingPosition],
) -> Result<Vec<BillingPosition>, BillingError> {
let usage = quantities.service.as_ref().cloned().unwrap_or_default();
let product = &self.product;
let months = usage.months.unwrap_or(dec!(1));
let mut positions: Vec<BillingPosition> = Vec::new();
if let Some(fee_eur) = product.service_fee_eur {
positions.push(
BillingPosition::debit(
"Energiedienstleistung Grundgebühr",
months,
"Monate",
fee_eur,
PositionCategory::Fee,
)
.with_tag("service"),
);
}
let event_price = usage.event_price_eur.or(product.service_event_price_eur);
if let (Some(events), Some(event_eur)) = (usage.event_count, event_price)
&& events > 0
{
positions.push(
BillingPosition::debit(
"Energiedienstleistung Ereignisgebühr",
Decimal::from(events),
"Ereignisse",
event_eur,
PositionCategory::Fee,
)
.with_tag("service_events")
.with_tag("service"),
);
}
if let Some(rate) = product.mwst_rate_override {
for pos in &mut positions {
if pos.applicable_tax_rate.is_none()
&& !matches!(
pos.category,
PositionCategory::Tax | PositionCategory::Abschlag | PositionCategory::Info
)
{
pos.applicable_tax_rate = Some(rate);
}
}
}
Ok(positions)
}
}
pub struct DynamicElectricityProvider {
product: ElectricityProduct,
grid: GridInput,
spot_price_source: Box<dyn crate::provider::SpotPriceSource>,
}
impl DynamicElectricityProvider {
pub fn new(
product: ElectricityProduct,
grid: GridInput,
spot_source: impl crate::provider::SpotPriceSource + 'static,
) -> Self {
Self {
product,
grid,
spot_price_source: Box::new(spot_source),
}
}
pub fn with_epex_map(
product: ElectricityProduct,
grid: GridInput,
epex_prices: std::collections::HashMap<(i32, u8, u8, u8), Decimal>,
) -> Self {
Self::new(
product,
grid,
crate::provider::EpexSpotSource {
prices: epex_prices,
},
)
}
}
impl BillingProvider for DynamicElectricityProvider {
fn validate_warnings(
&self,
_ctx: &BillingContext,
quantities: &Quantities,
) -> Vec<BillingWarning> {
let is_non_imsys = quantities
.electricity
.as_ref()
.is_some_and(|m| m.metering_mode != crate::quantities::MeteringMode::Imsys);
if is_non_imsys {
return vec![BillingWarning {
code: "SECT41B_IMSYS_REQUIRED",
severity: WarningSeverity::Error,
message: "§41b Abs. 2 EnWG: dynamic tariffs (§41a) require an intelligent \
metering system (iMSys / Smart Meter Gateway). The meter point \
has MeteringMode::Slp or MeteringMode::Rlm. Update metering mode \
to MeteringMode::Imsys or switch the customer to a fixed-price product."
.to_owned(),
}];
}
vec![]
}
fn bill(
&self,
ctx: &BillingContext,
quantities: &Quantities,
_prior: &[BillingPosition],
) -> Result<Vec<BillingPosition>, BillingError> {
let product = &self.product;
let grid = &self.grid;
let rates = &ctx.regulatory_rates;
let days = ctx.days();
let floor_ct = product.dynamic_epex_floor_ct_kwh;
let source_name = self.spot_price_source.source_name().to_owned();
let mut positions: Vec<BillingPosition> = Vec::new();
if let Some(gp_ct_day) = product.grundpreis_ct_per_day {
positions.push(
grundpreis_position(
"Grundpreis Strom (§41a)",
gp_ct_day / dec!(100),
days,
"§41a EnWG",
&["strom"],
)
.with_tag("strom"),
);
}
let mut missing_price_intervals: u32 = 0;
let mut priced_pairs: Vec<(Decimal, billing::Amount<5>)> =
Vec::with_capacity(quantities.dynamic_intervals.len());
for interval in &quantities.dynamic_intervals {
let price_ct = self
.spot_price_source
.price_ct_kwh(interval.timestamp_utc)
.or_else(|| {
if quantities.dynamic_epex_prices.is_empty() {
return None;
}
use time_tz::{OffsetDateTimeExt, timezones};
let berlin = timezones::db::europe::BERLIN;
let local = interval.timestamp_utc.to_timezone(berlin);
let key = (local.year(), local.month() as u8, local.day(), local.hour());
quantities.dynamic_epex_prices.get(&key).copied()
});
let Some(price_ct) = price_ct else {
missing_price_intervals += 1;
continue;
};
let effective_ct = if let Some(floor) = floor_ct {
price_ct.max(floor)
} else {
price_ct
};
if let Ok(price_eur) =
billing::Amount::<5>::try_from((effective_ct / dec!(100)).round_dp(5))
{
priced_pairs.push((interval.kwh, price_eur));
}
}
if missing_price_intervals > 0 {
tracing::warn!(
missing_intervals = missing_price_intervals,
total_intervals = quantities.dynamic_intervals.len(),
"DynamicElectricityProvider: {missing_price_intervals} interval(s) skipped \
(no EPEX price data). Set quantities.dynamic_epex_prices from marktd."
);
}
if !priced_pairs.is_empty() {
let item = DynamicPricing::from_intervals(priced_pairs)
.and_then(|dp| dp.with_unit("kWh").calculate())
.map_err(|e| BillingError::InvalidInput {
reason: format!("§41a DynamicPricing: {e}"),
})?;
let total_kwh = item.quantity_value().unwrap_or_default();
let total_eur = item.net_amount.to_decimal();
let avg_ct = if total_kwh.is_zero() {
Decimal::ZERO
} else {
(total_eur * dec!(100) / total_kwh).round_dp(4)
};
positions.push(BillingPosition {
description: format!("Arbeitspreis {source_name} (∅ {avg_ct:.4} ct/kWh)",),
legal_basis: Some("§41a EnWG".to_owned()),
quantity: total_kwh,
unit: "kWh".to_owned(),
unit_price_eur: avg_ct / dec!(100),
net_eur: total_eur,
category: PositionCategory::Commodity,
tags: vec![
"commodity".to_owned(),
"arbeitspreis".to_owned(),
"strom".to_owned(),
"§41a".to_owned(),
],
applicable_tax_rate: None,
trace: crate::position::PositionTrace::default(),
});
if let Some(nne_ap_ct) = grid.nne_arbeitspreis_ct_per_kwh {
positions.push(
BillingPosition::debit(
"Netznutzungsentgelt Arbeitspreis",
total_kwh,
"kWh",
nne_ap_ct / dec!(100),
PositionCategory::GridCharge,
)
.with_legal_basis("StromNEV")
.with_tag("nne_arbeitspreis")
.with_tag("nne"),
);
}
if let Some(ka_ct) = grid.ka_ct_per_kwh {
positions.push(
BillingPosition::debit(
"Konzessionsabgabe",
total_kwh,
"kWh",
ka_ct / dec!(100),
PositionCategory::GridCharge,
)
.with_legal_basis("KAV")
.with_tag("konzessionsabgabe")
.with_tag("nne"),
);
}
let st_rate = rates.effective_stromsteuer(product.stromsteuer_ct_per_kwh_override);
if st_rate > Decimal::ZERO {
positions.push(
levy_position(
"Stromsteuer",
total_kwh,
"kWh",
st_rate,
"§3 StromStG",
"stromsteuer",
)
.with_tag("strom"),
);
}
}
if let Some(nne_gp) = grid.nne_grundpreis_eur_per_year {
let daily = nne_gp / dec!(365);
positions.push(
BillingPosition::debit(
"Netznutzungsentgelt Grundpreis",
Decimal::from(days),
"Tage",
daily,
PositionCategory::GridCharge,
)
.with_legal_basis("StromNEV")
.with_tag("nne_grundpreis")
.with_tag("nne"),
);
}
if let Some(rate) = product.mwst_rate_override {
for pos in &mut positions {
if pos.applicable_tax_rate.is_none()
&& !matches!(
pos.category,
PositionCategory::Tax | PositionCategory::Abschlag | PositionCategory::Info
)
{
pos.applicable_tax_rate = Some(rate);
}
}
}
if let Some(comp) = &quantities.sect41a_annual_comparison {
let sign = if comp.savings_eur >= Decimal::ZERO {
"Ersparnis"
} else {
"Mehrkosten"
};
positions.push(BillingPosition {
description: format!(
"§41a Abs. 6 EnWG Jahresvergleich: {:.2} EUR (Dynamisch) vs. {:.2} EUR (Festpreis {:.4} ct/kWh) -> {} {:.2} EUR",
comp.actual_eur_brutto, comp.reference_eur_brutto,
comp.reference_price_ct_per_kwh, sign, comp.savings_eur.abs(),
),
legal_basis: Some("§41a Abs. 6 EnWG".to_owned()),
quantity: comp.actual_kwh,
unit: "kWh".to_owned(),
unit_price_eur: Decimal::ZERO,
net_eur: Decimal::ZERO,
category: PositionCategory::Info,
tags: vec!["sect41a_annual_comparison".to_owned()],
applicable_tax_rate: None,
trace: crate::position::PositionTrace::default(),
});
}
Ok(positions)
}
}
pub struct MwStProvider {
rate: Decimal,
}
impl MwStProvider {
#[must_use]
pub fn new(rate: Decimal) -> Self {
Self { rate }
}
}
impl BillingProvider for MwStProvider {
fn bill(
&self,
_ctx: &BillingContext,
_quantities: &Quantities,
prior: &[BillingPosition],
) -> Result<Vec<BillingPosition>, BillingError> {
use std::collections::BTreeMap;
let mut rate_buckets: BTreeMap<String, (Decimal, Decimal)> = BTreeMap::new();
for p in prior {
if matches!(
p.category,
PositionCategory::Tax | PositionCategory::Abschlag | PositionCategory::Info
) {
continue;
}
let effective_rate = p.applicable_tax_rate.unwrap_or(self.rate);
if effective_rate.is_zero() {
continue; }
let key = effective_rate.to_string();
let entry = rate_buckets
.entry(key)
.or_insert((effective_rate, Decimal::ZERO));
entry.1 += p.net_eur;
}
if rate_buckets.is_empty() {
return Ok(vec![]);
}
let mut tax_positions: Vec<BillingPosition> = Vec::with_capacity(rate_buckets.len());
for (_key, (rate, net_base)) in rate_buckets {
if net_base.is_zero() {
continue;
}
let mwst_eur = validated_eur(net_base.abs() * rate);
let mwst_eur = if net_base < Decimal::ZERO {
-mwst_eur
} else {
mwst_eur
};
let pct = (rate * dec!(100)).normalize();
tax_positions.push(BillingPosition {
description: format!("Mehrwertsteuer {pct}\u{202f}%"),
legal_basis: Some("\u{a7}12 UStG".to_owned()),
quantity: Decimal::ONE,
unit: "%".to_owned(),
unit_price_eur: mwst_eur,
net_eur: mwst_eur,
category: PositionCategory::Tax,
tags: vec!["mwst".to_owned(), "tax".to_owned()],
applicable_tax_rate: Some(rate),
trace: crate::position::PositionTrace::tax(rate, net_base, "§12 UStG"),
});
}
Ok(tax_positions)
}
fn is_tax_pass(&self) -> bool {
true
}
}
#[inline]
fn billing_item_to_position(
item: billing::LineItem,
category: PositionCategory,
legal_basis: &str,
tags: &[&str],
) -> BillingPosition {
BillingPosition {
description: item.description,
legal_basis: Some(legal_basis.to_owned()),
quantity: item.quantity.as_ref().map(|q| q.value).unwrap_or_default(),
unit: item
.quantity
.as_ref()
.map(|q| q.unit.clone())
.unwrap_or_default(),
unit_price_eur: item
.unit_price
.as_ref()
.map(|p| p.value)
.unwrap_or_default(),
net_eur: item.net_amount.into_decimal(),
category,
tags: tags.iter().map(|s| s.to_string()).collect(),
applicable_tax_rate: None,
trace: crate::position::PositionTrace::default(),
}
}
fn build_block_tariff_positions(
tiers: &[crate::tariff::BlockTierInput],
kwh: Decimal,
extra_tags: &[&str],
) -> Result<Vec<BillingPosition>, BillingError> {
let mut builder = TariffSchedule::graduated().unit("kWh");
let mut prev: Option<Decimal> = None;
for (idx, tier) in tiers.iter().enumerate() {
let price_eur =
billing::Amount::<5>::try_from((tier.preis_ct_per_kwh / dec!(100)).round_dp(5))
.map_err(|_| BillingError::InvalidInput {
reason: format!("block tier {} price out of range", idx + 1),
})?;
let desc = match tier.bis_kwh {
Some(upper) => format!(
"Arbeitspreis Strom Stufe {} (bis {upper}\u{202f}kWh)",
idx + 1
),
None => format!("Arbeitspreis Strom Stufe {}", idx + 1),
};
let band = match (prev, tier.bis_kwh) {
(None, Some(upper)) => TariffBand::up_to(upper, price_eur),
(Some(lower), Some(upper)) => TariffBand::between(lower, upper, price_eur),
(lower, None) => TariffBand::over(lower.unwrap_or(Decimal::ZERO), price_eur),
}
.with_description(desc);
builder = builder.band(band);
prev = tier.bis_kwh;
}
let items = builder.build().and_then(|s| s.split(kwh))?;
let mut tags: Vec<&str> = vec!["strom", "arbeitspreis", "block_tier"];
tags.extend_from_slice(extra_tags);
Ok(items
.into_iter()
.map(|item| billing_item_to_position(item, PositionCategory::Commodity, "§41 EnWG", &tags))
.collect())
}
pub struct EnergyShareProvider {
product: SharingProduct,
}
impl EnergyShareProvider {
pub fn new(product: SharingProduct) -> Self {
Self { product }
}
}
impl BillingProvider for EnergyShareProvider {
fn bill(
&self,
_ctx: &crate::context::BillingContext,
quantities: &crate::quantities::Quantities,
_prior: &[BillingPosition],
) -> Result<Vec<BillingPosition>, BillingError> {
let product = &self.product;
let mut positions: Vec<BillingPosition> = Vec::new();
let credit_rate_ct = product.sharing_credit_ct_per_kwh.unwrap_or(Decimal::ZERO);
if credit_rate_ct.is_zero() {
return Ok(positions);
}
let credit_rate_eur = credit_rate_ct / dec!(100);
let allocated_kwh = quantities
.energy_share
.as_ref()
.map(|s| s.allocated_kwh)
.unwrap_or(Decimal::ZERO);
if allocated_kwh <= Decimal::ZERO {
return Ok(positions);
}
let description = product
.sharing_description
.clone()
.unwrap_or_else(|| "Energiegemeinschaft Gutschrift (§42c EnWG)".to_owned());
let mut pos = BillingPosition::credit(
description,
allocated_kwh,
"kWh",
credit_rate_eur,
PositionCategory::EnergyShare,
)
.with_legal_basis("§42c EnWG")
.with_tag("sharing")
.with_tag("strom");
pos.trace = crate::position::PositionTrace::commodity(
allocated_kwh,
"kWh",
-credit_rate_eur,
"§42c EnWG",
)
.with_basis("Energiegemeinschaft-Liefervertrag");
positions.push(pos);
Ok(positions)
}
}