1use rust_decimal::Decimal;
36use rust_decimal::dec;
37use time::Date;
38
39use crate::error::BillingError;
40use crate::types::{
41 BillingPositionKind, CalculationTrace, LegalReference, SettlementPeriod, SettlementPosition,
42 SettlementResult, SettlementStatus, SettlementType, Sparte, TariffSource,
43};
44
45const STUFE_A: Date = time::macros::date!(2026 - 07 - 01);
47const STUFE_B: Date = time::macros::date!(2027 - 01 - 01);
49const STUFE_C: Date = time::macros::date!(2028 - 01 - 01);
51const ENDE: Date = time::macros::date!(2029 - 01 - 01);
53
54#[must_use]
61pub fn abschmelzfaktor(tag: Date) -> Decimal {
62 if tag >= ENDE {
63 Decimal::ZERO
64 } else if tag >= STUFE_C {
65 dec!(0.25)
66 } else if tag >= STUFE_B || tag >= STUFE_A {
67 dec!(0.50)
68 } else {
69 Decimal::ONE
70 }
71}
72
73#[must_use]
75pub fn period_crosses_a_step(period: SettlementPeriod) -> bool {
76 abschmelzfaktor(period.from()) != abschmelzfaktor(period.to())
77}
78
79#[derive(Debug, Clone, serde::Serialize)]
81pub struct DezentraleEinspeisungInput {
82 pub malo_id: String,
84 pub nb_mp_id: String,
86 pub anlagenbetreiber_mp_id: String,
88 pub period: SettlementPeriod,
90 pub einspeisung_kwh: Decimal,
92 pub vermiedene_kosten_ct_per_kwh: Decimal,
95 pub ist_eeg_gefoerdert: bool,
100 pub tariff_sheet_id: Option<String>,
102}
103
104pub fn settle_dezentrale_einspeisung(
120 input: &DezentraleEinspeisungInput,
121) -> Result<SettlementResult, BillingError> {
122 if input.ist_eeg_gefoerdert {
123 return Err(BillingError::InvalidInput {
124 reason: "an EEG-funded plant receives no Entgelt für dezentrale Erzeugung \
125 (§18 Abs. 1 Satz 4 Nr. 1 StromNEV)"
126 .to_owned(),
127 });
128 }
129 if input.einspeisung_kwh < Decimal::ZERO {
130 return Err(BillingError::InvalidInput {
131 reason: "einspeisung_kwh must be non-negative".to_owned(),
132 });
133 }
134 if input.vermiedene_kosten_ct_per_kwh < Decimal::ZERO {
135 return Err(BillingError::InvalidInput {
136 reason: "vermiedene_kosten_ct_per_kwh must be non-negative".to_owned(),
137 });
138 }
139 if period_crosses_a_step(input.period) {
140 return Err(BillingError::InvalidInput {
141 reason: format!(
142 "the period {} – {} crosses a GBK-25-02-1#1 Abschmelzung step; \
143 split it at the step date so each part is paid at its factor",
144 input.period.from(),
145 input.period.to()
146 ),
147 });
148 }
149
150 let faktor = abschmelzfaktor(input.period.from());
151 let base_eur = input.vermiedene_kosten_ct_per_kwh / dec!(100);
152 let reduced_eur = (base_eur * faktor).round_dp(6);
153 let net_eur = -(input.einspeisung_kwh * reduced_eur).round_dp(5);
155
156 let mut positions = Vec::new();
157 let mut warnings = Vec::new();
158 crate::billing::warn_if_straddles_turnover(
164 input.period.from(),
165 input.period.to(),
166 &mut warnings,
167 );
168 if faktor.is_zero() {
169 warnings.push(crate::types::SettlementWarning {
170 severity: crate::types::WarningSeverity::Info,
171 code: "SECT18_ABGESCHMOLZEN",
172 message: "the Entgelt für dezentrale Erzeugung is fully phased out for this \
173 period (GBK-25-02-1#1); nothing is payable"
174 .to_owned(),
175 });
176 } else {
177 positions.push(SettlementPosition {
178 text: format!(
179 "Entgelt für dezentrale Erzeugung ({} % nach Abschmelzung)",
180 (faktor * dec!(100)).normalize()
181 ),
182 kind: BillingPositionKind::DezentraleEinspeisung,
183 quantity: input.einspeisung_kwh.round_dp(3),
184 unit: crate::types::QuantityUnit::Kwh,
185 unit_price_eur: reduced_eur,
186 net_eur,
187 spot_price_formula: None,
188 trace: CalculationTrace {
189 explanation: format!(
190 "{:.3} kWh × {:.6} EUR/kWh (= {:.6} × {faktor} Abschmelzung) = {:.5} EUR \
191 payable to the plant operator",
192 input.einspeisung_kwh,
193 reduced_eur,
194 base_eur,
195 net_eur.abs()
196 ),
197 input_quantity: input.einspeisung_kwh,
198 input_unit_price_eur: reduced_eur,
199 gross_eur: net_eur,
200 legal_refs: vec![
201 LegalReference::StromNev { paragraph: "§18" },
202 LegalReference::BnetzaDecision {
203 reference: "GBK-25-02-1#1",
204 },
205 ],
206 tariff_source: input
207 .tariff_sheet_id
208 .clone()
209 .map(|sheet_id| TariffSource::PublishedTariffSheet { sheet_id }),
210 regulatory_reduction_factor: Some(faktor),
211 rounding_note: Some("unit price to 6 dp; net to 5 dp"),
212 },
213 });
214 }
215
216 Ok(SettlementResult {
217 malo_id: input.malo_id.clone(),
218 sparte: Sparte::Strom,
219 regime: crate::regulatory::RegulatoryRegime::for_period(
220 input.period.from(),
221 input.period.to(),
222 ),
223 settlement_type: SettlementType::DezentraleEinspeisung,
224 status: SettlementStatus::Initial,
225 korrektur_grund: None,
226 period: input.period,
227 sender_mp_id: input.nb_mp_id.clone(),
228 recipient_mp_id: input.anlagenbetreiber_mp_id.clone(),
229 total_eur: positions
230 .iter()
231 .map(|p| p.net_eur)
232 .sum::<Decimal>()
233 .round_dp(2),
234 steuer: crate::umsatzsteuer::steuerausweis(
238 positions
239 .iter()
240 .map(|p| p.net_eur)
241 .sum::<Decimal>()
242 .round_dp(2),
243 crate::umsatzsteuer::Leistungsart::SonstigeLeistung,
244 crate::umsatzsteuer::Wiederverkaeuferstatus::KEINER,
245 input.period,
246 )?,
247 positions,
248 warnings,
249 })
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255 use time::macros::date;
256
257 fn base(period: SettlementPeriod) -> DezentraleEinspeisungInput {
258 DezentraleEinspeisungInput {
259 malo_id: "51238696012".to_owned(),
260 nb_mp_id: "9900357000004".to_owned(),
261 anlagenbetreiber_mp_id: "9900012345678".to_owned(),
262 period,
263 einspeisung_kwh: dec!(10_000),
264 vermiedene_kosten_ct_per_kwh: dec!(0.60),
265 ist_eeg_gefoerdert: false,
266 tariff_sheet_id: None,
267 }
268 }
269
270 fn p(from: Date, to: Date) -> SettlementPeriod {
271 SettlementPeriod::new(from, to).expect("valid period")
272 }
273
274 #[test]
276 fn the_tenor_schedule() {
277 assert_eq!(abschmelzfaktor(date!(2026 - 06 - 30)), Decimal::ONE);
278 assert_eq!(abschmelzfaktor(date!(2026 - 07 - 01)), dec!(0.50));
279 assert_eq!(abschmelzfaktor(date!(2027 - 06 - 15)), dec!(0.50));
280 assert_eq!(abschmelzfaktor(date!(2028 - 01 - 01)), dec!(0.25));
281 assert_eq!(abschmelzfaktor(date!(2028 - 12 - 31)), dec!(0.25));
282 assert_eq!(abschmelzfaktor(date!(2029 - 01 - 01)), Decimal::ZERO);
283 }
284
285 #[test]
289 fn the_annual_averages_fall_by_a_quarter() {
290 let h1 = abschmelzfaktor(date!(2026 - 03 - 01));
292 let h2 = abschmelzfaktor(date!(2026 - 09 - 01));
293 assert_eq!((h1 + h2) / dec!(2), dec!(0.75));
294 assert_eq!(abschmelzfaktor(date!(2027 - 07 - 01)), dec!(0.50));
295 assert_eq!(abschmelzfaktor(date!(2028 - 07 - 01)), dec!(0.25));
296 }
297
298 #[test]
300 fn the_factor_reaches_the_payment() {
301 let full =
302 settle_dezentrale_einspeisung(&base(p(date!(2026 - 01 - 01), date!(2026 - 01 - 31))))
303 .expect("settles");
304 assert_eq!(full.total_eur, dec!(-60.00));
306
307 let quarter =
308 settle_dezentrale_einspeisung(&base(p(date!(2028 - 03 - 01), date!(2028 - 03 - 31))))
309 .expect("settles");
310 assert_eq!(quarter.total_eur, dec!(-15.00));
311 assert_eq!(
312 quarter.positions[0].trace.regulatory_reduction_factor,
313 Some(dec!(0.25))
314 );
315 }
316
317 #[test]
319 fn a_period_across_a_step_is_refused() {
320 let r =
321 settle_dezentrale_einspeisung(&base(p(date!(2026 - 06 - 15), date!(2026 - 07 - 15))));
322 assert!(matches!(r, Err(BillingError::InvalidInput { .. })));
323 }
324
325 #[test]
328 fn an_eeg_plant_is_refused() {
329 let mut i = base(p(date!(2026 - 01 - 01), date!(2026 - 01 - 31)));
330 i.ist_eeg_gefoerdert = true;
331 assert!(matches!(
332 settle_dezentrale_einspeisung(&i),
333 Err(BillingError::InvalidInput { .. })
334 ));
335 }
336
337 #[test]
341 fn a_period_across_the_netzzugang_turnover_warns() {
342 let r =
343 settle_dezentrale_einspeisung(&base(p(date!(2025 - 12 - 15), date!(2026 - 01 - 15))))
344 .expect("no Abschmelzung step is crossed");
345 assert!(
346 r.warnings
347 .iter()
348 .any(|w| w.code == "REGIME_TURNOVER_IN_PERIOD"),
349 "warnings: {:?}",
350 r.warnings
351 );
352 }
353
354 #[test]
356 fn from_2029_nothing_is_payable() {
357 let r =
358 settle_dezentrale_einspeisung(&base(p(date!(2029 - 02 - 01), date!(2029 - 02 - 28))))
359 .expect("settles to zero");
360 assert!(r.positions.is_empty());
361 assert_eq!(r.total_eur, Decimal::ZERO);
362 assert!(r.warnings.iter().any(|w| w.code == "SECT18_ABGESCHMOLZEN"));
363 }
364}