Skip to main content

grid_billing/
billing.rs

1//! NNE, MMM, and MSB settlement calculation logic.
2//!
3//! Amounts are computed in `rust_decimal::Decimal`; every EUR result is
4//! range-checked through [`crate::EuroAmount`] for exact
5//! representation.  Functions return [`SettlementResult`] — a pure domain type
6//! with no BO4E coupling.  The service layer (netzbilanzd / invoicd) converts
7//! `SettlementResult` to `rubo4e::current::Rechnung` via a local `into_rechnung()`
8//! helper, keeping BO4E as a service-layer concern.
9//!
10//! ## Explainability
11//!
12//! Every position carries a [`CalculationTrace`] that answers *"why is this
13//! amount here?"* with:
14//! - input values (quantity, unit price before rounding)
15//! - gross intermediate result
16//! - applicable [`LegalReference`]s (e.g. `StromNEV §17`, `KAV §2`)
17//! - the [`TariffSource`] used
18//! - any regulatory reduction factor
19//!
20//! This enables AI-assisted invoice explainability and regulator audits without
21//! re-running the calculation.
22
23use crate::EuroAmount;
24use rust_decimal::Decimal;
25
26use crate::error::BillingError;
27use crate::types::{
28    AbschlagInput, ArbeitspreisModell, BillingPositionKind, CalculationTrace, GasAwhInput,
29    KaKundengruppe, KorrekturGrund, LegalReference, MmmInput, MsbInput, NneInput, PriceReference,
30    PriceStep, QuantityUnit, Sect14aModule, SettlementPosition, SettlementResult, SettlementStatus,
31    SettlementType, SettlementWarning, Sparte, SpotPriceFormula, SpotpreisInterval,
32    TariffCalculationMethod, TariffSource, WarningSeverity,
33};
34
35// ── helpers ───────────────────────────────────────────────────────────────────
36
37const HUNDRED: Decimal = Decimal::from_parts(100, 0, 0, false, 0);
38
39fn ct_to_eur(ct: Decimal) -> Decimal {
40    ct / HUNDRED
41}
42
43fn pos_net(qty: Decimal, unit_price_eur: Decimal) -> Decimal {
44    (qty * unit_price_eur).round_dp(5)
45}
46
47fn kwh_pos_traced(
48    text: &str,
49    kind: BillingPositionKind,
50    kwh: Decimal,
51    unit_price_eur: Decimal,
52    legal_refs: Vec<LegalReference>,
53    tariff_source: Option<TariffSource>,
54) -> SettlementPosition {
55    let gross_eur = kwh * unit_price_eur;
56    SettlementPosition {
57        text: text.to_owned(),
58        kind,
59        quantity: kwh.round_dp(3),
60        unit: QuantityUnit::Kwh,
61        unit_price_eur: unit_price_eur.round_dp(6),
62        net_eur: pos_net(kwh, unit_price_eur),
63        spot_price_formula: None,
64
65        trace: CalculationTrace {
66            explanation: format!(
67                "{kwh:.3} kWh × {:.6} EUR/kWh = {:.5} EUR",
68                unit_price_eur,
69                gross_eur.round_dp(5)
70            ),
71            input_quantity: kwh,
72            input_unit_price_eur: unit_price_eur,
73            gross_eur,
74            legal_refs,
75            tariff_source,
76            regulatory_reduction_factor: None,
77            rounding_note: Some("quantity rounded to 3 dp; unit price to 6 dp; net to 5 dp"),
78        },
79    }
80}
81
82fn kw_pos_traced(
83    text: &str,
84    kind: BillingPositionKind,
85    kw: Decimal,
86    unit_price_eur: Decimal,
87    legal_refs: Vec<LegalReference>,
88    tariff_source: Option<TariffSource>,
89) -> SettlementPosition {
90    let gross_eur = kw * unit_price_eur;
91    SettlementPosition {
92        text: text.to_owned(),
93        kind,
94        quantity: kw.round_dp(3),
95        unit: QuantityUnit::Kw,
96        unit_price_eur: unit_price_eur.round_dp(6),
97        net_eur: pos_net(kw, unit_price_eur),
98        spot_price_formula: None,
99
100        trace: CalculationTrace {
101            explanation: format!(
102                "{kw:.3} kW × {:.6} EUR/kW = {:.5} EUR",
103                unit_price_eur,
104                gross_eur.round_dp(5)
105            ),
106            input_quantity: kw,
107            input_unit_price_eur: unit_price_eur,
108            gross_eur,
109            legal_refs,
110            tariff_source,
111            regulatory_reduction_factor: None,
112            rounding_note: Some("quantity rounded to 3 dp; unit price to 6 dp; net to 5 dp"),
113        },
114    }
115}
116
117fn monat_pos_traced(
118    text: &str,
119    kind: BillingPositionKind,
120    months: Decimal,
121    unit_price_eur: Decimal,
122    legal_refs: Vec<LegalReference>,
123    tariff_source: Option<TariffSource>,
124) -> SettlementPosition {
125    let gross_eur = months * unit_price_eur;
126    SettlementPosition {
127        text: text.to_owned(),
128        kind,
129        quantity: months.round_dp(3),
130        unit: QuantityUnit::Monat,
131        unit_price_eur: unit_price_eur.round_dp(6),
132        net_eur: pos_net(months, unit_price_eur),
133        spot_price_formula: None,
134
135        trace: CalculationTrace {
136            explanation: format!(
137                "{months} Monate × {:.6} EUR/Monat = {:.5} EUR",
138                unit_price_eur,
139                gross_eur.round_dp(5)
140            ),
141            input_quantity: months,
142            input_unit_price_eur: unit_price_eur,
143            gross_eur,
144            legal_refs,
145            tariff_source,
146            regulatory_reduction_factor: None,
147            rounding_note: Some("quantity rounded to 3 dp; unit price to 6 dp; net to 5 dp"),
148        },
149    }
150}
151
152fn decimal_to_euro_amount(d: Decimal) -> Result<EuroAmount, BillingError> {
153    EuroAmount::checked_from_decimal(d).map_err(|_| BillingError::MonetaryOverflow {
154        input_value: Some(d),
155    })
156}
157
158/// Reject an invoice total that cannot be represented as a [`EuroAmount`].
159///
160/// The converted value is deliberately discarded — the call is a range check run
161/// before returning a document, so that a total which would overflow is refused
162/// here rather than truncated by a downstream consumer.
163fn ensure_representable_eur(d: Decimal) -> Result<(), BillingError> {
164    decimal_to_euro_amount(d).map(|_| ())
165}
166
167fn make_tariff_source(sheet_id: Option<&str>) -> Option<TariffSource> {
168    sheet_id.map(|id| TariffSource::PublishedTariffSheet {
169        sheet_id: id.to_owned(),
170    })
171}
172
173/// Push the `REGIME_TURNOVER_IN_PERIOD` warning when the delivery period
174/// crosses a regulatory turnover (see [`crate::regulatory`]).
175///
176/// Such a period is governed by different rules at its start and its end, so a
177/// single settlement over it applies the wrong rules to part of the supply.
178/// Every settlement builder emits this the same way, so the caller learns to
179/// split the period regardless of which document type it asked for.
180pub(crate) fn warn_if_straddles_turnover(
181    period_from: time::Date,
182    period_to: time::Date,
183    warnings: &mut Vec<SettlementWarning>,
184) {
185    if crate::regulatory::RegulatoryRegime::straddles_turnover(period_from, period_to) {
186        warnings.push(SettlementWarning {
187            severity: WarningSeverity::Warning,
188            code: "REGIME_TURNOVER_IN_PERIOD",
189            message: "the delivery period crosses a regulatory turnover; different \
190                      rules govern its start and its end — split the period"
191                .to_owned(),
192        });
193    }
194}
195
196// ── NNE invoice (PID 31002 — NN-Rechnung, Strom + Gas) ───────────────────────
197
198/// Calculate a NNE settlement (PID 31002 NN-Rechnung, Strom and Gas).
199///
200/// Returns a [`SettlementResult`] with full [`CalculationTrace`] per position
201/// and applicable [`LegalReference`]s. The service layer converts this to
202/// BO4E `Rechnung` and validates via `invoic-checker`.
203///
204/// ## Positions (in order)
205///
206/// | # | Description | Condition |
207/// |---|---|---|
208/// | 1 | Gas Grundpreis (Verrechnungspreis) | when `nne_grundpreis_eur_per_month` set (Gas only) |
209/// | next | Netznutzung Arbeit (§14a Modul 1 reduced) | Modul 1 flat reduction mode |
210/// | next | Netznutzung Arbeit HT + ST + NT (§14a Modul 3) | zeitvariables Netzentgelt (BK6-22-300) |
211/// | next | Netznutzung Arbeit (§14a Modul 2, reduzierter Arbeitspreis) | prozentuale Reduzierung |
212/// | next | Netznutzung Arbeit je Dispatch-Intervall (§14a Modul 3 Spot) | spot-priced mode |
213/// | next | Netznutzung Arbeit | flat mode (no §14a) |
214/// | next | Netznutzung Leistung (StromNEV §17) | RLM only |
215/// | last | Konzessionsabgabe (KAV §2) | when `ka_satz_ct_per_kwh` set |
216///
217/// ## Legal references
218///
219/// - Gas Grundpreis position → `GasNEV §14`
220/// - Arbeit positions → `StromNEV §21` (or `GasNEV §14` for Gas)
221/// - §14a Modul 1 positions → `Sect14aEnwg { module: Modul1 }` + `BNetzA BK6-22-300`
222/// - §14a Modul 2 position → `Sect14aEnwg { module: Modul2 }` + `BNetzA BK6-22-300`
223/// - §14a Modul 3 positions (HT/ST/NT and Spot) → `Sect14aEnwg { module: Modul3 }` + `BNetzA BK6-22-300`
224/// - Leistung position → `StromNEV §17`
225/// - Konzessionsabgabe → `KAV §2 Abs. 2`
226///
227/// ## §14a Modul 1 — pauschale Reduzierung
228///
229/// Select `ArbeitspreisModell::Modul1Pauschal` with the NB's published annual
230/// amount and the fraction of a year the period covers: the energy is billed at
231/// the full Arbeitspreis and the pauschale is credited pro rata alongside it.
232///
233/// **Known limitation.** BK6-22-300 permits Modul 1 alongside Modul 3, but
234/// `ArbeitspreisModell` holds one model at a time, so that combination is not
235/// yet representable. Modul 2 with Modul 3 is genuinely forbidden and stays
236/// unrepresentable by design — see [`Sect14aModule::combinable_with`].
237///
238/// ## Errors
239///
240/// [`BillingError::InvalidInput`], [`BillingError::MonetaryOverflow`], or
241/// [`BillingError::UnsupportedEntgeltRegime`] for a period governed by AgNeS
242/// (from 01.01.2029), whose methodology is not yet festgelegt.
243#[must_use = "handle the BillingError"]
244pub fn settle_nne(input: &NneInput) -> Result<SettlementResult, BillingError> {
245    // The period is ordered by construction, the Leistungspreis is paired by
246    // construction, and the §14a modules are exclusive by construction — none of
247    // those needs a runtime guard.
248    //
249    // What remains is what the types cannot express. It runs here rather than in
250    // a validator the caller may skip: these are the errors that otherwise
251    // produce a plausible-looking invoice billed on the wrong basis.
252    if input.arbeitspreis.menge_kwh() < Decimal::ZERO {
253        return Err(BillingError::InvalidInput {
254            reason: "metered energy must be non-negative".to_owned(),
255        });
256    }
257    if let ArbeitspreisModell::SpotpreisNetzentgelt { intervalle } = &input.arbeitspreis {
258        if intervalle.is_empty() {
259            return Err(BillingError::InvalidInput {
260                reason: "§14a Modul 3 requires at least one dispatch interval".to_owned(),
261            });
262        }
263        for (i, iv) in intervalle.iter().enumerate() {
264            if iv.period_from >= iv.period_to {
265                return Err(BillingError::InvalidInput {
266                    reason: format!("Modul 3 interval {i}: start is not before end"),
267                });
268            }
269            if iv.menge_kwh < Decimal::ZERO {
270                return Err(BillingError::InvalidInput {
271                    reason: format!("Modul 3 interval {i}: metered energy is negative"),
272                });
273            }
274        }
275    }
276    if let Some(lp) = input.leistungspreis
277        && lp.spitzenleistung_kw < Decimal::ZERO
278    {
279        return Err(BillingError::InvalidInput {
280            reason: "Spitzenleistung must be non-negative".to_owned(),
281        });
282    }
283    if let Some(gp) = input.grundpreis
284        && gp.months < Decimal::ZERO
285    {
286        return Err(BillingError::InvalidInput {
287            reason: "Grundpreis months must be non-negative".to_owned(),
288        });
289    }
290
291    // Resolved once from the period and recorded on the result. NNE positions
292    // are priced on the Entgelt axis (StromNEV §§17/21, GasNEV §§14–15, the
293    // §19 individual forms), which AgNeS replaces from 2029 — so a period the
294    // Verordnung methodology no longer governs is refused here rather than
295    // computed with lapsed math and merely tagged.
296    let regime =
297        crate::regulatory::RegulatoryRegime::for_period(input.period.from(), input.period.to());
298    regime.ensure_berechenbar()?;
299
300    let tariff_src = make_tariff_source(input.tariff_sheet_id.as_deref());
301    let mut positions: Vec<SettlementPosition> = Vec::new();
302    let mut total = Decimal::ZERO;
303    let mut warnings: Vec<SettlementWarning> = Vec::new();
304    warn_if_straddles_turnover(input.period.from(), input.period.to(), &mut warnings);
305
306    // Sparte determines settlement type and Arbeit legal reference
307    let (settlement_type, arbeit_ref) = match input.sparte {
308        Sparte::Gas => (
309            SettlementType::NneGas,
310            LegalReference::GasNev { paragraph: "§14" },
311        ),
312        Sparte::Strom => (
313            SettlementType::NneStrom,
314            LegalReference::StromNev { paragraph: "§21" },
315        ),
316    };
317
318    // Gas Grundpreis / Verrechnungspreis (Gas NNE monthly standing charge per GasNEV).
319    //
320    // Sparte-guarded like the Kapazitätsentgelt below: the position kind, its
321    // label and its GasNEV §14 citation are all gas-specific, so billing it on a
322    // Strom settlement would put "Netzentgelt Grundpreis Gas" and a gas ordinance
323    // on an electricity invoice.
324    if let Some(gp) = input.grundpreis
325        && gp.months > Decimal::ZERO
326    {
327        if input.sparte != Sparte::Gas {
328            warnings.push(SettlementWarning {
329                severity: WarningSeverity::Warning,
330                code: "GRUNDPREIS_ON_STROM",
331                message: "a Grundpreis was supplied on a Strom settlement — the Gas \
332                          Verrechnungspreis position of §14 GasNEV does not apply to Strom"
333                    .to_owned(),
334            });
335        } else {
336            let months = gp.months;
337            let p = monat_pos_traced(
338                "Netzentgelt Grundpreis Gas (Verrechnungspreis)",
339                BillingPositionKind::NneGasGrundpreis,
340                months,
341                gp.eur_per_month,
342                vec![LegalReference::GasNev { paragraph: "§14" }],
343                tariff_src.clone(),
344            );
345            total += p.net_eur;
346            positions.push(p);
347        }
348    }
349
350    // §17 StromNEV context, recorded rather than applied: the Netzebene a rate
351    // was published for, and the utilisation the price sheet should have been
352    // read at. Neither selects a rate here — the caller supplies rates — but an
353    // auditor cannot check that the right rate was used without them.
354    if let (Some(arbeit), Some(peak)) = (input.jahresarbeit_kwh, input.jahreshoechstleistung_kw)
355        && let Some(bh) = crate::netzebene::benutzungsstundenzahl(arbeit, peak)
356    {
357        warnings.push(SettlementWarning {
358            severity: WarningSeverity::Info,
359            code: "BENUTZUNGSSTUNDENZAHL",
360            message: format!(
361                "{bh} h/a ({arbeit} kWh / {peak} kW){}",
362                input
363                    .netzebene
364                    .map(|e| format!(" in {}", e.label()))
365                    .unwrap_or_default()
366            ),
367        });
368    }
369    // §17 Abs. 6 permits an Arbeitspreis-only tariff only in the
370    // Niederspannungsnetz at or below 100 000 kWh a year. Billing without a
371    // Leistungspreis outside that is a tariff-structure error, not a rounding one.
372    if input.leistungspreis.is_none()
373        && let (Some(ebene), Some(arbeit)) = (input.netzebene, input.jahresarbeit_kwh)
374        && !crate::netzebene::arbeitspreis_nur_zulaessig(ebene, arbeit)
375    {
376        warnings.push(SettlementWarning {
377            severity: WarningSeverity::Warning,
378            code: "ARBEITSPREIS_ONLY_OUTSIDE_SECT17_ABS6",
379            message: format!(
380                "billed on an Arbeitspreis alone at {} with {arbeit} kWh/a — §17 Abs. 6 \
381                 StromNEV allows this only in Niederspannung up to 100 000 kWh/a",
382                ebene.label()
383            ),
384        });
385    }
386
387    // Gas Kapazitätsentgelt (§15 GasNEV). The rate is annual, the settlement
388    // is not — so it is pro-rated by calendar days, and the trace says so.
389    if let Some(kap) = input.gas_kapazitaet {
390        if input.sparte != Sparte::Gas {
391            warnings.push(SettlementWarning {
392                severity: WarningSeverity::Warning,
393                code: "GAS_KAPAZITAET_ON_STROM",
394                message: "a gas capacity charge was supplied on a Strom settlement — \
395                          §15 GasNEV does not apply to Strom"
396                    .to_owned(),
397            });
398        } else {
399            // The divisor is the actual length of the settlement year, not a
400            // flat 365. §15 GasNEV fixes no day-count convention, so the only
401            // defensible reading of an *annual* Entgelt is that a full year of
402            // capacity costs exactly that Entgelt — a fixed 365 would bill a leap
403            // year at 366/365 = 100.274 % of the price sheet's annual figure.
404            let jahrestage = Decimal::from(time::util::days_in_year(input.period.from().year()));
405            let tage = Decimal::from(input.period.days());
406            let anteil = tage / jahrestage;
407            let price_eur = (kap.entgelt_eur_per_kwh_h_a * anteil).round_dp(6);
408            let net_eur = (kap.bestellte_kapazitaet_kwh_h * price_eur).round_dp(5);
409            let stufe = kap
410                .druckstufe
411                .map(|d| format!(", {}", d.label()))
412                .unwrap_or_default();
413            let p = SettlementPosition {
414                text: format!("Kapazitätsentgelt Gas ({}{stufe})", kap.produkt.label()),
415                kind: BillingPositionKind::GasKapazitaetsentgelt,
416                quantity: kap.bestellte_kapazitaet_kwh_h.round_dp(3),
417                unit: QuantityUnit::Kw,
418                unit_price_eur: price_eur,
419                net_eur,
420                spot_price_formula: None,
421                trace: CalculationTrace {
422                    explanation: format!(
423                        "{:.3} kWh/h × {:.6} EUR (= {:.6} EUR/a × {tage}/{jahrestage} days) \
424                         = {:.5} EUR ({}{stufe})",
425                        kap.bestellte_kapazitaet_kwh_h,
426                        price_eur,
427                        kap.entgelt_eur_per_kwh_h_a,
428                        net_eur,
429                        kap.produkt.label(),
430                    ),
431                    input_quantity: kap.bestellte_kapazitaet_kwh_h,
432                    input_unit_price_eur: price_eur,
433                    gross_eur: kap.bestellte_kapazitaet_kwh_h * price_eur,
434                    legal_refs: vec![match kap.produkt {
435                        crate::gas::Kapazitaetsprodukt::Fest => {
436                            LegalReference::GasNev { paragraph: "§15" }
437                        }
438                        crate::gas::Kapazitaetsprodukt::Unterbrechbar => LegalReference::GasNev {
439                            paragraph: "§15 Abs. 5",
440                        },
441                    }],
442                    tariff_source: tariff_src.clone(),
443                    regulatory_reduction_factor: None,
444                    rounding_note: Some(
445                        "annual rate pro-rated by calendar days over the actual year length \
446                         (365 or 366); unit price to 6 dp; net to 5 dp",
447                    ),
448                },
449            };
450            total += p.net_eur;
451            positions.push(p);
452        }
453    }
454
455    // The Arbeitspreis model decides what is billed; the four shapes are
456    // mutually exclusive by construction, so there is no precedence to get wrong
457    // and no partial state to fall through.
458    match &input.arbeitspreis {
459        ArbeitspreisModell::Modul3ZeitVariabel { ht, st, nt } => {
460            for (label, kind, mp) in [
461                (
462                    "Netznutzung Arbeit HT (§14a Modul 3)",
463                    BillingPositionKind::NneArbeitHt,
464                    ht,
465                ),
466                (
467                    "Netznutzung Arbeit ST (§14a Modul 3)",
468                    BillingPositionKind::NneArbeitSt,
469                    st,
470                ),
471                (
472                    "Netznutzung Arbeit NT (§14a Modul 3)",
473                    BillingPositionKind::NneArbeitNt,
474                    nt,
475                ),
476            ] {
477                let p = kwh_pos_traced(
478                    label,
479                    kind,
480                    mp.menge_kwh,
481                    ct_to_eur(mp.preis_ct_per_kwh),
482                    vec![
483                        arbeit_ref.clone(),
484                        LegalReference::Sect14aEnwg {
485                            module: Sect14aModule::Modul3,
486                        },
487                        LegalReference::BnetzaDecision {
488                            reference: "BK6-22-300",
489                        },
490                    ],
491                    tariff_src.clone(),
492                );
493                total += p.net_eur;
494                positions.push(p);
495            }
496        }
497
498        ArbeitspreisModell::Modul1Pauschal {
499            basis,
500            pauschale_eur_pro_jahr,
501            jahresanteil,
502        } => {
503            // Two positions, because Modul 1 is not a rate change: the energy is
504            // billed at the published Arbeitspreis in full, and the pauschale is
505            // credited alongside it. Folding it into the rate would make the
506            // credit scale with consumption, which is precisely what "pauschal"
507            // excludes — and is Modul 2's mechanism, not this one's.
508            let arbeit_eur = ct_to_eur(basis.preis_ct_per_kwh);
509            let p = kwh_pos_traced(
510                "Netznutzung Arbeit (§14a Modul 1)",
511                BillingPositionKind::NneArbeitModul1,
512                basis.menge_kwh,
513                arbeit_eur,
514                vec![
515                    arbeit_ref.clone(),
516                    LegalReference::Sect14aEnwg {
517                        module: Sect14aModule::Modul1,
518                    },
519                    LegalReference::BnetzaDecision {
520                        reference: "BK6-22-300",
521                    },
522                ],
523                tariff_src.clone(),
524            );
525            total += p.net_eur;
526            positions.push(p);
527
528            let credit_eur = -(*pauschale_eur_pro_jahr * *jahresanteil).round_dp(6);
529            let c = SettlementPosition {
530                text: "§14a Modul 1 pauschale Reduzierung".to_owned(),
531                kind: BillingPositionKind::NneArbeitModul1,
532                quantity: jahresanteil.round_dp(6),
533                unit: QuantityUnit::Monat,
534                unit_price_eur: credit_eur,
535                net_eur: credit_eur.round_dp(5),
536                spot_price_formula: None,
537                trace: CalculationTrace {
538                    explanation: format!(
539                        "{pauschale_eur_pro_jahr:.2} EUR/Jahr × {jahresanteil:.6} \
540                         Jahresanteil = {credit_eur:.5} EUR (Gutschrift)"
541                    ),
542                    input_quantity: *jahresanteil,
543                    input_unit_price_eur: credit_eur,
544                    gross_eur: credit_eur,
545                    legal_refs: vec![
546                        LegalReference::Sect14aEnwg {
547                            module: Sect14aModule::Modul1,
548                        },
549                        LegalReference::BnetzaDecision {
550                            reference: "BK6-22-300",
551                        },
552                    ],
553                    tariff_source: tariff_src.clone(),
554                    regulatory_reduction_factor: None,
555                    rounding_note: Some("annual pauschale pro-rated; net to 5 dp"),
556                },
557            };
558            total += c.net_eur;
559            positions.push(c);
560        }
561
562        // §14a Modul 2 — the device's own Arbeitspreis, reduced by a percentage.
563        // Unlike Modul 1's flat credit, this one scales with consumption, and it
564        // attaches to the controllable device's *separately metered* energy —
565        // which is why Modul 2 requires that metering and Modul 1 does not.
566        ArbeitspreisModell::Modul2ProzentualeReduzierung { basis, reduktion } => {
567            let base_eur = ct_to_eur(basis.preis_ct_per_kwh);
568            let factor = reduktion.get();
569            let reduced_eur = (base_eur * factor).round_dp(6);
570            let gross = basis.menge_kwh * reduced_eur;
571            let p = SettlementPosition {
572                text: format!(
573                    "Netznutzung Arbeit §14a Modul 2 ({:.0}% Reduzierung)",
574                    (Decimal::ONE - factor) * HUNDRED
575                ),
576                kind: BillingPositionKind::NneArbeitModul2,
577                quantity: basis.menge_kwh.round_dp(3),
578                unit: QuantityUnit::Kwh,
579                unit_price_eur: reduced_eur,
580                net_eur: pos_net(basis.menge_kwh, reduced_eur),
581                spot_price_formula: None,
582                trace: CalculationTrace {
583                    explanation: format!(
584                        "{:.3} kWh × {:.6} EUR/kWh (= {:.6} × {factor} Modul 2) = {:.5} EUR",
585                        basis.menge_kwh,
586                        reduced_eur,
587                        base_eur,
588                        gross.round_dp(5)
589                    ),
590                    input_quantity: basis.menge_kwh,
591                    input_unit_price_eur: reduced_eur,
592                    gross_eur: gross,
593                    legal_refs: vec![
594                        arbeit_ref.clone(),
595                        LegalReference::Sect14aEnwg {
596                            module: Sect14aModule::Modul2,
597                        },
598                        LegalReference::BnetzaDecision {
599                            reference: "BK6-22-300",
600                        },
601                    ],
602                    tariff_source: tariff_src.clone(),
603                    regulatory_reduction_factor: Some(factor),
604                    rounding_note: Some(
605                        "quantity rounded to 3 dp; unit price to 6 dp; net to 5 dp",
606                    ),
607                },
608            };
609            total += p.net_eur;
610            positions.push(p);
611        }
612
613        ArbeitspreisModell::Einheitlich(mp) => {
614            let p = kwh_pos_traced(
615                "Netznutzung Arbeit",
616                BillingPositionKind::NneArbeit,
617                mp.menge_kwh,
618                ct_to_eur(mp.preis_ct_per_kwh),
619                vec![arbeit_ref.clone()],
620                tariff_src.clone(),
621            );
622            total += p.net_eur;
623            positions.push(p);
624        }
625
626        // Modul 3 positions are emitted below, per dispatch interval.
627        ArbeitspreisModell::SpotpreisNetzentgelt { .. } => {}
628    }
629
630    // Leistung (RLM only) — StromNEV §17.
631    //
632    // Sparte-guarded like the Grundpreis and the Kapazitätsentgelt: §17 StromNEV
633    // is the Leistungspreis authorisation for electricity, and gas prices
634    // capacity through §15 GasNEV instead. Citing §17 on a gas invoice claims a
635    // basis the ordinance does not give.
636    if let Some(lp) = input.leistungspreis {
637        if input.sparte == Sparte::Gas {
638            warnings.push(SettlementWarning {
639                severity: WarningSeverity::Warning,
640                code: "LEISTUNGSPREIS_ON_GAS",
641                message: "a Leistungspreis was supplied on a Gas settlement — §17 StromNEV \
642                          does not apply to gas, which prices capacity through the \
643                          Kapazitätsentgelt of §15 GasNEV"
644                    .to_owned(),
645            });
646        }
647        let p = kw_pos_traced(
648            "Netznutzung Leistung",
649            BillingPositionKind::NneLeistung,
650            lp.spitzenleistung_kw,
651            lp.preis_eur_per_kw,
652            vec![match input.sparte {
653                Sparte::Strom => LegalReference::StromNev { paragraph: "§17" },
654                Sparte::Gas => LegalReference::GasNev { paragraph: "§15" },
655            }],
656            tariff_src.clone(),
657        );
658        total += p.net_eur;
659        positions.push(p);
660    }
661
662    // ── Netzseitige Umlagen (EnFG) ────────────────────────────────────────────
663    //
664    // The three levies ride on the same energy base as the Arbeitspreis and are
665    // billed per Entnahmestelle at the rate its Letztverbrauchergruppe carries.
666    // A missing tabled rate is a warning rather than a silent zero: billing a
667    // levy at nothing understates the invoice by an amount the ÜNB will reclaim.
668    let umlage_base_kwh = input.arbeitspreis.menge_kwh();
669    if input.sparte == Sparte::Strom {
670        let year = input.period.from().year();
671        let gruppe = input.letztverbrauchergruppe;
672        let levies: [(&str, BillingPositionKind, Option<Decimal>, LegalReference); 3] = [
673            (
674                "Aufschlag für besondere Netznutzung (§19 StromNEV)",
675                BillingPositionKind::Sect19StromNevUmlage,
676                input
677                    .sect19_umlage_ct_per_kwh
678                    .or_else(|| crate::umlagen::sect19_stromnev_ct_per_kwh(year, gruppe)),
679                LegalReference::StromNev {
680                    paragraph: "§19 Abs. 2",
681                },
682            ),
683            (
684                "Offshore-Netzumlage",
685                BillingPositionKind::OffshoreNetzumlage,
686                input
687                    .offshore_umlage_ct_per_kwh
688                    .or_else(|| crate::umlagen::offshore_netzumlage_ct_per_kwh(year, gruppe)),
689                LegalReference::Enwg { paragraph: "§17f" },
690            ),
691            (
692                "KWKG-Umlage",
693                BillingPositionKind::KwkgUmlage,
694                input
695                    .kwkg_umlage_ct_per_kwh
696                    .or_else(|| crate::umlagen::kwkg_umlage_ct_per_kwh(year, gruppe)),
697                LegalReference::Kwkg { paragraph: "§26" },
698            ),
699        ];
700
701        for (label, kind, rate, legal) in levies {
702            let Some(rate_ct) = rate else {
703                // Only for years the series undertakes to cover: below that it
704                // claims nothing, and warning would be noise rather than signal.
705                if year >= crate::umlagen::ERSTES_ERFASSTES_JAHR {
706                    warnings.push(SettlementWarning {
707                        severity: WarningSeverity::Warning,
708                        code: "UMLAGE_RATE_MISSING",
709                        message: format!(
710                            "{label}: no published rate for {year} and no override — \
711                             the levy is omitted from this invoice"
712                        ),
713                    });
714                }
715                continue;
716            };
717            if rate_ct.is_zero() {
718                // §21 EnFG exempts entirely; a zero line adds nothing.
719                continue;
720            }
721            let price_eur = ct_to_eur(rate_ct);
722            let net_eur = pos_net(umlage_base_kwh, price_eur);
723            total += net_eur;
724            positions.push(SettlementPosition {
725                text: label.to_owned(),
726                kind,
727                quantity: umlage_base_kwh.round_dp(3),
728                unit: QuantityUnit::Kwh,
729                unit_price_eur: price_eur.round_dp(6),
730                net_eur,        spot_price_formula: None,
731
732                trace: CalculationTrace {
733                    explanation: format!(
734                        "{umlage_base_kwh:.3} kWh × {price_eur:.6} EUR/kWh = {:.5} EUR ({gruppe:?})",
735                        (umlage_base_kwh * price_eur).round_dp(5),
736                    ),
737                    input_quantity: umlage_base_kwh,
738                    input_unit_price_eur: price_eur,
739                    gross_eur: umlage_base_kwh * price_eur,
740                    legal_refs: vec![legal, LegalReference::EnFG {
741                        paragraph: "§§21 ff.",
742                    }],
743                    tariff_source: None,
744                    regulatory_reduction_factor: None,
745                    rounding_note: None,
746                },
747            });
748        }
749    }
750
751    // Blindmehrarbeit — reactive energy beyond the Preisblatt's free share.
752    //
753    // Billed on the excess only: the free share travels with the active energy,
754    // and an unused allowance is not a credit. The share and the rate are terms
755    // of the Netzbetreiber's price sheet, so both arrive as input rather than as
756    // constants here — networks differ, and some set separate shares for
757    // inductive and capacitive draw.
758    if let Some(blind) = input.blindarbeit {
759        let wirkarbeit_kwh = input.arbeitspreis.menge_kwh();
760        let mehrarbeit = blind.mehrarbeit_kvarh(wirkarbeit_kwh);
761        if mehrarbeit > Decimal::ZERO {
762            let preis_eur = ct_to_eur(blind.preis_ct_per_kvarh);
763            let gross = mehrarbeit * preis_eur;
764            let p = SettlementPosition {
765                text: "Blindmehrarbeit".to_owned(),
766                kind: BillingPositionKind::Blindmehrarbeit,
767                quantity: mehrarbeit.round_dp(3),
768                unit: QuantityUnit::Kvarh,
769                unit_price_eur: preis_eur,
770                net_eur: pos_net(mehrarbeit, preis_eur),
771                spot_price_formula: None,
772                trace: CalculationTrace {
773                    explanation: format!(
774                        "{:.3} kvarh bezogen − {:.3} kvarh frei ({:.3} kWh × {}) \
775                         = {:.3} kvarh × {:.6} EUR/kvarh = {:.5} EUR",
776                        blind.blindarbeit_kvarh,
777                        (wirkarbeit_kwh * blind.freigrenze_anteil).round_dp(3),
778                        wirkarbeit_kwh,
779                        blind.freigrenze_anteil,
780                        mehrarbeit,
781                        preis_eur,
782                        gross.round_dp(5)
783                    ),
784                    input_quantity: mehrarbeit,
785                    input_unit_price_eur: preis_eur,
786                    gross_eur: gross,
787                    legal_refs: vec![LegalReference::StromNev { paragraph: "§17" }],
788                    tariff_source: tariff_src.clone(),
789                    regulatory_reduction_factor: None,
790                    rounding_note: Some(
791                        "quantity rounded to 3 dp; unit price to 6 dp; net to 5 dp",
792                    ),
793                },
794            };
795            total += p.net_eur;
796            positions.push(p);
797        }
798    }
799
800    // Konzessionsabgabe (KAV §2 Abs. 2)
801    let ka_base_kwh = input.arbeitspreis.menge_kwh();
802    if let Some(ka) = input.konzessionsabgabe {
803        let ka_ct = ka.satz_ct_per_kwh;
804        let gruppe = ka.klasse;
805        if ka_ct < Decimal::ZERO {
806            warnings.push(SettlementWarning {
807                severity: WarningSeverity::Warning,
808                code: "KA_NEGATIVE_RATE",
809                message: format!("KA rate {ka_ct} ct/kWh is negative — verify tariff sheet"),
810            });
811        }
812        // KAV §2 rates are Höchstbeträge, so a rate above the statutory ceiling
813        // is a compliance defect, not merely unusual. Because the rate and the
814        // customer group arrive together, so this check cannot be skipped.
815        // Making it conditional on a separately-optional group is precisely
816        // when an over-charge goes unnoticed.
817        match gruppe.hoechstsatz_ct_per_kwh(input.sparte) {
818            Some(max) if ka_ct > max => warnings.push(SettlementWarning {
819                severity: WarningSeverity::Warning,
820                code: "KA_ABOVE_KAV_MAXIMUM",
821                message: format!(
822                    "KA rate {ka_ct} ct/kWh exceeds the KAV §2 Höchstbetrag {max} ct/kWh for {}",
823                    gruppe.label()
824                ),
825            }),
826            None if gruppe == KaKundengruppe::Exempt && ka_ct > Decimal::ZERO => {
827                warnings.push(SettlementWarning {
828                    severity: WarningSeverity::Warning,
829                    code: "KA_CHARGED_WHILE_EXEMPT",
830                    message: format!(
831                        "KA rate {ka_ct} ct/kWh charged although the customer is \
832                         freigestellt nach KAV §2 Abs. 7"
833                    ),
834                });
835            }
836            _ => {}
837        }
838        let ka_klasse_note = format!(" ({})", gruppe.label());
839        let p = SettlementPosition {
840            text: format!("Konzessionsabgabe{ka_klasse_note}"),
841            kind: BillingPositionKind::Konzessionsabgabe,
842            quantity: ka_base_kwh.round_dp(3),
843            unit: QuantityUnit::Kwh,
844            unit_price_eur: ct_to_eur(ka_ct).round_dp(6),
845            net_eur: pos_net(ka_base_kwh, ct_to_eur(ka_ct)),
846            spot_price_formula: None,
847            trace: CalculationTrace {
848                explanation: format!(
849                    "{ka_base_kwh:.3} kWh × {:.6} EUR/kWh = {:.5} EUR{ka_klasse_note}",
850                    ct_to_eur(ka_ct),
851                    (ka_base_kwh * ct_to_eur(ka_ct)).round_dp(5),
852                ),
853                input_quantity: ka_base_kwh,
854                input_unit_price_eur: ct_to_eur(ka_ct),
855                gross_eur: ka_base_kwh * ct_to_eur(ka_ct),
856                legal_refs: vec![LegalReference::Kav {
857                    paragraph: gruppe.kav_paragraph(),
858                }],
859                tariff_source: tariff_src.clone(),
860                regulatory_reduction_factor: None,
861                rounding_note: Some("quantity rounded to 3 dp; unit price to 6 dp; net to 5 dp"),
862            },
863        };
864        total += p.net_eur;
865        positions.push(p);
866    }
867
868    // ── §14a Modul 3: per-dispatch-interval Spotpreis-NNE ─────────────────────
869    // BNetzA BK6-22-300 Anlage 2 §3: One position per 15-min dispatch interval.
870    // The rate is pre-calculated by the caller from the spot-price formula in
871    // `PreisblattNetznutzung.lastvariablePreispositionen`.
872    // Each position carries a `LastvariablePreisposition` JSON for ERP validation.
873    let modul3_intervalle: &[SpotpreisInterval] = match &input.arbeitspreis {
874        ArbeitspreisModell::SpotpreisNetzentgelt { intervalle } => intervalle,
875        _ => &[],
876    };
877    for interval in modul3_intervalle.iter() {
878        if interval.menge_kwh <= Decimal::ZERO {
879            continue; // skip zero-energy intervals (e.g. overnight no-load)
880        }
881        let rate_eur = ct_to_eur(interval.nne_rate_ct_per_kwh);
882        let net = pos_net(interval.menge_kwh, rate_eur);
883
884        use time::format_description::well_known::Rfc3339;
885        let from_str = interval
886            .period_from
887            .format(&Rfc3339)
888            .unwrap_or_else(|_| interval.period_from.to_string());
889        let to_str = interval
890            .period_to
891            .format(&Rfc3339)
892            .unwrap_or_else(|_| interval.period_to.to_string());
893
894        let label = format!("§14a Modul 3 Spotpreis-NNE {from_str}–{to_str}");
895
896        // Build typed LastvariablePreisposition JSON for ERP-side validation.
897        // The formula as a value, not as somebody's document schema. An adapter
898        // that needs BO4E `LastvariablePreisposition` builds it from this.
899        let formula = SpotPriceFormula {
900            reference: PriceReference::Energiemenge,
901            unit: QuantityUnit::Kwh,
902            method: TariffCalculationMethod::Spotpreis,
903            steps: vec![PriceStep {
904                from: Decimal::ZERO,
905                to: None,
906                unit_price_eur: rate_eur,
907            }],
908        };
909
910        let mut explanation = format!(
911            "{:.3} kWh × {:.6} EUR/kWh (§14a Modul 3 Spotpreis, interval {}/{}) = {:.5} EUR",
912            interval.menge_kwh, rate_eur, from_str, to_str, net
913        );
914        if let Some(epex) = interval.epex_spot_ct_per_kwh {
915            explanation.push_str(&format!(" [EPEX {epex:.4} ct/kWh]"));
916        }
917
918        let p = SettlementPosition {
919            text: label,
920            kind: BillingPositionKind::NneArbeitModul3,
921            quantity: interval.menge_kwh.round_dp(3),
922            unit: QuantityUnit::Kwh,
923            unit_price_eur: rate_eur.round_dp(6),
924            net_eur: net,
925            spot_price_formula: Some(formula),
926            trace: CalculationTrace {
927                explanation,
928                input_quantity: interval.menge_kwh,
929                input_unit_price_eur: rate_eur,
930                gross_eur: interval.menge_kwh * rate_eur,
931                legal_refs: vec![
932                    LegalReference::Sect14aEnwg {
933                        module: Sect14aModule::Modul3,
934                    },
935                    LegalReference::BnetzaDecision {
936                        reference: "BK6-22-300",
937                    },
938                    arbeit_ref.clone(),
939                ],
940                tariff_source: tariff_src.clone(),
941                regulatory_reduction_factor: None,
942                rounding_note: Some("rate ct→EUR 6 dp; net 5 dp; BK6-22-300 Anlage 2 §3"),
943            },
944        };
945        total += p.net_eur;
946        positions.push(p);
947    }
948
949    // §19 Abs. 2 StromNEV — an agreed individual charge replaces the published
950    // Netzentgelt at a fraction the ordinance floors. The reduction covers the
951    // Arbeits- and Leistungspreis positions and nothing else: the KA and the
952    // levies are not the Netzbetreiber's revenue to reduce, and the lost NNE
953    // revenue is recovered through the §19-Umlage billed above.
954    //
955    // Sequenced last on purpose. It reduces a *basis*, so every NNE position it
956    // covers has to exist before it runs — the §14a Modul 3 Spotpreis positions
957    // are emitted per dispatch interval further down and would otherwise be
958    // outside the basis entirely, leaving a Modul-3 customer with a 10 %
959    // agreement billed as though they had none.
960    if let Some(v) = &input.sect19 {
961        let floor = match v.art {
962            crate::sect19::Sect19Art::AtypischeNetznutzung => {
963                Some(crate::sect19::ATYPISCH_MINDESTENTGELT)
964            }
965            crate::sect19::Sect19Art::IntensiveNetznutzung => {
966                match (input.jahresarbeit_kwh, input.jahreshoechstleistung_kw) {
967                    (Some(arbeit), Some(peak)) => {
968                        crate::netzebene::benutzungsstundenzahl(arbeit, peak)
969                            .and_then(|bh| crate::sect19::bandlast_mindestentgelt(bh, arbeit))
970                    }
971                    _ => None,
972                }
973            }
974        };
975        match floor {
976            None => warnings.push(SettlementWarning {
977                severity: WarningSeverity::Warning,
978                code: "SECT19_BANDLAST_CRITERIA_NOT_MET",
979                message: "a §19 Abs. 2 Satz 2 agreement needs at least 7 000 \
980                          Benutzungsstunden and 10 GWh a year — the utilisation data \
981                          supplied does not qualify (or is missing)"
982                    .to_owned(),
983            }),
984            Some(f) if v.vereinbarter_prozentsatz < f => warnings.push(SettlementWarning {
985                severity: WarningSeverity::Warning,
986                code: "SECT19_BELOW_MINDESTENTGELT",
987                message: format!(
988                    "the agreed {} % is below the statutory Mindestentgelt of {} % \
989                     (§19 Abs. 2 StromNEV)",
990                    (v.vereinbarter_prozentsatz * HUNDRED).normalize(),
991                    (f * HUNDRED).normalize()
992                ),
993            }),
994            Some(_) => {}
995        }
996
997        let nne_basis: Decimal = positions
998            .iter()
999            .filter(|p| {
1000                matches!(
1001                    p.kind,
1002                    BillingPositionKind::NneArbeit
1003                        | BillingPositionKind::NneArbeitHt
1004                        | BillingPositionKind::NneArbeitSt
1005                        | BillingPositionKind::NneArbeitNt
1006                        | BillingPositionKind::NneArbeitModul1
1007                        | BillingPositionKind::NneArbeitModul2
1008                        | BillingPositionKind::NneArbeitModul3
1009                        | BillingPositionKind::NneLeistung
1010                )
1011            })
1012            .map(|p| p.net_eur)
1013            .sum();
1014        let reduction = -(nne_basis * (Decimal::ONE - v.vereinbarter_prozentsatz)).round_dp(5);
1015        if !reduction.is_zero() {
1016            let art_label = match v.art {
1017                crate::sect19::Sect19Art::AtypischeNetznutzung => "atypische Netznutzung",
1018                crate::sect19::Sect19Art::IntensiveNetznutzung => "intensive Netznutzung",
1019            };
1020            let genehmigung = v
1021                .genehmigung
1022                .as_deref()
1023                .map(|g| format!(", {g}"))
1024                .unwrap_or_default();
1025            let p = SettlementPosition {
1026                text: format!(
1027                    "Individuelles Netzentgelt §19 Abs. 2 ({art_label}, {} %)",
1028                    (v.vereinbarter_prozentsatz * HUNDRED).normalize()
1029                ),
1030                kind: BillingPositionKind::Sect19IndividuellesEntgelt,
1031                quantity: Decimal::ONE,
1032                unit: QuantityUnit::Monat,
1033                unit_price_eur: reduction,
1034                net_eur: reduction,
1035                spot_price_formula: None,
1036                trace: CalculationTrace {
1037                    explanation: format!(
1038                        "-(1 − {}) × {nne_basis:.5} EUR Netzentgelt = {reduction:.5} EUR \
1039                         ({art_label}{genehmigung})",
1040                        v.vereinbarter_prozentsatz
1041                    ),
1042                    input_quantity: nne_basis,
1043                    input_unit_price_eur: reduction,
1044                    gross_eur: reduction,
1045                    legal_refs: vec![
1046                        LegalReference::StromNev {
1047                            paragraph: "§19 Abs. 2",
1048                        },
1049                        LegalReference::BnetzaDecision {
1050                            reference: "BK4-22-089",
1051                        },
1052                    ],
1053                    tariff_source: None,
1054                    regulatory_reduction_factor: Some(v.vereinbarter_prozentsatz),
1055                    rounding_note: Some("net to 5 dp"),
1056                },
1057            };
1058            total += p.net_eur;
1059            positions.push(p);
1060        }
1061    }
1062
1063    let total_eur = total.round_dp(2);
1064    ensure_representable_eur(total_eur)?;
1065
1066    // Netznutzung is a sonstige Leistung: UStAE 13b.3a excludes it from §13b by
1067    // name, so the Netzbetreiber always owes the tax at the Regelsteuersatz.
1068    let steuer = crate::umsatzsteuer::steuerausweis(
1069        total_eur,
1070        crate::umsatzsteuer::Leistungsart::SonstigeLeistung,
1071        crate::umsatzsteuer::Wiederverkaeuferstatus::KEINER,
1072        input.period,
1073    )?;
1074    ensure_representable_eur(steuer.brutto_eur().abs())?;
1075
1076    let result = SettlementResult {
1077        malo_id: input.malo_id.clone(),
1078        sparte: input.sparte,
1079        regime,
1080        settlement_type,
1081        status: SettlementStatus::Initial,
1082        korrektur_grund: None,
1083        period: input.period,
1084        sender_mp_id: input.nb_mp_id.clone(),
1085        recipient_mp_id: input.lf_mp_id.clone(),
1086        positions,
1087        total_eur,
1088        steuer,
1089        warnings,
1090    };
1091    debug_assert_eq!(
1092        result.total_eur,
1093        result.recomputed_total(),
1094        "NNE: total_eur mismatch — calculation bug"
1095    );
1096    Ok(result)
1097}
1098
1099// ── MMM invoice (PID 31005) ───────────────────────────────────────────────────
1100
1101/// Calculate a Mehr-/Mindermengen settlement invoice (PID 31005, Strom and Gas).
1102///
1103/// ## Legal references
1104///
1105/// Selected from the **delivery period**, because StromNZV and GasNZV both ceased
1106/// to apply with effect from the end of 31.12.2025:
1107///
1108/// | Period | Strom | Gas |
1109/// |---|---|---|
1110/// | to 31.12.2025 | StromNZV §13 Abs. 3 | GasNZV §25 |
1111/// | from 01.01.2026 | GPKE (BK6-24-174) Teil 1 Kap. 8.4 | GaBi Gas 2.1 (BK7-24-01-008) |
1112///
1113/// GeLi Gas 3.0 does **not** carry Mehr-/Mindermengen; its transferred scope is
1114/// Netzzugangsverträge, Lieferantenwechsel and Messung.
1115///
1116/// ## Errors
1117///
1118/// [`BillingError::InvalidInput`] when `period_from >= period_to`.
1119#[must_use = "handle the BillingError"]
1120pub fn settle_mmm(input: &MmmInput) -> Result<SettlementResult, BillingError> {
1121    if input.period.from() >= input.period.to() {
1122        return Err(BillingError::InvalidInput {
1123            reason: "period_from must be strictly before period_to".to_owned(),
1124        });
1125    }
1126
1127    let mehr_eur = ct_to_eur(input.mehr_preis_ct_per_kwh);
1128    let minder_eur = ct_to_eur(input.minder_preis_ct_per_kwh);
1129    let diff = input.actual_kwh - input.profil_kwh;
1130
1131    // Resolved once from the period; every decision below matches on the regime
1132    // rather than re-comparing dates, so a future turnover is a new variant the
1133    // compiler makes us handle everywhere it matters.
1134    //
1135    // Exempt from `ensure_berechenbar` (the AgNeS guard): Mehr-/Mindermengen
1136    // prices are formed on the *Netzzugang* axis — GPKE (BK6-24-174) Teil 1
1137    // Kap. 8.4 for Strom, GaBi Gas 2.1 (BK7-24-01-008) for Gas, both market-
1138    // price based — not by the StromNEV/ARegV Entgeltbildung that AgNeS
1139    // (GBK-25-01) replaces. A 2029 MMM settlement therefore stays computable.
1140    let mut warnings: Vec<SettlementWarning> = Vec::new();
1141    let regime =
1142        crate::regulatory::RegulatoryRegime::for_period(input.period.from(), input.period.to());
1143    warn_if_straddles_turnover(input.period.from(), input.period.to(), &mut warnings);
1144    use crate::regulatory::NetzzugangRegime as NZ;
1145    let mmm_refs = match (input.sparte, regime.netzzugang()) {
1146        (Sparte::Gas, NZ::Nzv) => vec![
1147            LegalReference::GasNzv { paragraph: "§25" },
1148            LegalReference::BdewAhb {
1149                reference: "GaBi Gas 2.1 (BK7-24-01-008)",
1150            },
1151        ],
1152        (Sparte::Gas, NZ::EnwgFestlegung) => vec![LegalReference::BdewAhb {
1153            reference: "GaBi Gas 2.1 (BK7-24-01-008)",
1154        }],
1155        (Sparte::Strom, NZ::Nzv) => vec![
1156            LegalReference::StromNzv {
1157                paragraph: "§13 Abs. 3",
1158            },
1159            LegalReference::BnetzaDecision {
1160                reference: "BK6-24-174",
1161            },
1162        ],
1163        (Sparte::Strom, NZ::EnwgFestlegung) => vec![
1164            LegalReference::Enwg {
1165                paragraph: "§20 Abs. 3",
1166            },
1167            LegalReference::BnetzaDecision {
1168                reference: "BK6-24-174",
1169            },
1170        ],
1171    };
1172    // Gas and Strom MMM use separate settlement types for correct audit
1173    // references. A self-issued Mehrmenge leg is a third: the AHB marks it
1174    // *Selbstausgestellt*, and that is carried by the settlement type because
1175    // the BO4E rendering reads `netznutzungrechnungsart` from it.
1176    let mmm_settlement_type = if input.selbstausgestellt {
1177        SettlementType::MmmSelbstausstellt
1178    } else {
1179        match input.sparte {
1180            Sparte::Gas => SettlementType::MmmGas,
1181            Sparte::Strom => SettlementType::MmmStrom,
1182        }
1183    };
1184
1185    // Sign convention per GPKE (BK6-24-174) Teil 1 Kap. 8.4 Nr. 3 and, for gas,
1186    // GaBi Gas 2.1 (BK7-24-01-008) Tenor Nr. 5. Both define the quantities from
1187    // the network operator's side, which inverts the intuitive reading:
1188    //
1189    //   measured < profiled  → ungewollte **Mehrmenge**   → NB vergütet   (credit)
1190    //   measured > profiled  → ungewollte **Mindermenge** → NB in Rechnung (charge)
1191    //
1192    // GPKE: "Unterschreitet die Summe der [...] ermittelten elektrischen Arbeit
1193    // die Summe der Arbeit, die den bilanzierten Profilen zu Grunde gelegt wurde
1194    // (ungewollte Mehrmenge), so vergütet der Netzbetreiber dem Lieferanten [...]
1195    // diese Differenzmenge."
1196    let mehr_kwh = if diff < Decimal::ZERO {
1197        -diff
1198    } else {
1199        Decimal::ZERO
1200    };
1201    let mehr_net = -pos_net(mehr_kwh, mehr_eur);
1202    let mehr_gross = mehr_kwh * mehr_eur;
1203    let p1 = SettlementPosition {
1204        text: "Mehrmengen (Gutschrift)".to_owned(),
1205        kind: BillingPositionKind::Mehrmenge,
1206        quantity: mehr_kwh.round_dp(3),
1207        unit: QuantityUnit::Kwh,
1208        unit_price_eur: mehr_eur.round_dp(6),
1209        net_eur: mehr_net,
1210        spot_price_formula: None,
1211
1212        trace: CalculationTrace {
1213            explanation: format!(
1214                "{mehr_kwh:.3} kWh × {:.6} EUR/kWh = {:.5} EUR (Gutschrift, negiert)",
1215                mehr_eur,
1216                mehr_gross.round_dp(5)
1217            ),
1218            input_quantity: mehr_kwh,
1219            input_unit_price_eur: mehr_eur,
1220            gross_eur: mehr_gross,
1221            legal_refs: mmm_refs.clone(),
1222            tariff_source: None,
1223            regulatory_reduction_factor: None,
1224            rounding_note: Some("Mehrmengen are credit positions — net_eur is negated"),
1225        },
1226    };
1227
1228    let minder_kwh = if diff > Decimal::ZERO {
1229        diff
1230    } else {
1231        Decimal::ZERO
1232    };
1233    let minder_net = pos_net(minder_kwh, minder_eur);
1234    let minder_gross = minder_kwh * minder_eur;
1235    let p2 = SettlementPosition {
1236        text: "Mindermengen".to_owned(),
1237        kind: BillingPositionKind::Mindermenge,
1238        quantity: minder_kwh.round_dp(3),
1239        unit: QuantityUnit::Kwh,
1240        unit_price_eur: minder_eur.round_dp(6),
1241        net_eur: minder_net,
1242        spot_price_formula: None,
1243
1244        trace: CalculationTrace {
1245            explanation: format!(
1246                "{minder_kwh:.3} kWh × {:.6} EUR/kWh = {:.5} EUR",
1247                minder_eur,
1248                minder_gross.round_dp(5)
1249            ),
1250            input_quantity: minder_kwh,
1251            input_unit_price_eur: minder_eur,
1252            gross_eur: minder_gross,
1253            legal_refs: mmm_refs,
1254            tariff_source: None,
1255            regulatory_reduction_factor: None,
1256            rounding_note: None,
1257        },
1258    };
1259
1260    let total_eur = (p1.net_eur + p2.net_eur).round_dp(2);
1261    ensure_representable_eur(total_eur.abs())?;
1262
1263    // A Mehr-/Mindermenge is a **Lieferung** of the commodity, not a network
1264    // service, so §13b Abs. 2 Nr. 5 Buchst. b can shift the tax to the recipient
1265    // — and the gas rate reduction of §28 Abs. 5 UStG can reach it.
1266    let leistungsart = match input.sparte {
1267        Sparte::Strom => crate::umsatzsteuer::Leistungsart::LieferungStrom,
1268        Sparte::Gas => crate::umsatzsteuer::Leistungsart::LieferungGas,
1269    };
1270    let steuer = crate::umsatzsteuer::steuerausweis(
1271        total_eur,
1272        leistungsart,
1273        input.wiederverkaeufer,
1274        input.period,
1275    )?;
1276    ensure_representable_eur(steuer.brutto_eur().abs())?;
1277
1278    Ok(SettlementResult {
1279        malo_id: input.malo_id.clone(),
1280        sparte: input.sparte,
1281        regime,
1282        settlement_type: mmm_settlement_type,
1283        status: SettlementStatus::Initial,
1284        korrektur_grund: None,
1285        period: input.period,
1286        sender_mp_id: input.nb_mp_id.clone(),
1287        recipient_mp_id: input.lf_mp_id.clone(),
1288        positions: vec![p1, p2],
1289        total_eur,
1290        steuer,
1291        warnings,
1292    })
1293}
1294
1295// ── Abschlagsrechnung (PID 31001) ─────────────────────────────────────────────
1296
1297/// Calculate an Abschlagsrechnung Netznutzung (PID 31001): a payment on account.
1298///
1299/// This settles nothing. It asks the Lieferant for an amount against a period
1300/// the Netzbetreiber has not yet billed, and the Abschlussrechnung that follows
1301/// deducts it by invoice number ([`crate::Abschlagsverrechnung`]).
1302///
1303/// **Exactly one position**, per INVOIC AHB 1.0b Änd-ID 26817 — "Eine
1304/// Abschlagsrechnung kann und muss genau eine Positionszeile enthalten", with
1305/// `LIN DE1082` fixed at 1. So there is no quantity and no unit price here: an
1306/// Abschlag prices no energy, and giving it a kWh figure would assert a
1307/// measurement nobody took.
1308///
1309/// # Errors
1310///
1311/// [`BillingError::InvalidInput`] when the amount is not positive — an Abschlag
1312/// asking for nothing, or crediting money, is not an Abschlag. Reverse it with
1313/// [`reverse`] instead.
1314#[must_use = "handle the BillingError"]
1315pub fn settle_abschlag(input: &AbschlagInput) -> Result<SettlementResult, BillingError> {
1316    if input.betrag_netto_eur <= Decimal::ZERO {
1317        return Err(BillingError::InvalidInput {
1318            reason: format!(
1319                "an Abschlag must ask for a positive amount, got {} EUR — to give money back, \
1320                 reverse the Abschlagsrechnung rather than issuing a negative one",
1321                input.betrag_netto_eur
1322            ),
1323        });
1324    }
1325
1326    // The Abschlag rests on the same charge authorisation as the invoice it
1327    // anticipates, so a period AgNeS governs is refused here too.
1328    let regime =
1329        crate::regulatory::RegulatoryRegime::for_period(input.period.from(), input.period.to());
1330    regime.ensure_berechenbar()?;
1331
1332    let mut warnings: Vec<SettlementWarning> = Vec::new();
1333    warn_if_straddles_turnover(input.period.from(), input.period.to(), &mut warnings);
1334
1335    let betrag = input.betrag_netto_eur.round_dp(2);
1336    ensure_representable_eur(betrag)?;
1337
1338    let position = SettlementPosition {
1339        text: format!("Abschlag Netznutzung ({})", input.grundlage.label()),
1340        kind: BillingPositionKind::NneAbschlag,
1341        // No quantity and no unit price: the amount *is* the position.
1342        quantity: Decimal::ONE,
1343        unit: QuantityUnit::Monat,
1344        unit_price_eur: betrag,
1345        net_eur: betrag,
1346        spot_price_formula: None,
1347        trace: CalculationTrace {
1348            explanation: format!(
1349                "Abschlag {betrag:.2} EUR für {} – {} ({})",
1350                input.period.from(),
1351                input.period.to(),
1352                input.grundlage.label(),
1353            ),
1354            input_quantity: Decimal::ONE,
1355            input_unit_price_eur: betrag,
1356            gross_eur: betrag,
1357            legal_refs: vec![
1358                match input.sparte {
1359                    Sparte::Strom => LegalReference::StromNev { paragraph: "§21" },
1360                    Sparte::Gas => LegalReference::GasNev { paragraph: "§14" },
1361                },
1362                LegalReference::Ustg {
1363                    paragraph: "§14 Abs. 5",
1364                },
1365            ],
1366            tariff_source: None,
1367            regulatory_reduction_factor: None,
1368            rounding_note: Some("amount rounded to 2 dp"),
1369        },
1370    };
1371
1372    Ok(SettlementResult {
1373        malo_id: input.malo_id.clone(),
1374        sparte: input.sparte,
1375        regime,
1376        settlement_type: SettlementType::NneAbschlag,
1377        status: SettlementStatus::Initial,
1378        korrektur_grund: None,
1379        period: input.period,
1380        sender_mp_id: input.nb_mp_id.clone(),
1381        recipient_mp_id: input.lf_mp_id.clone(),
1382        positions: vec![position],
1383        total_eur: betrag,
1384        // An Anzahlung is taxed when it is received (§14 Abs. 5 UStG), so the
1385        // Abschlagsrechnung states the tax like any other — and the
1386        // Abschlussrechnung must then not tax the same money twice, which is why
1387        // the deduction happens on `zu_zahlen` rather than on the net.
1388        steuer: crate::umsatzsteuer::steuerausweis(
1389            betrag,
1390            crate::umsatzsteuer::Leistungsart::SonstigeLeistung,
1391            crate::umsatzsteuer::Wiederverkaeuferstatus::KEINER,
1392            input.period,
1393        )?,
1394        warnings,
1395    })
1396}
1397
1398// ── MSB invoice (PID 31009) ───────────────────────────────────────────────────
1399
1400/// Calculate a MSB-Rechnung (PID 31009): **MSB → NB / LF / ESA** metering
1401/// service settlement.
1402///
1403/// The MSB is the invoicer in all seven Anwendungsfälle of the PID overview 4.0;
1404/// the recipient's market role varies and is carried on
1405/// [`MsbInput::empfaenger`]. 31009 is Strom-only.
1406///
1407/// ## Legal references
1408///
1409/// - Grundgebühr Messstellenbetrieb → `MsbG §§6–7`, `MsbG §2`
1410/// - Messdienstleistung → `MsbG §2`
1411///
1412/// ## Errors
1413///
1414/// [`BillingError::InvalidInput`] or [`BillingError::MonetaryOverflow`].
1415#[must_use = "handle the BillingError"]
1416pub fn settle_msb(input: &MsbInput) -> Result<SettlementResult, BillingError> {
1417    if input.period.from() >= input.period.to() {
1418        return Err(BillingError::InvalidInput {
1419            reason: "period_from must be strictly before period_to".to_owned(),
1420        });
1421    }
1422    if input.grundgebuehr_eur_per_month < Decimal::ZERO {
1423        return Err(BillingError::InvalidInput {
1424            reason: "grundgebuehr_eur_per_month must be non-negative".to_owned(),
1425        });
1426    }
1427    // Exempt from `ensure_berechenbar` (the AgNeS guard): Messstellenbetrieb
1428    // charges are formed under the MsbG (§§6–7, §30 Preisobergrenzen), which
1429    // does not lapse with StromNEV/ARegV at the end of 2028 — AgNeS (GBK-25-01)
1430    // replaces the Netzentgeltbildung, not the metering-charge law.
1431    let mut warnings: Vec<SettlementWarning> = Vec::new();
1432    warn_if_straddles_turnover(input.period.from(), input.period.to(), &mut warnings);
1433
1434    // §30 MsbG Preisobergrenze. The ceiling is annual and the charge monthly, so
1435    // the charge is annualised before comparison — billing a year in monthly
1436    // instalments does not raise the cap.
1437    if let (Some(kategorie), Some(schuldner)) =
1438        (input.messstellen_kategorie, input.entgeltschuldner)
1439    {
1440        let annual = input.grundgebuehr_eur_per_month * Decimal::from(12);
1441        if let Some(pog) = crate::msbg::preisobergrenze_eur_per_jahr(kategorie, schuldner)
1442            && annual > pog
1443        {
1444            warnings.push(SettlementWarning {
1445                severity: WarningSeverity::Warning,
1446                code: "MSB_ABOVE_MSBG_POG",
1447                message: format!(
1448                    "Messstellenbetrieb {annual} EUR/a exceeds the §30 MsbG Preisobergrenze \
1449                     {pog} EUR/a for {kategorie:?} / {schuldner:?}"
1450                ),
1451            });
1452        }
1453    }
1454
1455    if input.billing_months == 0 {
1456        return Err(BillingError::InvalidInput {
1457            reason: "billing_months must be at least 1".to_owned(),
1458        });
1459    }
1460
1461    // The months billed have to be the months served. `billing_months` and the
1462    // delivery period were independent, so a request could bill twelve months of
1463    // Grundgebühr over a one-month period — a twelvefold over-charge that adds
1464    // up perfectly and reads as a normal annual invoice.
1465    //
1466    // The comparison is deliberately loose: a period may run from the 15th to
1467    // the 14th, or cover a Gerätewechsel mid-month, so anything within a month
1468    // of the calendar length passes. What it catches is an order-of-magnitude
1469    // mismatch, which is the error worth catching.
1470    let period_months = Decimal::from(input.period.days()) / rust_decimal::dec!(30.44);
1471    let billed = Decimal::from(input.billing_months);
1472    if (billed - period_months).abs() > Decimal::ONE {
1473        warnings.push(SettlementWarning {
1474            severity: WarningSeverity::Warning,
1475            code: "BILLING_MONTHS_MISMATCH",
1476            message: format!(
1477                "billing {billed} months of Messstellenbetrieb over a period of {} days \
1478                 (≈ {period_months:.1} months) — check the period or the month count",
1479                input.period.days()
1480            ),
1481        });
1482    }
1483
1484    let mut positions: Vec<SettlementPosition> = Vec::new();
1485    let mut total = Decimal::ZERO;
1486
1487    let months = Decimal::from(input.billing_months);
1488    let p = monat_pos_traced(
1489        "Grundgebühr Messstellenbetrieb",
1490        BillingPositionKind::MsbGrundgebuehr,
1491        months,
1492        input.grundgebuehr_eur_per_month,
1493        vec![
1494            LegalReference::MsbG {
1495                paragraph: "§§6–7"
1496            },
1497            LegalReference::MsbG { paragraph: "§30" },
1498        ],
1499        None,
1500    );
1501    total += p.net_eur;
1502    positions.push(p);
1503
1504    if let Some(msl_eur) = input.messdienstleistung_eur {
1505        let msl: Decimal = msl_eur.round_dp(5);
1506        let p = SettlementPosition {
1507            text: "Messdienstleistung".to_owned(),
1508            kind: BillingPositionKind::Messdienstleistung,
1509            quantity: Decimal::ONE,
1510            unit: QuantityUnit::Monat,
1511            unit_price_eur: msl,
1512            net_eur: msl,
1513            spot_price_formula: None,
1514
1515            trace: CalculationTrace {
1516                explanation: format!("Messdienstleistung Pauschale {msl:.5} EUR"),
1517                input_quantity: Decimal::ONE,
1518                input_unit_price_eur: msl,
1519                gross_eur: msl,
1520                legal_refs: vec![LegalReference::MsbG {
1521                    paragraph: "§§34–35",
1522                }],
1523                tariff_source: None,
1524                regulatory_reduction_factor: None,
1525                rounding_note: Some("flat fee — rounded to 5 dp"),
1526            },
1527        };
1528        total += p.net_eur;
1529        positions.push(p);
1530    }
1531
1532    let total_eur = total.round_dp(2);
1533    ensure_representable_eur(total_eur)?;
1534
1535    Ok(SettlementResult {
1536        malo_id: input.malo_id.clone(),
1537        // §30 MsbG prices metering rather than energy, so the Sparte drives no
1538        // arithmetic here — it is carried because it is what the invoice states.
1539        sparte: input.sparte,
1540        regime: crate::regulatory::RegulatoryRegime::for_period(
1541            input.period.from(),
1542            input.period.to(),
1543        ),
1544        settlement_type: SettlementType::MsbRechnung,
1545        status: SettlementStatus::Initial,
1546        korrektur_grund: None,
1547        period: input.period,
1548        // PID 31009 is issued *by* the MSB, so the MSB is the sender. NB as
1549        // sender and MSB as recipient inverts the invoice: the party owed money
1550        // is named as the one billing for it.
1551        sender_mp_id: input.msb_mp_id.clone(),
1552        recipient_mp_id: input.empfaenger.mp_id.clone(),
1553        positions,
1554        total_eur,
1555        // Messstellenbetrieb is a sonstige Leistung — never reverse-charged.
1556        steuer: crate::umsatzsteuer::steuerausweis(
1557            total_eur,
1558            crate::umsatzsteuer::Leistungsart::SonstigeLeistung,
1559            crate::umsatzsteuer::Wiederverkaeuferstatus::KEINER,
1560            input.period,
1561        )?,
1562        warnings,
1563    })
1564}
1565
1566// ── Reversal (Stornorechnung) ─────────────────────────────────────────────────────────
1567
1568/// Create a reversal (Stornorechnung) of a prior settlement.
1569///
1570/// All positions are negated. The result references the original via
1571/// `correction_of`. No re-calculation is performed — the reversal is
1572/// a pure mirror of the original, ensuring auditability.
1573///
1574/// ## Usage
1575///
1576/// ```rust,no_run
1577/// # use grid_billing::{SettlementResult, reverse};
1578/// # let original: SettlementResult = unimplemented!();
1579/// let reversal = reverse(&original, grid_billing::KorrekturGrund::Messwertkorrektur);
1580/// assert_eq!(reversal.total_eur, -original.total_eur);
1581/// ```
1582#[must_use]
1583pub fn reverse(original: &SettlementResult, grund: KorrekturGrund) -> SettlementResult {
1584    use crate::types::SettlementStatus;
1585    let reversed_positions: Vec<_> = original
1586        .positions
1587        .iter()
1588        .map(|p| SettlementPosition {
1589            text: format!("Storno: {}", p.text),
1590            kind: p.kind,
1591            quantity: p.quantity,
1592            unit: p.unit,
1593            unit_price_eur: p.unit_price_eur,
1594            net_eur: -p.net_eur,
1595            spot_price_formula: p.spot_price_formula.clone(),
1596            trace: CalculationTrace {
1597                explanation: format!("Storno: {} (negated)", p.trace.explanation),
1598                input_quantity: p.trace.input_quantity,
1599                input_unit_price_eur: p.trace.input_unit_price_eur,
1600                gross_eur: -p.trace.gross_eur,
1601                legal_refs: p.trace.legal_refs.clone(),
1602                tariff_source: p.trace.tariff_source.clone(),
1603                regulatory_reduction_factor: p.trace.regulatory_reduction_factor,
1604                rounding_note: Some("reversal — all amounts negated"),
1605            },
1606        })
1607        .collect();
1608
1609    // Exempt from `ensure_berechenbar` (the AgNeS guard): a reversal prices
1610    // nothing — it mirrors amounts an already-guarded builder computed, under
1611    // the regime recorded on the original. Refusing here would make a
1612    // Verordnung-era settlement irreversible once the 2029 turnover has
1613    // passed, which is the opposite of what the guard protects.
1614    //
1615    // The turnover warning is re-emitted, though: a straddling period is as
1616    // wrong reversed as it was billed, and the mirror should say so too.
1617    let mut warnings: Vec<SettlementWarning> = Vec::new();
1618    warn_if_straddles_turnover(original.period.from(), original.period.to(), &mut warnings);
1619
1620    SettlementResult {
1621        // A reversal is the same supply under the same rules — only the signs
1622        // differ, so identity and regime carry over unchanged.
1623        malo_id: original.malo_id.clone(),
1624        sparte: original.sparte,
1625        regime: original.regime,
1626        settlement_type: original.settlement_type,
1627        status: SettlementStatus::Reversal,
1628        korrektur_grund: Some(grund),
1629        period: original.period,
1630        sender_mp_id: original.sender_mp_id.clone(),
1631        recipient_mp_id: original.recipient_mp_id.clone(),
1632        positions: reversed_positions,
1633        total_eur: -original.total_eur,
1634        // The tax is mirrored, not recomputed. A reversal cancels the invoice
1635        // that was issued, so it has to carry that invoice's treatment even if
1636        // the rate or the counterparty's §3g status has since changed —
1637        // recomputing would leave a tax residue the reversal never cancels.
1638        steuer: crate::umsatzsteuer::Steuerausweis {
1639            kategorie: original.steuer.kategorie,
1640            satz_prozent: original.steuer.satz_prozent,
1641            bemessungsgrundlage_eur: -original.steuer.bemessungsgrundlage_eur,
1642            steuer_eur: -original.steuer.steuer_eur,
1643            hinweis: original.steuer.hinweis,
1644            rechtsgrundlage: original.steuer.rechtsgrundlage,
1645        },
1646        warnings,
1647    }
1648}
1649
1650// ── Correction ────────────────────────────────────────────────────────────────
1651
1652/// Create a correction of a prior settlement by applying a new settlement.
1653///
1654/// Combines the original settlement (reversed) and the corrected calculation
1655/// into a correction-pair. Callers typically dispatch both the reversal and the
1656/// new settlement to the EDIFACT channel — `calculate_correction` returns both
1657/// in order so dispatch logic stays simple.
1658///
1659/// Returns `(reversal, replacement)` where:
1660/// - `reversal` negates all original positions and references the original invoice.
1661/// - `replacement` is the new calculation, carrying `status = Correction`.
1662///
1663/// Which document supersedes which is recorded on the [`crate::types::InvoiceDocument`]s built
1664/// around these two results, not here: the correction chain is a property of the
1665/// documents exchanged, and the same pair of settlements could be presented
1666/// under different invoice numbers.
1667///
1668/// ## Example
1669///
1670/// ```rust,no_run
1671/// # use grid_billing::{SettlementResult, correct};
1672/// # let original: SettlementResult = unimplemented!();
1673/// # let corrected: SettlementResult = unimplemented!();
1674/// let (reversal, replacement) = correct(&original, corrected, grid_billing::KorrekturGrund::Tarifkorrektur);
1675/// assert_eq!(reversal.total_eur, -original.total_eur);
1676/// assert_eq!(replacement.status, grid_billing::SettlementStatus::Correction);
1677/// ```
1678#[must_use]
1679pub fn correct(
1680    original: &SettlementResult,
1681    mut replacement: SettlementResult,
1682    grund: KorrekturGrund,
1683) -> (SettlementResult, SettlementResult) {
1684    // No AgNeS guard here: the reversal prices nothing (see [`reverse`]) and
1685    // the replacement was computed by a settlement builder that already ran
1686    // `ensure_berechenbar` for its own period.
1687    let reversal = reverse(original, grund);
1688    replacement.status = SettlementStatus::Correction;
1689    replacement.korrektur_grund = Some(grund);
1690    (reversal, replacement)
1691}
1692
1693// ── GeLi Gas AWH Sperrprozesse invoice (PID 31011) ────────────────────────────
1694
1695/// Calculate a GeLi Gas AWH Sperrprozesse settlement (PID 31011).
1696///
1697/// **Rechnung sonstige Leistung (NB → LF)** — bills the LF (LFG/LFA) for
1698/// abrechnungswürdige Handlungen (AWH) performed by the GNB/VNB during the
1699/// Sperrung/Entsperrung Gas process.
1700///
1701/// Governed by **BK7-24-01-009 §5.4** (GeLi Gas 3.0, Beschluss 12.09.2025).
1702///
1703/// ## Positions
1704///
1705/// One position per [`crate::AwhPositionInput`] — quantity = `anzahl` (unit: pieces → Monat
1706/// placeholder), unit price = `preis_eur`. Positions are self-explaining with the
1707/// action description in the `text` field.
1708///
1709/// ## Legal references
1710///
1711/// Every position cites:
1712/// - `BdewAhb { reference: "GeLi Gas 3.0 (BK7-24-01-009) §5.4" }` (governing ruling)
1713/// - `GasNev { paragraph: "§14" }` (general GasNEV charge authorisation)
1714///
1715/// ## Errors
1716///
1717/// [`BillingError::InvalidInput`] when:
1718/// - `period_from >= period_to`
1719/// - `awh_positionen` is empty
1720/// - Any position has `anzahl == 0` or `preis_eur < 0`
1721///
1722/// [`BillingError::UnsupportedEntgeltRegime`] for a period governed by AgNeS
1723/// (from 01.01.2029) — the GasNEV §14 charge authorisation the AWH positions
1724/// rest on lapses with 2028.
1725#[must_use = "handle the BillingError"]
1726pub fn settle_gas_awh(input: &GasAwhInput) -> Result<SettlementResult, BillingError> {
1727    if input.period.from() >= input.period.to() {
1728        return Err(BillingError::InvalidInput {
1729            reason: "period_from must be strictly before period_to".to_owned(),
1730        });
1731    }
1732    if input.awh_positionen.is_empty() {
1733        return Err(BillingError::InvalidInput {
1734            reason: "awh_positionen must contain at least one position".to_owned(),
1735        });
1736    }
1737    for (i, awh) in input.awh_positionen.iter().enumerate() {
1738        if awh.anzahl == 0 {
1739            return Err(BillingError::InvalidInput {
1740                reason: format!("awh_positionen[{i}].anzahl must be ≥ 1"),
1741            });
1742        }
1743        if awh.preis_eur < Decimal::ZERO {
1744            return Err(BillingError::InvalidInput {
1745                reason: format!("awh_positionen[{i}].preis_eur must be non-negative"),
1746            });
1747        }
1748    }
1749
1750    // AWH charges rest on the GasNEV §14 charge authorisation, i.e. on the
1751    // Entgelt axis that lapses with 2028 — so a period under AgNeS is refused
1752    // like an NNE settlement, not billed under an authorisation that no
1753    // longer exists.
1754    let regime =
1755        crate::regulatory::RegulatoryRegime::for_period(input.period.from(), input.period.to());
1756    regime.ensure_berechenbar()?;
1757
1758    let mut warnings: Vec<SettlementWarning> = Vec::new();
1759    warn_if_straddles_turnover(input.period.from(), input.period.to(), &mut warnings);
1760
1761    let tariff_src = make_tariff_source(input.tariff_sheet_id.as_deref());
1762    let awh_legal_refs = vec![
1763        LegalReference::BdewAhb {
1764            reference: "GeLi Gas 3.0 (BK7-24-01-009) §5.4",
1765        },
1766        LegalReference::GasNev { paragraph: "§14" },
1767    ];
1768
1769    let mut positions: Vec<SettlementPosition> = Vec::new();
1770    let mut total = Decimal::ZERO;
1771
1772    for awh in input.awh_positionen.iter() {
1773        let qty = Decimal::from(awh.anzahl);
1774        let gross = qty * awh.preis_eur;
1775        let net = pos_net(qty, awh.preis_eur);
1776        positions.push(SettlementPosition {
1777            text: awh.beschreibung.clone(),
1778            kind: BillingPositionKind::GasAwhSonstige, // service layer refines if artikel_id present
1779            quantity: qty,
1780            unit: QuantityUnit::Monat, // AWH positions have no standard EDIFACT unit; Monat placeholder
1781            unit_price_eur: awh.preis_eur.round_dp(6),
1782            net_eur: net,
1783            spot_price_formula: None,
1784
1785            trace: CalculationTrace {
1786                explanation: format!(
1787                    "{} × {:.5} EUR = {:.5} EUR",
1788                    awh.anzahl,
1789                    awh.preis_eur,
1790                    gross.round_dp(5)
1791                ),
1792                input_quantity: qty,
1793                input_unit_price_eur: awh.preis_eur,
1794                gross_eur: gross,
1795                legal_refs: awh_legal_refs.clone(),
1796                tariff_source: tariff_src.clone(),
1797                regulatory_reduction_factor: None,
1798                rounding_note: Some("net rounded to 5 dp"),
1799            },
1800        });
1801        total += net;
1802    }
1803
1804    let total_eur = total.round_dp(2);
1805    ensure_representable_eur(total_eur)?;
1806
1807    let result = SettlementResult {
1808        malo_id: input.malo_id.clone(),
1809        // AWH Sperrprozesse are a Gas process (GeLi Gas 3.0 §5.4).
1810        sparte: Sparte::Gas,
1811        regime,
1812        settlement_type: SettlementType::GasAwhSperrung,
1813        status: SettlementStatus::Initial,
1814        korrektur_grund: None,
1815        period: input.period,
1816        sender_mp_id: input.nb_mp_id.clone(),
1817        recipient_mp_id: input.lf_mp_id.clone(),
1818        positions,
1819        total_eur,
1820        // Abrechnungswürdige Handlungen are services performed during the
1821        // Sperrprozess — a sonstige Leistung, never reverse-charged.
1822        steuer: crate::umsatzsteuer::steuerausweis(
1823            total_eur,
1824            crate::umsatzsteuer::Leistungsart::SonstigeLeistung,
1825            crate::umsatzsteuer::Wiederverkaeuferstatus::KEINER,
1826            input.period,
1827        )?,
1828        warnings,
1829    };
1830    debug_assert_eq!(
1831        result.total_eur,
1832        result.recomputed_total(),
1833        "AWH: total_eur mismatch — calculation bug"
1834    );
1835    Ok(result)
1836}
1837#[cfg(test)]
1838/// Build a §14a Modul 1 Arbeitspreis over the standard test basis.
1839///
1840/// `pauschale_eur_pro_jahr` is the NB's published annual amount; the period here
1841/// is one month, so a twelfth of it is credited.
1842fn modul1(pauschale_eur_pro_jahr: Decimal) -> ArbeitspreisModell {
1843    use crate::types::MengePreis;
1844    ArbeitspreisModell::Modul1Pauschal {
1845        basis: MengePreis {
1846            menge_kwh: rust_decimal::dec!(1500),
1847            preis_ct_per_kwh: rust_decimal::dec!(3.5),
1848        },
1849        pauschale_eur_pro_jahr,
1850        jahresanteil: rust_decimal::dec!(1) / rust_decimal::dec!(12),
1851    }
1852}
1853
1854#[cfg(test)]
1855mod tests {
1856    use super::*;
1857    use crate::types::{
1858        AwhPositionInput, GasAwhInput, InvoiceDocument, MsbEmpfaengerRolle, MsbRechnungsempfaenger,
1859        SettlementPeriod, validate_msb_input,
1860    };
1861    use crate::types::{
1862        GemeindeGroesse, Grundpreis, Konzessionsabgabe, Leistungspreis, MengePreis,
1863    };
1864    use rust_decimal::Decimal;
1865    use rust_decimal::dec;
1866    use time::macros::date;
1867
1868    fn d(s: &str) -> Decimal {
1869        Decimal::from_str_exact(s).expect("valid decimal literal")
1870    }
1871
1872    fn base_nne() -> NneInput {
1873        NneInput {
1874            blindarbeit: None,
1875            malo_id: "51238696012".into(),
1876            nb_mp_id: "9900357000004".to_owned(),
1877            lf_mp_id: "9900012345678".into(),
1878            period: SettlementPeriod::new(date!(2025 - 01 - 01), date!(2025 - 01 - 31)).unwrap(),
1879            arbeitspreis: ArbeitspreisModell::Einheitlich(MengePreis {
1880                menge_kwh: d("1500"),
1881                preis_ct_per_kwh: d("3.5"),
1882            }),
1883            leistungspreis: None,
1884            letztverbrauchergruppe: Default::default(),
1885            sect19_umlage_ct_per_kwh: None,
1886            offshore_umlage_ct_per_kwh: None,
1887            kwkg_umlage_ct_per_kwh: None,
1888            netzebene: None,
1889            sect19: None,
1890            gas_kapazitaet: None,
1891            jahreshoechstleistung_kw: None,
1892            jahresarbeit_kwh: None,
1893            konzessionsabgabe: None,
1894            grundpreis: None,
1895            tariff_sheet_id: None,
1896            sparte: Sparte::Strom,
1897        }
1898    }
1899
1900    /// Billing more months than the period serves is caught.
1901    ///
1902    /// `billing_months` and the delivery period were independent, so a request
1903    /// could bill twelve months of Grundgebühr over one month of service — a
1904    /// twelvefold over-charge that adds up perfectly and reads as an annual
1905    /// invoice.
1906    #[test]
1907    fn billing_more_months_than_the_period_serves_is_flagged() {
1908        let mut input = base_msb();
1909        input.billing_months = 12; // period is one month
1910        let r = settle_msb(&input).expect("settles");
1911        assert!(
1912            r.warnings
1913                .iter()
1914                .any(|w| w.code == "BILLING_MONTHS_MISMATCH"),
1915            "{:#?}",
1916            r.warnings
1917        );
1918
1919        // The matching case is silent.
1920        let matching = settle_msb(&base_msb()).expect("settles");
1921        assert!(
1922            !matching
1923                .warnings
1924                .iter()
1925                .any(|w| w.code == "BILLING_MONTHS_MISMATCH"),
1926            "{:#?}",
1927            matching.warnings
1928        );
1929
1930        // A full year over a full year is silent too.
1931        let mut annual = base_msb();
1932        annual.period =
1933            SettlementPeriod::new(date!(2026 - 01 - 01), date!(2026 - 12 - 31)).unwrap();
1934        annual.billing_months = 12;
1935        let annual = settle_msb(&annual).expect("settles");
1936        assert!(
1937            !annual
1938                .warnings
1939                .iter()
1940                .any(|w| w.code == "BILLING_MONTHS_MISMATCH"),
1941            "{:#?}",
1942            annual.warnings
1943        );
1944    }
1945
1946    /// A Leistungspreis on a Gas settlement cites GasNEV, and says it is odd.
1947    ///
1948    /// §17 StromNEV is the electricity Leistungspreis authorisation; gas prices
1949    /// capacity through §15 GasNEV. Citing §17 on a gas invoice claims a basis
1950    /// the ordinance does not give.
1951    #[test]
1952    fn a_leistungspreis_on_gas_does_not_cite_stromnev() {
1953        let mut input = base_nne();
1954        input.sparte = Sparte::Gas;
1955        input.leistungspreis = Some(Leistungspreis {
1956            spitzenleistung_kw: d("40"),
1957            preis_eur_per_kw: d("12.50"),
1958        });
1959        let r = settle_nne(&input).expect("settles");
1960        assert!(
1961            r.warnings.iter().any(|w| w.code == "LEISTUNGSPREIS_ON_GAS"),
1962            "{:#?}",
1963            r.warnings
1964        );
1965        let refs: Vec<String> = r
1966            .positions
1967            .iter()
1968            .filter(|p| p.kind == BillingPositionKind::NneLeistung)
1969            .flat_map(|p| p.trace.legal_refs.iter().map(LegalReference::citation))
1970            .collect();
1971        assert!(
1972            refs.iter().all(|r| !r.contains("StromNEV")),
1973            "a gas Leistungspreis must not cite StromNEV: {refs:?}"
1974        );
1975    }
1976
1977    fn base_msb() -> MsbInput {
1978        MsbInput {
1979            sparte: Sparte::Strom,
1980            malo_id: "51238696012".to_owned(),
1981            empfaenger: MsbRechnungsempfaenger {
1982                rolle: MsbEmpfaengerRolle::Netzbetreiber,
1983                mp_id: "9900357000004".to_owned(),
1984            },
1985            msb_mp_id: "4012345000023".to_owned(),
1986            period: SettlementPeriod::new(date!(2026 - 01 - 01), date!(2026 - 01 - 31)).unwrap(),
1987            grundgebuehr_eur_per_month: d("3.00"),
1988            billing_months: 1,
1989            messdienstleistung_eur: None,
1990            messstellen_kategorie: None,
1991            entgeltschuldner: None,
1992        }
1993    }
1994
1995    fn base_mmm() -> MmmInput {
1996        MmmInput {
1997            malo_id: "51238696012".into(),
1998            nb_mp_id: "9900357000004".to_owned(),
1999            lf_mp_id: "9900012345678".into(),
2000            period: SettlementPeriod::new(date!(2025 - 01 - 01), date!(2025 - 01 - 31)).unwrap(),
2001            sparte: Sparte::Strom,
2002            actual_kwh: d("1600"),
2003            profil_kwh: d("1500"),
2004            mehr_preis_ct_per_kwh: d("4.0"),
2005            minder_preis_ct_per_kwh: d("2.0"),
2006            wiederverkaeufer: crate::umsatzsteuer::Wiederverkaeuferstatus::KEINER,
2007            selbstausgestellt: false,
2008        }
2009    }
2010
2011    #[test]
2012    fn nne_slp_no_ka_arithmetic() {
2013        let r = settle_nne(&base_nne()).unwrap();
2014        assert_eq!(r.total_eur, d("52.50"));
2015        assert_eq!(r.positions.len(), 1);
2016        assert_eq!(r.positions[0].unit, QuantityUnit::Kwh);
2017        assert_eq!(r.positions[0].net_eur, d("52.50000"));
2018    }
2019
2020    #[test]
2021    fn nne_slp_with_ka() {
2022        let mut i = base_nne();
2023        i.konzessionsabgabe = Some(Konzessionsabgabe {
2024            satz_ct_per_kwh: d("0.11"),
2025            klasse: KaKundengruppe::Sondervertragskunde,
2026        });
2027        let r = settle_nne(&i).unwrap();
2028        assert_eq!(r.total_eur, d("54.15"));
2029        assert_eq!(r.positions.len(), 2);
2030        // The position names the KAV group, because the group now always
2031        // accompanies the rate — which is what lets the Höchstbetrag be checked.
2032        assert_eq!(
2033            r.positions[1].text,
2034            "Konzessionsabgabe (KAV §2 Abs. 3 Sondervertragskunde)"
2035        );
2036    }
2037
2038    #[test]
2039    fn nne_rlm_with_leistungspreis() {
2040        let mut i = base_nne();
2041        i.leistungspreis = Some(Leistungspreis {
2042            spitzenleistung_kw: d("12.5"),
2043            preis_eur_per_kw: d("4.20"),
2044        });
2045        i.konzessionsabgabe = Some(Konzessionsabgabe {
2046            satz_ct_per_kwh: d("0.11"),
2047            klasse: KaKundengruppe::Sondervertragskunde,
2048        });
2049        let r = settle_nne(&i).unwrap();
2050        assert_eq!(r.total_eur, d("106.65"));
2051        assert_eq!(r.positions.len(), 3);
2052        assert_eq!(r.positions[1].unit, QuantityUnit::Kw);
2053    }
2054
2055    #[test]
2056    fn nne_sect14a_tou_arithmetic() {
2057        let mut i = base_nne();
2058        i.arbeitspreis = ArbeitspreisModell::Modul3ZeitVariabel {
2059            ht: MengePreis {
2060                menge_kwh: d("900"),
2061                preis_ct_per_kwh: d("4.0"),
2062            },
2063            st: MengePreis {
2064                menge_kwh: d("0"),
2065                preis_ct_per_kwh: d("0"),
2066            },
2067            nt: MengePreis {
2068                menge_kwh: d("600"),
2069                preis_ct_per_kwh: d("2.0"),
2070            },
2071        };
2072        let r = settle_nne(&i).unwrap();
2073        assert_eq!(r.total_eur, d("48.00"));
2074        // BK6-22-300 defines three Tarifstufen; the ST band carries no energy
2075        // here but is still billed, so the invoice shows the full structure.
2076        assert_eq!(r.positions.len(), 3);
2077        assert_eq!(r.positions[0].text, "Netznutzung Arbeit HT (§14a Modul 3)");
2078        assert_eq!(r.positions[0].net_eur, d("36.00000"));
2079        assert_eq!(r.positions[1].text, "Netznutzung Arbeit ST (§14a Modul 3)");
2080        assert_eq!(r.positions[1].net_eur, d("0.00000"));
2081        assert_eq!(r.positions[2].net_eur, d("12.00000"));
2082    }
2083
2084    /// Blindmehrarbeit is billed on the excess only, and only when there is one.
2085    ///
2086    /// The position kind, the Artikelnummer and the BO4E bridge all existed
2087    /// before the calculation did — nothing could produce the position, so a
2088    /// network that charges reactive energy was simply under-billed with no
2089    /// signal anywhere.
2090    #[test]
2091    fn blindmehrarbeit_bills_only_the_excess() {
2092        use crate::types::Blindarbeit;
2093
2094        let mut i = base_nne();
2095        i.arbeitspreis = ArbeitspreisModell::Einheitlich(MengePreis {
2096            menge_kwh: d("1000"),
2097            preis_ct_per_kwh: d("5.0"),
2098        });
2099
2100        // Inside the free share → no position at all.
2101        i.blindarbeit = Some(Blindarbeit {
2102            blindarbeit_kvarh: d("400"),
2103            freigrenze_anteil: Blindarbeit::COS_PHI_0_9,
2104            preis_ct_per_kvarh: d("2.0"),
2105        });
2106        let r = settle_nne(&i).expect("settles");
2107        assert!(
2108            !r.positions
2109                .iter()
2110                .any(|p| p.kind == BillingPositionKind::Blindmehrarbeit),
2111            "a draw inside the free share raises no charge"
2112        );
2113
2114        // Beyond it → 600 − (1 000 × 0,4843) = 115,7 kvarh × 0,02 EUR = 2,314 EUR.
2115        i.blindarbeit = Some(Blindarbeit {
2116            blindarbeit_kvarh: d("600"),
2117            freigrenze_anteil: Blindarbeit::COS_PHI_0_9,
2118            preis_ct_per_kvarh: d("2.0"),
2119        });
2120        let r = settle_nne(&i).expect("settles");
2121        let p = r
2122            .positions
2123            .iter()
2124            .find(|p| p.kind == BillingPositionKind::Blindmehrarbeit)
2125            .expect("the excess is billed");
2126        assert_eq!(p.quantity, d("115.700"));
2127        assert_eq!(p.unit, QuantityUnit::Kvarh);
2128        assert_eq!(p.net_eur, d("2.31400"));
2129        // The basis is the NB's Preisblatt under StromNEV §17 — not §18.
2130        assert!(
2131            p.trace.legal_refs.iter().any(
2132                |r| matches!(r, LegalReference::StromNev { paragraph } if *paragraph == "§17")
2133            ),
2134            "{:?}",
2135            p.trace.legal_refs
2136        );
2137    }
2138
2139    /// An inverted period cannot reach the engine at all.
2140    ///
2141    /// Constructing `SettlementPeriod` is the check, so no per-calculation guard
2142    /// re-tests it.
2143    #[test]
2144    fn an_inverted_period_is_unrepresentable() {
2145        assert!(matches!(
2146            SettlementPeriod::new(date!(2025 - 01 - 31), date!(2025 - 01 - 01)),
2147            Err(BillingError::InvalidInput { .. })
2148        ));
2149        // A single day is a valid period, not an inverted one.
2150        assert!(SettlementPeriod::new(date!(2025 - 01 - 01), date!(2025 - 01 - 01)).is_ok());
2151    }
2152
2153    /// A Bandlast agreement takes the Netzentgelt down to the agreed fraction,
2154    /// leaves the KA and levies whole, and records the factor in the trace.
2155    #[test]
2156    fn a_sect19_agreement_reduces_only_the_netzentgelt() {
2157        use crate::sect19::{Sect19Art, Sect19Vereinbarung};
2158
2159        let mut i = base_nne();
2160        // 12 GWh at 1500 kW → 8000 h: the 10 % floor tier.
2161        i.jahresarbeit_kwh = Some(d("12000000"));
2162        i.jahreshoechstleistung_kw = Some(d("1500"));
2163        i.leistungspreis = Some(Leistungspreis {
2164            spitzenleistung_kw: d("1500"),
2165            preis_eur_per_kw: d("10.00"),
2166        });
2167        i.konzessionsabgabe = Some(Konzessionsabgabe {
2168            satz_ct_per_kwh: d("0.11"),
2169            klasse: KaKundengruppe::Sondervertragskunde,
2170        });
2171        i.sect19 = Some(Sect19Vereinbarung {
2172            art: Sect19Art::IntensiveNetznutzung,
2173            vereinbarter_prozentsatz: d("0.10"),
2174            genehmigung: Some("BK4-22-089".to_owned()),
2175        });
2176
2177        let r = settle_nne(&i).expect("settles");
2178        let reduction = r
2179            .positions
2180            .iter()
2181            .find(|p| p.kind == BillingPositionKind::Sect19IndividuellesEntgelt)
2182            .expect("the reduction position exists");
2183
2184        // Netzentgelt basis: 1500 kWh × 0.035 + 1500 kW × 10 = 52.50 + 15000.
2185        // Reduction: −90 % of 15052.50 = −13547.25.
2186        assert_eq!(reduction.net_eur, d("-13547.25000"));
2187        assert_eq!(
2188            reduction.trace.regulatory_reduction_factor,
2189            Some(d("0.10")),
2190            "the agreed fraction is in the trace"
2191        );
2192        // The KA position is untouched by the reduction.
2193        let ka = r
2194            .positions
2195            .iter()
2196            .find(|p| p.kind == BillingPositionKind::Konzessionsabgabe)
2197            .expect("KA still billed");
2198        assert!(ka.net_eur > Decimal::ZERO);
2199        // 10 % is exactly the floor at 8000 h — no warning.
2200        assert!(
2201            !r.warnings
2202                .iter()
2203                .any(|w| w.code == "SECT19_BELOW_MINDESTENTGELT"),
2204            "{:?}",
2205            r.warnings
2206        );
2207    }
2208
2209    /// Below the statutory floor the settlement still computes, but says so.
2210    #[test]
2211    fn an_agreement_below_the_floor_is_reported() {
2212        use crate::sect19::{Sect19Art, Sect19Vereinbarung};
2213
2214        let mut i = base_nne();
2215        // 10 GWh at ~1408 kW → 7102 h: the 20 % tier.
2216        i.jahresarbeit_kwh = Some(d("10000000"));
2217        i.jahreshoechstleistung_kw = Some(d("1408"));
2218        i.sect19 = Some(Sect19Vereinbarung {
2219            art: Sect19Art::IntensiveNetznutzung,
2220            vereinbarter_prozentsatz: d("0.10"),
2221            genehmigung: None,
2222        });
2223        let r = settle_nne(&i).expect("settles");
2224        assert!(
2225            r.warnings
2226                .iter()
2227                .any(|w| w.code == "SECT19_BELOW_MINDESTENTGELT"),
2228            "10 % agreed where the floor is 20 %: {:?}",
2229            r.warnings
2230        );
2231    }
2232
2233    /// A gas capacity charge is pro-rated by calendar days over the year.
2234    #[test]
2235    fn a_gas_capacity_charge_is_pro_rated_by_days() {
2236        use crate::gas::{Druckstufe, GasKapazitaet, Kapazitaetsprodukt};
2237
2238        let mut i = base_nne();
2239        i.sparte = Sparte::Gas;
2240        // base period is January 2025: 31 days.
2241        i.gas_kapazitaet = Some(GasKapazitaet {
2242            bestellte_kapazitaet_kwh_h: d("500"),
2243            entgelt_eur_per_kwh_h_a: d("14.60"),
2244            produkt: Kapazitaetsprodukt::Unterbrechbar,
2245            druckstufe: Some(Druckstufe::Mitteldruck),
2246        });
2247        let r = settle_nne(&i).expect("settles");
2248        let kap = r
2249            .positions
2250            .iter()
2251            .find(|p| p.kind == BillingPositionKind::GasKapazitaetsentgelt)
2252            .expect("capacity position exists");
2253        // 14.60 × 31/365 = 1.24 EUR per kWh/h; × 500 = 620.00.
2254        assert_eq!(kap.unit_price_eur, d("1.24"));
2255        assert_eq!(kap.net_eur, d("620.00000"));
2256        assert!(
2257            kap.trace
2258                .legal_refs
2259                .iter()
2260                .any(|lr| lr.citation().contains("GasNEV §15 Abs. 5")),
2261            "interruptible capacity cites Abs. 5: {:?}",
2262            kap.trace.legal_refs
2263        );
2264        assert!(kap.text.contains("Mitteldruck"));
2265    }
2266
2267    /// Supplied on Strom, the gas structure is refused with a warning, not billed.
2268    #[test]
2269    fn a_gas_capacity_charge_on_strom_is_not_billed() {
2270        use crate::gas::{GasKapazitaet, Kapazitaetsprodukt};
2271
2272        let mut i = base_nne();
2273        i.gas_kapazitaet = Some(GasKapazitaet {
2274            bestellte_kapazitaet_kwh_h: d("500"),
2275            entgelt_eur_per_kwh_h_a: d("14.60"),
2276            produkt: Kapazitaetsprodukt::Fest,
2277            druckstufe: None,
2278        });
2279        let r = settle_nne(&i).expect("settles");
2280        assert!(
2281            !r.positions
2282                .iter()
2283                .any(|p| p.kind == BillingPositionKind::GasKapazitaetsentgelt)
2284        );
2285        assert!(
2286            r.warnings
2287                .iter()
2288                .any(|w| w.code == "GAS_KAPAZITAET_ON_STROM")
2289        );
2290    }
2291
2292    /// A demand charge is a pair, so half of one cannot be built.
2293    ///
2294    /// The `Leistungspreis` type is the check, rather than a runtime error
2295    /// repeated at each call site.
2296    #[test]
2297    fn a_demand_charge_is_a_pair() {
2298        let mut i = base_nne();
2299        i.leistungspreis = Some(Leistungspreis {
2300            spitzenleistung_kw: d("10"),
2301            preis_eur_per_kw: d("4.20"),
2302        });
2303        let r = settle_nne(&i).expect("a complete pair settles");
2304        assert!(
2305            r.positions
2306                .iter()
2307                .any(|p| p.kind == BillingPositionKind::NneLeistung),
2308            "the demand charge must be billed"
2309        );
2310    }
2311
2312    /// measured > profiled is an **ungewollte Mindermenge** — the NB supplied the
2313    /// shortfall and invoices it. GPKE (BK6-24-174) Teil 1 Kap. 8.4 Nr. 3.
2314    #[test]
2315    fn over_consumption_is_a_mindermenge_charge() {
2316        let input = MmmInput {
2317            malo_id: "51238696012".into(),
2318            nb_mp_id: "9900357000004".to_owned(),
2319            lf_mp_id: "9900012345678".into(),
2320            period: SettlementPeriod::new(date!(2025 - 01 - 01), date!(2025 - 01 - 31)).unwrap(),
2321            sparte: Sparte::Strom,
2322            actual_kwh: d("1600"),
2323            profil_kwh: d("1500"),
2324            mehr_preis_ct_per_kwh: d("4.0"),
2325            minder_preis_ct_per_kwh: d("2.0"),
2326            wiederverkaeufer: crate::umsatzsteuer::Wiederverkaeuferstatus::KEINER,
2327            selbstausgestellt: false,
2328        };
2329        let r = settle_mmm(&input).unwrap();
2330        // 100 kWh over profile × 2.0 ct = 2.00 EUR charged at the Mindermengen price.
2331        assert_eq!(r.total_eur, d("2.00"));
2332        assert_eq!(
2333            r.positions[0].net_eur,
2334            Decimal::ZERO,
2335            "no Mehrmenge position"
2336        );
2337        assert_eq!(r.positions[1].quantity, d("100.000"));
2338    }
2339
2340    /// measured < profiled is an **ungewollte Mehrmenge** — the NB took the
2341    /// surplus and reimburses it. GPKE (BK6-24-174) Teil 1 Kap. 8.4 Nr. 3:
2342    /// "so vergütet der Netzbetreiber dem Lieferanten [...] diese Differenzmenge".
2343    #[test]
2344    fn under_consumption_is_a_mehrmenge_credit() {
2345        let input = MmmInput {
2346            malo_id: "51238696012".into(),
2347            nb_mp_id: "9900357000004".to_owned(),
2348            lf_mp_id: "9900012345678".into(),
2349            period: SettlementPeriod::new(date!(2025 - 01 - 01), date!(2025 - 01 - 31)).unwrap(),
2350            sparte: Sparte::Strom,
2351            actual_kwh: d("1400"),
2352            profil_kwh: d("1500"),
2353            mehr_preis_ct_per_kwh: d("4.0"),
2354            minder_preis_ct_per_kwh: d("2.0"),
2355            wiederverkaeufer: crate::umsatzsteuer::Wiederverkaeuferstatus::KEINER,
2356            selbstausgestellt: false,
2357        };
2358        let r = settle_mmm(&input).unwrap();
2359        // 100 kWh under profile × 4.0 ct = 4.00 EUR credited at the Mehrmengen price.
2360        assert_eq!(r.total_eur, d("-4.00"));
2361        assert_eq!(r.positions[0].net_eur, d("-4.00000"));
2362        assert_eq!(
2363            r.positions[1].net_eur,
2364            Decimal::ZERO,
2365            "no Mindermenge position"
2366        );
2367    }
2368
2369    /// The two quantities must never both be non-zero.
2370    #[test]
2371    fn mehr_and_minder_are_mutually_exclusive() {
2372        for (actual, profil) in [("1600", "1500"), ("1400", "1500"), ("1500", "1500")] {
2373            let mut i = base_mmm();
2374            i.actual_kwh = d(actual);
2375            i.profil_kwh = d(profil);
2376            let r = settle_mmm(&i).unwrap();
2377            assert!(
2378                r.positions[0].quantity == Decimal::ZERO
2379                    || r.positions[1].quantity == Decimal::ZERO,
2380                "{actual}/{profil}: both positions carry a quantity"
2381            );
2382        }
2383    }
2384
2385    #[test]
2386    fn msb_grundgebuehr_only() {
2387        let input = MsbInput {
2388            sparte: Sparte::Strom,
2389            malo_id: "51238696012".into(),
2390            empfaenger: MsbRechnungsempfaenger {
2391                rolle: MsbEmpfaengerRolle::Netzbetreiber,
2392                mp_id: "9900357000004".to_owned(),
2393            },
2394            msb_mp_id: "9900123400001".into(),
2395            period: SettlementPeriod::new(date!(2025 - 01 - 01), date!(2025 - 01 - 31)).unwrap(),
2396            grundgebuehr_eur_per_month: d("12.50"),
2397            billing_months: 1,
2398            messdienstleistung_eur: None,
2399            messstellen_kategorie: None,
2400            entgeltschuldner: None,
2401        };
2402        let r = settle_msb(&input).unwrap();
2403        assert_eq!(r.total_eur, d("12.50"));
2404        assert_eq!(r.positions.len(), 1);
2405        assert_eq!(r.positions[0].unit, QuantityUnit::Monat);
2406    }
2407
2408    #[test]
2409    fn msb_with_messdienstleistung() {
2410        let input = MsbInput {
2411            sparte: Sparte::Strom,
2412            malo_id: "51238696012".into(),
2413            empfaenger: MsbRechnungsempfaenger {
2414                rolle: MsbEmpfaengerRolle::Netzbetreiber,
2415                mp_id: "9900357000004".to_owned(),
2416            },
2417            msb_mp_id: "9900123400001".into(),
2418            period: SettlementPeriod::new(date!(2025 - 01 - 01), date!(2025 - 03 - 31)).unwrap(),
2419            grundgebuehr_eur_per_month: d("12.50"),
2420            billing_months: 3,
2421            messdienstleistung_eur: Some(d("8.00")),
2422            messstellen_kategorie: None,
2423            entgeltschuldner: None,
2424        };
2425        let r = settle_msb(&input).unwrap();
2426        assert_eq!(r.total_eur, d("45.50"));
2427        assert_eq!(r.positions.len(), 2);
2428    }
2429
2430    /// The Prüfidentifikator is a property of the document, not the settlement.
2431    ///
2432    /// It lives on `InvoiceDocument`, where routing information belongs — not
2433    /// as a mutable field the caller patches after calculation.
2434    #[test]
2435    fn the_pid_lives_on_the_document_not_the_settlement() {
2436        let settlement = settle_nne(&base_nne()).unwrap();
2437        let doc = InvoiceDocument {
2438            settlement,
2439            pid: 31002,
2440            rechnungsnummer: "NNE-2025-001".to_owned(),
2441            correction_of: None,
2442            invoice_date: date!(2025 - 02 - 15),
2443            due_date: date!(2025 - 03 - 15),
2444            cadence: None,
2445            abschlaege: Vec::new(),
2446        };
2447        assert_eq!(doc.pid, 31002);
2448        // and numbering is assigned at rendering time
2449        let numbers: Vec<u32> = doc.numbered_positions().map(|(n, _)| n).collect();
2450        assert_eq!(numbers.first(), Some(&1));
2451    }
2452
2453    // ── New: explainability and audit trail tests ─────────────────────────────
2454
2455    #[test]
2456    fn nne_slp_has_legal_reference_stromnev() {
2457        let r = settle_nne(&base_nne()).unwrap();
2458        let refs = r.all_legal_refs();
2459        assert!(
2460            refs.iter().any(|r| r.contains("StromNEV")),
2461            "expected StromNEV reference, got: {refs:?}"
2462        );
2463    }
2464
2465    #[test]
2466    fn nne_ka_has_kav_reference() {
2467        let mut i = base_nne();
2468        i.konzessionsabgabe = Some(Konzessionsabgabe {
2469            satz_ct_per_kwh: d("0.11"),
2470            klasse: KaKundengruppe::Sondervertragskunde,
2471        });
2472        let r = settle_nne(&i).unwrap();
2473        let refs = r.all_legal_refs();
2474        assert!(
2475            refs.iter().any(|r| r.contains("KAV")),
2476            "expected KAV reference, got: {refs:?}"
2477        );
2478    }
2479
2480    #[test]
2481    fn nne_tou_has_sect14a_reference() {
2482        let mut i = base_nne();
2483        i.arbeitspreis = ArbeitspreisModell::Modul3ZeitVariabel {
2484            ht: MengePreis {
2485                menge_kwh: d("900"),
2486                preis_ct_per_kwh: d("4.0"),
2487            },
2488            st: MengePreis {
2489                menge_kwh: d("0"),
2490                preis_ct_per_kwh: d("0"),
2491            },
2492            nt: MengePreis {
2493                menge_kwh: d("600"),
2494                preis_ct_per_kwh: d("2.0"),
2495            },
2496        };
2497        let r = settle_nne(&i).unwrap();
2498        let refs = r.all_legal_refs();
2499        assert!(
2500            refs.iter().any(|r| r.contains("§14a EnWG")),
2501            "expected §14a EnWG reference, got: {refs:?}"
2502        );
2503        assert!(
2504            refs.iter().any(|r| r.contains("BK6-22-300")),
2505            "expected BK6-22-300 reference, got: {refs:?}"
2506        );
2507    }
2508
2509    #[test]
2510    fn mmm_has_strom_nzv_reference() {
2511        let input = base_mmm();
2512        let r = settle_mmm(&input).unwrap();
2513        let refs = r.all_legal_refs();
2514        assert!(
2515            refs.iter().any(|r| r.contains("StromNZV")),
2516            "expected StromNZV reference for a 2025 period, got: {refs:?}"
2517        );
2518    }
2519
2520    /// A metering charge above the §30 MsbG Preisobergrenze is reported.
2521    ///
2522    /// The §30 MsbG Preisobergrenze and the KAV ceiling are both Höchstbeträge,
2523    /// so an amount above either is one the customer may reclaim. Validating
2524    /// only that the fee is non-negative catches neither.
2525    #[test]
2526    fn a_metering_charge_above_the_msbg_ceiling_is_reported() {
2527        use crate::msbg::{Entgeltschuldner, MessstellenKategorie, PflichtBand};
2528
2529        let mut i = base_msb();
2530        i.messstellen_kategorie = Some(MessstellenKategorie::Pflichteinbau(PflichtBand::Bis10000));
2531        i.entgeltschuldner = Some(Entgeltschuldner::Letztverbraucher);
2532
2533        // 40 EUR/a is the ceiling for this band; 5 EUR/month is 60 EUR/a.
2534        i.grundgebuehr_eur_per_month = d("5.00");
2535        let over = settle_msb(&i).expect("settles");
2536        assert!(
2537            over.warnings.iter().any(|w| w.code == "MSB_ABOVE_MSBG_POG"),
2538            "60 EUR/a exceeds the 40 EUR/a ceiling: {:?}",
2539            over.warnings
2540        );
2541
2542        // 3 EUR/month is 36 EUR/a — within it.
2543        i.grundgebuehr_eur_per_month = d("3.00");
2544        let within = settle_msb(&i).expect("settles");
2545        assert!(
2546            !within
2547                .warnings
2548                .iter()
2549                .any(|w| w.code == "MSB_ABOVE_MSBG_POG"),
2550            "36 EUR/a is within the ceiling: {:?}",
2551            within.warnings
2552        );
2553    }
2554
2555    /// Annualising is what makes the comparison right.
2556    ///
2557    /// The ceiling is per year and the charge per month; billing a year in
2558    /// instalments does not raise the cap.
2559    #[test]
2560    fn the_ceiling_is_compared_against_the_annualised_charge() {
2561        use crate::msbg::{Entgeltschuldner, MessstellenKategorie, PflichtBand};
2562
2563        let mut i = base_msb();
2564        i.messstellen_kategorie = Some(MessstellenKategorie::Pflichteinbau(PflichtBand::Bis100000));
2565        i.entgeltschuldner = Some(Entgeltschuldner::Letztverbraucher);
2566        // 140 EUR/a ceiling. 12 EUR/month = 144 EUR/a — over, even though a
2567        // single month is far below the annual figure.
2568        i.grundgebuehr_eur_per_month = d("12.00");
2569        let r = settle_msb(&i).expect("settles");
2570        assert!(r.warnings.iter().any(|w| w.code == "MSB_ABOVE_MSBG_POG"));
2571    }
2572
2573    #[test]
2574    fn msb_has_msbg_reference() {
2575        let input = MsbInput {
2576            sparte: Sparte::Strom,
2577            malo_id: "51238696012".into(),
2578            empfaenger: MsbRechnungsempfaenger {
2579                rolle: MsbEmpfaengerRolle::Netzbetreiber,
2580                mp_id: "9900357000004".to_owned(),
2581            },
2582            msb_mp_id: "9900123400001".into(),
2583            period: SettlementPeriod::new(date!(2025 - 01 - 01), date!(2025 - 01 - 31)).unwrap(),
2584            grundgebuehr_eur_per_month: d("12.50"),
2585            billing_months: 1,
2586            messdienstleistung_eur: None,
2587            messstellen_kategorie: None,
2588            entgeltschuldner: None,
2589        };
2590        let r = settle_msb(&input).unwrap();
2591        let refs = r.all_legal_refs();
2592        assert!(
2593            refs.iter().any(|r| r.contains("MsbG")),
2594            "expected MsbG reference, got: {refs:?}"
2595        );
2596    }
2597
2598    #[test]
2599    fn calculation_trace_explanation_non_empty() {
2600        let r = settle_nne(&base_nne()).unwrap();
2601        for pos in &r.positions {
2602            assert!(
2603                !pos.trace.explanation.is_empty(),
2604                "every position must explain itself: {}",
2605                pos.text
2606            );
2607        }
2608    }
2609
2610    #[test]
2611    fn settlement_type_and_status_set() {
2612        let r = settle_nne(&base_nne()).unwrap();
2613        assert_eq!(r.settlement_type, SettlementType::NneStrom);
2614        assert_eq!(r.status, SettlementStatus::Initial);
2615    }
2616
2617    #[test]
2618    fn recomputed_total_matches_total_eur() {
2619        let mut i = base_nne();
2620        i.leistungspreis = Some(Leistungspreis {
2621            spitzenleistung_kw: d("12.5"),
2622            preis_eur_per_kw: d("4.20"),
2623        });
2624        i.konzessionsabgabe = Some(Konzessionsabgabe {
2625            satz_ct_per_kwh: d("0.11"),
2626            klasse: KaKundengruppe::Sondervertragskunde,
2627        });
2628        let r = settle_nne(&i).unwrap();
2629        assert_eq!(
2630            r.total_eur,
2631            r.recomputed_total(),
2632            "total_eur does not match sum of positions"
2633        );
2634    }
2635
2636    #[test]
2637    fn tariff_sheet_id_propagates_to_traces() {
2638        let mut i = base_nne();
2639        i.tariff_sheet_id = Some("Preisblatt-NNE-2025-Q1".to_owned());
2640        let r = settle_nne(&i).unwrap();
2641        for pos in &r.positions {
2642            if pos.text != "Konzessionsabgabe" {
2643                assert!(
2644                    pos.trace.tariff_source.is_some(),
2645                    "position '{}' should have a tariff source",
2646                    pos.text
2647                );
2648            }
2649        }
2650    }
2651
2652    #[test]
2653    fn nne_negative_zero_nt_does_not_panic() {
2654        // Guard: zero consumption in one ToU band must produce zero position, not NaN
2655        let mut i = base_nne();
2656        i.arbeitspreis = ArbeitspreisModell::Modul3ZeitVariabel {
2657            ht: MengePreis {
2658                menge_kwh: d("1500"),
2659                preis_ct_per_kwh: d("4.0"),
2660            },
2661            st: MengePreis {
2662                menge_kwh: d("0"),
2663                preis_ct_per_kwh: d("0"),
2664            },
2665            nt: MengePreis {
2666                menge_kwh: d("0"),
2667                preis_ct_per_kwh: d("2.0"),
2668            },
2669        };
2670        let r = settle_nne(&i).unwrap();
2671        assert_eq!(r.positions[1].net_eur, Decimal::ZERO);
2672    }
2673
2674    /// A Strom NNE invoice for a covered year carries all three network levies.
2675    ///
2676    /// 1500 kWh at the 2026 A′ rates: §19 1.559 + Offshore 0.941 + KWKG 0.446
2677    /// = 2.946 ct/kWh → 44.19 EUR on top of the Arbeitspreis.
2678    #[test]
2679    fn a_covered_year_bills_all_three_network_levies() {
2680        let mut i = base_nne();
2681        i.period = SettlementPeriod::new(date!(2026 - 01 - 01), date!(2026 - 01 - 31)).unwrap();
2682        i.letztverbrauchergruppe = crate::umlagen::Letztverbrauchergruppe::A;
2683        let r = settle_nne(&i).unwrap();
2684
2685        let levies: Vec<_> = r
2686            .positions
2687            .iter()
2688            .filter(|p| {
2689                matches!(
2690                    p.kind,
2691                    BillingPositionKind::Sect19StromNevUmlage
2692                        | BillingPositionKind::OffshoreNetzumlage
2693                        | BillingPositionKind::KwkgUmlage
2694                )
2695            })
2696            .collect();
2697        assert_eq!(levies.len(), 3, "all three levies must appear");
2698
2699        let levy_total: Decimal = levies.iter().map(|p| p.net_eur).sum();
2700        assert_eq!(levy_total.round_dp(2), dec!(44.19));
2701        assert!(r.is_clean(), "a covered year must raise no warning");
2702    }
2703
2704    /// §21 EnFG exempts entirely — no line at all rather than a zero one.
2705    #[test]
2706    fn an_exempt_entnahmestelle_carries_no_levy_line() {
2707        let mut i = base_nne();
2708        i.period = SettlementPeriod::new(date!(2026 - 01 - 01), date!(2026 - 01 - 31)).unwrap();
2709        i.letztverbrauchergruppe = crate::umlagen::Letztverbrauchergruppe::Befreit;
2710        let r = settle_nne(&i).unwrap();
2711
2712        assert!(
2713            !r.positions.iter().any(|p| matches!(
2714                p.kind,
2715                BillingPositionKind::Sect19StromNevUmlage
2716                    | BillingPositionKind::OffshoreNetzumlage
2717                    | BillingPositionKind::KwkgUmlage
2718            )),
2719            "an exempt Entnahmestelle must carry no levy line"
2720        );
2721    }
2722
2723    /// A year the series does not cover omits the levy and says so.
2724    #[test]
2725    fn an_uncovered_year_warns_rather_than_billing_zero() {
2726        let mut i = base_nne();
2727        i.period = SettlementPeriod::new(date!(2027 - 01 - 01), date!(2027 - 01 - 31)).unwrap();
2728        let r = settle_nne(&i).unwrap();
2729
2730        let missing = r
2731            .warnings
2732            .iter()
2733            .filter(|w| w.code == "UMLAGE_RATE_MISSING")
2734            .count();
2735        assert_eq!(missing, 3, "each unresolvable levy must be reported");
2736    }
2737
2738    /// An override wins over the tabled rate — the EnFG-decision escape hatch.
2739    #[test]
2740    fn an_explicit_rate_overrides_the_tabled_one() {
2741        let mut i = base_nne();
2742        i.period = SettlementPeriod::new(date!(2026 - 01 - 01), date!(2026 - 01 - 31)).unwrap();
2743        i.sect19_umlage_ct_per_kwh = Some(dec!(0.100));
2744        let r = settle_nne(&i).unwrap();
2745
2746        let sect19 = r
2747            .positions
2748            .iter()
2749            .find(|p| p.kind == BillingPositionKind::Sect19StromNevUmlage)
2750            .expect("§19 position");
2751        // 1500 kWh × 0.100 ct/kWh = 1.50 EUR, not the tabled 23.39.
2752        assert_eq!(sect19.net_eur.round_dp(2), dec!(1.50));
2753    }
2754
2755    #[test]
2756    fn settlement_is_clean_with_valid_inputs() {
2757        let r = settle_nne(&base_nne()).unwrap();
2758        assert!(r.is_clean(), "clean NNE should have no warnings");
2759    }
2760
2761    #[test]
2762    fn legal_reference_citations_non_empty() {
2763        for lr in [
2764            LegalReference::StromNev { paragraph: "§17" },
2765            LegalReference::GasNev { paragraph: "§14" },
2766            LegalReference::Kav {
2767                paragraph: "§2 Abs. 2",
2768            },
2769            LegalReference::Sect14aEnwg {
2770                module: Sect14aModule::Modul3,
2771            },
2772            LegalReference::MsbG {
2773                paragraph: "§§6–7"
2774            },
2775            LegalReference::BnetzaDecision {
2776                reference: "BK6-22-300",
2777            },
2778            LegalReference::BdewAhb {
2779                reference: "GPKE BK6-22-024",
2780            },
2781            LegalReference::StromNzv { paragraph: "§15" },
2782            LegalReference::GasNzv { paragraph: "§14" },
2783            LegalReference::Enwg { paragraph: "§14a" },
2784            LegalReference::ARegV { paragraph: "§17" },
2785        ] {
2786            assert!(!lr.citation().is_empty());
2787        }
2788    }
2789
2790    #[test]
2791    fn settlement_type_default_pids() {
2792        assert_eq!(SettlementType::NneStrom.default_pid(), 31002);
2793        assert_eq!(SettlementType::NneGas.default_pid(), 31002);
2794        assert_eq!(SettlementType::MmmStrom.default_pid(), 31005);
2795        assert_eq!(SettlementType::MmmGas.default_pid(), 31005);
2796        assert_eq!(SettlementType::MmmSelbstausstellt.default_pid(), 31006);
2797        assert_eq!(SettlementType::MsbRechnung.default_pid(), 31009);
2798        assert_eq!(SettlementType::GasAwhSperrung.default_pid(), 31011);
2799    }
2800
2801    // ── sparte, recipient_mp_id, reversal, Gas path, KA group, validation ──
2802
2803    #[test]
2804    fn nne_gas_sparte_sets_gas_type_and_ref() {
2805        let mut i = base_nne();
2806        i.sparte = Sparte::Gas;
2807        let r = settle_nne(&i).unwrap();
2808        assert_eq!(r.settlement_type, SettlementType::NneGas);
2809        let refs = r.all_legal_refs();
2810        assert!(
2811            refs.iter().any(|r| r.contains("GasNEV")),
2812            "Gas NNE must cite GasNEV, got: {refs:?}"
2813        );
2814        assert!(
2815            !refs.iter().any(|r| r.contains("StromNEV")),
2816            "Gas NNE must not cite StromNEV, got: {refs:?}"
2817        );
2818    }
2819
2820    #[test]
2821    fn recipient_mp_id_is_populated_for_nne() {
2822        let r = settle_nne(&base_nne()).unwrap();
2823        assert_eq!(r.recipient_mp_id, "9900012345678");
2824    }
2825
2826    /// PID 31009 is issued **by** the MSB, to the NB / LF / ESA.
2827    ///
2828    /// Naming the MSB as `counterparty` (recipient) and the NB as sender inverts
2829    /// the invoice: the party owed money becomes the one billing for it.
2830    /// Verified against the
2831    /// *Anwendungsübersicht der Prüfidentifikatoren* 4.0, which lists seven
2832    /// Anwendungsfälle for 31009, all `MSB -> {NB, LF, ESA}`.
2833    #[test]
2834    fn msb_invoice_is_sent_by_the_msb_to_each_of_the_three_recipient_roles() {
2835        for (rolle, empfaenger_id) in [
2836            (MsbEmpfaengerRolle::Netzbetreiber, "9900357000004"),
2837            (MsbEmpfaengerRolle::Lieferant, "9900111000002"),
2838            (MsbEmpfaengerRolle::Energieserviceanbieter, "9905550000005"),
2839        ] {
2840            let input = MsbInput {
2841                sparte: Sparte::Strom,
2842                malo_id: "51238696012".into(),
2843                msb_mp_id: "9900999000001".into(),
2844                empfaenger: MsbRechnungsempfaenger {
2845                    rolle,
2846                    mp_id: empfaenger_id.to_owned(),
2847                },
2848                period: SettlementPeriod::new(date!(2025 - 01 - 01), date!(2025 - 01 - 31))
2849                    .unwrap(),
2850                grundgebuehr_eur_per_month: d("15.00"),
2851                billing_months: 1,
2852                messdienstleistung_eur: None,
2853                messstellen_kategorie: None,
2854                entgeltschuldner: None,
2855            };
2856            let r = settle_msb(&input).unwrap();
2857            assert_eq!(
2858                r.sender_mp_id,
2859                "9900999000001",
2860                "the MSB issues the invoice ({})",
2861                rolle.code()
2862            );
2863            assert_eq!(
2864                r.recipient_mp_id,
2865                empfaenger_id,
2866                "the {} is billed",
2867                rolle.code()
2868            );
2869        }
2870    }
2871
2872    #[test]
2873    fn reversal_negates_all_positions_and_total() {
2874        let original = settle_nne(&base_nne()).unwrap();
2875        let storno = reverse(&original, KorrekturGrund::Messwertkorrektur);
2876        assert_eq!(storno.total_eur, -original.total_eur);
2877        assert_eq!(storno.status, SettlementStatus::Reversal);
2878        for (orig, rev) in original.positions.iter().zip(storno.positions.iter()) {
2879            assert_eq!(rev.net_eur, -orig.net_eur);
2880            assert!(rev.text.starts_with("Storno:"));
2881        }
2882    }
2883
2884    #[test]
2885    fn reversal_preserves_recipient_mp_id() {
2886        let original = settle_nne(&base_nne()).unwrap();
2887        let storno = reverse(&original, KorrekturGrund::Messwertkorrektur);
2888        assert_eq!(storno.recipient_mp_id, original.recipient_mp_id);
2889    }
2890
2891    #[test]
2892    fn ka_gruppe_annotation_appears_in_position_text() {
2893        let mut i = base_nne();
2894        i.konzessionsabgabe = Some(Konzessionsabgabe {
2895            satz_ct_per_kwh: d("0.09"),
2896            klasse: KaKundengruppe::Sondervertragskunde,
2897        });
2898        if let Some(ka) = i.konzessionsabgabe.as_mut() {
2899            ka.klasse = KaKundengruppe::Sondervertragskunde;
2900        }
2901        let r = settle_nne(&i).unwrap();
2902        let ka_pos = r
2903            .positions
2904            .iter()
2905            .find(|p| p.text.contains("Konzessionsabgabe"))
2906            .unwrap();
2907        assert!(
2908            ka_pos.text.contains("KAV"),
2909            "KA group annotation should appear in position text: {}",
2910            ka_pos.text
2911        );
2912    }
2913
2914    /// KAV §2 rates are Höchstbeträge. Strom Sondervertragskunden cap at
2915    /// 0.11 ct/kWh, so a higher agreed rate is a compliance defect.
2916    #[test]
2917    fn ka_rate_above_kav_maximum_warns() {
2918        let mut i = base_nne();
2919        i.konzessionsabgabe = Some(Konzessionsabgabe {
2920            satz_ct_per_kwh: d("1.32"), // the Tarifkunde ≤25k rate
2921            klasse: KaKundengruppe::Tarifkunde {
2922                gemeinde: GemeindeGroesse::Bis25k,
2923                nur_kochen_warmwasser: false,
2924            },
2925        });
2926        if let Some(ka) = i.konzessionsabgabe.as_mut() {
2927            ka.klasse = KaKundengruppe::Sondervertragskunde;
2928        }
2929        let r = settle_nne(&i).unwrap();
2930        assert!(
2931            r.warnings.iter().any(|w| w.code == "KA_ABOVE_KAV_MAXIMUM"),
2932            "expected KAV ceiling warning, got: {:?}",
2933            r.warnings
2934        );
2935    }
2936
2937    /// The Tarifkunde bands key on municipality inhabitants, not consumption.
2938    #[test]
2939    fn kav_hoechstbetraege_match_the_statutory_table() {
2940        use crate::types::GemeindeGroesse::{Bis25k, Bis100k, Bis500k, Ueber500k};
2941        let tarif = |g, kw| KaKundengruppe::Tarifkunde {
2942            gemeinde: g,
2943            nur_kochen_warmwasser: kw,
2944        };
2945
2946        // Strom Tarifkunden, KAV §2 Abs. 2.
2947        for (g, want) in [
2948            (Bis25k, "1.32"),
2949            (Bis100k, "1.59"),
2950            (Bis500k, "1.99"),
2951            (Ueber500k, "2.39"),
2952        ] {
2953            assert_eq!(
2954                tarif(g, false).hoechstsatz_ct_per_kwh(Sparte::Strom),
2955                Some(d(want))
2956            );
2957        }
2958
2959        // Gas splits Tariflieferungen into cooking/hot-water and all others.
2960        assert_eq!(
2961            tarif(Bis25k, true).hoechstsatz_ct_per_kwh(Sparte::Gas),
2962            Some(d("0.51"))
2963        );
2964        assert_eq!(
2965            tarif(Bis25k, false).hoechstsatz_ct_per_kwh(Sparte::Gas),
2966            Some(d("0.22"))
2967        );
2968
2969        // Sondervertragskunden are flat and independent of municipality size.
2970        assert_eq!(
2971            KaKundengruppe::Sondervertragskunde.hoechstsatz_ct_per_kwh(Sparte::Strom),
2972            Some(d("0.11"))
2973        );
2974        assert_eq!(
2975            KaKundengruppe::Sondervertragskunde.hoechstsatz_ct_per_kwh(Sparte::Gas),
2976            Some(d("0.03"))
2977        );
2978
2979        // Schwachlast exists for Strom only; KAV provides no gas equivalent.
2980        assert_eq!(
2981            KaKundengruppe::Schwachlast.hoechstsatz_ct_per_kwh(Sparte::Strom),
2982            Some(d("0.61"))
2983        );
2984        assert_eq!(
2985            KaKundengruppe::Schwachlast.hoechstsatz_ct_per_kwh(Sparte::Gas),
2986            None
2987        );
2988
2989        assert_eq!(
2990            KaKundengruppe::Exempt.hoechstsatz_ct_per_kwh(Sparte::Strom),
2991            None
2992        );
2993    }
2994
2995    /// A 2025 gas period still cites GasNZV §25, and never the Strom ordinance.
2996    #[test]
2997    fn gas_mmm_for_a_2025_period_cites_gasnzv() {
2998        let mut i = base_mmm();
2999        i.sparte = Sparte::Gas;
3000        let r = settle_mmm(&i).unwrap();
3001        let refs = r.all_legal_refs();
3002        assert!(
3003            refs.iter().any(|r| r.contains("GasNZV §25")),
3004            "Gas MMM must cite GasNZV §25, got: {refs:?}"
3005        );
3006        assert!(
3007            !refs.iter().any(|r| r.contains("StromNZV")),
3008            "Gas MMM must not cite StromNZV, got: {refs:?}"
3009        );
3010    }
3011
3012    /// From 01.01.2026 the NZVs no longer apply, so a settlement for that period
3013    /// must not cite them.
3014    #[test]
3015    fn mmm_from_2026_drops_the_repealed_ordinances() {
3016        for sparte in [Sparte::Strom, Sparte::Gas] {
3017            let mut i = base_mmm();
3018            i.sparte = sparte;
3019            i.period = SettlementPeriod::new(date!(2026 - 01 - 01), date!(2026 - 01 - 31)).unwrap();
3020            let r = settle_mmm(&i).unwrap();
3021            let refs = r.all_legal_refs();
3022            assert!(
3023                !refs.iter().any(|r| r.contains("NZV")),
3024                "{sparte:?} 2026 settlement must not cite a repealed NZV, got: {refs:?}"
3025            );
3026            let expected = match sparte {
3027                Sparte::Strom => "BK6-24-174",
3028                Sparte::Gas => "BK7-24-01-008",
3029            };
3030            assert!(
3031                refs.iter().any(|r| r.contains(expected)),
3032                "{sparte:?} 2026 settlement must cite {expected}, got: {refs:?}"
3033            );
3034        }
3035    }
3036
3037    /// A repealed ordinance must carry its expiry in the citation string, so an
3038    /// archived invoice stays self-explanatory.
3039    #[test]
3040    fn repealed_ordinance_citations_state_their_expiry() {
3041        let c = LegalReference::StromNzv {
3042            paragraph: "§13 Abs. 3",
3043        }
3044        .citation();
3045        assert!(c.contains("außer Kraft"), "got: {c}");
3046    }
3047
3048    #[test]
3049    fn validate_msb_zero_months_is_error() {
3050        let input = MsbInput {
3051            sparte: Sparte::Strom,
3052            malo_id: "51238696012".into(),
3053            empfaenger: MsbRechnungsempfaenger {
3054                rolle: MsbEmpfaengerRolle::Netzbetreiber,
3055                mp_id: "9900357000004".to_owned(),
3056            },
3057            msb_mp_id: "9900123400001".into(),
3058            period: SettlementPeriod::new(date!(2025 - 01 - 01), date!(2025 - 01 - 31)).unwrap(),
3059            grundgebuehr_eur_per_month: d("12.50"),
3060            billing_months: 0,
3061            messdienstleistung_eur: None,
3062            messstellen_kategorie: None,
3063            entgeltschuldner: None,
3064        };
3065        let v = validate_msb_input(&input);
3066        assert!(!v.is_valid);
3067        assert!(v.warnings.iter().any(|w| w.code == "ZERO_BILLING_MONTHS"));
3068    }
3069
3070    #[test]
3071    fn reversal_of_rlm_matches_negative_total() {
3072        let mut i = base_nne();
3073        i.leistungspreis = Some(Leistungspreis {
3074            spitzenleistung_kw: d("12.5"),
3075            preis_eur_per_kw: d("4.20"),
3076        });
3077        i.konzessionsabgabe = Some(Konzessionsabgabe {
3078            satz_ct_per_kwh: d("0.11"),
3079            klasse: KaKundengruppe::Sondervertragskunde,
3080        });
3081        let original = settle_nne(&i).unwrap();
3082        let storno = reverse(&original, KorrekturGrund::Messwertkorrektur);
3083        assert_eq!(storno.positions.len(), original.positions.len());
3084        assert_eq!(storno.total_eur, -original.total_eur);
3085        assert_eq!(storno.recomputed_total(), storno.total_eur);
3086    }
3087
3088    // ── §14a Modul 1 (BNetzA BK6-22-300 flat reduction) ──────────────────────
3089
3090    /// Modul 1 is a *pauschale* reduction: the energy is billed at the full
3091    /// Arbeitspreis and a flat annual amount is credited pro rata alongside it.
3092    ///
3093    /// The credit does not scale with consumption — that is what makes it
3094    /// pauschal, and what separates it from Modul 2, which reduces the
3095    /// Arbeitspreis by a percentage. Both were once the same computation here,
3096    /// with Modul 1 wearing Modul 2's mechanism.
3097    #[test]
3098    fn sect14a_modul1_credits_a_flat_amount_beside_the_full_arbeitspreis() {
3099        let mut i = base_nne();
3100        i.arbeitspreis = modul1(d("120.00"));
3101        let r = settle_nne(&i).unwrap();
3102
3103        assert_eq!(r.positions.len(), 2, "the Arbeit position plus the credit");
3104        // 1500 kWh × 3.5 ct = 52.50 EUR, billed in full.
3105        assert_eq!(r.positions[0].net_eur, d("52.50000"));
3106        // 120 EUR/year ÷ 12 = 10.00 EUR credited for the month.
3107        assert_eq!(r.positions[1].net_eur, d("-10.00000"));
3108        assert_eq!(r.total_eur, d("42.50"));
3109
3110        assert!(
3111            r.positions[1].text.contains("pauschale"),
3112            "{}",
3113            r.positions[1].text
3114        );
3115        let refs = r.all_legal_refs();
3116        assert!(refs.iter().any(|x| x.contains("Modul 1")));
3117        assert!(refs.iter().any(|x| x.contains("BK6-22-300")));
3118    }
3119
3120    /// Doubling the consumption does not double the credit — the defining
3121    /// property of a pauschale, and the one the old factor model got wrong.
3122    #[test]
3123    fn the_modul1_credit_does_not_scale_with_consumption() {
3124        let credit_for = |kwh: &str| {
3125            let mut i = base_nne();
3126            i.arbeitspreis = ArbeitspreisModell::Modul1Pauschal {
3127                basis: MengePreis {
3128                    menge_kwh: d(kwh),
3129                    preis_ct_per_kwh: d("3.5"),
3130                },
3131                pauschale_eur_pro_jahr: d("120.00"),
3132                jahresanteil: Decimal::ONE / Decimal::from(12u32),
3133            };
3134            settle_nne(&i).unwrap().positions[1].net_eur
3135        };
3136        assert_eq!(credit_for("1500"), credit_for("3000"));
3137    }
3138
3139    #[test]
3140    /// A zero pauschale bills exactly like plain Arbeit — the credit position is
3141    /// still emitted, so the invoice shows the module was in force.
3142    fn sect14a_modul1_with_a_zero_pauschale_bills_the_full_arbeitspreis() {
3143        let mut i = base_nne();
3144        i.arbeitspreis = modul1(d("0.00"));
3145        let r = settle_nne(&i).unwrap();
3146        assert_eq!(r.total_eur, d("52.50"));
3147    }
3148
3149    // ── Gas Grundpreis ────────────────────────────────────────────────────────
3150
3151    #[test]
3152    fn nne_gas_with_grundpreis_adds_position() {
3153        let mut i = base_nne();
3154        i.sparte = Sparte::Gas;
3155        i.grundpreis = Some(Grundpreis {
3156            eur_per_month: d("15.00"),
3157            months: Decimal::from(1),
3158        });
3159        let r = settle_nne(&i).unwrap();
3160        assert_eq!(r.positions.len(), 2, "Grundpreis + Arbeit");
3161        assert!(
3162            r.positions[0].text.contains("Grundpreis"),
3163            "first position must be Grundpreis"
3164        );
3165        assert_eq!(r.positions[0].net_eur, d("15.00000"));
3166        let refs_p0 = &r.positions[0].trace.legal_refs;
3167        assert!(
3168            refs_p0.iter().any(|lr| lr.citation().contains("GasNEV")),
3169            "Grundpreis must cite GasNEV"
3170        );
3171    }
3172
3173    // ── Gas AWH Sperrprozesse (PID 31011) ─────────────────────────────────────
3174
3175    #[test]
3176    fn gas_awh_single_sperrung_arithmetic() {
3177        let input = GasAwhInput {
3178            malo_id: "51238696012".into(),
3179            nb_mp_id: "9900357000004".to_owned(),
3180            lf_mp_id: "9900012345678".into(),
3181            period: SettlementPeriod::new(date!(2025 - 01 - 01), date!(2025 - 01 - 31)).unwrap(),
3182            tariff_sheet_id: None,
3183            awh_positionen: vec![AwhPositionInput {
3184                beschreibung: "Sperrung Gaszähler".into(),
3185                anzahl: 1,
3186                preis_eur: d("45.00"),
3187                artikel_id: Some("2-01-7-001".to_owned()),
3188            }],
3189        };
3190        let r = settle_gas_awh(&input).unwrap();
3191        assert_eq!(r.settlement_type, SettlementType::GasAwhSperrung);
3192        assert_eq!(r.total_eur, d("45.00"));
3193        assert_eq!(r.positions.len(), 1);
3194        assert_eq!(r.positions[0].text, "Sperrung Gaszähler");
3195        let refs = r.all_legal_refs();
3196        assert!(refs.iter().any(|r| r.contains("BK7-24-01-009")));
3197    }
3198
3199    #[test]
3200    fn gas_awh_multiple_actions_total_correct() {
3201        let input = GasAwhInput {
3202            malo_id: "51238696012".into(),
3203            nb_mp_id: "9900357000004".to_owned(),
3204            lf_mp_id: "9900012345678".into(),
3205            period: SettlementPeriod::new(date!(2025 - 01 - 01), date!(2025 - 01 - 31)).unwrap(),
3206            tariff_sheet_id: None,
3207            awh_positionen: vec![
3208                AwhPositionInput {
3209                    beschreibung: "Sperrung".into(),
3210                    anzahl: 1,
3211                    preis_eur: d("45.00"),
3212                    artikel_id: Some("2-01-7-001".to_owned()),
3213                },
3214                AwhPositionInput {
3215                    beschreibung: "Entsperrung".into(),
3216                    anzahl: 2,
3217                    preis_eur: d("30.00"),
3218                    artikel_id: Some("2-01-7-002".to_owned()),
3219                },
3220            ],
3221        };
3222        let r = settle_gas_awh(&input).unwrap();
3223        // 45 + 2×30 = 105
3224        assert_eq!(r.total_eur, d("105.00"));
3225        assert_eq!(r.positions.len(), 2);
3226        assert_eq!(r.recomputed_total(), r.total_eur);
3227    }
3228
3229    #[test]
3230    fn gas_awh_empty_positions_rejected() {
3231        let input = GasAwhInput {
3232            malo_id: "51238696012".into(),
3233            nb_mp_id: "9900357000004".to_owned(),
3234            lf_mp_id: "9900012345678".into(),
3235            period: SettlementPeriod::new(date!(2025 - 01 - 01), date!(2025 - 01 - 31)).unwrap(),
3236            tariff_sheet_id: None,
3237            awh_positionen: vec![],
3238        };
3239        assert!(matches!(
3240            settle_gas_awh(&input),
3241            Err(BillingError::InvalidInput { .. })
3242        ));
3243    }
3244
3245    // ── Correction lifecycle ──────────────────────────────────────────────────
3246
3247    #[test]
3248    fn correction_pair_status_and_reference() {
3249        let original = settle_nne(&base_nne()).unwrap();
3250        let mut corrected_input = base_nne();
3251        if let ArbeitspreisModell::Einheitlich(mp) = &mut corrected_input.arbeitspreis {
3252            mp.menge_kwh = d("1600");
3253        }
3254        let replacement = settle_nne(&corrected_input).unwrap();
3255
3256        let (reversal, corrected) = correct(&original, replacement, KorrekturGrund::Tarifkorrektur);
3257        assert_eq!(reversal.status, SettlementStatus::Reversal);
3258        assert_eq!(reversal.total_eur, -original.total_eur);
3259        assert_eq!(corrected.status, SettlementStatus::Correction);
3260    }
3261
3262    // ── recomputed_total consistency ──────────────────────────────────────────
3263
3264    #[test]
3265    fn nne_recomputed_total_matches_total_eur() {
3266        let r = settle_nne(&base_nne()).unwrap();
3267        assert_eq!(r.recomputed_total(), r.total_eur);
3268    }
3269
3270    #[test]
3271    fn mmm_recomputed_total_matches_total_eur() {
3272        let r = settle_mmm(&base_mmm()).unwrap();
3273        assert_eq!(r.recomputed_total(), r.total_eur);
3274    }
3275
3276    // ── Gas MMM uses MmmGas settlement type ───────────────────────────────────
3277
3278    #[test]
3279    fn mmm_gas_uses_mmm_gas_settlement_type() {
3280        let mut i = base_mmm();
3281        i.sparte = Sparte::Gas;
3282        let r = settle_mmm(&i).unwrap();
3283        assert_eq!(
3284            r.settlement_type,
3285            SettlementType::MmmGas,
3286            "Gas MMM must use MmmGas settlement type"
3287        );
3288    }
3289
3290    #[test]
3291    fn mmm_strom_uses_mmm_strom_settlement_type() {
3292        let r = settle_mmm(&base_mmm()).unwrap();
3293        assert_eq!(r.settlement_type, SettlementType::MmmStrom);
3294    }
3295
3296    // ── AgNeS refusal (regime turnover 2028 → 2029) ───────────────────────────
3297
3298    /// NNE positions are priced under StromNEV/GasNEV — a 2029 period is
3299    /// governed by AgNeS (GBK-25-01), whose tables are not festgelegt, so the
3300    /// settlement is refused rather than computed under lapsed rules.
3301    #[test]
3302    fn a_2029_nne_settlement_is_refused_under_agnes() {
3303        let mut i = base_nne();
3304        i.period = SettlementPeriod::new(date!(2029 - 01 - 01), date!(2029 - 01 - 31)).unwrap();
3305        let err = settle_nne(&i).expect_err("an AgNeS period must be refused");
3306        assert!(matches!(
3307            err,
3308            BillingError::UnsupportedEntgeltRegime { tarifjahr: 2029 }
3309        ));
3310        assert!(err.to_string().contains("GBK-25-01"), "{err}");
3311    }
3312
3313    /// AWH charges rest on the GasNEV §14 authorisation — same refusal.
3314    #[test]
3315    fn a_2029_gas_awh_settlement_is_refused_under_agnes() {
3316        let input = GasAwhInput {
3317            malo_id: "51238696012".into(),
3318            nb_mp_id: "9900357000004".to_owned(),
3319            lf_mp_id: "9900012345678".into(),
3320            period: SettlementPeriod::new(date!(2029 - 01 - 01), date!(2029 - 01 - 31)).unwrap(),
3321            tariff_sheet_id: None,
3322            awh_positionen: vec![AwhPositionInput {
3323                beschreibung: "Sperrung Gaszähler".into(),
3324                anzahl: 1,
3325                preis_eur: d("45.00"),
3326                artikel_id: Some("2-01-7-001".to_owned()),
3327            }],
3328        };
3329        assert!(matches!(
3330            settle_gas_awh(&input),
3331            Err(BillingError::UnsupportedEntgeltRegime { tarifjahr: 2029 })
3332        ));
3333    }
3334
3335    /// MMM prices are formed on the Netzzugang axis (GPKE / GaBi Gas), not by
3336    /// the Entgeltbildung AgNeS replaces — a 2029 MMM settlement stays
3337    /// computable, and its regime tag records the AgNeS Entgelt axis.
3338    #[test]
3339    fn a_2029_mmm_settlement_stays_computable() {
3340        let mut i = base_mmm();
3341        i.period = SettlementPeriod::new(date!(2029 - 02 - 01), date!(2029 - 02 - 28)).unwrap();
3342        let r = settle_mmm(&i).expect("MMM does not price on the Entgelt axis");
3343        assert_eq!(r.regime.entgelt(), crate::regulatory::EntgeltRegime::AgNeS);
3344    }
3345
3346    /// MSB charges are formed under MsbG, which does not lapse with the
3347    /// Verordnungen — a 2029 MSB settlement stays computable.
3348    #[test]
3349    fn a_2029_msb_settlement_stays_computable() {
3350        let mut i = base_msb();
3351        i.period = SettlementPeriod::new(date!(2029 - 02 - 01), date!(2029 - 02 - 28)).unwrap();
3352        let r = settle_msb(&i).expect("MSB does not price on the Entgelt axis");
3353        assert_eq!(r.regime.entgelt(), crate::regulatory::EntgeltRegime::AgNeS);
3354    }
3355
3356    // ── REGIME_TURNOVER_IN_PERIOD is emitted by every builder ─────────────────
3357
3358    /// A period across the 2025/2026 Netzzugang turnover warns on NNE too —
3359    /// not only on MMM, where the check first lived.
3360    #[test]
3361    fn a_straddling_nne_period_warns() {
3362        let mut i = base_nne();
3363        i.period = SettlementPeriod::new(date!(2025 - 12 - 15), date!(2026 - 01 - 15)).unwrap();
3364        let r = settle_nne(&i).unwrap();
3365        assert!(
3366            r.warnings
3367                .iter()
3368                .any(|w| w.code == "REGIME_TURNOVER_IN_PERIOD"),
3369            "warnings: {:?}",
3370            r.warnings
3371        );
3372    }
3373
3374    /// …and on MSB, which is exempt from the AgNeS guard but must still report
3375    /// a period the turnover cuts in two.
3376    #[test]
3377    fn a_straddling_msb_period_warns() {
3378        let mut i = base_msb();
3379        i.period = SettlementPeriod::new(date!(2028 - 12 - 15), date!(2029 - 01 - 15)).unwrap();
3380        let r = settle_msb(&i).unwrap();
3381        assert!(
3382            r.warnings
3383                .iter()
3384                .any(|w| w.code == "REGIME_TURNOVER_IN_PERIOD"),
3385            "warnings: {:?}",
3386            r.warnings
3387        );
3388    }
3389
3390    /// A reversal re-emits the turnover warning: the mirror of a straddling
3391    /// settlement straddles just the same.
3392    #[test]
3393    fn a_reversal_of_a_straddling_settlement_carries_the_warning() {
3394        let mut i = base_nne();
3395        i.period = SettlementPeriod::new(date!(2025 - 12 - 15), date!(2026 - 01 - 15)).unwrap();
3396        let original = settle_nne(&i).unwrap();
3397        let reversal = reverse(&original, KorrekturGrund::Messwertkorrektur);
3398        assert!(
3399            reversal
3400                .warnings
3401                .iter()
3402                .any(|w| w.code == "REGIME_TURNOVER_IN_PERIOD"),
3403            "warnings: {:?}",
3404            reversal.warnings
3405        );
3406    }
3407}
3408
3409// ── Property tests ────────────────────────────────────────────────────────────
3410
3411#[cfg(test)]
3412mod proptests {
3413    use super::*;
3414    use crate::types::MengePreis;
3415    use crate::types::SettlementPeriod;
3416    use proptest::prelude::*;
3417    use rust_decimal::Decimal;
3418    use time::macros::date;
3419
3420    fn arb_positive_kwh() -> impl Strategy<Value = Decimal> {
3421        (1u64..100_000u64).prop_map(Decimal::from)
3422    }
3423
3424    fn arb_ct_per_kwh() -> impl Strategy<Value = Decimal> {
3425        (1u64..2000u64).prop_map(|n| Decimal::new(n as i64, 2)) // 0.01 – 20.00 ct/kWh
3426    }
3427
3428    proptest! {
3429        /// Invariant: reversal of any valid NNE settlement negates the total.
3430        ///
3431        /// For any valid (kwh, price) pair, the reversal total equals -original.total_eur.
3432        #[test]
3433        fn reversal_always_negates_total(
3434            kwh in arb_positive_kwh(),
3435            ct in arb_ct_per_kwh(),
3436        ) {
3437            let input = NneInput {
3438                blindarbeit: None,
3439                malo_id: "51238696012".into(),
3440                nb_mp_id: "9900357000004".to_owned(),
3441                lf_mp_id: "9900012345678".into(),
3442            period: SettlementPeriod::new(date!(2025 - 01 - 01), date!(2025 - 12 - 31)).unwrap(),
3443            arbeitspreis: ArbeitspreisModell::Einheitlich(MengePreis {
3444                menge_kwh: kwh,
3445                preis_ct_per_kwh: ct,
3446            }),
3447            leistungspreis: None,
3448                letztverbrauchergruppe: Default::default(),
3449            sect19_umlage_ct_per_kwh: None,
3450            offshore_umlage_ct_per_kwh: None,
3451            kwkg_umlage_ct_per_kwh: None,
3452            netzebene: None,
3453            sect19: None,
3454            gas_kapazitaet: None,
3455            jahreshoechstleistung_kw: None,
3456            jahresarbeit_kwh: None,
3457            konzessionsabgabe: None,
3458            grundpreis: None,
3459                tariff_sheet_id: None,
3460                sparte: Sparte::Strom,
3461            };
3462            if let Ok(original) = settle_nne(&input) {
3463                let reversal = reverse(&original, KorrekturGrund::Messwertkorrektur);
3464                prop_assert_eq!(reversal.total_eur, -original.total_eur);
3465                prop_assert_eq!(reversal.recomputed_total(), reversal.total_eur);
3466                prop_assert_eq!(reversal.positions.len(), original.positions.len());
3467            }
3468        }
3469
3470        /// Invariant: §14a Modul 1 reduction factor ∈ (0, 1] → billed total ≤ unreduced total.
3471        #[test]
3472        fn modul1_total_lte_unreduced(
3473            kwh in arb_positive_kwh(),
3474            ct in arb_ct_per_kwh(),
3475            // factor ∈ [1%, 100%]
3476            factor_pct in 1u64..=100u64,
3477        ) {
3478            let factor = Decimal::new(factor_pct as i64, 2);
3479            let base = NneInput {
3480                blindarbeit: None,
3481                malo_id: "51238696012".into(),
3482                nb_mp_id: "9900357000004".to_owned(),
3483                lf_mp_id: "9900012345678".into(),
3484            period: SettlementPeriod::new(date!(2025 - 01 - 01), date!(2025 - 12 - 31)).unwrap(),
3485            arbeitspreis: ArbeitspreisModell::Einheitlich(MengePreis {
3486                menge_kwh: kwh,
3487                preis_ct_per_kwh: ct,
3488            }),
3489            leistungspreis: None,
3490                letztverbrauchergruppe: Default::default(),
3491            sect19_umlage_ct_per_kwh: None,
3492            offshore_umlage_ct_per_kwh: None,
3493            kwkg_umlage_ct_per_kwh: None,
3494            netzebene: None,
3495            sect19: None,
3496            gas_kapazitaet: None,
3497            jahreshoechstleistung_kw: None,
3498            jahresarbeit_kwh: None,
3499            konzessionsabgabe: None,
3500            grundpreis: None,
3501                tariff_sheet_id: None,
3502                sparte: Sparte::Strom,
3503            };
3504            if let Ok(unreduced) = settle_nne(&base) {
3505                let mut reduced_input = base.clone();
3506                reduced_input.arbeitspreis = ArbeitspreisModell::Modul1Pauschal {
3507                    basis: MengePreis {
3508                        menge_kwh: kwh,
3509                        preis_ct_per_kwh: ct,
3510                    },
3511                    // Any non-negative pauschale is a credit, so the total can
3512                    // only move down — that is the invariant, and it no longer
3513                    // depends on consumption the way a rate factor did.
3514                    pauschale_eur_pro_jahr: (Decimal::ONE - factor)
3515                        * Decimal::from(1200u32),
3516                    jahresanteil: Decimal::ONE / Decimal::from(12u32),
3517                };
3518                if let Ok(reduced) = settle_nne(&reduced_input) {
3519                    prop_assert!(
3520                        reduced.total_eur <= unreduced.total_eur,
3521                        "Modul 1 reduced total must be ≤ unreduced total"
3522                    );
3523                }
3524            }
3525        }
3526    }
3527}
3528
3529// ── §14a Modul 3 unit tests ───────────────────────────────────────────────────
3530
3531#[cfg(test)]
3532mod modul3_tests {
3533    use super::*;
3534    use crate::types::MengePreis;
3535    use crate::types::SettlementPeriod;
3536    use crate::types::SpotpreisInterval;
3537    use rust_decimal::Decimal;
3538    use time::macros::date;
3539
3540    fn d(s: &str) -> Decimal {
3541        Decimal::from_str_exact(s).expect("valid decimal literal")
3542    }
3543
3544    fn base_nne() -> NneInput {
3545        NneInput {
3546            blindarbeit: None,
3547            malo_id: "51238696012".into(),
3548            nb_mp_id: "9900357000004".to_owned(),
3549            lf_mp_id: "9900012345678".into(),
3550            period: SettlementPeriod::new(date!(2026 - 01 - 15), date!(2026 - 01 - 16)).unwrap(),
3551            arbeitspreis: ArbeitspreisModell::Einheitlich(MengePreis {
3552                menge_kwh: d("1500"),
3553                preis_ct_per_kwh: d("3.5"),
3554            }),
3555            leistungspreis: None,
3556            letztverbrauchergruppe: Default::default(),
3557            sect19_umlage_ct_per_kwh: None,
3558            offshore_umlage_ct_per_kwh: None,
3559            kwkg_umlage_ct_per_kwh: None,
3560            netzebene: None,
3561            sect19: None,
3562            gas_kapazitaet: None,
3563            jahreshoechstleistung_kw: None,
3564            jahresarbeit_kwh: None,
3565            konzessionsabgabe: None,
3566            grundpreis: None,
3567            tariff_sheet_id: None,
3568            sparte: Sparte::Strom,
3569        }
3570    }
3571
3572    #[test]
3573    fn nne_sect14a_modul3_single_interval() {
3574        use time::OffsetDateTime;
3575        let start = OffsetDateTime::parse(
3576            "2026-01-15T10:00:00Z",
3577            &time::format_description::well_known::Rfc3339,
3578        )
3579        .unwrap();
3580        let end = start + time::Duration::minutes(15);
3581
3582        let mut i = base_nne();
3583        i.arbeitspreis = ArbeitspreisModell::SpotpreisNetzentgelt {
3584            intervalle: vec![SpotpreisInterval {
3585                period_from: start,
3586                period_to: end,
3587                menge_kwh: d("2.5"),
3588                nne_rate_ct_per_kwh: d("1.80"),
3589                epex_spot_ct_per_kwh: Some(d("12.50")),
3590            }],
3591        };
3592        let r = settle_nne(&i).unwrap();
3593
3594        // Flat Arbeit + one Modul 3 interval, plus the three network levies a
3595        // Strom NNE invoice for a covered year always carries.
3596        assert_eq!(
3597            r.positions.len(),
3598            4,
3599            "1 Modul 3 position + 3 Umlagen — and no flat Arbeit position: \
3600             the interval rates replace it rather than adding to it"
3601        );
3602        assert_eq!(
3603            r.positions
3604                .iter()
3605                .filter(|p| matches!(
3606                    p.kind,
3607                    BillingPositionKind::Sect19StromNevUmlage
3608                        | BillingPositionKind::OffshoreNetzumlage
3609                        | BillingPositionKind::KwkgUmlage
3610                ))
3611                .count(),
3612            3
3613        );
3614
3615        let modul3_pos = r
3616            .positions
3617            .iter()
3618            .find(|p| p.kind == BillingPositionKind::NneArbeitModul3)
3619            .expect("Modul 3 position must be present");
3620
3621        // 2.5 kWh × 0.018 EUR/kWh = 0.045 EUR
3622        assert_eq!(modul3_pos.net_eur, d("0.04500"), "Modul 3 net_eur");
3623        assert_eq!(modul3_pos.quantity, d("2.500"));
3624        assert_eq!(modul3_pos.unit, QuantityUnit::Kwh);
3625
3626        // The pricing formula is a value, not a serialised document. What an
3627        // auditor needs is the method and the rate that applied; how a BO4E
3628        // `LastvariablePreisposition` renders that is the adapter's problem.
3629        let formula = modul3_pos
3630            .spot_price_formula
3631            .as_ref()
3632            .expect("a Modul 3 position states the formula behind its rate");
3633        assert_eq!(formula.method, TariffCalculationMethod::Spotpreis);
3634        assert_eq!(formula.reference, PriceReference::Energiemenge);
3635        assert_eq!(formula.unit, QuantityUnit::Kwh);
3636        assert_eq!(formula.steps.len(), 1);
3637        assert_eq!(formula.steps[0].unit_price_eur, d("0.018"));
3638        assert_eq!(formula.steps[0].from, Decimal::ZERO);
3639        assert_eq!(formula.steps[0].to, None, "the top step is open");
3640
3641        // The EPEX price that produced the rate stays in the trace, which is
3642        // where an auditor looks for inputs.
3643        assert!(
3644            modul3_pos.trace.explanation.contains("12.5"),
3645            "the spot price behind the rate must be recoverable: {}",
3646            modul3_pos.trace.explanation
3647        );
3648
3649        // Legal references
3650        let refs = &modul3_pos.trace.legal_refs;
3651        assert!(
3652            refs.iter().any(|r| matches!(
3653                r,
3654                LegalReference::Sect14aEnwg {
3655                    module: Sect14aModule::Modul3
3656                }
3657            )),
3658            "must reference §14a Modul 3"
3659        );
3660        assert!(
3661            refs.iter().any(|r| matches!(
3662                r,
3663                LegalReference::BnetzaDecision {
3664                    reference: "BK6-22-300"
3665                }
3666            )),
3667            "must reference BK6-22-300"
3668        );
3669    }
3670
3671    #[test]
3672    fn nne_sect14a_modul3_multiple_intervals_sum_correctly() {
3673        let base = time::OffsetDateTime::parse(
3674            "2026-01-15T10:00:00Z",
3675            &time::format_description::well_known::Rfc3339,
3676        )
3677        .unwrap();
3678        let mut i = base_nne();
3679        i.arbeitspreis = ArbeitspreisModell::SpotpreisNetzentgelt {
3680            intervalle: vec![
3681                SpotpreisInterval {
3682                    period_from: base,
3683                    period_to: base + time::Duration::minutes(15),
3684                    menge_kwh: d("1.25"),
3685                    nne_rate_ct_per_kwh: d("2.00"),
3686                    epex_spot_ct_per_kwh: None,
3687                },
3688                SpotpreisInterval {
3689                    period_from: base + time::Duration::minutes(15),
3690                    period_to: base + time::Duration::minutes(30),
3691                    menge_kwh: d("1.75"),
3692                    nne_rate_ct_per_kwh: d("1.50"),
3693                    epex_spot_ct_per_kwh: None,
3694                },
3695            ],
3696        };
3697        let r = settle_nne(&i).unwrap();
3698
3699        // 2 Modul 3 intervals + the three network levies. The flat Arbeit
3700        // position is absent by design: billing it alongside the interval rates
3701        // charged the same energy twice.
3702        assert_eq!(r.positions.len(), 5);
3703        let modul3: Vec<_> = r
3704            .positions
3705            .iter()
3706            .filter(|p| p.kind == BillingPositionKind::NneArbeitModul3)
3707            .collect();
3708        assert_eq!(modul3.len(), 2);
3709        // Interval 1: 1.25 kWh × 0.02 EUR/kWh = 0.025 EUR
3710        assert_eq!(modul3[0].net_eur, d("0.02500"));
3711        // Interval 2: 1.75 kWh × 0.015 EUR/kWh = 0.02625 EUR
3712        assert_eq!(modul3[1].net_eur, d("0.02625"));
3713        // Each interval states its own rate, so the two formulas differ.
3714        let f0 = modul3[0].spot_price_formula.as_ref().unwrap();
3715        let f1 = modul3[1].spot_price_formula.as_ref().unwrap();
3716        assert_eq!(f0.steps[0].unit_price_eur, d("0.02"));
3717        assert_eq!(f1.steps[0].unit_price_eur, d("0.015"));
3718    }
3719
3720    #[test]
3721    fn nne_modul3_zero_kwh_interval_is_skipped() {
3722        let base = time::OffsetDateTime::parse(
3723            "2026-01-15T10:00:00Z",
3724            &time::format_description::well_known::Rfc3339,
3725        )
3726        .unwrap();
3727        let mut i = base_nne();
3728        i.arbeitspreis = ArbeitspreisModell::SpotpreisNetzentgelt {
3729            intervalle: vec![
3730                SpotpreisInterval {
3731                    period_from: base,
3732                    period_to: base + time::Duration::minutes(15),
3733                    menge_kwh: d("0"),
3734                    nne_rate_ct_per_kwh: d("2.00"),
3735                    epex_spot_ct_per_kwh: None,
3736                },
3737                SpotpreisInterval {
3738                    period_from: base + time::Duration::minutes(15),
3739                    period_to: base + time::Duration::minutes(30),
3740                    menge_kwh: d("1.50"),
3741                    nne_rate_ct_per_kwh: d("1.80"),
3742                    epex_spot_ct_per_kwh: None,
3743                },
3744            ],
3745        };
3746        let r = settle_nne(&i).unwrap();
3747        let modul3: Vec<_> = r
3748            .positions
3749            .iter()
3750            .filter(|p| p.kind == BillingPositionKind::NneArbeitModul3)
3751            .collect();
3752        assert_eq!(modul3.len(), 1, "zero-kWh interval must be skipped");
3753    }
3754
3755    /// The §14a modules are mutually exclusive by construction.
3756    ///
3757    /// Modul 1 applies a flat reduction to the whole Arbeitsmenge; Modul 3 prices
3758    /// each dispatch interval. Both together billed the same energy twice, and
3759    /// the engine did it silently because the conflict check lived in a validator
3760    /// nothing called. `ArbeitspreisModell` now holds one model at a time, so the
3761    /// combination cannot be expressed.
3762    #[test]
3763    fn the_sect14a_modules_are_mutually_exclusive() {
3764        let base = time::OffsetDateTime::parse(
3765            "2026-01-15T10:00:00Z",
3766            &time::format_description::well_known::Rfc3339,
3767        )
3768        .unwrap();
3769
3770        let mut i = base_nne();
3771        i.arbeitspreis = modul1(d("120.00"));
3772        assert_eq!(i.arbeitspreis.sect14a_modul(), Some(Sect14aModule::Modul1));
3773
3774        // A spot-linked Netzentgelt replaces Modul 1 rather than adding to it —
3775        // and is not itself one of the three modules BK6-22-300 defines.
3776        i.arbeitspreis = ArbeitspreisModell::SpotpreisNetzentgelt {
3777            intervalle: vec![SpotpreisInterval {
3778                period_from: base,
3779                period_to: base + time::Duration::minutes(15),
3780                menge_kwh: d("1.0"),
3781                nne_rate_ct_per_kwh: d("2.0"),
3782                epex_spot_ct_per_kwh: None,
3783            }],
3784        };
3785        assert_eq!(
3786            i.arbeitspreis.sect14a_modul(),
3787            None,
3788            "a spot-linked Netzentgelt is the NB's own price model, not §14a Modul 3"
3789        );
3790
3791        // And the settlement bills the interval once, not the flat rate as well.
3792        let r = settle_nne(&i).expect("the spot model settles");
3793        let modul1_positions = r
3794            .positions
3795            .iter()
3796            .filter(|p| p.kind == BillingPositionKind::NneArbeitModul1)
3797            .count();
3798        assert_eq!(
3799            modul1_positions, 0,
3800            "no flat Modul 1 position alongside Modul 3"
3801        );
3802    }
3803
3804    // ── §19 Abs. 2 StromNEV — the reduction basis ────────────────────────────
3805
3806    /// The §19 Abs. 2 reduction must cover the §14a Modul 3 Spotpreis positions.
3807    ///
3808    /// They are emitted per dispatch interval and must be in the basis before
3809    /// the §19 block runs. Pushed after it, a Modul-3 customer with a 10 %
3810    /// agreement has a basis of zero and is billed the published Netzentgelt in
3811    /// full.
3812    #[test]
3813    fn sect19_reduction_covers_the_modul3_spot_positions() {
3814        use time::macros::datetime;
3815        let interval = |kwh: &str, ct: &str, hour: u8| SpotpreisInterval {
3816            period_from: datetime!(2025-01-01 00:00 UTC) + time::Duration::hours(hour as i64),
3817            period_to: datetime!(2025-01-01 00:15 UTC) + time::Duration::hours(hour as i64),
3818            menge_kwh: d(kwh),
3819            nne_rate_ct_per_kwh: d(ct),
3820            epex_spot_ct_per_kwh: None,
3821        };
3822        let out = settle_nne(&NneInput {
3823            arbeitspreis: ArbeitspreisModell::SpotpreisNetzentgelt {
3824                intervalle: vec![interval("400", "5.0", 1), interval("600", "5.0", 2)],
3825            },
3826            jahresarbeit_kwh: Some(d("70000000")),
3827            jahreshoechstleistung_kw: Some(d("8000")),
3828            sect19: Some(crate::sect19::Sect19Vereinbarung {
3829                art: crate::sect19::Sect19Art::IntensiveNetznutzung,
3830                vereinbarter_prozentsatz: d("0.10"),
3831                genehmigung: Some("BK4-24-001".to_owned()),
3832            }),
3833            ..base_nne()
3834        })
3835        .expect("a settleable NNE");
3836
3837        let modul3: Decimal = out
3838            .positions
3839            .iter()
3840            .filter(|p| p.kind == BillingPositionKind::NneArbeitModul3)
3841            .map(|p| p.net_eur)
3842            .sum();
3843        assert_eq!(modul3, d("50.00000"), "1 000 kWh × 5 ct");
3844
3845        let reduktion = out
3846            .positions
3847            .iter()
3848            .find(|p| p.kind == BillingPositionKind::Sect19IndividuellesEntgelt)
3849            .expect("a §19 reduction position");
3850        // 10 % agreed → 90 % of the Modul-3 NNE is credited back.
3851        assert_eq!(reduktion.net_eur, d("-45.00000"));
3852    }
3853
3854    /// The basis covers every NNE kind the settlement can emit — including the
3855    /// Modul 3 ST band and the Modul 2 reduced Arbeitspreis, which a filter
3856    /// keyed on the plain Arbeitspreis alone would omit.
3857    #[test]
3858    fn sect19_reduction_covers_the_modul3_time_of_use_bands() {
3859        let mp = |kwh: &str| MengePreis {
3860            menge_kwh: d(kwh),
3861            preis_ct_per_kwh: d("10.0"),
3862        };
3863        let out = settle_nne(&NneInput {
3864            arbeitspreis: ArbeitspreisModell::Modul3ZeitVariabel {
3865                ht: mp("100"),
3866                st: mp("200"),
3867                nt: mp("300"),
3868            },
3869            sect19: Some(crate::sect19::Sect19Vereinbarung {
3870                art: crate::sect19::Sect19Art::AtypischeNetznutzung,
3871                vereinbarter_prozentsatz: d("0.20"),
3872                genehmigung: None,
3873            }),
3874            ..base_nne()
3875        })
3876        .expect("a settleable NNE");
3877
3878        let reduktion = out
3879            .positions
3880            .iter()
3881            .find(|p| p.kind == BillingPositionKind::Sect19IndividuellesEntgelt)
3882            .expect("a §19 reduction position");
3883        // HT+ST+NT = 600 kWh × 10 ct = 60 EUR; 80 % is credited back.
3884        assert_eq!(reduktion.net_eur, d("-48.00000"));
3885    }
3886
3887    // ── §15 GasNEV — Kapazitätsentgelt pro-ration ────────────────────────────
3888
3889    /// A full year of capacity costs exactly the annual Entgelt, leap year or not.
3890    ///
3891    /// §15 GasNEV fixes no day-count convention; a hard-coded 365 divisor billed
3892    /// a leap year at 366/365 = 100.274 % of the price sheet figure.
3893    #[test]
3894    fn gas_kapazitaetsentgelt_a_full_year_costs_the_annual_entgelt() {
3895        use crate::gas::{GasKapazitaet, Kapazitaetsprodukt};
3896        let jahr = |from, to| {
3897            settle_nne(&NneInput {
3898                sparte: Sparte::Gas,
3899                period: SettlementPeriod::new(from, to).unwrap(),
3900                gas_kapazitaet: Some(GasKapazitaet {
3901                    bestellte_kapazitaet_kwh_h: d("100"),
3902                    entgelt_eur_per_kwh_h_a: d("36.5"),
3903                    produkt: Kapazitaetsprodukt::Fest,
3904                    druckstufe: None,
3905                }),
3906                ..base_nne()
3907            })
3908            .expect("a settleable NNE")
3909            .positions
3910            .iter()
3911            .find(|p| p.kind == BillingPositionKind::GasKapazitaetsentgelt)
3912            .expect("a Kapazitätsentgelt position")
3913            .net_eur
3914        };
3915        // 2024 is a leap year (366 days), 2025 is not (365).
3916        let leap = jahr(date!(2024 - 01 - 01), date!(2024 - 12 - 31));
3917        let common = jahr(date!(2025 - 01 - 01), date!(2025 - 12 - 31));
3918        assert_eq!(common, d("3650.00000"), "100 kWh/h × 36.50 EUR/a");
3919        assert_eq!(leap, common, "a leap year is not 0.274 % more capacity");
3920    }
3921
3922    // ── Sparte guards ────────────────────────────────────────────────────────
3923
3924    /// A Grundpreis on a Strom settlement is not billed as a GasNEV §14 position.
3925    #[test]
3926    fn a_grundpreis_on_strom_is_refused_not_labelled_gas() {
3927        let out = settle_nne(&NneInput {
3928            sparte: Sparte::Strom,
3929            grundpreis: Some(crate::types::Grundpreis {
3930                eur_per_month: d("12.00"),
3931                months: Decimal::ONE,
3932            }),
3933            ..base_nne()
3934        })
3935        .expect("a settleable NNE");
3936
3937        assert!(
3938            !out.positions
3939                .iter()
3940                .any(|p| p.kind == BillingPositionKind::NneGasGrundpreis),
3941            "no Gas Grundpreis position on a Strom invoice"
3942        );
3943        assert!(
3944            out.warnings.iter().any(|w| w.code == "GRUNDPREIS_ON_STROM"),
3945            "the refusal must be visible to the caller"
3946        );
3947    }
3948
3949    /// …and on Gas it is billed, unchanged.
3950    #[test]
3951    fn a_grundpreis_on_gas_is_billed() {
3952        let out = settle_nne(&NneInput {
3953            sparte: Sparte::Gas,
3954            grundpreis: Some(crate::types::Grundpreis {
3955                eur_per_month: d("12.00"),
3956                months: Decimal::ONE,
3957            }),
3958            ..base_nne()
3959        })
3960        .expect("a settleable NNE");
3961        assert_eq!(
3962            out.positions
3963                .iter()
3964                .find(|p| p.kind == BillingPositionKind::NneGasGrundpreis)
3965                .map(|p| p.net_eur),
3966            Some(d("12.00000"))
3967        );
3968    }
3969}