grid_billing/types.rs
1//! Input/output types for the settlement calculation engine.
2//!
3//! ## Architecture
4//!
5//! The preferred calculation flow is:
6//!
7//! ```text
8//! Input → Validation → Settlement Engine → SettlementResult → InvoiceDocument → BO4E → EDIFACT
9//! ```
10//!
11//! [`SettlementResult`] is the canonical output. It carries every position
12//! alongside its [`CalculationTrace`], applicable [`LegalReference`]s, the
13//! [`TariffSource`] that justified each rate, and any [`SettlementWarning`]s.
14//!
15//! The service layer (`netzbilanzd`, `invoicd`) adapts `SettlementResult` into
16//! `rubo4e::current::Rechnung` via a local `into_rechnung()` helper — keeping
17//! BO4E as a purely rendering concern outside this crate.
18//!
19//! ## No float money
20//!
21//! All monetary amounts use [`rust_decimal::Decimal`]. The `crate::EuroAmount`
22//! newtype provides overflow-safe EUR arithmetic. No `f32`/`f64` appears anywhere
23//! in settlement calculations.
24
25use rust_decimal::Decimal;
26
27// ── Sparte ────────────────────────────────────────────────────────────────────
28
29/// Commodity — Strom (electricity) or Gas.
30///
31/// Controls which legal references are applied to each settlement position:
32/// - `Strom` → `StromNEV`, BK6 Festlegungen
33/// - `Gas` → `GasNEV`, BK7 Festlegungen
34#[derive(
35 Debug,
36 Clone,
37 Copy,
38 PartialEq,
39 Eq,
40 PartialOrd,
41 Ord,
42 Hash,
43 Default,
44 serde::Serialize,
45 serde::Deserialize,
46)]
47pub enum Sparte {
48 /// Electricity (Strom). Default.
49 #[default]
50 Strom,
51 /// Natural gas (Gas).
52 Gas,
53}
54
55// ── Konzessionsabgabe (KAV §2) ────────────────────────────────────────────────
56
57/// Municipality size band for Konzessionsabgabe, per **KAV §2 Abs. 2**.
58///
59/// KAV bands Tarifkunden rates by the municipality's **inhabitant count**, not by
60/// the customer's annual consumption.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
62pub enum GemeindeGroesse {
63 /// bis 25 000 Einwohner.
64 Bis25k,
65 /// bis 100 000 Einwohner.
66 Bis100k,
67 /// bis 500 000 Einwohner.
68 Bis500k,
69 /// über 500 000 Einwohner.
70 Ueber500k,
71}
72
73/// Konzessionsabgabe customer group per **KAV §2**.
74///
75/// The Tarifkunde/Sondervertragskunde split is a **contract-type** test, not a
76/// consumption threshold: KAV §2 Abs. 3 applies to Sondervertragskunden whatever
77/// they consume, and Abs. 2 bands Tarifkunden by municipality size.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
79pub enum KaKundengruppe {
80 /// Tarifkunde — KAV §2 Abs. 2. Rate depends on [`GemeindeGroesse`].
81 ///
82 /// For gas, `nur_kochen_warmwasser` selects between the two Abs. 2 columns:
83 /// supply limited to cooking and hot water, or all other Tariflieferungen.
84 Tarifkunde {
85 /// Municipality size band.
86 gemeinde: GemeindeGroesse,
87 /// Gas only: supply limited to cooking/hot water. Ignored for Strom.
88 nur_kochen_warmwasser: bool,
89 },
90 /// Schwachlaststrom — KAV §2 Abs. 2. **Strom only**; gas has no such tier.
91 Schwachlast,
92 /// Sondervertragskunde — KAV §2 Abs. 3. Flat, independent of municipality size.
93 Sondervertragskunde,
94 /// Freigestellt nach KAV §2 Abs. 7.
95 Exempt,
96}
97
98impl KaKundengruppe {
99 /// The KAV §2 **Höchstbetrag** in ct/kWh for this group and Sparte.
100 ///
101 /// Returns `None` for [`KaKundengruppe::Exempt`], and for
102 /// [`KaKundengruppe::Schwachlast`] on gas, which KAV does not provide.
103 ///
104 /// These are statutory **maxima**, not the agreed rate — a concession contract
105 /// may set anything up to them.
106 #[must_use]
107 pub fn hoechstsatz_ct_per_kwh(self, sparte: Sparte) -> Option<Decimal> {
108 let pick = |a: &str| Decimal::from_str_exact(a).ok();
109 match (self, sparte) {
110 (Self::Exempt, _) => None,
111 (Self::Schwachlast, Sparte::Strom) => pick("0.61"),
112 (Self::Schwachlast, Sparte::Gas) => None,
113 (Self::Sondervertragskunde, Sparte::Strom) => pick("0.11"),
114 (Self::Sondervertragskunde, Sparte::Gas) => pick("0.03"),
115 (Self::Tarifkunde { gemeinde, .. }, Sparte::Strom) => pick(match gemeinde {
116 GemeindeGroesse::Bis25k => "1.32",
117 GemeindeGroesse::Bis100k => "1.59",
118 GemeindeGroesse::Bis500k => "1.99",
119 GemeindeGroesse::Ueber500k => "2.39",
120 }),
121 (
122 Self::Tarifkunde {
123 gemeinde,
124 nur_kochen_warmwasser: true,
125 },
126 Sparte::Gas,
127 ) => pick(match gemeinde {
128 GemeindeGroesse::Bis25k => "0.51",
129 GemeindeGroesse::Bis100k => "0.61",
130 GemeindeGroesse::Bis500k => "0.77",
131 GemeindeGroesse::Ueber500k => "0.93",
132 }),
133 (
134 Self::Tarifkunde {
135 gemeinde,
136 nur_kochen_warmwasser: false,
137 },
138 Sparte::Gas,
139 ) => pick(match gemeinde {
140 GemeindeGroesse::Bis25k => "0.22",
141 GemeindeGroesse::Bis100k => "0.27",
142 GemeindeGroesse::Bis500k => "0.33",
143 GemeindeGroesse::Ueber500k => "0.40",
144 }),
145 }
146 }
147
148 /// Short label for the invoice position text.
149 #[must_use]
150 pub const fn label(self) -> &'static str {
151 match self {
152 Self::Tarifkunde { .. } => "KAV §2 Abs. 2 Tarifkunde",
153 Self::Schwachlast => "KAV §2 Abs. 2 Schwachlast",
154 Self::Sondervertragskunde => "KAV §2 Abs. 3 Sondervertragskunde",
155 Self::Exempt => "KAV §2 Abs. 7 — freigestellt",
156 }
157 }
158
159 /// The KAV paragraph that fixes this group's Höchstbetrag.
160 ///
161 /// Cited on the position, so the invoice states the rule it was actually
162 /// billed under: §2 Abs. 2 for a Tarifkunde, Abs. 3 for a
163 /// Sondervertragskunde, Abs. 7 for a freigestellter Kunde.
164 #[must_use]
165 pub const fn kav_paragraph(self) -> &'static str {
166 match self {
167 Self::Tarifkunde { .. } | Self::Schwachlast => "§2 Abs. 2",
168 Self::Sondervertragskunde => "§2 Abs. 3",
169 Self::Exempt => "§2 Abs. 7",
170 }
171 }
172}
173
174// ── QuantityUnit ──────────────────────────────────────────────────────────────
175
176/// Unit of measure for a settlement position quantity.
177#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
178pub enum QuantityUnit {
179 /// Kilowatt-hours (active energy).
180 Kwh,
181 /// Kilowatts (demand / peak load).
182 Kw,
183 /// Reactive energy (Blindarbeit) — kilovolt-ampere reactive hours.
184 ///
185 /// Used for reactive energy settlement positions per StromNEV §18.
186 Kvarh,
187 /// Reactive power (Blindleistung) — kilovolt-ampere reactive.
188 Kvar,
189 /// Calendar months.
190 Monat,
191}
192
193// ── Sect14aModule ─────────────────────────────────────────────────────────────
194
195/// §14a EnWG module for steuerbare Verbrauchseinrichtungen (controllable loads).
196///
197/// Source: BNetzA BK6-22-300 (Beschluss 27.11.2023, in force 01.01.2024).
198///
199/// All three modules are **mandatory** for eligible controllable loads (heat pumps,
200/// EV chargers, battery storage ≥ 4.2 kW) registered with the NB. The LF/NB
201/// must offer at least Modul 1 to all eligible customers.
202#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
203pub enum Sect14aModule {
204 /// Modul 1 — **pauschale Reduzierung des Netzentgelts**.
205 ///
206 /// A flat reduction applied for the whole billing period, published by the
207 /// NB either as an annual EUR amount or as a factor on the rate. It needs no
208 /// additional metering, which is why it is the default where the connection
209 /// holder makes no choice. It may be **combined with Modul 3**.
210 Modul1,
211 /// Modul 2 — **prozentuale Reduzierung des Arbeitspreises**.
212 ///
213 /// The Arbeitspreis of the Netzentgelt is reduced by a percentage for the
214 /// controllable device, which therefore needs its **own metering** — the
215 /// reduction attaches to that device's energy, not to the whole connection.
216 ///
217 /// **Mutually exclusive with Modul 3** (see [`Sect14aModule::combinable_with`]).
218 Modul2,
219 /// Modul 3 — **zeitvariable Netzentgelte**, available from 01.04.2025.
220 ///
221 /// Three Tarifstufen — Hochtarif, Standardtarif and Niedertarif — whose
222 /// windows the NB publishes in the UTILTS Zählzeitdefinition. Requires an
223 /// intelligent metering system. It may be combined with Modul 1 but **not**
224 /// with Modul 2.
225 Modul3,
226}
227
228impl Sect14aModule {
229 /// Canonical BNetzA decision reference for this module.
230 #[must_use]
231 pub fn bnentza_reference(self) -> &'static str {
232 "BK6-22-300"
233 }
234
235 /// Whether two modules may be held at once.
236 ///
237 /// Modul 1 is a flat reduction that needs no metering, so it composes with
238 /// the time-variable Modul 3. Modul 2 and Modul 3 both re-price the
239 /// Arbeitspreis and cannot both apply — a connection holding both would have
240 /// its network usage reduced twice.
241 #[must_use]
242 pub fn combinable_with(self, other: Self) -> bool {
243 !matches!(
244 (self, other),
245 (Self::Modul2, Self::Modul3) | (Self::Modul3, Self::Modul2)
246 )
247 }
248
249 /// Display label for the module.
250 #[must_use]
251 pub fn label(self) -> &'static str {
252 match self {
253 Self::Modul1 => "§14a EnWG Modul 1 (pauschale Reduzierung)",
254 Self::Modul2 => "§14a EnWG Modul 2 (prozentuale Arbeitspreisreduzierung)",
255 Self::Modul3 => "§14a EnWG Modul 3 (zeitvariable Netzentgelte)",
256 }
257 }
258}
259
260// ── SettlementType ────────────────────────────────────────────────────────────
261
262/// Which regulated settlement process produced this result.
263///
264/// Determines which BDEW PIDs are applicable and which regulatory references apply.
265#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
266pub enum SettlementType {
267 /// Abschlagsrechnung Netznutzung — PID 31001 (NB → LF).
268 ///
269 /// A payment on account, not a settled period: it prices no energy and
270 /// carries **exactly one** Positionszeile (INVOIC AHB 1.0b, Änd-ID 26817 —
271 /// "Eine Abschlagsrechnung kann und muss genau eine Positionszeile
272 /// enthalten"). What settles it is the Abschlussrechnung that follows,
273 /// which deducts it by invoice number.
274 NneAbschlag,
275 /// Netznutzungsentgelt (NNE) Strom — PID 31002 (NN-Rechnung, NB → LF).
276 NneStrom,
277 /// Netznutzungsentgelt (NNE) Gas — PID 31002 (NN-Rechnung, NB → LF, GasNEV).
278 ///
279 /// NNE Strom and Gas share the INVOIC Prüfidentifikator 31002 (NN-Rechnung);
280 /// the Sparte is carried in the message content, not the PID. Keeping a
281 /// separate variant preserves the correct legal references (StromNEV vs
282 /// GasNEV) without conditional logic in call sites.
283 NneGas,
284 /// Mehr-/Mindermengen settlement Strom — PID 31005 (NB → LF, GPKE (BK6-24-174) Teil 1 Kap. 8.4).
285 MmmStrom,
286 /// Mehr-/Mindermengen settlement Gas — PID 31005 (NB → LF, GaBi Gas 2.1 (BK7-24-01-008)).
287 ///
288 /// Gas MMM settlement uses different legal references from Strom MMM:
289 /// `GaBi Gas 2.1 (BK7-24-01-008)` and `GeLi Gas 3.0 (BK7-24-01-009)`. Using a separate variant
290 /// ensures correct audit traces without conditional logic in call sites.
291 MmmGas,
292 /// Mehr-/Mindermengen Mehrmenge, selbst ausgestellte Rechnung (Lieferung) — PID 31006.
293 ///
294 /// Per INVOIC AHB §3.x, PID 31006 covers the Mehrmenge leg when the Mehr-/
295 /// Mindermenge is treated as a „Lieferung“ and the invoice is self-issued.
296 MmmSelbstausstellt,
297 /// Messstellenbetrieb settlement — PID 31009 (MSB → NB / LF / ESA).
298 MsbRechnung,
299 /// GaBi Gas AWH Sperrprozesse settlement — PID 31011 (NB → LF, BK7-24-01-009 §5.4).
300 ///
301 /// Rechnung sonstige Leistung: bills the LF (LFG/LFA) for abrechnungswürdige
302 /// Handlungen (AWH) performed by the GNB/VNB during Sperrung/Entsperrung.
303 GasAwhSperrung,
304 /// Redispatch 2.0 Einsatzkosten (NB → ÜNB, BK6-20-061).
305 RedispatchKostenblatt,
306 /// Entgelt für dezentrale Erzeugung — §18 StromNEV, NB → Anlagenbetreiber.
307 ///
308 /// A bilateral payment relationship, not an EDIFACT market process: it has
309 /// no Prüfidentifikator and is rendered as an ordinary commercial credit.
310 DezentraleEinspeisung,
311}
312
313impl SettlementType {
314 /// Default BDEW PID for this settlement type.
315 ///
316 /// Callers may override the PID after construction if needed.
317 #[must_use]
318 pub fn default_pid(self) -> u32 {
319 match self {
320 Self::NneAbschlag => 31001,
321 Self::NneStrom => 31002,
322 Self::NneGas => 31002,
323 Self::MmmStrom => 31005,
324 Self::MmmGas => 31005,
325 Self::MmmSelbstausstellt => 31006,
326 Self::MsbRechnung => 31009,
327 Self::GasAwhSperrung => 31011,
328 Self::RedispatchKostenblatt => 0, // no standard PID
329 // Bilateral NB → Anlagenbetreiber payment; not an EDIFACT process.
330 Self::DezentraleEinspeisung => 0,
331 }
332 }
333}
334
335// ── SettlementStatus ──────────────────────────────────────────────────────────
336
337/// Lifecycle status of a settlement result.
338///
339/// Settlements are never destroyed — a correction or cancellation produces a new
340/// result and leaves the original intact.
341///
342/// **Which document supersedes which is not recorded here.** The invoice numbers
343/// linking a correction to what it replaces live on
344/// [`InvoiceDocument::correction_of`], because the same pair of settlements can
345/// be presented under different invoice numbers. What *is* recorded here is
346/// [`SettlementResult::korrektur_grund`] — why the recalculation happened, which
347/// is a fact about the settlement rather than about the document.
348#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
349pub enum SettlementStatus {
350 /// Initial calculation — no prior settlement exists for this period.
351 Initial,
352 /// Correction of a prior settlement.
353 Correction,
354 /// Cancellation of a prior settlement — all positions are negated.
355 Reversal,
356 /// Final settlement — no further corrections expected.
357 Final,
358}
359
360// ── KorrekturGrund ────────────────────────────────────────────────────────────
361
362/// Why a settlement was recalculated.
363///
364/// A correction that cannot say why it happened is not an audit trail. The
365/// invoice numbers alone answer *what* was replaced; they never answer whether
366/// the meter was wrong, the tariff was wrong, or the law changed underneath —
367/// and those have different consequences. A retroactive regulatory change is a
368/// lawful recalculation; a Rechenfehler in the same period is a defect that
369/// should be counted and investigated.
370///
371/// Carried on every non-`Initial` [`SettlementResult`].
372#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
373#[cfg_attr(feature = "bo4e", derive(serde::Deserialize))]
374pub enum KorrekturGrund {
375 /// Corrected metering — a replaced or re-read value (§ 60 Abs. 2 MsbG).
376 Messwertkorrektur,
377 /// The wrong tariff or price sheet version was applied.
378 Tarifkorrektur,
379 /// Master data was wrong — Netzebene, KA-Klasse, Konzessionsgemeinde.
380 Stammdatenkorrektur,
381 /// A regulatory change applies retroactively to a settled period.
382 RegulatorischeAenderung,
383 /// An arithmetic or logic error in the original settlement.
384 Rechenfehler,
385 /// A clearing result between the parties (Mehr-/Mindermengen, MaBiS).
386 Clearing,
387 /// Anything else — carry the detail in the settlement's warnings.
388 Sonstiges,
389}
390
391impl KorrekturGrund {
392 /// Stable machine-readable code for structured records and reporting.
393 #[must_use]
394 pub const fn code(self) -> &'static str {
395 match self {
396 Self::Messwertkorrektur => "MESSWERTKORREKTUR",
397 Self::Tarifkorrektur => "TARIFKORREKTUR",
398 Self::Stammdatenkorrektur => "STAMMDATENKORREKTUR",
399 Self::RegulatorischeAenderung => "REGULATORISCHE_AENDERUNG",
400 Self::Rechenfehler => "RECHENFEHLER",
401 Self::Clearing => "CLEARING",
402 Self::Sonstiges => "SONSTIGES",
403 }
404 }
405
406 /// Whether this reason indicates a defect in the original settlement rather
407 /// than a lawful recalculation.
408 ///
409 /// Separating the two is the point of recording the reason: a rising count
410 /// of `Rechenfehler` is an engineering signal, while a rising count of
411 /// `RegulatorischeAenderung` is not.
412 #[must_use]
413 pub const fn indicates_defect(self) -> bool {
414 matches!(self, Self::Rechenfehler | Self::Stammdatenkorrektur)
415 }
416}
417
418// ── LegalReference ────────────────────────────────────────────────────────────
419
420/// Regulatory citation that justifies a billing position or rate.
421///
422/// Every [`SettlementPosition`] should carry at least one `LegalReference`.
423/// This enables full auditability: any operator or regulator can trace
424/// exactly which paragraph, ruling, and version authorised each charge.
425#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
426pub enum LegalReference {
427 /// StromNEV — Stromnetzentgeltverordnung (grid usage charges, Strom).
428 ///
429 /// Example: `StromNev { paragraph: "§17" }` for Leistungspreise.
430 StromNev {
431 /// Paragraph reference, e.g. `"§17"`, `"§21"`.
432 paragraph: &'static str,
433 },
434 /// GasNEV — Gasnetzentgeltverordnung (grid usage charges, Gas).
435 GasNev {
436 /// Paragraph reference, e.g. `"§14"`.
437 paragraph: &'static str,
438 },
439 /// KAV — Konzessionsabgabenverordnung (municipal concession fee).
440 ///
441 /// Example: `Kav { paragraph: "§2 Abs. 2" }`.
442 Kav {
443 /// Paragraph reference, e.g. `"§2 Abs. 2"`.
444 paragraph: &'static str,
445 },
446 /// KWKG — Kraft-Wärme-Kopplungsgesetz.
447 ///
448 /// Example: `Kwkg { paragraph: "§26" }` for the KWKG-Umlage.
449 Kwkg {
450 /// Paragraph citation, e.g. `"§26"`.
451 paragraph: &'static str,
452 },
453 /// EnFG — Energiefinanzierungsgesetz.
454 ///
455 /// Governs which Letztverbrauchergruppe an Entnahmestelle falls into and so
456 /// which rate of a network levy applies.
457 EnFG {
458 /// Paragraph citation, e.g. `"§§21 ff."`.
459 paragraph: &'static str,
460 },
461 /// UStG — Umsatzsteuergesetz.
462 ///
463 /// Cited where the tax treatment is itself part of what the position claims:
464 /// an Anzahlung under §14 Abs. 5, a reverse charge under §13b.
465 Ustg {
466 /// Paragraph citation, e.g. `"§14 Abs. 5"`.
467 paragraph: &'static str,
468 },
469 /// §14a EnWG — Steuerbare Verbrauchseinrichtungen (controllable loads).
470 ///
471 /// Governs time-variable (ToU) NNE for heat pumps, EV chargers, etc.
472 Sect14aEnwg {
473 /// Module: Modul1 (flat reduction), Modul2 (HT/NT), or Modul3 (spot).
474 module: Sect14aModule,
475 },
476 /// MsbG — Messstellenbetriebsgesetz (metering point operation).
477 MsbG {
478 /// Paragraph citation, e.g. `"§§6–7"`.
479 paragraph: &'static str,
480 },
481 /// BNetzA decision (Beschluss).
482 ///
483 /// Example: `BnetzaDecision { reference: "BK6-22-300" }`.
484 BnetzaDecision {
485 /// Decision reference, e.g. `"BK6-22-300"`, `"BK6-24-174"`.
486 reference: &'static str,
487 },
488 /// BDEW application handbook (Anwendungshandbuch).
489 BdewAhb {
490 /// AHB reference, e.g. `"GPKE BK6-22-024"`.
491 reference: &'static str,
492 },
493 /// StromNZV — Stromnetzzugangsverordnung.
494 ///
495 /// **Außer Kraft mit Ablauf des 31.12.2025** (Art. 15 Abs. 4 des Gesetzes
496 /// v. 22.12.2023, BGBl. 2023 I Nr. 405). Valid only for Lieferzeiträume up
497 /// to that date; the successor competence is §20 Abs. 3 EnWG, exercised
498 /// through the BK6 Festlegungen. [`LegalReference::citation`] appends the
499 /// expiry so an archived invoice stays self-explanatory.
500 StromNzv {
501 /// Paragraph citation, e.g. `"§13 Abs. 3"`.
502 paragraph: &'static str,
503 },
504 /// GasNZV — Gasnetzzugangsverordnung 2010.
505 ///
506 /// **Außer Kraft mit Ablauf des 31.12.2025** (Art. 15 Abs. 6 des Gesetzes
507 /// v. 22.12.2023, BGBl. 2023 I Nr. 405). Succeeded by KARLA Gas 2.0
508 /// (BK7-24-01-007), GaBi Gas 2.1 (BK7-24-01-008), GeLi Gas 3.0
509 /// (BK7-24-01-009) and ZuBio (BK7-24-01-010), all in force 01.01.2026.
510 GasNzv {
511 /// Paragraph citation, e.g. `"§25"`.
512 paragraph: &'static str,
513 },
514 /// EnWG — Energiewirtschaftsgesetz (general energy law).
515 Enwg {
516 /// Paragraph citation, e.g. `"§14a"`.
517 paragraph: &'static str,
518 },
519 /// ARegV — Anreizregulierungsverordnung (incentive regulation).
520 ///
521 /// ARegV §§17–21 define the allowed NNE revenue caps and efficiency targets.
522 /// Relevant when documenting why a specific regulated tariff level was approved.
523 ARegV {
524 /// Paragraph citation, e.g. `"§17"`, `"§21"`.
525 paragraph: &'static str,
526 },
527}
528
529impl LegalReference {
530 /// Short human-readable citation string (German).
531 #[must_use]
532 pub fn citation(&self) -> String {
533 match self {
534 Self::StromNev { paragraph } => format!("StromNEV {paragraph}"),
535 Self::GasNev { paragraph } => format!("GasNEV {paragraph}"),
536 Self::Kav { paragraph } => format!("KAV {paragraph}"),
537 Self::Ustg { paragraph } => format!("UStG {paragraph}"),
538 Self::Kwkg { paragraph } => format!("KWKG {paragraph}"),
539 Self::EnFG { paragraph } => format!("EnFG {paragraph}"),
540 Self::Sect14aEnwg { module } => format!("§14a EnWG {}", module.label()),
541 Self::MsbG { paragraph } => format!("MsbG {paragraph}"),
542 Self::BnetzaDecision { reference } => format!("BNetzA {reference}"),
543 Self::BdewAhb { reference } => format!("BDEW {reference}"),
544 Self::StromNzv { paragraph } => {
545 format!("StromNZV {paragraph} (außer Kraft seit 01.01.2026)")
546 }
547 Self::GasNzv { paragraph } => {
548 format!("GasNZV {paragraph} (außer Kraft seit 01.01.2026)")
549 }
550 Self::Enwg { paragraph } => format!("EnWG {paragraph}"),
551 Self::ARegV { paragraph } => format!("ARegV {paragraph}"),
552 }
553 }
554}
555
556// ── TariffSource ──────────────────────────────────────────────────────────────
557
558/// Origin of the tariff rate applied in a settlement position.
559///
560/// Every rate used in a billing position must be traceable to a `TariffSource`.
561/// This enables operators and auditors to answer: *"Why was this rate used?"*
562#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
563pub enum TariffSource {
564 /// Rate from the published and approved `PreisblattNetznutzung` tariff sheet.
565 PublishedTariffSheet {
566 /// Tariff sheet identifier or version, e.g. `"Preisblatt 2025 Q1"`.
567 sheet_id: String,
568 },
569 /// Rate from a historical tariff (retroactive billing or correction).
570 HistoricalTariff {
571 /// Original valid_from date of the tariff.
572 valid_from: time::Date,
573 },
574 /// Regulatory rate mandated by a BNetzA decision.
575 RegulatoryTariff {
576 /// BNetzA decision reference.
577 decision_ref: &'static str,
578 },
579 /// Contract-specific rate negotiated between NB and customer.
580 ContractTariff {
581 /// Contract reference.
582 contract_ref: String,
583 },
584 /// Manual override by operator (requires documentation).
585 ManualOverride {
586 /// Reason for the override.
587 reason: String,
588 },
589}
590
591// ── CalculationTrace ──────────────────────────────────────────────────────────
592
593/// Full audit record for how one [`SettlementPosition`] was computed.
594///
595/// Answers the question: *"Why is this amount on the invoice?"*
596///
597/// Every `CalculationTrace` carries the input values, the applied legal rules,
598/// intermediate results, and the tariff source. This enables:
599/// - Regulator audits (BNetzA §20 EnWG)
600/// - Operator review
601/// - LF dispute resolution
602/// - AI-assisted invoice explainability (MCP tools)
603#[derive(Debug, Clone, serde::Serialize)]
604pub struct CalculationTrace {
605 /// Human-readable explanation of this position.
606 ///
607 /// Example: `"Arbeit 1500 kWh × 3.5 ct/kWh = 52.50 EUR"`
608 pub explanation: String,
609 /// Input quantity used (before rounding).
610 pub input_quantity: Decimal,
611 /// Input unit price in EUR (before rounding, already converted from ct).
612 pub input_unit_price_eur: Decimal,
613 /// Intermediate result before rounding (qty × price).
614 pub gross_eur: Decimal,
615 /// Applied legal references (at least one required).
616 pub legal_refs: Vec<LegalReference>,
617 /// Source of the tariff rate.
618 pub tariff_source: Option<TariffSource>,
619 /// Any §14a reductions applied, expressed as a fraction (0.0–1.0).
620 ///
621 /// `None` when no regulatory reduction applies.
622 /// Example: `Some(Decimal::new(85, 2))` = 85% of full rate (15% reduction).
623 pub regulatory_reduction_factor: Option<Decimal>,
624 /// Notes on rounding applied.
625 ///
626 /// Example: `"rounded to 5 dp per StromNEV §17"`.
627 pub rounding_note: Option<&'static str>,
628}
629
630// ── SettlementWarning ─────────────────────────────────────────────────────────
631
632/// A non-blocking validation issue found during settlement calculation.
633///
634/// Warnings do not prevent the invoice from being generated but should be
635/// reviewed before dispatch. The service layer may choose to block dispatch
636/// on `Severity::Error` warnings.
637#[derive(Debug, Clone, serde::Serialize)]
638pub struct SettlementWarning {
639 /// Severity: informational, warning, or error.
640 pub severity: WarningSeverity,
641 /// Machine-readable warning code.
642 pub code: &'static str,
643 /// Human-readable description.
644 pub message: String,
645}
646
647/// Severity level for [`SettlementWarning`].
648#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
649pub enum WarningSeverity {
650 /// Informational — no action required.
651 Info,
652 /// Potential issue — review recommended before dispatch.
653 Warning,
654 /// Definite issue — should be resolved before dispatch.
655 Error,
656}
657
658// ── InvoicePosition ───────────────────────────────────────────────────────────
659
660/// Semantic kind of a billing position — used by the service layer to derive
661/// the correct `BdewArtikelnummer` for the BO4E `Rechnungsposition`.
662///
663/// `grid-billing` has no `rubo4e` dependency, so this enum is the bridge:
664/// the service layer maps `BillingPositionKind` → `BdewArtikelnummer` in
665/// `into_rechnung()`. Every position in every `SettlementResult` must carry
666/// a `kind` so the INVOIC `Rechnungsposition.artikelnummer` is never missing.
667///
668/// ## BDEW INVOIC AHB requirement
669///
670/// BDEW INVOIC AHBs (FV2025-10-01) mandate `artikelnummer` in every
671/// `SG28 PIA` line item. Missing or wrong Artikelnummern cause counterparty
672/// APERAK rejection. The `invoic-checker` checks 6 plausibility rules;
673/// Artikelnummer matching is part of the tariff-found rule (check 5).
674///
675/// ## Mapping to `BdewArtikelnummer`
676///
677/// | `BillingPositionKind` | `BdewArtikelnummer` | INVOIC AHB ref |
678/// |---|---|---|
679/// | `NneArbeit` | `Wirkarbeit` | PID 31002 (NN-Rechnung) Arbeit |
680/// | `NneArbeitHt` | `Wirkarbeit` | PID 31002 §14a Modul 3 HT |
681/// | `NneArbeitSt` | `Wirkarbeit` | PID 31002 §14a Modul 3 ST |
682/// | `NneArbeitNt` | `Wirkarbeit` | PID 31002 §14a Modul 3 NT |
683/// | `NneArbeitModul2` | `Wirkarbeit` | PID 31002 §14a Modul 2 (rate reduced) |
684/// | `NneArbeitModul1` | `Wirkarbeit` | PID 31002 §14a Modul 1 (rate reduced) |
685/// | `NneLeistung` | `Leistung` | PID 31002 RLM kW charge |
686/// | `NneGasGrundpreis` | `Grundpreis` | PID 31002 Gas monthly base fee |
687/// | `Konzessionsabgabe` | `Konzessionsabgabe` | PID 31002 KAV §2 |
688/// | `Mehrmenge` | `Mehrmenge` | PID 31005 positive imbalance |
689/// | `Mindermenge` | `Mindermenge` | PID 31005 negative imbalance (credit) |
690/// | `MsbGrundgebuehr` | `EntgeltEinbauBetriebWartungMesstechnik` | PID 31009 MSB monthly fee |
691/// | `Messdienstleistung` | `EntgeltMessungAblesung` | PID 31009 reading service |
692/// | `GasAwhSperrung` | `Sperrkosten` | PID 31011 AWH disconnection |
693/// | `GasAwhEntsprrung` | `Entsperrkosten` | PID 31011 AWH reconnection |
694/// | `GasAwhSonstige` | `EntgeltAbrechnung` | PID 31011 other AWH |
695/// | `Blindmehrarbeit` | `Blindmehrarbeit` | Reactive energy excess |
696#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
697pub enum BillingPositionKind {
698 /// The single line of an Abschlagsrechnung — a payment on account.
699 ///
700 /// Carries an amount and nothing else: no quantity, no unit price, because
701 /// an Abschlag prices no energy. What it is *for* is the delivery period on
702 /// the settlement.
703 NneAbschlag,
704 /// Netznutzungsentgelt Arbeit — flat-rate active energy charge (kWh).
705 /// SLP or Gas. → `BdewArtikelnummer::Wirkarbeit`
706 NneArbeit,
707 /// §14a Modul 3 Hochtarif (HT) Arbeit — zeitvariables Netzentgelt, high band.
708 /// → `BdewArtikelnummer::Wirkarbeit`
709 NneArbeitHt,
710 /// §14a Modul 3 Standardtarif (ST) Arbeit — zeitvariables Netzentgelt, middle band.
711 /// → `BdewArtikelnummer::Wirkarbeit`
712 NneArbeitSt,
713 /// §14a Modul 3 Niedertarif (NT) Arbeit — zeitvariables Netzentgelt, low band.
714 /// → `BdewArtikelnummer::Wirkarbeit`
715 NneArbeitNt,
716 /// §14a Modul 1 Arbeit — pauschale Reduzierung applied to the Arbeitspreis.
717 /// → `BdewArtikelnummer::Wirkarbeit` (same article, different rate)
718 NneArbeitModul1,
719 /// §14a Modul 2 Arbeit — prozentuale Reduzierung of the device's Arbeitspreis.
720 /// → `BdewArtikelnummer::Wirkarbeit` (same article, different rate)
721 NneArbeitModul2,
722 /// §14a Modul 3 Spotpreis-NNE — per-dispatch-interval variable rate position.
723 ///
724 /// One `InvoicePosition` is generated per dispatch interval from
725 /// `NneInput::sect14a_modul3_intervals`. Each carries a
726 /// `lastvariable_preisposition_json` with the BO4E `LastvariablePreisposition`
727 /// COM data (pricing formula parameters) for ERP-side validation and portal
728 /// display of the per-interval tariff breakdown.
729 ///
730 /// Regulatory basis: BNetzA BK6-22-300 Anlage 2 §3 — Spotpreis-Netzentgelt.
731 /// → `BdewArtikelnummer::Wirkarbeit`
732 NneArbeitModul3,
733 /// Netznutzungsentgelt Leistung — RLM peak demand charge (kW).
734 /// → `BdewArtikelnummer::Leistung`
735 NneLeistung,
736 /// Gas NNE monthly base fee (Grundpreis / Verrechnungspreis).
737 /// GasNEV §14. → `BdewArtikelnummer::Grundpreis`
738 NneGasGrundpreis,
739 /// Konzessionsabgabe — KAV §2 municipal concession fee.
740 /// → `BdewArtikelnummer::Konzessionsabgabe`
741 Konzessionsabgabe,
742 /// Mehrmengen — positive imbalance (actual > profiled).
743 /// PID 31005 GPKE (BK6-24-174) Teil 1 Kap. 8.4 / GaBi Gas 2.1 (BK7-24-01-008). → `BdewArtikelnummer::Mehrmenge`
744 Mehrmenge,
745 /// Mindermengen — negative imbalance credit note (actual < profiled).
746 /// PID 31005. → `BdewArtikelnummer::Mindermenge`
747 Mindermenge,
748 /// MSB Grundgebühr Messstellenbetrieb — monthly metering base fee.
749 /// MsbG §§6–7. → `BdewArtikelnummer::EntgeltEinbauBetriebWartungMesstechnik`
750 MsbGrundgebuehr,
751 /// Messdienstleistung — periodic reading service fee.
752 /// MsbG §2. → `BdewArtikelnummer::EntgeltMessungAblesung`
753 Messdienstleistung,
754 /// Gas AWH Sperrung — abrechnungswürdige Handlung disconnection.
755 /// BK7-24-01-009 §5.4. → `BdewArtikelnummer::Sperrkosten`
756 GasAwhSperrung,
757 /// Gas AWH Entsperrung — abrechnungswürdige Handlung reconnection.
758 /// BK7-24-01-009 §5.4. → `BdewArtikelnummer::Entsperrkosten`
759 GasAwhEntsprrung,
760 /// Gas AWH sonstige — other abrechnungswürdige Handlung.
761 /// BK7-24-01-009 §5.4. → `BdewArtikelnummer::EntgeltAbrechnung`
762 GasAwhSonstige,
763 /// Blindmehrarbeit — reactive energy beyond the free share.
764 ///
765 /// Charged from the Netzbetreiber's published Preisblatt; StromNEV §17
766 /// governs how those Netzentgelte are formed. **Not** §18, which is the
767 /// Entgelt für dezentrale Erzeugung, and not §19, which is Sonderformen der
768 /// Netznutzung. → `BdewArtikelnummer::Blindmehrarbeit`
769 Blindmehrarbeit,
770 /// Aufschlag für besondere Netznutzung (§19 StromNEV-Umlage).
771 ///
772 /// Funds the reduced individual network charges granted under §19 Abs. 2
773 /// StromNEV. Rate depends on the Letztverbrauchergruppe (EnFG).
774 Sect19StromNevUmlage,
775 /// Offshore-Netzumlage (§17f EnWG).
776 ///
777 /// Funds offshore connection cost and the compensation owed to offshore
778 /// wind farms for unavailable connections.
779 OffshoreNetzumlage,
780 /// KWKG-Umlage (§26 KWKG).
781 ///
782 /// Funds the KWK-Zuschlag paid to CHP operators.
783 KwkgUmlage,
784 /// Entgelt für dezentrale Erzeugung — §18 StromNEV, under Abschmelzung
785 /// (GBK-25-02-1#1). A payment out, so its `net_eur` is negative.
786 DezentraleEinspeisung,
787 /// §19 Abs. 2 StromNEV individual-charge reduction over the Netzentgelt.
788 /// Negative: it takes the published charge down to the agreed fraction.
789 Sect19IndividuellesEntgelt,
790 /// Gas Kapazitätsentgelt — booked capacity at the price sheet's annual
791 /// rate, pro-rated over the period. §15 GasNEV.
792 GasKapazitaetsentgelt,
793}
794
795/// One line item in a grid settlement.
796///
797/// Carries raw numbers for the service layer to map into the required format
798/// (BO4E `Rechnungsposition`, EN16931 UBL, etc.).
799///
800/// Invariant: `net_eur == (quantity × unit_price_eur).round_dp(5)`.
801/// The pricing formula behind a §14a Modul 3 spot-priced position.
802///
803/// Modelled as a value object rather than a serialised BO4E document. The engine
804/// states *what the formula was*; translating that into
805/// `LastvariablePreisposition` — or into any other representation — is the
806/// adapter's job. Carrying BO4E JSON here would put schema knowledge inside the
807/// calculation, untyped and unvalidated, which is the coupling the crate exists
808/// to avoid.
809#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
810pub struct SpotPriceFormula {
811 /// What the price refers to — for Modul 3 always the metered energy.
812 pub reference: PriceReference,
813 /// The unit the price is expressed per.
814 pub unit: QuantityUnit,
815 /// How the rate was derived.
816 pub method: TariffCalculationMethod,
817 /// The rate steps that applied, in order.
818 pub steps: Vec<PriceStep>,
819}
820
821/// What a price refers to.
822#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
823pub enum PriceReference {
824 /// The metered energy quantity.
825 Energiemenge,
826 /// Contracted or metered capacity.
827 Leistung,
828}
829
830/// How a rate was derived.
831#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
832pub enum TariffCalculationMethod {
833 /// A published fixed rate.
834 Festpreis,
835 /// Derived from a spot-market price — §14a Modul 3, BK6-22-300 Anlage 2 §3.
836 Spotpreis,
837}
838
839/// One step of a rate schedule.
840#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
841pub struct PriceStep {
842 /// Lower bound of the step, inclusive.
843 pub from: Decimal,
844 /// Upper bound, exclusive; `None` for the open top step.
845 pub to: Option<Decimal>,
846 /// The rate in EUR per [`SpotPriceFormula::unit`].
847 pub unit_price_eur: Decimal,
848}
849
850/// One line of a settlement.
851///
852/// Carries no position number and no BDEW Artikel-ID: both are properties of the
853/// *document* that presents the settlement, not of the calculation. An adapter
854/// numbers the positions it renders and resolves article identifiers from the
855/// price sheet.
856#[derive(Debug, Clone, serde::Serialize)]
857pub struct SettlementPosition {
858 /// Human-readable description.
859 pub text: String,
860 /// Semantic kind — what was charged, independent of how it is coded.
861 pub kind: BillingPositionKind,
862 /// Metered or contracted quantity.
863 pub quantity: Decimal,
864 /// Unit of measure.
865 pub unit: QuantityUnit,
866 /// Unit price in EUR.
867 pub unit_price_eur: Decimal,
868 /// Net amount in EUR, rounded to 5 decimal places.
869 ///
870 /// May be negative for credit positions (Mindermengen, Gutschriften).
871 pub net_eur: Decimal,
872 /// The formula behind the rate, where one applied.
873 pub spot_price_formula: Option<SpotPriceFormula>,
874 /// Why this amount is what it is.
875 pub trace: CalculationTrace,
876}
877
878impl BillingPositionKind {
879 /// The BDEW Artikelnummer that codes this position, as its codelist name.
880 ///
881 /// Which article number applies depends on both what was charged and what
882 /// kind of settlement it appears in — Gas NNE keeps the classic `WIRKARBEIT`
883 /// code, while Strom NNE moved to Artikel-IDs under BK6-20-160 and carries
884 /// no Artikelnummer at all.
885 ///
886 /// Returned as the codelist *name* rather than a BO4E enum so that this
887 /// crate stays free of BO4E types. A consumer parses it into whatever it
888 /// renders — `rubo4e::current::BdewArtikelnummer` implements `FromStr` over
889 /// exactly these names.
890 ///
891 /// `None` means the position carries an Artikel-ID instead, resolved from
892 /// the price sheet by the renderer.
893 ///
894 /// Source: BDEW Codeliste der Artikelnummern und Artikel-IDs v5.6.
895 #[must_use]
896 pub fn artikelnummer(self, settlement_type: SettlementType) -> Option<&'static str> {
897 use BillingPositionKind as K;
898 use SettlementType as ST;
899 match (self, settlement_type) {
900 // An Abschlag prices nothing, so it carries no Artikelnummer: the
901 // codelist names charges, and a payment on account is not one.
902 (K::NneAbschlag, _) => None,
903 // Gas NNE keeps the classic codes — BK6-20-160 changed Strom only.
904 (
905 K::NneArbeit
906 | K::NneArbeitHt
907 | K::NneArbeitSt
908 | K::NneArbeitNt
909 | K::NneArbeitModul1
910 | K::NneArbeitModul2
911 | K::NneArbeitModul3,
912 ST::NneGas,
913 ) => Some("WIRKARBEIT"),
914 (K::NneLeistung, ST::NneGas) => Some("LEISTUNG"),
915 (K::NneGasGrundpreis, _) => Some("GRUNDPREIS"),
916 // Strom NNE: the Artikel-ID replaces the Artikelnummer.
917 (
918 K::NneArbeit
919 | K::NneArbeitHt
920 | K::NneArbeitSt
921 | K::NneArbeitNt
922 | K::NneArbeitModul1
923 | K::NneArbeitModul2
924 | K::NneArbeitModul3
925 | K::NneLeistung,
926 _,
927 ) => None,
928 (K::Konzessionsabgabe, _) => Some("KONZESSIONSABGABE"),
929 (K::Mehrmenge, _) => Some("MEHRMENGE"),
930 (K::Mindermenge, _) => Some("MINDERMENGE"),
931 (K::MsbGrundgebuehr, _) => Some("ENTGELT_EINBAU_BETRIEB_WARTUNG_MESSTECHNIK"),
932 (K::Messdienstleistung, _) => Some("ENTGELT_MESSUNG_ABLESUNG"),
933 // AWH Gas positions carry a 2-01-7-xxx Artikel-ID from the input.
934 (K::GasAwhSperrung | K::GasAwhEntsprrung | K::GasAwhSonstige, _) => None,
935 (K::Blindmehrarbeit, _) => Some("BLINDMEHRARBEIT"),
936 // Netzseitige Umlagen (EnFG). `OFFSHORE_HAFTUNGSUMLAGE` is the code's
937 // legacy name — the levy was renamed Offshore-Netzumlage, the article
938 // number was not.
939 (K::Sect19StromNevUmlage, _) => Some("PARAGRAF_19_STROM_NEV_UMLAGE"),
940 // Bilateral payment outside the INVOIC market processes — the
941 // codelist has no article number for it.
942 (K::DezentraleEinspeisung, _) => None,
943 // A reduction over Strom NNE positions, which carry Artikel-IDs.
944 (K::Sect19IndividuellesEntgelt, _) => None,
945 // Capacity is the gas Leistung analogue and keeps the classic code.
946 (K::GasKapazitaetsentgelt, ST::NneGas) => Some("LEISTUNG"),
947 (K::GasKapazitaetsentgelt, _) => None,
948 (K::OffshoreNetzumlage, _) => Some("OFFSHORE_HAFTUNGSUMLAGE"),
949 (K::KwkgUmlage, _) => Some("ABGABE_KWKG"),
950 }
951 }
952}
953
954// ── Arbeitspreis model ────────────────────────────────────────────────────────
955
956/// A §14a **Modul 2** reduction factor — the fraction of the published
957/// Arbeitspreis actually paid.
958///
959/// Modul 2 is the *prozentuale* reduction; Modul 1 is the flat annual pauschale
960/// and carries no factor at all (see [`ArbeitspreisModell::Modul1Pauschal`]).
961///
962/// A newtype because the range matters: `0.85` is a 15 % reduction, and a value
963/// outside `(0, 1]` is not a reduction at all. The unconstrained `Decimal` this
964/// replaces was range-checked in the validator and *not* in the engine, so a
965/// caller who skipped validation could multiply the tariff by 5.
966#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
967pub struct Reduktionsfaktor(Decimal);
968
969impl Reduktionsfaktor {
970 /// A commonly published factor — 85 % of the tariff, i.e. a 15 % reduction.
971 ///
972 /// Not a statutory rate: BK8-22/010-A leaves the Modul-2 percentage to each
973 /// Netzbetreiber's published Preisblatt, so this is a convenience default
974 /// and never a substitute for the operator's own figure.
975 pub const REGELFALL: Self = Self(rust_decimal::dec!(0.85));
976
977 /// Build a factor.
978 ///
979 /// # Errors
980 ///
981 /// Returns [`crate::error::BillingError::InvalidInput`] outside `(0, 1]`.
982 pub fn new(factor: Decimal) -> Result<Self, crate::error::BillingError> {
983 if factor <= Decimal::ZERO || factor > Decimal::ONE {
984 return Err(crate::error::BillingError::InvalidInput {
985 reason: format!("§14a Modul 2 reduction factor must be in (0, 1], got {factor}"),
986 });
987 }
988 Ok(Self(factor))
989 }
990
991 /// The factor as a fraction.
992 #[must_use]
993 pub const fn get(self) -> Decimal {
994 self.0
995 }
996}
997
998/// Deserialising goes through [`Reduktionsfaktor::new`], so a factor that
999/// arrives over the wire is range-checked exactly like one built in process.
1000///
1001/// Deriving it would have reintroduced the unconstrained `Decimal` this newtype
1002/// exists to prevent: a request body carrying `5` would then multiply the
1003/// Arbeitspreis by five with no error anywhere.
1004impl<'de> serde::Deserialize<'de> for Reduktionsfaktor {
1005 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
1006 let raw = <Decimal as serde::Deserialize>::deserialize(d)?;
1007 Self::new(raw).map_err(serde::de::Error::custom)
1008 }
1009}
1010
1011/// A metered quantity priced at a rate.
1012#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1013pub struct MengePreis {
1014 /// Metered energy in kWh.
1015 pub menge_kwh: Decimal,
1016 /// Rate in ct/kWh.
1017 pub preis_ct_per_kwh: Decimal,
1018}
1019
1020/// How the Arbeitspreis is structured, and whether §14a applies.
1021///
1022/// One enum rather than three independent field groups. The four variants are
1023/// mutually exclusive **by construction**, which removes a whole class of defect:
1024///
1025/// - The four HT/NT fields were 2⁴ states of which two were valid. Setting three
1026/// of them fell through to flat billing with no error — the invoice looked
1027/// right and was billed on the wrong basis.
1028/// - Modul 1 and Modul 3 could both be set. The engine applied the flat
1029/// reduction *and* the per-interval rates, double-billing the same energy.
1030/// - Modul 1 and Modul 2 could both be set; the engine silently preferred
1031/// Modul 2 rather than rejecting the conflict.
1032///
1033/// Those were runtime warnings in a validator the engine never called. They are
1034/// now unrepresentable.
1035#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
1036pub enum ArbeitspreisModell {
1037 /// A single rate for all metered energy.
1038 Einheitlich(MengePreis),
1039
1040 /// **§14a Modul 1** — pauschale Reduzierung des Netzentgelts.
1041 ///
1042 /// A **flat annual amount** the Netzbetreiber publishes, credited pro rata
1043 /// for the settlement period. It does not scale with consumption — that is
1044 /// what makes it *pauschal*, and what distinguishes it from
1045 /// [`Self::Modul2ProzentualeReduzierung`], which reduces the Arbeitspreis by
1046 /// a percentage. The two were structurally identical here once; a factor on
1047 /// the Arbeitspreis is Modul 2's mechanism wearing Modul 1's name.
1048 ///
1049 /// Needs no additional metering, which is why it is the default where the
1050 /// connection holder makes no choice.
1051 Modul1Pauschal {
1052 /// The energy delivered in the period, at its published rate. Billed in
1053 /// full — Modul 1 does not touch the Arbeitspreis.
1054 basis: MengePreis,
1055 /// The Netzbetreiber's published annual pauschale, in EUR per year.
1056 pauschale_eur_pro_jahr: Decimal,
1057 /// The fraction of a year this settlement period covers, so the annual
1058 /// pauschale is credited pro rata.
1059 jahresanteil: Decimal,
1060 },
1061
1062 /// **§14a Modul 2** — the Arbeitspreis reduced by a percentage.
1063 ///
1064 /// The reduction attaches to the controllable device's own metered energy,
1065 /// so `basis` carries that device's consumption rather than the whole
1066 /// connection's.
1067 Modul2ProzentualeReduzierung {
1068 /// The device's metered energy and its published rate, before reduction.
1069 basis: MengePreis,
1070 /// The fraction of that rate actually paid.
1071 reduktion: Reduktionsfaktor,
1072 },
1073
1074 /// **§14a Modul 3** — zeitvariable Netzentgelte in three Tarifstufen.
1075 ///
1076 /// All three bands are required: BK6-22-300 defines Hochtarif, Standardtarif
1077 /// and Niedertarif, and permitting a subset would reintroduce the partial
1078 /// state this type exists to prevent. A band with no energy carries
1079 /// `menge_kwh = 0` rather than being omitted.
1080 Modul3ZeitVariabel {
1081 /// Hochtarif band.
1082 ht: MengePreis,
1083 /// Standardtarif band.
1084 st: MengePreis,
1085 /// Niedertarif band.
1086 nt: MengePreis,
1087 },
1088
1089 /// A spot-derived NNE rate per dispatch interval.
1090 ///
1091 /// **Not a §14a module.** BK6-22-300 defines exactly three, none of which is
1092 /// spot-linked; this models a Netzentgelt whose rate follows the spot price
1093 /// under the NB's own `PreisblattNetznutzung` formula. The rates arrive
1094 /// already derived — this crate never queries a spot market.
1095 SpotpreisNetzentgelt {
1096 /// The dispatch intervals, each with its own rate.
1097 intervalle: Vec<SpotpreisInterval>,
1098 },
1099}
1100
1101impl ArbeitspreisModell {
1102 /// Total metered energy across the model, in kWh.
1103 ///
1104 /// This is the base the Konzessionsabgabe and the network levies are charged
1105 /// on, so it is derived here once rather than recomputed per levy.
1106 #[must_use]
1107 pub fn menge_kwh(&self) -> Decimal {
1108 match self {
1109 Self::Einheitlich(mp) | Self::Modul1Pauschal { basis: mp, .. } => mp.menge_kwh,
1110 Self::Modul2ProzentualeReduzierung { basis, .. } => basis.menge_kwh,
1111 Self::Modul3ZeitVariabel { ht, st, nt } => ht.menge_kwh + st.menge_kwh + nt.menge_kwh,
1112 Self::SpotpreisNetzentgelt { intervalle } => {
1113 intervalle.iter().map(|i| i.menge_kwh).sum()
1114 }
1115 }
1116 }
1117
1118 /// The §14a module in play, if any.
1119 #[must_use]
1120 pub const fn sect14a_modul(&self) -> Option<Sect14aModule> {
1121 match self {
1122 Self::Einheitlich(_) => None,
1123 Self::Modul1Pauschal { .. } => Some(Sect14aModule::Modul1),
1124 Self::Modul2ProzentualeReduzierung { .. } => Some(Sect14aModule::Modul2),
1125 Self::Modul3ZeitVariabel { .. } => Some(Sect14aModule::Modul3),
1126 // A spot-linked Netzentgelt is the NB's own price model, not one of
1127 // the three modules BK6-22-300 defines.
1128 Self::SpotpreisNetzentgelt { .. } => None,
1129 }
1130 }
1131}
1132
1133// ── Blindarbeit ───────────────────────────────────────────────────────────────
1134
1135/// Reactive energy and the terms on which its excess is charged.
1136///
1137/// A Netzbetreiber supplies a *free share* of reactive energy alongside the
1138/// active energy delivered, and charges only what exceeds it (Blindmehrarbeit).
1139/// The customary boundary is a power factor of cos φ 0,9 — reactive energy up to
1140/// **tan φ ≈ 0,4843** of the active energy — though many Preisblätter round that
1141/// to a flat 50 %, and some set different shares for inductive and capacitive
1142/// draw.
1143///
1144/// The share is therefore an **input**, not a constant: it is a term of the
1145/// Netzbetreiber's price sheet, and hard-coding one would bill some networks
1146/// wrongly. [`Blindarbeit::COS_PHI_0_9`] is the documented default.
1147#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1148pub struct Blindarbeit {
1149 /// Reactive energy drawn in the period, in kvarh.
1150 pub blindarbeit_kvarh: Decimal,
1151 /// Free share of the active energy, as a fraction.
1152 ///
1153 /// `0.4843` for cos φ 0,9; `0.5` where the Preisblatt rounds it.
1154 pub freigrenze_anteil: Decimal,
1155 /// Price per excess kvarh, in ct/kvarh, from the Preisblatt.
1156 pub preis_ct_per_kvarh: Decimal,
1157}
1158
1159impl Blindarbeit {
1160 /// tan φ at cos φ 0,9 — the customary free share, to 4 dp.
1161 pub const COS_PHI_0_9: Decimal = rust_decimal::dec!(0.4843);
1162
1163 /// The chargeable excess in kvarh for the given active energy.
1164 ///
1165 /// Zero when the draw stays inside the free share; never negative, because
1166 /// an unused allowance is not a credit.
1167 #[must_use]
1168 pub fn mehrarbeit_kvarh(&self, wirkarbeit_kwh: Decimal) -> Decimal {
1169 let frei = wirkarbeit_kwh * self.freigrenze_anteil;
1170 (self.blindarbeit_kvarh - frei).max(Decimal::ZERO)
1171 }
1172}
1173
1174// ── Paired inputs ─────────────────────────────────────────────────────────────
1175
1176/// An RLM demand charge — peak demand and its rate.
1177///
1178/// A pair, because billing one without the other is meaningless — which two
1179/// independent `Option`s cannot express.
1180#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1181pub struct Leistungspreis {
1182 /// Peak demand in kW.
1183 pub spitzenleistung_kw: Decimal,
1184 /// Rate in EUR per kW.
1185 pub preis_eur_per_kw: Decimal,
1186}
1187
1188/// A Gas NNE Grundpreis — monthly rate and the months billed.
1189#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1190pub struct Grundpreis {
1191 /// Rate in EUR per month.
1192 pub eur_per_month: Decimal,
1193 /// Months in the billing period.
1194 pub months: Decimal,
1195}
1196
1197/// A Konzessionsabgabe — the rate together with the customer group it applies to.
1198///
1199/// Paired so the KAV §2 Höchstbetrag check can always run. They were independent
1200/// `Option`s, and the ceiling check was skipped entirely when the group was
1201/// absent — which is exactly when an over-charge is most likely to go unnoticed.
1202#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1203pub struct Konzessionsabgabe {
1204 /// Published rate in ct/kWh.
1205 pub satz_ct_per_kwh: Decimal,
1206 /// The KAV §2 customer group, which fixes the ceiling.
1207 pub klasse: KaKundengruppe,
1208}
1209
1210// ── SettlementPeriod ──────────────────────────────────────────────────────────
1211
1212/// The delivery period a settlement covers.
1213///
1214/// A validated pair rather than two loose dates. Every input struct previously
1215/// carried `period_from` and `period_to` independently, and every calculation
1216/// re-checked their ordering — five copies of the same guard, each able to be
1217/// forgotten. Constructing this type is the check.
1218///
1219/// Both bounds are inclusive: a monthly period runs from the 1st to the last day
1220/// of the month, matching how Netzentgelte are published and billed.
1221#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
1222pub struct SettlementPeriod {
1223 from: time::Date,
1224 to: time::Date,
1225}
1226
1227impl SettlementPeriod {
1228 /// Build a period.
1229 ///
1230 /// # Errors
1231 ///
1232 /// Returns [`crate::error::BillingError::InvalidInput`] when `from` is after `to`. A
1233 /// zero-length period (`from == to`) is a valid single day.
1234 pub fn new(from: time::Date, to: time::Date) -> Result<Self, crate::error::BillingError> {
1235 if from > to {
1236 return Err(crate::error::BillingError::InvalidInput {
1237 reason: format!("period start {from} is after its end {to}"),
1238 });
1239 }
1240 Ok(Self { from, to })
1241 }
1242
1243 /// Start of the period, inclusive.
1244 #[must_use]
1245 pub const fn from(&self) -> time::Date {
1246 self.from
1247 }
1248
1249 /// End of the period, inclusive.
1250 #[must_use]
1251 pub const fn to(&self) -> time::Date {
1252 self.to
1253 }
1254
1255 /// Number of days covered, both bounds inclusive.
1256 #[must_use]
1257 pub fn days(&self) -> i64 {
1258 (self.to - self.from).whole_days() + 1
1259 }
1260}
1261
1262// ── SettlementResult ──────────────────────────────────────────────────────────
1263
1264/// What a settlement calculation produced.
1265///
1266/// This is the canonical output of every calculation in this crate. It answers
1267/// *what is owed and why*, and deliberately not *what the invoice looks like*:
1268/// invoice numbers, issue and due dates, Prüfidentifikatoren and position
1269/// numbering live on [`InvoiceDocument`], which an adapter builds around this.
1270///
1271/// The separation is what makes a settlement recomputable. The same period can
1272/// be settled twice — for a correction, a dispute, or an audit — and the two
1273/// results compared, without inventing a document each time.
1274///
1275/// ## Explainability
1276///
1277/// Every position carries a [`CalculationTrace`]; [`Self::all_legal_refs`]
1278/// collects the paragraphs the settlement rests on. `warnings` records what the
1279/// engine could not do, which is as much part of the result as the amounts.
1280#[derive(Debug, Clone, serde::Serialize)]
1281pub struct SettlementResult {
1282 /// What was settled.
1283 pub settlement_type: SettlementType,
1284 /// Where this settlement sits in the correction lifecycle.
1285 pub status: SettlementStatus,
1286 /// Why this settlement was recalculated.
1287 ///
1288 /// `None` for an `Initial` settlement and required for every other status —
1289 /// see [`SettlementResult::lineage_is_consistent`].
1290 pub korrektur_grund: Option<KorrekturGrund>,
1291 /// The delivery period.
1292 pub period: SettlementPeriod,
1293 /// The rules the calculation applied.
1294 pub regime: crate::regulatory::RegulatoryRegime,
1295 /// Commodity.
1296 pub sparte: Sparte,
1297 /// The metering location settled.
1298 pub malo_id: String,
1299 /// Sender MP-ID — the party issuing the invoice.
1300 ///
1301 /// The Netzbetreiber for NNE/MMM, and the **Messstellenbetreiber** for a
1302 /// MSB-Rechnung (PID 31009). Named for the role it plays, not for one of
1303 /// the roles that can fill it.
1304 pub sender_mp_id: String,
1305 /// Recipient MP-ID — the party being billed (LF, NB, MSB, MGV or ESA).
1306 pub recipient_mp_id: String,
1307 /// The positions, in calculation order.
1308 pub positions: Vec<SettlementPosition>,
1309 /// Net total in EUR, rounded to 2 decimal places.
1310 pub total_eur: Decimal,
1311 /// The Umsatzsteuer on that net total.
1312 ///
1313 /// §14 Abs. 4 Nr. 8 UStG requires the rate and the amount on every invoice,
1314 /// or a note saying why neither is stated. A settlement that carries only a
1315 /// net figure cannot be rendered as a lawful Rechnung, and the recipient
1316 /// gets no Vorsteuerabzug from one.
1317 pub steuer: crate::umsatzsteuer::Steuerausweis,
1318 /// What the engine could not do, or did with a caveat.
1319 pub warnings: Vec<SettlementWarning>,
1320}
1321
1322impl SettlementResult {
1323 /// Whether the lifecycle status and the recorded reason agree.
1324 ///
1325 /// An `Initial` settlement corrects nothing and must carry no reason; every
1326 /// other status is a recalculation and must say why. A `Correction` with no
1327 /// reason is the state this check exists to catch — it looks like a complete
1328 /// settlement and answers none of the questions an audit asks of one.
1329 #[must_use]
1330 pub const fn lineage_is_consistent(&self) -> bool {
1331 match self.status {
1332 SettlementStatus::Initial => self.korrektur_grund.is_none(),
1333 _ => self.korrektur_grund.is_some(),
1334 }
1335 }
1336
1337 /// Whether this settlement records a defect in an earlier one.
1338 ///
1339 /// Distinguishes an engineering signal from a lawful recalculation — see
1340 /// [`KorrekturGrund::indicates_defect`].
1341 #[must_use]
1342 pub fn corrects_a_defect(&self) -> bool {
1343 self.korrektur_grund
1344 .is_some_and(KorrekturGrund::indicates_defect)
1345 }
1346}
1347
1348// ── Abschlagsverrechnung ──────────────────────────────────────────────────────
1349
1350/// An Abschlagsrechnung a later invoice deducts.
1351///
1352/// The INVOIC AHB puts these in the Summenteil, not among the positions:
1353/// `SG50 MOA+113` carries the **gross** amount already paid, `SG51 RFF+AFL` the
1354/// invoice number it was billed under and `SG51 DTM+3` that invoice's date. They
1355/// therefore reduce what is *owed*, never the net or the tax — §14 Abs. 5 UStG
1356/// taxes the Anzahlung when it is received, so the Abschlussrechnung does not
1357/// tax it a second time.
1358///
1359/// Two rules from the AHB travel with this type:
1360///
1361/// - **\[526\]** — the amount stated must equal the referenced Abschlagsrechnung's
1362/// own Rechnungsbetrag. A deduction that does not match what was billed is a
1363/// deduction the counterparty will reject.
1364/// - **\[519\]** — a *stornierte* Abschlagsrechnung is not listed. It was
1365/// reversed, so nothing was paid on it, and deducting it would credit money
1366/// that never moved.
1367#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1368pub struct Abschlagsverrechnung {
1369 /// The Abschlagsrechnung's invoice number (`SG51 RFF+AFL`).
1370 pub rechnungsnummer: String,
1371 /// That invoice's date (`SG51 DTM+3`).
1372 pub rechnungsdatum: time::Date,
1373 /// The **gross** amount already billed on it (`SG50 MOA+113`, inkl. USt.).
1374 pub betrag_brutto_eur: Decimal,
1375}
1376
1377// ── InvoiceDocument ───────────────────────────────────────────────────────────
1378
1379/// A settlement presented as an invoice.
1380///
1381/// Everything here is a property of the document rather than of the calculation:
1382/// an invoice number, the dates it was issued and falls due, the
1383/// Prüfidentifikator that routes it, and the reference to whatever it corrects.
1384/// None of it affects what is owed.
1385///
1386/// Built by an adapter around a [`SettlementResult`]; the engine never produces
1387/// one, which is why the engine can be run without inventing an invoice number.
1388#[derive(Debug, Clone, serde::Serialize)]
1389pub struct InvoiceDocument {
1390 /// What the document presents.
1391 pub settlement: SettlementResult,
1392 /// BDEW Prüfidentifikator.
1393 pub pid: u32,
1394 /// Unique invoice reference.
1395 pub rechnungsnummer: String,
1396 /// The `rechnungsnummer` this corrects, if any.
1397 pub correction_of: Option<String>,
1398 /// Issue date.
1399 pub invoice_date: time::Date,
1400 /// Payment due date (Zahlungsziel, §271 BGB).
1401 pub due_date: time::Date,
1402 /// The billing cadence — `IMD+7081` on the wire.
1403 ///
1404 /// A document fact, not a calculation one: an NNE settlement is the same
1405 /// arithmetic whether it is billed monthly, per Turnus or as the
1406 /// Abschlussrechnung that closes a year. `None` leaves the field unset
1407 /// rather than guessing a rhythm nothing supports.
1408 pub cadence: Option<Rechnungscharakter>,
1409 /// Abschlagsrechnungen this document settles, deducted from what is owed.
1410 ///
1411 /// Empty on an Abschlagsrechnung itself and on every document that has no
1412 /// payments on account to reconcile.
1413 pub abschlaege: Vec<Abschlagsverrechnung>,
1414}
1415
1416/// The billing cadence of a Netznutzungsrechnung — `IMD+7081`.
1417///
1418/// Named for what the AHB calls it. The set is closed: these are the codes the
1419/// INVOIC AHB 1.0b permits for a Netznutzungsrechnung, and inventing a rhythm
1420/// outside them would put a claim on the wire the standard does not carry.
1421#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1422pub enum Rechnungscharakter {
1423 /// `ABS` — a payment on account (PID 31001).
1424 Abschlagsrechnung,
1425 /// `ABR` — the invoice that closes a period and settles its Abschläge.
1426 Abschlussrechnung,
1427 /// `JVR` — the periodic invoice of a billing cycle.
1428 Turnusrechnung,
1429 /// `MVR` — a monthly invoice.
1430 Monatsrechnung,
1431 /// `ZVR` — an invoice between two Turnus invoices.
1432 Zwischenrechnung,
1433}
1434
1435impl InvoiceDocument {
1436 /// Positions paired with their 1-based document numbers.
1437 ///
1438 /// Numbering is assigned here, at rendering time, rather than carried through
1439 /// the calculation as mutable state.
1440 pub fn numbered_positions(&self) -> impl Iterator<Item = (u32, &SettlementPosition)> {
1441 self.settlement
1442 .positions
1443 .iter()
1444 .enumerate()
1445 .map(|(i, p)| (u32::try_from(i + 1).unwrap_or(u32::MAX), p))
1446 }
1447}
1448
1449impl SettlementResult {
1450 /// Number of billing positions.
1451 #[must_use]
1452 pub fn positions_count(&self) -> usize {
1453 self.positions.len()
1454 }
1455
1456 /// `true` when the settlement has no warnings at `Warning` or `Error` severity.
1457 #[must_use]
1458 pub fn is_clean(&self) -> bool {
1459 !self
1460 .warnings
1461 .iter()
1462 .any(|w| w.severity >= WarningSeverity::Warning)
1463 }
1464
1465 /// All legal references cited across all positions (deduplicated by citation string).
1466 #[must_use]
1467 pub fn all_legal_refs(&self) -> Vec<String> {
1468 let mut seen = std::collections::HashSet::new();
1469 self.positions
1470 .iter()
1471 .flat_map(|p| p.trace.legal_refs.iter().map(|r| r.citation()))
1472 .filter(|c| seen.insert(c.clone()))
1473 .collect()
1474 }
1475
1476 /// Net total as computed from positions (re-summed for verification).
1477 ///
1478 /// Should equal `total_eur`. A mismatch indicates a calculation bug.
1479 #[must_use]
1480 pub fn recomputed_total(&self) -> Decimal {
1481 self.positions
1482 .iter()
1483 .map(|p| p.net_eur)
1484 .sum::<Decimal>()
1485 .round_dp(2)
1486 }
1487}
1488
1489// ── Input types ───────────────────────────────────────────────────────────────
1490
1491/// Input for NNE (Netznutzungsentgelt) invoice calculation.
1492///
1493/// Covers:
1494/// - **PID 31002** (NN-Rechnung) — NNE Strom and Gas (NB → LF, monthly network
1495/// usage billing). The Sparte is carried in the message content, not the PID.
1496///
1497/// For **RLM** (Leistungsmessung) meters:
1498/// - Set `spitzenleistung_kw` to the peak demand in kW.
1499/// - Set `leistungspreis_eur_per_kw` to the published tariff.
1500///
1501/// For **SLP** meters:
1502/// - Leave both fields as `None` (Arbeitspreisanteil only).
1503///
1504/// For **§14a Modul 2 time-variable NNE** (BNetzA BK6-22-300):
1505/// - Set `arbeitsmenge_ht_kwh` + `arbeitspreis_ht_ct_per_kwh` for Hochlast periods.
1506/// - Set `arbeitsmenge_nt_kwh` + `arbeitspreis_nt_ct_per_kwh` for Niedertarif periods.
1507/// - Leave `arbeitsmenge_kwh` / `arbeitspreis_ct_per_kwh` as the base fallback.
1508///
1509/// For Gas:
1510/// - The `arbeitsmenge_kwh` should already be converted from m³ using
1511/// `brennwert × zustandszahl` before being supplied here.
1512/// (edmd's `MeterBillingPeriod.arbeitsmenge_kwh` carries this converted value.)
1513#[derive(Debug, Clone)]
1514pub struct NneInput {
1515 /// 11-digit Marktlokations-ID.
1516 pub malo_id: String,
1517 /// Invoice sender — Netzbetreiber or Gasnetzbetreiber MP-ID.
1518 pub nb_mp_id: String,
1519 /// Invoice recipient — Lieferant MP-ID.
1520 pub lf_mp_id: String,
1521 /// The delivery period being settled.
1522 pub period: SettlementPeriod,
1523
1524 /// Letztverbrauchergruppe for the network levies (EnFG §§21 ff.).
1525 ///
1526 /// Determines which rate of the §19 StromNEV-, Offshore- and KWKG-Umlage
1527 /// applies at this Entnahmestelle.
1528 pub letztverbrauchergruppe: crate::umlagen::Letztverbrauchergruppe,
1529
1530 /// §19 StromNEV-Umlage in ct/kWh, overriding the tabled rate.
1531 ///
1532 /// `None` uses the statutory rate for the delivery year and group. Set it
1533 /// where an EnFG decision grants a rate the published schedule does not
1534 /// express.
1535 pub sect19_umlage_ct_per_kwh: Option<Decimal>,
1536 /// Offshore-Netzumlage in ct/kWh, overriding the tabled rate.
1537 pub offshore_umlage_ct_per_kwh: Option<Decimal>,
1538 /// KWKG-Umlage in ct/kWh, overriding the tabled rate.
1539 pub kwkg_umlage_ct_per_kwh: Option<Decimal>,
1540
1541 /// Reactive energy and its Preisblatt terms.
1542 ///
1543 /// `None` = the network does not charge Blindmehrarbeit at this location, or
1544 /// the reactive energy was not metered.
1545 pub blindarbeit: Option<Blindarbeit>,
1546
1547 /// Optional tariff sheet identifier for audit tracing.
1548 ///
1549 /// When set, each position's `trace.tariff_source` references this sheet.
1550 pub tariff_sheet_id: Option<String>,
1551 /// Commodity — drives legal references (StromNEV vs GasNEV) and `SettlementType`.
1552 ///
1553 /// - `Sparte::Strom` (default) → `StromNEV §21` Arbeit, `StromNEV §17` Leistung,
1554 /// `SettlementType::NneStrom`
1555 /// - `Sparte::Gas` → `GasNEV §14`, `SettlementType::NneGas`
1556 pub sparte: Sparte,
1557
1558 // ── §14a Modul 3 Spotpreis-NNE per-interval dispatch data ────────────────
1559 /// §14a Modul 3 (BNetzA BK6-22-300 Anlage 2 §3) per-dispatch-interval positions.
1560 ///
1561 /// Each entry represents one 15-min interval during which a spot-price-linked
1562 /// NNE rate applies. The caller fetches the EPEX Spot day-ahead price for each
1563 /// interval and applies the formula from `PreisblattNetznutzung.lastvariablePreispositionen`
1564 /// to derive `nne_rate_ct_per_kwh`. `grid-billing` receives pre-calculated rates —
1565 /// it never queries EPEX directly.
1566 ///
1567 /// **Empty (default)** when no spot-linked Netzentgelt applies to this MaLo.
1568 ///
1569 /// Selecting this model excludes every other `ArbeitspreisModell` by
1570 /// construction — the enum holds one at a time.
1571 ///
1572 /// Each interval generates one `InvoicePosition` with
1573 /// `kind = NneArbeitModul3` and `lastvariable_preisposition_json` populated.
1574 #[doc = "Spot-linked Netzentgelt per-interval input data."]
1575 ///
1576 /// One value rather than twelve loose fields: the four shapes are mutually
1577 /// exclusive by construction.
1578 pub arbeitspreis: ArbeitspreisModell,
1579
1580 /// RLM demand charge — peak demand and its rate, or neither.
1581 pub leistungspreis: Option<Leistungspreis>,
1582
1583 /// Gas NNE Grundpreis. `None` for Strom, which has no separate Grundpreis.
1584 pub grundpreis: Option<Grundpreis>,
1585
1586 /// Konzessionsabgabe — rate and customer group together, so the KAV §2
1587 /// ceiling can always be checked.
1588 pub konzessionsabgabe: Option<Konzessionsabgabe>,
1589
1590 /// The Netzebene this metering point takes supply from.
1591 ///
1592 /// Netzentgelte are published per level, so the level is what makes a rate
1593 /// checkable against a price sheet. Recorded on the settlement and in the
1594 /// trace; it does not itself select a rate — this crate is given the rates.
1595 pub netzebene: Option<crate::netzebene::Netzebene>,
1596
1597 /// Annual peak demand in kW, where the metering point has one.
1598 ///
1599 /// Used with the annual energy to record the Benutzungsstundenzahl in the
1600 /// trace. This is the *annual* peak, which is not the same as the peak in
1601 /// the billing period — a monthly settlement carries the annual figure so
1602 /// the utilisation can be checked against the price sheet that priced it.
1603 pub jahreshoechstleistung_kw: Option<Decimal>,
1604
1605 /// Annual energy in kWh, where known.
1606 ///
1607 /// Pairs with `jahreshoechstleistung_kw` for the Benutzungsstundenzahl, and
1608 /// decides whether §17 Abs. 6 permits an Arbeitspreis-only tariff.
1609 pub jahresarbeit_kwh: Option<Decimal>,
1610
1611 /// An agreed §19 Abs. 2 StromNEV individual charge, where one exists.
1612 ///
1613 /// Applied as a reduction over the Arbeits- and Leistungspreis positions,
1614 /// with the statutory Mindestentgelt floor checked against the utilisation
1615 /// data above. The Konzessionsabgabe and the network levies are unaffected —
1616 /// the Netzbetreiber's lost revenue is compensated through the
1617 /// §19 StromNEV-Umlage, billed separately.
1618 pub sect19: Option<crate::sect19::Sect19Vereinbarung>,
1619
1620 /// A booked gas capacity, billed alongside the commodity charge.
1621 ///
1622 /// Gas only; §15 GasNEV. The annual rate is pro-rated over the settlement
1623 /// period by calendar days.
1624 pub gas_kapazitaet: Option<crate::gas::GasKapazitaet>,
1625}
1626
1627// ── SpotpreisInterval ─────────────────────────────────────────────────────
1628
1629/// One dispatch interval for a spot-linked Netzentgelt (not a §14a module).
1630///
1631/// Each interval represents a 15-min period during which the DSO exercised load
1632/// control and the NNE rate is derived from the day-ahead spot price via the
1633/// formula published in `PreisblattNetznutzung.lastvariablePreispositionen`.
1634///
1635/// ## Calculation
1636///
1637/// `Einsatzkosten = menge_kwh × nne_rate_ct_per_kwh / 100`
1638///
1639/// The NB computes one `InvoicePosition` per interval, allowing the LF (and their
1640/// customers) to see the exact tariff breakdown for each dispatch event.
1641///
1642/// ## Caller responsibility
1643///
1644/// The caller (service layer) must:
1645/// 1. Fetch the EPEX Spot day-ahead price for each 15-min interval from `productd`
1646/// or the `PreisblattNetznutzung` formula.
1647/// 2. Apply the formula from `lastvariablePreispositionen` to derive `nne_rate_ct_per_kwh`.
1648/// 3. Fetch `menge_kwh` from `edmd Lastgang` for the interval.
1649///
1650/// `grid-billing` receives pre-calculated rates — it does NOT query EPEX or `edmd`.
1651///
1652/// ## Regulatory basis
1653///
1654/// BNetzA BK6-22-300 Anlage 2 §3 — Modul 3: Spotpreis-Netzentgelt.
1655/// The NNE varies per 15-min interval based on the spot market price.
1656/// All controllable loads ≥ 3.7 kW registered under §14a must have Modul 1 at minimum;
1657/// Modul 3 is the opt-in premium variant (lower NNE when spot prices are low).
1658#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
1659pub struct SpotpreisInterval {
1660 /// UTC start of this controlled dispatch interval (ISO-8601).
1661 ///
1662 /// Typically the start of a 15-min settlement slot.
1663 pub period_from: time::OffsetDateTime,
1664 /// UTC end of this controlled dispatch interval (ISO-8601).
1665 ///
1666 /// Typically `period_from + 15 min`.
1667 pub period_to: time::OffsetDateTime,
1668 /// Energy consumption (or reduction) during this interval in kWh.
1669 ///
1670 /// Sourced from `edmd Lastgang` for the MaLo during the interval window.
1671 pub menge_kwh: Decimal,
1672 /// Effective NNE rate in **ct/kWh** for this interval.
1673 ///
1674 /// Derived from the `LastvariablePreisposition` formula applied to the
1675 /// applicable EPEX Spot day-ahead price. Pre-calculated by the caller.
1676 pub nne_rate_ct_per_kwh: Decimal,
1677 /// EPEX Spot day-ahead price in ct/kWh used to derive `nne_rate_ct_per_kwh`.
1678 ///
1679 /// Stored in the `CalculationTrace.explanation` for audit transparency.
1680 /// `None` when the rate was determined by a fixed formula without market reference.
1681 pub epex_spot_ct_per_kwh: Option<Decimal>,
1682}
1683
1684// ── MmmInput ──────────────────────────────────────────────────────────────────
1685
1686/// Input for Mehr-/Mindermengen (MMM) settlement invoice calculation.
1687///
1688/// Covers:
1689/// - **PID 31005** — MMM-Rechnung used for Mehr-/Mindermengen settlement between
1690/// NB and LF (Strom and Gas).
1691///
1692/// Mehr-/Mindermengen settle the difference between the LF's forecast profile
1693/// (SLP standard load profile) and the actual measured consumption.
1694///
1695/// - **Mehrmengen** (positive deviation): actual > profil → LF owes NB
1696/// - **Mindermengen** (negative deviation): actual < profil → NB owes LF
1697///
1698/// The settlement amount is the algebraic sum of both positions. It can be
1699/// negative (i.e. a credit note from NB to LF) when Mindermengen dominate.
1700#[derive(Debug, Clone)]
1701pub struct MmmInput {
1702 /// 11-digit Marktlokations-ID.
1703 pub malo_id: String,
1704 /// Invoice sender — Netzbetreiber MP-ID.
1705 pub nb_mp_id: String,
1706 /// Invoice recipient — Lieferant MP-ID.
1707 pub lf_mp_id: String,
1708 /// The delivery period being settled.
1709 pub period: SettlementPeriod,
1710 /// Commodity — determines which Festlegung the legal references cite.
1711 ///
1712 /// - `Sparte::Strom` → `GPKE (BK6-24-174) Teil 1 Kap. 8.4`, `GPKE BK6-22-024`
1713 /// - `Sparte::Gas` → `GaBi Gas 2.1 (BK7-24-01-008)`, `GeLi Gas 3.0 (BK7-24-01-009)`
1714 pub sparte: Sparte,
1715 /// Actual measured consumption in kWh (from MSCONS / `MeterBillingPeriod`).
1716 pub actual_kwh: Decimal,
1717 /// Standard load profile (SLP) forecast consumption in kWh.
1718 pub profil_kwh: Decimal,
1719 /// Mehrmengen price in **ct/kWh** (from `PreisblattNetznutzung` MMM position).
1720 pub mehr_preis_ct_per_kwh: Decimal,
1721 /// Mindermengen price in **ct/kWh** (from `PreisblattNetznutzung` MMM position).
1722 pub minder_preis_ct_per_kwh: Decimal,
1723 /// Who holds §3g Wiederverkäufer status, evidenced by a *USt 1 TH*.
1724 ///
1725 /// A Mehr-/Mindermenge is a **Lieferung** of electricity or gas, not a
1726 /// network service, so §13b Abs. 2 Nr. 5 Buchst. b UStG can shift the tax to
1727 /// the recipient. The condition differs by Sparte — electricity needs both
1728 /// parties, gas needs the recipient — which is why this is a status rather
1729 /// than a `reverse_charge: bool` the caller has to reason out.
1730 pub wiederverkaeufer: crate::umsatzsteuer::Wiederverkaeuferstatus,
1731 /// The receiving party issues this invoice itself (Gutschriftverfahren).
1732 ///
1733 /// PID 31006 (Strom) / 31008 (Gas) is the Mehrmenge leg written by the
1734 /// party that would otherwise receive it, which the AHB marks as
1735 /// *Selbstausgestellt* rather than *Handelsrechnung*. That distinction is
1736 /// on the wire (`IMD+7081` and the Rechnungsart), so it has to come from
1737 /// the settlement rather than be stamped on the document afterwards:
1738 /// labelling an ordinary [`SettlementType::MmmStrom`] with PID 31006
1739 /// produces a message that states Handelsrechnung under a
1740 /// Selbstausstellung Prüfidentifikator.
1741 pub selbstausgestellt: bool,
1742}
1743
1744// ── AbschlagInput ─────────────────────────────────────────────────────────────
1745
1746/// Input for an Abschlagsrechnung Netznutzung (PID 31001).
1747///
1748/// A payment on account. There is no metered quantity and no Arbeitspreis: the
1749/// Netzbetreiber asks for an amount against a period it has not settled yet, and
1750/// the Abschlussrechnung that follows deducts it by invoice number.
1751///
1752/// How the amount is arrived at — a share of last year's Turnusrechnung, a
1753/// forecast from the Jahresarbeit, a figure agreed in the
1754/// Lieferantenrahmenvertrag — is the operator's judgement, not arithmetic this
1755/// crate can check. [`Self::grundlage`] records which of them it was, so an
1756/// auditor can see the basis rather than infer it from a bare number.
1757#[derive(Debug, Clone)]
1758pub struct AbschlagInput {
1759 /// 11-digit Marktlokations-ID.
1760 pub malo_id: String,
1761 /// Invoice sender — the Netzbetreiber.
1762 pub nb_mp_id: String,
1763 /// Invoice recipient — the Lieferant.
1764 pub lf_mp_id: String,
1765 /// The period the payment is on account of.
1766 pub period: SettlementPeriod,
1767 /// Commodity.
1768 pub sparte: Sparte,
1769 /// The **net** amount requested, in EUR.
1770 ///
1771 /// Net rather than gross: the tax is stated separately on the invoice, as
1772 /// §14 Abs. 4 Nr. 8 UStG requires of an Anzahlungsrechnung like any other.
1773 pub betrag_netto_eur: Decimal,
1774 /// How the amount was arrived at.
1775 pub grundlage: AbschlagGrundlage,
1776}
1777
1778/// How an Abschlag's amount was arrived at.
1779///
1780/// Recorded rather than computed. The engine cannot check a forecast, but an
1781/// audit can ask which basis was used, and an invoice that answers "a share of
1782/// the prior Turnusrechnung" is defensible where a bare figure is not.
1783#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1784pub enum AbschlagGrundlage {
1785 /// A share of the previous settled period's invoice.
1786 Vorjahresverbrauch,
1787 /// A forecast of the period being paid for.
1788 Prognose,
1789 /// A figure fixed in the Lieferantenrahmenvertrag.
1790 Vereinbarung,
1791}
1792
1793impl AbschlagGrundlage {
1794 /// Short label for the position text and the trace.
1795 #[must_use]
1796 pub const fn label(self) -> &'static str {
1797 match self {
1798 Self::Vorjahresverbrauch => "auf Basis des Vorjahresverbrauchs",
1799 Self::Prognose => "auf Basis einer Verbrauchsprognose",
1800 Self::Vereinbarung => "gemäß Vereinbarung im Lieferantenrahmenvertrag",
1801 }
1802 }
1803}
1804
1805// ── MsbInput ──────────────────────────────────────────────────────────────────
1806
1807/// Input for MSB (Messstellenbetreiber) invoice calculation.
1808///
1809/// Covers:
1810/// - **PID 31009** — MSB-Rechnung (MSB → NB / LF / ESA, monthly metering
1811/// service settlement; Strom only)
1812///
1813/// The NB bills the MSB for the metering service period. Positions:
1814/// 1. Grundgebühr Messstellenbetrieb — flat monthly base fee × billing months.
1815/// 2. Messdienstleistung — optional per-period measurement service fee.
1816#[derive(Debug, Clone)]
1817pub struct MsbInput {
1818 /// 11-digit Marktlokations-ID.
1819 pub malo_id: String,
1820 /// Invoice **sender** — the Messstellenbetreiber.
1821 ///
1822 /// PID 31009 is issued *by* the MSB in all seven of its Anwendungsfälle; it
1823 /// is never sent to one. See [`MsbRechnungsempfaenger`].
1824 pub msb_mp_id: String,
1825 /// Invoice **recipient** — who is billed, and in which market role.
1826 pub empfaenger: MsbRechnungsempfaenger,
1827 /// The delivery period being settled.
1828 pub period: SettlementPeriod,
1829 /// The Sparte of the metering point.
1830 ///
1831 /// §30 MsbG prices metering, not energy, so this changes no arithmetic — but
1832 /// it is what the invoice states, and a service that stores one Sparte on
1833 /// the draft while the settlement carries another cannot answer which is
1834 /// right.
1835 pub sparte: Sparte,
1836 /// Grundgebühr Messstellenbetrieb in **EUR/month** (from `PreisblattMessung`).
1837 pub grundgebuehr_eur_per_month: Decimal,
1838 /// Number of full calendar months in the billing period.
1839 pub billing_months: u32,
1840 /// Optional Messdienstleistung flat fee in **EUR** for the full period.
1841 ///
1842 /// `None` when the MSB provides only the meter, not a separate measurement service.
1843 pub messdienstleistung_eur: Option<Decimal>,
1844
1845 /// Which §30 MsbG case this metering point falls under.
1846 ///
1847 /// Fixes the Preisobergrenze the charge is checked against. `None` skips the
1848 /// check, which should be rare: a metering charge above the POG is an amount
1849 /// the customer is entitled to have refunded.
1850 pub messstellen_kategorie: Option<crate::msbg::MessstellenKategorie>,
1851
1852 /// Whose share of the metering charge this settlement bills.
1853 ///
1854 /// §30 MsbG splits the ceiling between the Netzbetreiber and the
1855 /// Letztverbraucher, so the applicable cap depends on who is being billed.
1856 pub entgeltschuldner: Option<crate::msbg::Entgeltschuldner>,
1857}
1858
1859/// Recipient of a MSB-Rechnung (PID 31009).
1860///
1861/// The *Anwendungsübersicht der Prüfidentifikatoren* 4.0 (01.04.2026) lists
1862/// seven Anwendungsfälle for 31009, and the sender is the **MSB** in every one:
1863///
1864/// | Prozessbeschreibung | von | an |
1865/// |---|---|---|
1866/// | GPKE Teil 3 | MSB | NB |
1867/// | GPKE Teil 3 | MSB | LF |
1868/// | WiM Strom Teil 1 | MSB (am Objekt Marktlokation) | LF |
1869/// | WiM Strom Teil 1 | MSB (am Objekt Marktlokation) | NB |
1870/// | WiM Strom Teil 2 | MSB | ESA |
1871/// | AWH Prozesse zur Änderung der Technik an Lokationen | MSB | NB |
1872/// | AWH Prozesse zur Änderung der Technik an Lokationen | MSB | LF |
1873///
1874/// So the recipient varies across three market roles while the sender does not,
1875/// which is why it is modelled as a role plus an MP-ID rather than a bare
1876/// `nb_mp_id`. 31009 is Strom-only — the overview marks Sparte Gas `--`.
1877///
1878/// Distinct from [`crate::msbg::Entgeltschuldner`], which selects the §30 MsbG
1879/// Preisobergrenze (whose *share* of the ceiling applies) rather than who
1880/// receives the invoice. The LF commonly receives an invoice for the
1881/// Letztverbraucher share under the Rechnungsabwicklung über den LF, so the two
1882/// axes do not coincide.
1883#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1884pub struct MsbRechnungsempfaenger {
1885 /// Which market role receives the invoice.
1886 pub rolle: MsbEmpfaengerRolle,
1887 /// The recipient's 13-digit MP-ID.
1888 pub mp_id: String,
1889}
1890
1891/// Market role a MSB-Rechnung (PID 31009) may be addressed to.
1892#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1893pub enum MsbEmpfaengerRolle {
1894 /// Netzbetreiber — GPKE Teil 3, WiM Strom Teil 1, AWH Technikänderung.
1895 Netzbetreiber,
1896 /// Lieferant — GPKE Teil 3, WiM Strom Teil 1, AWH Technikänderung
1897 /// (Rechnungsabwicklung des MSB über den LF).
1898 Lieferant,
1899 /// Energieserviceanbieter — WiM Strom Teil 2.
1900 Energieserviceanbieter,
1901}
1902
1903impl MsbEmpfaengerRolle {
1904 /// BDEW role code as it appears in the NAD segment.
1905 #[must_use]
1906 pub const fn code(self) -> &'static str {
1907 match self {
1908 Self::Netzbetreiber => "NB",
1909 Self::Lieferant => "LF",
1910 Self::Energieserviceanbieter => "ESA",
1911 }
1912 }
1913}
1914
1915// ── GasAwhInput ───────────────────────────────────────────────────────────────
1916
1917/// Input for GeLi Gas AWH Sperrprozesse settlement (PID 31011).
1918///
1919/// **PID 31011 — Rechnung sonstige Leistung (NB → LF)**
1920///
1921/// Bills the Lieferant (LFG/LFA) for abrechnungswürdige Handlungen (AWH)
1922/// performed by the GNB/VNB during the Sperrung/Entsperrung process.
1923/// Governed by BK7-24-01-009 §5.4 (GeLi Gas 3.0).
1924///
1925/// ## What counts as AWH
1926///
1927/// AWH are chargeable actions not included in the network tariff, triggered by
1928/// the LF through the Sperrung process. Typical AWH:
1929/// - `Sperrung` (disconnection)
1930/// - `Entsperrung` (reconnection)
1931/// - `Teilsperrung` (partial disconnection)
1932/// - `Unterbrechung Verfahren` (process interruption)
1933///
1934/// Each action type has a fixed price published in the `PreisblattNetznutzung`.
1935#[derive(Debug, Clone)]
1936pub struct GasAwhInput {
1937 /// 11-digit Marktlokations-ID.
1938 pub malo_id: String,
1939 /// Invoice sender — Gasnetzbetreiber (GNB/VNB) MP-ID.
1940 pub nb_mp_id: String,
1941 /// Invoice recipient — Lieferant Gas (LFG or LFA) MP-ID.
1942 pub lf_mp_id: String,
1943 /// The delivery period being settled.
1944 pub period: SettlementPeriod,
1945 /// Optional tariff sheet identifier for audit tracing.
1946 pub tariff_sheet_id: Option<String>,
1947 /// AWH line items: each chargeable action with count and unit price.
1948 ///
1949 /// At least one position is required.
1950 pub awh_positionen: Vec<AwhPositionInput>,
1951}
1952
1953/// One AWH action line item for [`GasAwhInput`].
1954///
1955/// ## Examples
1956///
1957/// ```rust
1958/// # use grid_billing::AwhPositionInput;
1959/// # use rust_decimal::dec;
1960/// let sperrung = AwhPositionInput {
1961/// beschreibung: "Sperrung Gaszähler".to_owned(),
1962/// anzahl: 1,
1963/// preis_eur: dec!(45.00),
1964/// artikel_id: Some("2-01-7-001".to_owned()),
1965/// };
1966/// ```
1967#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1968pub struct AwhPositionInput {
1969 /// Human-readable action description, e.g. `"Sperrung Gaszähler"`.
1970 pub beschreibung: String,
1971 /// Number of executions of this action.
1972 pub anzahl: u32,
1973 /// Price per execution in **EUR** (from `PreisblattNetznutzung`).
1974 pub preis_eur: Decimal,
1975 /// BDEW Artikel-ID from section 3.2 of the Codeliste Artikelnummern v5.6.
1976 ///
1977 /// Standard values for Gas AWH Sperrprozesse (BK7-24-01-009 §5.4):
1978 /// - `"2-01-7-001"` — Unterbrechung der Anschlussnutzung (reguläre AZ)
1979 /// - `"2-01-7-002"` — Wiederherstellung der Anschlussnutzung (reguläre AZ)
1980 /// - `"2-01-7-003"` — Erfolglose Unterbrechung
1981 /// - `"2-01-7-004"` — Stornierung Unterbrechungsauftrag (bis Vortag)
1982 /// - `"2-01-7-005"` — Stornierung Unterbrechungsauftrag (am Sperrtag)
1983 /// - `"2-01-7-006"` — Wiederherstellung außerhalb regulärer AZ
1984 ///
1985 /// `None` for custom / non-standard AWH positions.
1986 pub artikel_id: Option<String>,
1987}
1988
1989// ── ValidationResult ─────────────────────────────────────────────────────────
1990
1991/// Result of pre-calculation input validation.
1992///
1993/// For NNE there is no separate validator: the invariants that mattered are
1994/// either unrepresentable — an inverted [`SettlementPeriod`], a half-set
1995/// [`Leistungspreis`], two §14a modules at once — or enforced inside
1996/// [`crate::settle_nne`] itself. A validator the engine did not call was how a
1997/// caller who skipped it got billed on the wrong basis with no error.
1998///
1999/// [`validate_mmm_input`], [`validate_msb_input`] and [`validate_gas_awh_input`]
2000/// remain for inputs whose engines accept looser shapes.
2001#[derive(Debug, Clone)]
2002pub struct ValidationResult {
2003 /// Whether the input passed all validation checks.
2004 pub is_valid: bool,
2005 /// All warnings and errors found. May contain [`WarningSeverity::Info`] items
2006 /// even when `is_valid = true`.
2007 pub warnings: Vec<SettlementWarning>,
2008}
2009
2010impl ValidationResult {
2011 /// Returns a clean (valid, no warnings) result.
2012 #[must_use]
2013 pub fn ok() -> Self {
2014 Self {
2015 is_valid: true,
2016 warnings: Vec::new(),
2017 }
2018 }
2019
2020 /// Appends a warning. `WarningSeverity::Error` marks the result invalid.
2021 pub fn push(&mut self, w: SettlementWarning) {
2022 if w.severity == WarningSeverity::Error {
2023 self.is_valid = false;
2024 }
2025 self.warnings.push(w);
2026 }
2027}
2028
2029/// Validate a [`MmmInput`] before calling [`crate::settle_mmm`].
2030#[must_use]
2031pub fn validate_mmm_input(input: &MmmInput) -> ValidationResult {
2032 let mut r = ValidationResult::ok();
2033 if input.period.from() >= input.period.to() {
2034 r.push(SettlementWarning {
2035 severity: WarningSeverity::Error,
2036 code: "INVALID_PERIOD",
2037 message: "period_from must be strictly before period_to".to_owned(),
2038 });
2039 }
2040 if input.mehr_preis_ct_per_kwh < Decimal::ZERO {
2041 r.push(SettlementWarning {
2042 severity: WarningSeverity::Warning,
2043 code: "NEGATIVE_MEHR_PREIS",
2044 message: format!(
2045 "mehr_preis_ct_per_kwh is negative: {}",
2046 input.mehr_preis_ct_per_kwh
2047 ),
2048 });
2049 }
2050 if input.minder_preis_ct_per_kwh < Decimal::ZERO {
2051 r.push(SettlementWarning {
2052 severity: WarningSeverity::Warning,
2053 code: "NEGATIVE_MINDER_PREIS",
2054 message: format!(
2055 "minder_preis_ct_per_kwh is negative: {}",
2056 input.minder_preis_ct_per_kwh
2057 ),
2058 });
2059 }
2060 r
2061}
2062
2063/// Validate a [`MsbInput`] before calling [`crate::settle_msb`].
2064#[must_use]
2065pub fn validate_msb_input(input: &MsbInput) -> ValidationResult {
2066 let mut r = ValidationResult::ok();
2067 if input.period.from() >= input.period.to() {
2068 r.push(SettlementWarning {
2069 severity: WarningSeverity::Error,
2070 code: "INVALID_PERIOD",
2071 message: "period_from must be strictly before period_to".to_owned(),
2072 });
2073 }
2074 if input.grundgebuehr_eur_per_month < Decimal::ZERO {
2075 r.push(SettlementWarning {
2076 severity: WarningSeverity::Error,
2077 code: "NEGATIVE_GRUNDGEBUEHR",
2078 message: format!(
2079 "grundgebuehr_eur_per_month is negative: {}",
2080 input.grundgebuehr_eur_per_month
2081 ),
2082 });
2083 }
2084 if input.billing_months == 0 {
2085 r.push(SettlementWarning {
2086 severity: WarningSeverity::Error,
2087 code: "ZERO_BILLING_MONTHS",
2088 message: "billing_months must be at least 1".to_owned(),
2089 });
2090 }
2091 r
2092}
2093
2094/// Validate a [`GasAwhInput`] before calling [`crate::settle_gas_awh`].
2095///
2096/// Checks that:
2097/// - `period_from < period_to`
2098/// - `awh_positionen` is non-empty
2099/// - All positions have `anzahl ≥ 1` and `preis_eur ≥ 0`
2100#[must_use]
2101pub fn validate_gas_awh_input(input: &GasAwhInput) -> ValidationResult {
2102 let mut r = ValidationResult::ok();
2103 if input.period.from() >= input.period.to() {
2104 r.push(SettlementWarning {
2105 severity: WarningSeverity::Error,
2106 code: "INVALID_PERIOD",
2107 message: "period_from must be strictly before period_to".to_owned(),
2108 });
2109 }
2110 if input.awh_positionen.is_empty() {
2111 r.push(SettlementWarning {
2112 severity: WarningSeverity::Error,
2113 code: "EMPTY_AWH_POSITIONEN",
2114 message: "awh_positionen must contain at least one position".to_owned(),
2115 });
2116 }
2117 for (i, awh) in input.awh_positionen.iter().enumerate() {
2118 if awh.anzahl == 0 {
2119 r.push(SettlementWarning {
2120 severity: WarningSeverity::Error,
2121 code: "ZERO_AWH_ANZAHL",
2122 message: format!("awh_positionen[{i}].anzahl must be ≥ 1"),
2123 });
2124 }
2125 if awh.preis_eur < Decimal::ZERO {
2126 r.push(SettlementWarning {
2127 severity: WarningSeverity::Error,
2128 code: "NEGATIVE_AWH_PREIS",
2129 message: format!(
2130 "awh_positionen[{i}].preis_eur must be non-negative, got {}",
2131 awh.preis_eur
2132 ),
2133 });
2134 }
2135 }
2136 r
2137}
2138
2139#[cfg(test)]
2140mod input_model_tests {
2141
2142 /// A factor arriving over the wire is range-checked, not merely parsed.
2143 ///
2144 /// The whole point of the newtype is that an out-of-range value cannot
2145 /// exist; a derived `Deserialize` would have let one in through a request
2146 /// body and multiplied the Arbeitspreis by it.
2147 #[test]
2148 fn a_wire_reduktionsfaktor_is_range_checked() {
2149 let ok: Reduktionsfaktor = serde_json::from_str("0.85").expect("in range");
2150 assert_eq!(ok.get(), dec!(0.85));
2151
2152 for bad in ["0", "-0.5", "1.01", "5"] {
2153 assert!(
2154 serde_json::from_str::<Reduktionsfaktor>(bad).is_err(),
2155 "{bad} must be refused"
2156 );
2157 }
2158 // The boundary is inclusive at 1 — no reduction is still a valid factor.
2159 assert!(serde_json::from_str::<Reduktionsfaktor>("1").is_ok());
2160 }
2161
2162 /// The Arbeitspreis model round-trips, so a settlement input can be stored
2163 /// and recomputed rather than a rendered document being edited in place.
2164 #[test]
2165 fn the_arbeitspreis_model_round_trips() {
2166 let model = ArbeitspreisModell::Modul3ZeitVariabel {
2167 ht: MengePreis {
2168 menge_kwh: dec!(600),
2169 preis_ct_per_kwh: dec!(4.2),
2170 },
2171 st: MengePreis {
2172 menge_kwh: dec!(100),
2173 preis_ct_per_kwh: dec!(3.0),
2174 },
2175 nt: MengePreis {
2176 menge_kwh: dec!(400),
2177 preis_ct_per_kwh: dec!(1.5),
2178 },
2179 };
2180 let json = serde_json::to_string(&model).expect("serialize");
2181 let back: ArbeitspreisModell = serde_json::from_str(&json).expect("deserialize");
2182 assert_eq!(back, model);
2183 }
2184
2185 use super::*;
2186 use rust_decimal::dec;
2187
2188 /// A reduction factor outside `(0, 1]` cannot be built.
2189 ///
2190 /// The type is the check. A bare `Decimal` range-checked in a validator
2191 /// leaves `settle_nne` free to multiply the published tariff by 5 whenever
2192 /// the engine does not call it.
2193 #[test]
2194 fn a_reduction_factor_must_actually_reduce() {
2195 assert!(Reduktionsfaktor::new(dec!(0.85)).is_ok());
2196 assert!(
2197 Reduktionsfaktor::new(dec!(1)).is_ok(),
2198 "no reduction is still valid"
2199 );
2200 assert!(
2201 Reduktionsfaktor::new(dec!(0)).is_err(),
2202 "zero is not a reduction"
2203 );
2204 assert!(Reduktionsfaktor::new(dec!(-0.5)).is_err());
2205 assert!(
2206 Reduktionsfaktor::new(dec!(5)).is_err(),
2207 "5x is not a reduction"
2208 );
2209 assert_eq!(Reduktionsfaktor::REGELFALL.get(), dec!(0.85));
2210 }
2211
2212 /// The charged energy is the same figure whichever model priced it.
2213 ///
2214 /// The Konzessionsabgabe and the three network levies are all charged on it,
2215 /// so they read one figure rather than each deriving its own.
2216 #[test]
2217 fn every_model_reports_the_energy_it_priced() {
2218 let flat = ArbeitspreisModell::Einheitlich(MengePreis {
2219 menge_kwh: dec!(1000),
2220 preis_ct_per_kwh: dec!(3.5),
2221 });
2222 assert_eq!(flat.menge_kwh(), dec!(1000));
2223
2224 let tou = ArbeitspreisModell::Modul3ZeitVariabel {
2225 ht: MengePreis {
2226 menge_kwh: dec!(600),
2227 preis_ct_per_kwh: dec!(4.0),
2228 },
2229 st: MengePreis {
2230 menge_kwh: dec!(0),
2231 preis_ct_per_kwh: dec!(0),
2232 },
2233 nt: MengePreis {
2234 menge_kwh: dec!(400),
2235 preis_ct_per_kwh: dec!(1.5),
2236 },
2237 };
2238 assert_eq!(tou.menge_kwh(), dec!(1000), "HT + NT, not one of them");
2239
2240 let modul1 = ArbeitspreisModell::Modul1Pauschal {
2241 basis: MengePreis {
2242 menge_kwh: dec!(1000),
2243 preis_ct_per_kwh: dec!(3.5),
2244 },
2245 pauschale_eur_pro_jahr: dec!(120),
2246 jahresanteil: dec!(1) / dec!(12),
2247 };
2248 assert_eq!(
2249 modul1.menge_kwh(),
2250 dec!(1000),
2251 "the reduction changes the rate, not the energy"
2252 );
2253 }
2254
2255 /// Each model names its §14a module, and only one can be in play.
2256 #[test]
2257 fn a_model_carries_at_most_one_sect14a_module() {
2258 use Sect14aModule as M;
2259 let cases = [
2260 (
2261 ArbeitspreisModell::Einheitlich(MengePreis {
2262 menge_kwh: dec!(1),
2263 preis_ct_per_kwh: dec!(1),
2264 }),
2265 None,
2266 ),
2267 (
2268 ArbeitspreisModell::Modul1Pauschal {
2269 basis: MengePreis {
2270 menge_kwh: dec!(1),
2271 preis_ct_per_kwh: dec!(1),
2272 },
2273 pauschale_eur_pro_jahr: dec!(120),
2274 jahresanteil: dec!(1) / dec!(12),
2275 },
2276 Some(M::Modul1),
2277 ),
2278 (
2279 ArbeitspreisModell::Modul3ZeitVariabel {
2280 ht: MengePreis {
2281 menge_kwh: dec!(1),
2282 preis_ct_per_kwh: dec!(1),
2283 },
2284 st: MengePreis {
2285 menge_kwh: dec!(0),
2286 preis_ct_per_kwh: dec!(0),
2287 },
2288 nt: MengePreis {
2289 menge_kwh: dec!(1),
2290 preis_ct_per_kwh: dec!(1),
2291 },
2292 },
2293 Some(M::Modul3),
2294 ),
2295 (
2296 // Not a §14a module at all — BK6-22-300 defines exactly three,
2297 // none of them spot-linked.
2298 ArbeitspreisModell::SpotpreisNetzentgelt { intervalle: vec![] },
2299 None,
2300 ),
2301 ];
2302 for (model, expected) in cases {
2303 assert_eq!(model.sect14a_modul(), expected);
2304 }
2305 }
2306
2307 /// BK6-22-300 numbers the three modules in a specific way, and this project
2308 /// had them shuffled: the time-variable model was labelled Modul 2 and a
2309 /// spot-linked Netzentgelt was labelled Modul 3.
2310 ///
2311 /// Getting this wrong prints the wrong statutory module on a real invoice
2312 /// and makes the LF-side and NB-side engines disagree about the same
2313 /// connection, so it is pinned here rather than left to a doc comment.
2314 #[test]
2315 fn the_modules_are_numbered_as_bk6_22_300_defines_them() {
2316 use Sect14aModule as M;
2317 assert!(M::Modul1.label().contains("pauschale Reduzierung"));
2318 assert!(
2319 M::Modul2
2320 .label()
2321 .contains("prozentuale Arbeitspreisreduzierung")
2322 );
2323 assert!(M::Modul3.label().contains("zeitvariable Netzentgelte"));
2324 }
2325
2326 /// Modul 2 and Modul 3 both re-price the Arbeitspreis, so a connection
2327 /// cannot hold both — it would have its network usage reduced twice. Modul 1
2328 /// is a flat reduction needing no metering and composes with Modul 3.
2329 #[test]
2330 fn modul_2_and_modul_3_are_mutually_exclusive() {
2331 use Sect14aModule as M;
2332 assert!(!M::Modul2.combinable_with(M::Modul3));
2333 assert!(!M::Modul3.combinable_with(M::Modul2));
2334 assert!(M::Modul1.combinable_with(M::Modul3));
2335 assert!(M::Modul3.combinable_with(M::Modul1));
2336 assert!(M::Modul1.combinable_with(M::Modul2));
2337 }
2338
2339 /// A period is ordered by construction; a single day is valid.
2340 #[test]
2341 fn a_period_cannot_be_inverted() {
2342 use time::macros::date;
2343 assert!(SettlementPeriod::new(date!(2026 - 01 - 31), date!(2026 - 01 - 01)).is_err());
2344 let one_day = SettlementPeriod::new(date!(2026 - 01 - 01), date!(2026 - 01 - 01))
2345 .expect("a single day is a period");
2346 assert_eq!(one_day.days(), 1);
2347 let january =
2348 SettlementPeriod::new(date!(2026 - 01 - 01), date!(2026 - 01 - 31)).expect("valid");
2349 assert_eq!(january.days(), 31, "both bounds are inclusive");
2350 }
2351}
2352
2353#[cfg(test)]
2354mod korrektur_grund_tests {
2355 use super::*;
2356
2357 /// A correction that cannot say why it happened is not an audit trail.
2358 ///
2359 /// The invoice numbers answer *what* was replaced; only the reason
2360 /// distinguishes a lawful retroactive recalculation from a defect in the
2361 /// original settlement, and those have different consequences.
2362 #[test]
2363 fn a_reason_is_required_for_every_recalculation() {
2364 let mut r = sample_result(SettlementStatus::Initial, None);
2365 assert!(
2366 r.lineage_is_consistent(),
2367 "an initial settlement corrects nothing"
2368 );
2369
2370 r.status = SettlementStatus::Correction;
2371 assert!(
2372 !r.lineage_is_consistent(),
2373 "a correction with no reason must be detectable"
2374 );
2375
2376 r.korrektur_grund = Some(KorrekturGrund::Tarifkorrektur);
2377 assert!(r.lineage_is_consistent());
2378 }
2379
2380 /// An initial settlement carrying a correction reason is equally inconsistent.
2381 #[test]
2382 fn an_initial_settlement_carries_no_reason() {
2383 let r = sample_result(
2384 SettlementStatus::Initial,
2385 Some(KorrekturGrund::Rechenfehler),
2386 );
2387 assert!(!r.lineage_is_consistent());
2388 }
2389
2390 /// Separating defects from lawful recalculations is the point of the field:
2391 /// a rising Rechenfehler count is an engineering signal, a rising
2392 /// RegulatorischeAenderung count is not.
2393 #[test]
2394 fn only_some_reasons_indicate_a_defect() {
2395 assert!(KorrekturGrund::Rechenfehler.indicates_defect());
2396 assert!(KorrekturGrund::Stammdatenkorrektur.indicates_defect());
2397 assert!(!KorrekturGrund::RegulatorischeAenderung.indicates_defect());
2398 assert!(!KorrekturGrund::Messwertkorrektur.indicates_defect());
2399 assert!(!KorrekturGrund::Clearing.indicates_defect());
2400 }
2401
2402 /// The codes are stable — they reach reporting and structured records.
2403 #[test]
2404 fn the_codes_are_stable() {
2405 assert_eq!(
2406 KorrekturGrund::Messwertkorrektur.code(),
2407 "MESSWERTKORREKTUR"
2408 );
2409 assert_eq!(
2410 KorrekturGrund::RegulatorischeAenderung.code(),
2411 "REGULATORISCHE_AENDERUNG"
2412 );
2413 }
2414
2415 fn sample_result(
2416 status: SettlementStatus,
2417 korrektur_grund: Option<KorrekturGrund>,
2418 ) -> SettlementResult {
2419 SettlementResult {
2420 settlement_type: SettlementType::NneStrom,
2421 status,
2422 korrektur_grund,
2423 period: SettlementPeriod::new(
2424 time::macros::date!(2026 - 01 - 01),
2425 time::macros::date!(2026 - 01 - 31),
2426 )
2427 .expect("valid period"),
2428 regime: crate::regulatory::RegulatoryRegime::for_period(
2429 time::macros::date!(2026 - 01 - 01),
2430 time::macros::date!(2026 - 01 - 31),
2431 ),
2432 sparte: Sparte::Strom,
2433 malo_id: "51238696012".to_owned(),
2434 sender_mp_id: "9900000000001".to_owned(),
2435 recipient_mp_id: "9900000000002".to_owned(),
2436 positions: Vec::new(),
2437 total_eur: rust_decimal::Decimal::ZERO,
2438 steuer: crate::umsatzsteuer::Steuerausweis {
2439 kategorie: crate::umsatzsteuer::TaxCategory::Standard,
2440 satz_prozent: crate::umsatzsteuer::REGELSTEUERSATZ,
2441 bemessungsgrundlage_eur: rust_decimal::Decimal::ZERO,
2442 steuer_eur: rust_decimal::Decimal::ZERO,
2443 hinweis: None,
2444 rechtsgrundlage: "§12 Abs. 1 UStG",
2445 },
2446 warnings: Vec::new(),
2447 }
2448 }
2449}
2450
2451#[cfg(test)]
2452mod blindarbeit_tests {
2453 use super::*;
2454 use rust_decimal::dec;
2455
2456 /// cos φ 0,9 is the customary boundary: reactive energy up to tan φ ≈ 0,4843
2457 /// of the active energy travels with it and is not charged.
2458 #[test]
2459 fn draw_inside_the_free_share_costs_nothing() {
2460 let b = Blindarbeit {
2461 blindarbeit_kvarh: dec!(400),
2462 freigrenze_anteil: Blindarbeit::COS_PHI_0_9,
2463 preis_ct_per_kvarh: dec!(2.0),
2464 };
2465 // 1 000 kWh × 0,4843 = 484,3 kvarh free; 400 stays inside it.
2466 assert_eq!(b.mehrarbeit_kvarh(dec!(1000)), Decimal::ZERO);
2467 }
2468
2469 /// Only the excess is chargeable.
2470 #[test]
2471 fn only_the_excess_is_charged() {
2472 let b = Blindarbeit {
2473 blindarbeit_kvarh: dec!(600),
2474 freigrenze_anteil: Blindarbeit::COS_PHI_0_9,
2475 preis_ct_per_kvarh: dec!(2.0),
2476 };
2477 assert_eq!(b.mehrarbeit_kvarh(dec!(1000)), dec!(115.7));
2478 }
2479
2480 /// An unused allowance is not a credit — the excess floors at zero.
2481 #[test]
2482 fn an_unused_allowance_is_never_negative() {
2483 let b = Blindarbeit {
2484 blindarbeit_kvarh: dec!(10),
2485 freigrenze_anteil: Blindarbeit::COS_PHI_0_9,
2486 preis_ct_per_kvarh: dec!(2.0),
2487 };
2488 assert_eq!(b.mehrarbeit_kvarh(dec!(5000)), Decimal::ZERO);
2489 }
2490
2491 /// The share is a term of the Preisblatt, not a constant: many networks
2492 /// round cos φ 0,9 to a flat 50 %, and billing them at 0,4843 overcharges.
2493 #[test]
2494 fn the_free_share_follows_the_preisblatt() {
2495 let rounded = Blindarbeit {
2496 blindarbeit_kvarh: dec!(600),
2497 freigrenze_anteil: dec!(0.5),
2498 preis_ct_per_kvarh: dec!(2.0),
2499 };
2500 assert_eq!(rounded.mehrarbeit_kvarh(dec!(1000)), dec!(100.0));
2501
2502 let exact = Blindarbeit {
2503 freigrenze_anteil: Blindarbeit::COS_PHI_0_9,
2504 ..rounded
2505 };
2506 assert!(
2507 exact.mehrarbeit_kvarh(dec!(1000)) > rounded.mehrarbeit_kvarh(dec!(1000)),
2508 "the tighter cos φ 0,9 share charges more than a rounded 50 %"
2509 );
2510 }
2511
2512 /// Blindmehrarbeit rests on the Netzbetreiber's Preisblatt under StromNEV
2513 /// §17 — not §18 (dezentrale Erzeugung) and not §19 (Sonderformen).
2514 #[test]
2515 fn the_position_kind_maps_to_the_bdew_artikelnummer() {
2516 assert_eq!(
2517 BillingPositionKind::Blindmehrarbeit.artikelnummer(SettlementType::NneStrom),
2518 Some("BLINDMEHRARBEIT")
2519 );
2520 }
2521}