Skip to main content

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}