use rust_decimal::Decimal;
use rust_decimal::dec;
use time::Date;
use crate::error::BillingError;
use crate::types::{
BillingPositionKind, CalculationTrace, LegalReference, SettlementPeriod, SettlementPosition,
SettlementResult, SettlementStatus, SettlementType, Sparte, TariffSource,
};
const STUFE_A: Date = time::macros::date!(2026 - 07 - 01);
const STUFE_B: Date = time::macros::date!(2027 - 01 - 01);
const STUFE_C: Date = time::macros::date!(2028 - 01 - 01);
const ENDE: Date = time::macros::date!(2029 - 01 - 01);
#[must_use]
pub fn abschmelzfaktor(tag: Date) -> Decimal {
if tag >= ENDE {
Decimal::ZERO
} else if tag >= STUFE_C {
dec!(0.25)
} else if tag >= STUFE_B || tag >= STUFE_A {
dec!(0.50)
} else {
Decimal::ONE
}
}
#[must_use]
pub fn period_crosses_a_step(period: SettlementPeriod) -> bool {
abschmelzfaktor(period.from()) != abschmelzfaktor(period.to())
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct DezentraleEinspeisungInput {
pub malo_id: String,
pub nb_mp_id: String,
pub anlagenbetreiber_mp_id: String,
pub period: SettlementPeriod,
pub einspeisung_kwh: Decimal,
pub vermiedene_kosten_ct_per_kwh: Decimal,
pub ist_eeg_gefoerdert: bool,
pub tariff_sheet_id: Option<String>,
}
pub fn settle_dezentrale_einspeisung(
input: &DezentraleEinspeisungInput,
) -> Result<SettlementResult, BillingError> {
if input.ist_eeg_gefoerdert {
return Err(BillingError::InvalidInput {
reason: "an EEG-funded plant receives no Entgelt für dezentrale Erzeugung \
(§18 Abs. 1 Satz 4 Nr. 1 StromNEV)"
.to_owned(),
});
}
if input.einspeisung_kwh < Decimal::ZERO {
return Err(BillingError::InvalidInput {
reason: "einspeisung_kwh must be non-negative".to_owned(),
});
}
if input.vermiedene_kosten_ct_per_kwh < Decimal::ZERO {
return Err(BillingError::InvalidInput {
reason: "vermiedene_kosten_ct_per_kwh must be non-negative".to_owned(),
});
}
if period_crosses_a_step(input.period) {
return Err(BillingError::InvalidInput {
reason: format!(
"the period {} – {} crosses a GBK-25-02-1#1 Abschmelzung step; \
split it at the step date so each part is paid at its factor",
input.period.from(),
input.period.to()
),
});
}
let faktor = abschmelzfaktor(input.period.from());
let base_eur = input.vermiedene_kosten_ct_per_kwh / dec!(100);
let reduced_eur = (base_eur * faktor).round_dp(6);
let net_eur = -(input.einspeisung_kwh * reduced_eur).round_dp(5);
let mut positions = Vec::new();
let mut warnings = Vec::new();
crate::billing::warn_if_straddles_turnover(
input.period.from(),
input.period.to(),
&mut warnings,
);
if faktor.is_zero() {
warnings.push(crate::types::SettlementWarning {
severity: crate::types::WarningSeverity::Info,
code: "SECT18_ABGESCHMOLZEN",
message: "the Entgelt für dezentrale Erzeugung is fully phased out for this \
period (GBK-25-02-1#1); nothing is payable"
.to_owned(),
});
} else {
positions.push(SettlementPosition {
text: format!(
"Entgelt für dezentrale Erzeugung ({} % nach Abschmelzung)",
(faktor * dec!(100)).normalize()
),
kind: BillingPositionKind::DezentraleEinspeisung,
quantity: input.einspeisung_kwh.round_dp(3),
unit: crate::types::QuantityUnit::Kwh,
unit_price_eur: reduced_eur,
net_eur,
spot_price_formula: None,
trace: CalculationTrace {
explanation: format!(
"{:.3} kWh × {:.6} EUR/kWh (= {:.6} × {faktor} Abschmelzung) = {:.5} EUR \
payable to the plant operator",
input.einspeisung_kwh,
reduced_eur,
base_eur,
net_eur.abs()
),
input_quantity: input.einspeisung_kwh,
input_unit_price_eur: reduced_eur,
gross_eur: net_eur,
legal_refs: vec![
LegalReference::StromNev { paragraph: "§18" },
LegalReference::BnetzaDecision {
reference: "GBK-25-02-1#1",
},
],
tariff_source: input
.tariff_sheet_id
.clone()
.map(|sheet_id| TariffSource::PublishedTariffSheet { sheet_id }),
regulatory_reduction_factor: Some(faktor),
rounding_note: Some("unit price to 6 dp; net to 5 dp"),
},
});
}
Ok(SettlementResult {
malo_id: input.malo_id.clone(),
sparte: Sparte::Strom,
regime: crate::regulatory::RegulatoryRegime::for_period(
input.period.from(),
input.period.to(),
),
settlement_type: SettlementType::DezentraleEinspeisung,
status: SettlementStatus::Initial,
period: input.period,
nb_mp_id: input.nb_mp_id.clone(),
counterparty_mp_id: input.anlagenbetreiber_mp_id.clone(),
total_eur: positions
.iter()
.map(|p| p.net_eur)
.sum::<Decimal>()
.round_dp(2),
positions,
warnings,
})
}
#[cfg(test)]
mod tests {
use super::*;
use time::macros::date;
fn base(period: SettlementPeriod) -> DezentraleEinspeisungInput {
DezentraleEinspeisungInput {
malo_id: "51238696780".to_owned(),
nb_mp_id: "9900357000004".to_owned(),
anlagenbetreiber_mp_id: "9900012345678".to_owned(),
period,
einspeisung_kwh: dec!(10_000),
vermiedene_kosten_ct_per_kwh: dec!(0.60),
ist_eeg_gefoerdert: false,
tariff_sheet_id: None,
}
}
fn p(from: Date, to: Date) -> SettlementPeriod {
SettlementPeriod::new(from, to).expect("valid period")
}
#[test]
fn the_tenor_schedule() {
assert_eq!(abschmelzfaktor(date!(2026 - 06 - 30)), Decimal::ONE);
assert_eq!(abschmelzfaktor(date!(2026 - 07 - 01)), dec!(0.50));
assert_eq!(abschmelzfaktor(date!(2027 - 06 - 15)), dec!(0.50));
assert_eq!(abschmelzfaktor(date!(2028 - 01 - 01)), dec!(0.25));
assert_eq!(abschmelzfaktor(date!(2028 - 12 - 31)), dec!(0.25));
assert_eq!(abschmelzfaktor(date!(2029 - 01 - 01)), Decimal::ZERO);
}
#[test]
fn the_annual_averages_fall_by_a_quarter() {
let h1 = abschmelzfaktor(date!(2026 - 03 - 01));
let h2 = abschmelzfaktor(date!(2026 - 09 - 01));
assert_eq!((h1 + h2) / dec!(2), dec!(0.75));
assert_eq!(abschmelzfaktor(date!(2027 - 07 - 01)), dec!(0.50));
assert_eq!(abschmelzfaktor(date!(2028 - 07 - 01)), dec!(0.25));
}
#[test]
fn the_factor_reaches_the_payment() {
let full =
settle_dezentrale_einspeisung(&base(p(date!(2026 - 01 - 01), date!(2026 - 01 - 31))))
.expect("settles");
assert_eq!(full.total_eur, dec!(-60.00));
let quarter =
settle_dezentrale_einspeisung(&base(p(date!(2028 - 03 - 01), date!(2028 - 03 - 31))))
.expect("settles");
assert_eq!(quarter.total_eur, dec!(-15.00));
assert_eq!(
quarter.positions[0].trace.regulatory_reduction_factor,
Some(dec!(0.25))
);
}
#[test]
fn a_period_across_a_step_is_refused() {
let r =
settle_dezentrale_einspeisung(&base(p(date!(2026 - 06 - 15), date!(2026 - 07 - 15))));
assert!(matches!(r, Err(BillingError::InvalidInput { .. })));
}
#[test]
fn an_eeg_plant_is_refused() {
let mut i = base(p(date!(2026 - 01 - 01), date!(2026 - 01 - 31)));
i.ist_eeg_gefoerdert = true;
assert!(matches!(
settle_dezentrale_einspeisung(&i),
Err(BillingError::InvalidInput { .. })
));
}
#[test]
fn a_period_across_the_netzzugang_turnover_warns() {
let r =
settle_dezentrale_einspeisung(&base(p(date!(2025 - 12 - 15), date!(2026 - 01 - 15))))
.expect("no Abschmelzung step is crossed");
assert!(
r.warnings
.iter()
.any(|w| w.code == "REGIME_TURNOVER_IN_PERIOD"),
"warnings: {:?}",
r.warnings
);
}
#[test]
fn from_2029_nothing_is_payable() {
let r =
settle_dezentrale_einspeisung(&base(p(date!(2029 - 02 - 01), date!(2029 - 02 - 28))))
.expect("settles to zero");
assert!(r.positions.is_empty());
assert_eq!(r.total_eur, Decimal::ZERO);
assert!(r.warnings.iter().any(|w| w.code == "SECT18_ABGESCHMOLZEN"));
}
}