1use 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
35const 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
158fn 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
173pub(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#[must_use = "handle the BillingError"]
244pub fn settle_nne(input: &NneInput) -> Result<SettlementResult, BillingError> {
245 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 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 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 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 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 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 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 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 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 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 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 ArbeitspreisModell::SpotpreisNetzentgelt { .. } => {}
628 }
629
630 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 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 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 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 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 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 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 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; }
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 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 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 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#[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 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 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 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 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#[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 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 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 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#[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 let mut warnings: Vec<SettlementWarning> = Vec::new();
1432 warn_if_straddles_turnover(input.period.from(), input.period.to(), &mut warnings);
1433
1434 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 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 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 sender_mp_id: input.msb_mp_id.clone(),
1552 recipient_mp_id: input.empfaenger.mp_id.clone(),
1553 positions,
1554 total_eur,
1555 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#[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 let mut warnings: Vec<SettlementWarning> = Vec::new();
1618 warn_if_straddles_turnover(original.period.from(), original.period.to(), &mut warnings);
1619
1620 SettlementResult {
1621 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 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#[must_use]
1679pub fn correct(
1680 original: &SettlementResult,
1681 mut replacement: SettlementResult,
1682 grund: KorrekturGrund,
1683) -> (SettlementResult, SettlementResult) {
1684 let reversal = reverse(original, grund);
1688 replacement.status = SettlementStatus::Correction;
1689 replacement.korrektur_grund = Some(grund);
1690 (reversal, replacement)
1691}
1692
1693#[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 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, quantity: qty,
1780 unit: QuantityUnit::Monat, 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 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 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)]
1838fn 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 #[test]
1907 fn billing_more_months_than_the_period_serves_is_flagged() {
1908 let mut input = base_msb();
1909 input.billing_months = 12; 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 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 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 #[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 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 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 #[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 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 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 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 #[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 assert!(SettlementPeriod::new(date!(2025 - 01 - 01), date!(2025 - 01 - 01)).is_ok());
2151 }
2152
2153 #[test]
2156 fn a_sect19_agreement_reduces_only_the_netzentgelt() {
2157 use crate::sect19::{Sect19Art, Sect19Vereinbarung};
2158
2159 let mut i = base_nne();
2160 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 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 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 assert!(
2201 !r.warnings
2202 .iter()
2203 .any(|w| w.code == "SECT19_BELOW_MINDESTENTGELT"),
2204 "{:?}",
2205 r.warnings
2206 );
2207 }
2208
2209 #[test]
2211 fn an_agreement_below_the_floor_is_reported() {
2212 use crate::sect19::{Sect19Art, Sect19Vereinbarung};
2213
2214 let mut i = base_nne();
2215 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 #[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 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 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 #[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 #[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 #[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 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 #[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 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 #[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 #[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 let numbers: Vec<u32> = doc.numbered_positions().map(|(n, _)| n).collect();
2450 assert_eq!(numbers.first(), Some(&1));
2451 }
2452
2453 #[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 #[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 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 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 #[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 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 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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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"), 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 #[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 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 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 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 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 #[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 #[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 #[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 #[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 assert_eq!(r.positions[0].net_eur, d("52.50000"));
3106 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 #[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 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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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#[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)) }
3427
3428 proptest! {
3429 #[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 #[test]
3472 fn modul1_total_lte_unreduced(
3473 kwh in arb_positive_kwh(),
3474 ct in arb_ct_per_kwh(),
3475 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 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#[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 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 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 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 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 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 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 assert_eq!(modul3[0].net_eur, d("0.02500"));
3711 assert_eq!(modul3[1].net_eur, d("0.02625"));
3713 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 #[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 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 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 #[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 assert_eq!(reduktion.net_eur, d("-45.00000"));
3852 }
3853
3854 #[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 assert_eq!(reduktion.net_eur, d("-48.00000"));
3885 }
3886
3887 #[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 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 #[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 #[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}