Skip to main content

grid_billing/
sect18.rs

1//! Entgelte für dezentrale Erzeugung — §18 StromNEV, under Abschmelzung.
2//!
3//! §18 StromNEV pays the operator of a decentral generating plant for the
4//! upstream network costs its feed-in avoids. The payment is being phased out by
5//! Festlegung **GBK-25-02-1#1** (Große Beschlusskammer Energie, 17.02.2026):
6//! the underlying vermiedene Kosten are cut in three steps and the payments end
7//! entirely with 2028.
8//!
9//! ## The schedule, from the Tenor
10//!
11//! Tenorziffer 2 Satz 2 cuts the costs "in drei Stufen um a) 50 vom Hundert
12//! beginnend am 01. Juli 2026, b) 50 von Hundert beginnend am 01. Januar 2027
13//! c) und 75 vom Hundert beginnend am 01. Januar 2028"; Satz 3 states the
14//! effect: "Die Kürzungen entsprechen einer jährlichen Abschmelzung von 25 %."
15//!
16//! | Period | Remaining factor | Annual average |
17//! |---|---|---|
18//! | to 30.06.2026 | 1.00 | 2026: 0.75 |
19//! | 01.07.2026 – 31.12.2026 | 0.50 | |
20//! | 2027 | 0.50 | 0.50 |
21//! | 2028 | 0.25 | 0.25 |
22//! | from 2029 | 0.00 | — |
23//!
24//! The two 50 % steps are not cumulative: the 2027 cut restates the same level
25//! the July 2026 cut reached, which is what makes the *annual averages* fall by
26//! 25 percentage points a year.
27//!
28//! ## EEG plants are excluded
29//!
30//! §18 Abs. 1 Satz 4 Nr. 1 StromNEV: a plant funded under the EEG receives no
31//! Entgelt für dezentrale Erzeugung. Settling one is refused as an error, not
32//! warned — the payment would be unlawful, and unlike a ceiling breach there is
33//! no legitimate reading under which it goes out anyway.
34
35use 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
45/// First day of the first cut (Tenorziffer 2 Satz 2 lit. a).
46const STUFE_A: Date = time::macros::date!(2026 - 07 - 01);
47/// First day of the second cut (lit. b) — restates the 50 % level for 2027.
48const STUFE_B: Date = time::macros::date!(2027 - 01 - 01);
49/// First day of the third cut (lit. c).
50const STUFE_C: Date = time::macros::date!(2028 - 01 - 01);
51/// First day with no payment at all.
52const ENDE: Date = time::macros::date!(2029 - 01 - 01);
53
54/// The fraction of the vermiedene Kosten still payable on a given day.
55///
56/// GBK-25-02-1#1 Tenorziffer 2. The factor is a property of the *day the energy
57/// was fed in*, so a settlement over a period that crosses a step must split the
58/// period — which [`settle_dezentrale_einspeisung`] enforces rather than
59/// averaging across the step.
60#[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/// `true` when the factor changes inside the period.
74#[must_use]
75pub fn period_crosses_a_step(period: SettlementPeriod) -> bool {
76    abschmelzfaktor(period.from()) != abschmelzfaktor(period.to())
77}
78
79/// Input for a §18 settlement — the DSO's payment to a decentral generator.
80#[derive(Debug, Clone, serde::Serialize)]
81pub struct DezentraleEinspeisungInput {
82    /// The generating plant's metering location.
83    pub malo_id: String,
84    /// The paying Netzbetreiber.
85    pub nb_mp_id: String,
86    /// The plant operator being paid.
87    pub anlagenbetreiber_mp_id: String,
88    /// The delivery period.
89    pub period: SettlementPeriod,
90    /// Energy fed in during the period, in kWh.
91    pub einspeisung_kwh: Decimal,
92    /// The vermiedene Kosten of the upstream level, in ct/kWh, **before** the
93    /// Abschmelzung — the engine applies the factor for the period.
94    pub vermiedene_kosten_ct_per_kwh: Decimal,
95    /// `true` when the plant is funded under the EEG.
96    ///
97    /// §18 Abs. 1 Satz 4 Nr. 1 StromNEV excludes such plants from the payment
98    /// entirely; settling one is refused.
99    pub ist_eeg_gefoerdert: bool,
100    /// Price-sheet identifier for the trace, where one exists.
101    pub tariff_sheet_id: Option<String>,
102}
103
104/// Settle the §18 payment for one plant and period.
105///
106/// The result is a payment *from* the Netzbetreiber *to* the plant operator, so
107/// the position is negative from the NB's books — consistent with how a
108/// Mehrmengen credit is signed elsewhere in this crate.
109///
110/// # Errors
111///
112/// - [`BillingError::InvalidInput`] for an EEG-funded plant (§18 Abs. 1 S. 4
113///   Nr. 1 — the payment would be unlawful).
114/// - [`BillingError::InvalidInput`] when the period crosses an Abschmelzung
115///   step: the factor differs at its start and its end, so one settlement over
116///   it would pay part of the energy at the wrong level. Split the period at
117///   the step date.
118/// - [`BillingError::InvalidInput`] for negative energy or a negative rate.
119pub 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    // Negative: the NB pays out.
154    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    // Exempt from `ensure_berechenbar` (the AgNeS guard): the §18 payment has
159    // its own, earlier sunset — GBK-25-02-1#1 ends it with 2028, and a 2029+
160    // period settles to zero with the Info below. Nothing is ever priced under
161    // lapsed rules here, so refusing on the Entgelt axis would only turn an
162    // already-explicit "nothing is payable" into an error.
163    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        // The Entgelt für dezentrale Einspeisung is consideration for a service
235        // the Anlagenbetreiber renders to the network (§18 StromNEV), settled by
236        // Gutschrift. It is not a supply of energy, so §13b never reaches it.
237        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    /// The Tenor's schedule, day by day at each boundary.
275    #[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    /// The annual averages fall by 25 points a year — the Tenor's own
286    /// cross-check ("Die Kürzungen entsprechen einer jährlichen Abschmelzung
287    /// von 25 %").
288    #[test]
289    fn the_annual_averages_fall_by_a_quarter() {
290        // 2026: half the year at 1.00, half at 0.50.
291        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    /// A pre-cut month pays the full rate; a 2028 month a quarter of it.
299    #[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        // 10 000 kWh × 0.006 EUR = 60 EUR, paid out → negative.
305        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    /// June–July 2026 crosses the first step and must be split, not averaged.
318    #[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    /// §18 Abs. 1 Satz 4 Nr. 1: an EEG plant gets nothing, and settling one is
326    /// an error rather than a zero — the payment would be unlawful.
327    #[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    /// A period across the 2025/2026 Netzzugang turnover crosses no
338    /// Abschmelzung step, so it settles — but it carries the same
339    /// REGIME_TURNOVER_IN_PERIOD warning every other builder emits.
340    #[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    /// From 2029 nothing is payable: no position, an Info saying why.
355    #[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}