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 `billing::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(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize)]
35pub enum Sparte {
36    /// Electricity (Strom). Default.
37    #[default]
38    Strom,
39    /// Natural gas (Gas).
40    Gas,
41}
42
43// ── Konzessionsabgabe (KAV §2) ────────────────────────────────────────────────
44
45/// Municipality size band for Konzessionsabgabe, per **KAV §2 Abs. 2**.
46///
47/// KAV bands Tarifkunden rates by the municipality's **inhabitant count**, not by
48/// the customer's annual consumption.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
50pub enum GemeindeGroesse {
51    /// bis 25 000 Einwohner.
52    Bis25k,
53    /// bis 100 000 Einwohner.
54    Bis100k,
55    /// bis 500 000 Einwohner.
56    Bis500k,
57    /// über 500 000 Einwohner.
58    Ueber500k,
59}
60
61/// Konzessionsabgabe customer group per **KAV §2**.
62///
63/// The Tarifkunde/Sondervertragskunde split is a **contract-type** test, not a
64/// consumption threshold: KAV §2 Abs. 3 applies to Sondervertragskunden whatever
65/// they consume, and Abs. 2 bands Tarifkunden by municipality size.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
67pub enum KaKundengruppe {
68    /// Tarifkunde — KAV §2 Abs. 2. Rate depends on [`GemeindeGroesse`].
69    ///
70    /// For gas, `nur_kochen_warmwasser` selects between the two Abs. 2 columns:
71    /// supply limited to cooking and hot water, or all other Tariflieferungen.
72    Tarifkunde {
73        /// Municipality size band.
74        gemeinde: GemeindeGroesse,
75        /// Gas only: supply limited to cooking/hot water. Ignored for Strom.
76        nur_kochen_warmwasser: bool,
77    },
78    /// Schwachlaststrom — KAV §2 Abs. 2. **Strom only**; gas has no such tier.
79    Schwachlast,
80    /// Sondervertragskunde — KAV §2 Abs. 3. Flat, independent of municipality size.
81    Sondervertragskunde,
82    /// Freigestellt nach KAV §2 Abs. 7.
83    Exempt,
84}
85
86impl KaKundengruppe {
87    /// The KAV §2 **Höchstbetrag** in ct/kWh for this group and Sparte.
88    ///
89    /// Returns `None` for [`KaKundengruppe::Exempt`], and for
90    /// [`KaKundengruppe::Schwachlast`] on gas, which KAV does not provide.
91    ///
92    /// These are statutory **maxima**, not the agreed rate — a concession contract
93    /// may set anything up to them.
94    #[must_use]
95    pub fn hoechstsatz_ct_per_kwh(self, sparte: Sparte) -> Option<Decimal> {
96        let pick = |a: &str| Decimal::from_str_exact(a).ok();
97        match (self, sparte) {
98            (Self::Exempt, _) => None,
99            (Self::Schwachlast, Sparte::Strom) => pick("0.61"),
100            (Self::Schwachlast, Sparte::Gas) => None,
101            (Self::Sondervertragskunde, Sparte::Strom) => pick("0.11"),
102            (Self::Sondervertragskunde, Sparte::Gas) => pick("0.03"),
103            (Self::Tarifkunde { gemeinde, .. }, Sparte::Strom) => pick(match gemeinde {
104                GemeindeGroesse::Bis25k => "1.32",
105                GemeindeGroesse::Bis100k => "1.59",
106                GemeindeGroesse::Bis500k => "1.99",
107                GemeindeGroesse::Ueber500k => "2.39",
108            }),
109            (
110                Self::Tarifkunde {
111                    gemeinde,
112                    nur_kochen_warmwasser: true,
113                },
114                Sparte::Gas,
115            ) => pick(match gemeinde {
116                GemeindeGroesse::Bis25k => "0.51",
117                GemeindeGroesse::Bis100k => "0.61",
118                GemeindeGroesse::Bis500k => "0.77",
119                GemeindeGroesse::Ueber500k => "0.93",
120            }),
121            (
122                Self::Tarifkunde {
123                    gemeinde,
124                    nur_kochen_warmwasser: false,
125                },
126                Sparte::Gas,
127            ) => pick(match gemeinde {
128                GemeindeGroesse::Bis25k => "0.22",
129                GemeindeGroesse::Bis100k => "0.27",
130                GemeindeGroesse::Bis500k => "0.33",
131                GemeindeGroesse::Ueber500k => "0.40",
132            }),
133        }
134    }
135
136    /// Short label for the invoice position text.
137    #[must_use]
138    pub const fn label(self) -> &'static str {
139        match self {
140            Self::Tarifkunde { .. } => "KAV §2 Abs. 2 Tarifkunde",
141            Self::Schwachlast => "KAV §2 Abs. 2 Schwachlast",
142            Self::Sondervertragskunde => "KAV §2 Abs. 3 Sondervertragskunde",
143            Self::Exempt => "KAV §2 Abs. 7 — freigestellt",
144        }
145    }
146
147    /// The KAV paragraph that fixes this group's Höchstbetrag.
148    ///
149    /// Cited on the position, so the invoice states the rule it was actually
150    /// billed under. Every position used to cite §2 Abs. 2 regardless — wrong
151    /// for a Sondervertragskunde, whose ceiling is Abs. 3, and wrong again for a
152    /// customer freigestellt under Abs. 7.
153    #[must_use]
154    pub const fn kav_paragraph(self) -> &'static str {
155        match self {
156            Self::Tarifkunde { .. } | Self::Schwachlast => "§2 Abs. 2",
157            Self::Sondervertragskunde => "§2 Abs. 3",
158            Self::Exempt => "§2 Abs. 7",
159        }
160    }
161}
162
163// ── QuantityUnit ──────────────────────────────────────────────────────────────
164
165/// Unit of measure for a settlement position quantity.
166#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
167pub enum QuantityUnit {
168    /// Kilowatt-hours (active energy).
169    Kwh,
170    /// Kilowatts (demand / peak load).
171    Kw,
172    /// Reactive energy (Blindarbeit) — kilovolt-ampere reactive hours.
173    ///
174    /// Used for reactive energy settlement positions per StromNEV §18.
175    Kvarh,
176    /// Reactive power (Blindleistung) — kilovolt-ampere reactive.
177    Kvar,
178    /// Calendar months.
179    Monat,
180}
181
182// ── Sect14aModule ─────────────────────────────────────────────────────────────
183
184/// §14a EnWG module for steuerbare Verbrauchseinrichtungen (controllable loads).
185///
186/// Source: BNetzA BK6-22-300 (Beschluss 27.11.2023, in force 01.01.2024).
187///
188/// All three modules are **mandatory** for eligible controllable loads (heat pumps,
189/// EV chargers, battery storage ≥ 4.2 kW) registered with the NB. The LF/NB
190/// must offer at least Modul 1 to all eligible customers.
191#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
192pub enum Sect14aModule {
193    /// Modul 1 — pauschale Reduzierung (flat reduction).
194    ///
195    /// The NB applies a fixed percentage reduction to the Arbeitspreis (or
196    /// Arbeitspreis + Leistungspreis) for the entire billing period.
197    /// Reduction factor = 85 % (i.e. customer pays 85 % of full rate) per BK6-22-300
198    /// Anlage 2. The NB may set a different approved rate in their tariff sheet.
199    ///
200    /// Equivalent UTILTS segment: `CCI+ZG6 CAV+Z28:::0.85` (multiplier).
201    Modul1,
202    /// Modul 2 — variable Netzentgelte (time-variable, HT/NT split).
203    ///
204    /// Two Arbeitspreis tiers: Hochlast (HT, higher price) and Niedertarif (NT,
205    /// lower price). Periods are defined in the UTILTS Zählzeitdefinition published
206    /// by the NB. Required for iMSys meters with quarter-hour metering.
207    Modul2,
208    /// Modul 3 — Spotpreis-Netzentgelt (dynamic, spot-price linked).
209    ///
210    /// NNE follows the intraday or day-ahead electricity spot price. The calculation
211    /// basis is the `PreisblattNetznutzung.spotpreisNetzentgelt` formula defined by
212    /// the NB. Requires smart meter (iMSys) with 15-min resolution.
213    ///
214    /// Note: Modul 3 rates are not yet calculable from static inputs alone —
215    /// populate `regulatory_reduction_factor` in the trace with the effective
216    /// period-average rate when using this module.
217    Modul3,
218}
219
220impl Sect14aModule {
221    /// Canonical BNetzA decision reference for this module.
222    #[must_use]
223    pub fn bnentza_reference(self) -> &'static str {
224        "BK6-22-300"
225    }
226
227    /// Display label for the module.
228    #[must_use]
229    pub fn label(self) -> &'static str {
230        match self {
231            Self::Modul1 => "§14a EnWG Modul 1 (pauschale Reduzierung)",
232            Self::Modul2 => "§14a EnWG Modul 2 (HT/NT variable)",
233            Self::Modul3 => "§14a EnWG Modul 3 (Spotpreis)",
234        }
235    }
236}
237
238// ── SettlementType ────────────────────────────────────────────────────────────
239
240/// Which regulated settlement process produced this result.
241///
242/// Determines which BDEW PIDs are applicable and which regulatory references apply.
243#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
244pub enum SettlementType {
245    /// Netznutzungsentgelt (NNE) Strom — PID 31002 (NN-Rechnung, NB → LF).
246    NneStrom,
247    /// Netznutzungsentgelt (NNE) Gas — PID 31002 (NN-Rechnung, NB → LF, GasNEV).
248    ///
249    /// NNE Strom and Gas share the INVOIC Prüfidentifikator 31002 (NN-Rechnung);
250    /// the Sparte is carried in the message content, not the PID. Keeping a
251    /// separate variant preserves the correct legal references (StromNEV vs
252    /// GasNEV) without conditional logic in call sites.
253    NneGas,
254    /// Mehr-/Mindermengen settlement Strom — PID 31005 (NB → LF, GPKE (BK6-24-174) Teil 1 Kap. 8.4).
255    MmmStrom,
256    /// Mehr-/Mindermengen settlement Gas — PID 31005 (NB → LF, GaBi Gas 2.1 (BK7-24-01-008)).
257    ///
258    /// Gas MMM settlement uses different legal references from Strom MMM:
259    /// `GaBi Gas 2.1 (BK7-24-01-008)` and `GeLi Gas 3.0 (BK7-24-01-009)`. Using a separate variant
260    /// ensures correct audit traces without conditional logic in call sites.
261    MmmGas,
262    /// Mehr-/Mindermengen Mehrmenge, selbst ausgestellte Rechnung (Lieferung) — PID 31006.
263    ///
264    /// Per INVOIC AHB §3.x, PID 31006 covers the Mehrmenge leg when the Mehr-/
265    /// Mindermenge is treated as a „Lieferung“ and the invoice is self-issued.
266    MmmSelbstausstellt,
267    /// Messstellenbetrieb settlement — PID 31009 (NB → MSB).
268    MsbRechnung,
269    /// GaBi Gas AWH Sperrprozesse settlement — PID 31011 (NB → LF, BK7-24-01-009 §5.4).
270    ///
271    /// Rechnung sonstige Leistung: bills the LF (LFG/LFA) for abrechnungswürdige
272    /// Handlungen (AWH) performed by the GNB/VNB during Sperrung/Entsperrung.
273    GasAwhSperrung,
274    /// Redispatch 2.0 Einsatzkosten (NB → ÜNB, BK6-20-061).
275    RedispatchKostenblatt,
276    /// Entgelt für dezentrale Erzeugung — §18 StromNEV, NB → Anlagenbetreiber.
277    ///
278    /// A bilateral payment relationship, not an EDIFACT market process: it has
279    /// no Prüfidentifikator and is rendered as an ordinary commercial credit.
280    DezentraleEinspeisung,
281}
282
283impl SettlementType {
284    /// Default BDEW PID for this settlement type.
285    ///
286    /// Callers may override the PID after construction if needed.
287    #[must_use]
288    pub fn default_pid(self) -> u32 {
289        match self {
290            Self::NneStrom => 31002,
291            Self::NneGas => 31002,
292            Self::MmmStrom => 31005,
293            Self::MmmGas => 31005,
294            Self::MmmSelbstausstellt => 31006,
295            Self::MsbRechnung => 31009,
296            Self::GasAwhSperrung => 31011,
297            Self::RedispatchKostenblatt => 0, // no standard PID
298            // Bilateral NB → Anlagenbetreiber payment; not an EDIFACT process.
299            Self::DezentraleEinspeisung => 0,
300        }
301    }
302}
303
304// ── SettlementStatus ──────────────────────────────────────────────────────────
305
306/// Lifecycle status of a settlement result.
307///
308/// Settlements are never destroyed — every correction or cancellation creates
309/// a new result that references the original. This ensures an immutable audit trail.
310#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
311pub enum SettlementStatus {
312    /// Initial calculation — no prior settlement exists for this period.
313    Initial,
314    /// Correction of a prior settlement (references `correction_of`).
315    Correction,
316    /// Cancellation of a prior settlement — all positions are negated.
317    Reversal,
318    /// Final settlement — no further corrections expected.
319    Final,
320}
321
322// ── LegalReference ────────────────────────────────────────────────────────────
323
324/// Regulatory citation that justifies a billing position or rate.
325///
326/// Every [`SettlementPosition`] should carry at least one `LegalReference`.
327/// This enables full auditability: any operator or regulator can trace
328/// exactly which paragraph, ruling, and version authorised each charge.
329#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
330pub enum LegalReference {
331    /// StromNEV — Stromnetzentgeltverordnung (grid usage charges, Strom).
332    ///
333    /// Example: `StromNev { paragraph: "§17" }` for Leistungspreise.
334    StromNev {
335        /// Paragraph reference, e.g. `"§17"`, `"§21"`.
336        paragraph: &'static str,
337    },
338    /// GasNEV — Gasnetzentgeltverordnung (grid usage charges, Gas).
339    GasNev {
340        /// Paragraph reference, e.g. `"§14"`.
341        paragraph: &'static str,
342    },
343    /// KAV — Konzessionsabgabenverordnung (municipal concession fee).
344    ///
345    /// Example: `Kav { paragraph: "§2 Abs. 2" }`.
346    Kav {
347        /// Paragraph reference, e.g. `"§2 Abs. 2"`.
348        paragraph: &'static str,
349    },
350    /// KWKG — Kraft-Wärme-Kopplungsgesetz.
351    ///
352    /// Example: `Kwkg { paragraph: "§26" }` for the KWKG-Umlage.
353    Kwkg {
354        /// Paragraph citation, e.g. `"§26"`.
355        paragraph: &'static str,
356    },
357    /// EnFG — Energiefinanzierungsgesetz.
358    ///
359    /// Governs which Letztverbrauchergruppe an Entnahmestelle falls into and so
360    /// which rate of a network levy applies.
361    EnFG {
362        /// Paragraph citation, e.g. `"§§21 ff."`.
363        paragraph: &'static str,
364    },
365    /// §14a EnWG — Steuerbare Verbrauchseinrichtungen (controllable loads).
366    ///
367    /// Governs time-variable (ToU) NNE for heat pumps, EV chargers, etc.
368    Sect14aEnwg {
369        /// Module: Modul1 (flat reduction), Modul2 (HT/NT), or Modul3 (spot).
370        module: Sect14aModule,
371    },
372    /// MsbG — Messstellenbetriebsgesetz (metering point operation).
373    MsbG {
374        /// Paragraph citation, e.g. `"§§6–7"`.
375        paragraph: &'static str,
376    },
377    /// BNetzA decision (Beschluss).
378    ///
379    /// Example: `BnetzaDecision { reference: "BK6-22-300" }`.
380    BnetzaDecision {
381        /// Decision reference, e.g. `"BK6-22-300"`, `"BK6-24-174"`.
382        reference: &'static str,
383    },
384    /// BDEW application handbook (Anwendungshandbuch).
385    BdewAhb {
386        /// AHB reference, e.g. `"GPKE BK6-22-024"`.
387        reference: &'static str,
388    },
389    /// StromNZV — Stromnetzzugangsverordnung.
390    ///
391    /// **Außer Kraft mit Ablauf des 31.12.2025** (Art. 15 Abs. 4 des Gesetzes
392    /// v. 22.12.2023, BGBl. 2023 I Nr. 405). Valid only for Lieferzeiträume up
393    /// to that date; the successor competence is §20 Abs. 3 EnWG, exercised
394    /// through the BK6 Festlegungen. [`LegalReference::citation`] appends the
395    /// expiry so an archived invoice stays self-explanatory.
396    StromNzv {
397        /// Paragraph citation, e.g. `"§13 Abs. 3"`.
398        paragraph: &'static str,
399    },
400    /// GasNZV — Gasnetzzugangsverordnung 2010.
401    ///
402    /// **Außer Kraft mit Ablauf des 31.12.2025** (Art. 15 Abs. 6 des Gesetzes
403    /// v. 22.12.2023, BGBl. 2023 I Nr. 405). Succeeded by KARLA Gas 2.0
404    /// (BK7-24-01-007), GaBi Gas 2.1 (BK7-24-01-008), GeLi Gas 3.0
405    /// (BK7-24-01-009) and ZuBio (BK7-24-01-010), all in force 01.01.2026.
406    GasNzv {
407        /// Paragraph citation, e.g. `"§25"`.
408        paragraph: &'static str,
409    },
410    /// EnWG — Energiewirtschaftsgesetz (general energy law).
411    Enwg {
412        /// Paragraph citation, e.g. `"§14a"`.
413        paragraph: &'static str,
414    },
415    /// ARegV — Anreizregulierungsverordnung (incentive regulation).
416    ///
417    /// ARegV §§17–21 define the allowed NNE revenue caps and efficiency targets.
418    /// Relevant when documenting why a specific regulated tariff level was approved.
419    ARegV {
420        /// Paragraph citation, e.g. `"§17"`, `"§21"`.
421        paragraph: &'static str,
422    },
423}
424
425impl LegalReference {
426    /// Short human-readable citation string (German).
427    #[must_use]
428    pub fn citation(&self) -> String {
429        match self {
430            Self::StromNev { paragraph } => format!("StromNEV {paragraph}"),
431            Self::GasNev { paragraph } => format!("GasNEV {paragraph}"),
432            Self::Kav { paragraph } => format!("KAV {paragraph}"),
433            Self::Kwkg { paragraph } => format!("KWKG {paragraph}"),
434            Self::EnFG { paragraph } => format!("EnFG {paragraph}"),
435            Self::Sect14aEnwg { module } => format!("§14a EnWG {}", module.label()),
436            Self::MsbG { paragraph } => format!("MsbG {paragraph}"),
437            Self::BnetzaDecision { reference } => format!("BNetzA {reference}"),
438            Self::BdewAhb { reference } => format!("BDEW {reference}"),
439            Self::StromNzv { paragraph } => {
440                format!("StromNZV {paragraph} (außer Kraft seit 01.01.2026)")
441            }
442            Self::GasNzv { paragraph } => {
443                format!("GasNZV {paragraph} (außer Kraft seit 01.01.2026)")
444            }
445            Self::Enwg { paragraph } => format!("EnWG {paragraph}"),
446            Self::ARegV { paragraph } => format!("ARegV {paragraph}"),
447        }
448    }
449}
450
451// ── TariffSource ──────────────────────────────────────────────────────────────
452
453/// Origin of the tariff rate applied in a settlement position.
454///
455/// Every rate used in a billing position must be traceable to a `TariffSource`.
456/// This enables operators and auditors to answer: *"Why was this rate used?"*
457#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
458pub enum TariffSource {
459    /// Rate from the published and approved `PreisblattNetznutzung` tariff sheet.
460    PublishedTariffSheet {
461        /// Tariff sheet identifier or version, e.g. `"Preisblatt 2025 Q1"`.
462        sheet_id: String,
463    },
464    /// Rate from a historical tariff (retroactive billing or correction).
465    HistoricalTariff {
466        /// Original valid_from date of the tariff.
467        valid_from: time::Date,
468    },
469    /// Regulatory rate mandated by a BNetzA decision.
470    RegulatoryTariff {
471        /// BNetzA decision reference.
472        decision_ref: &'static str,
473    },
474    /// Contract-specific rate negotiated between NB and customer.
475    ContractTariff {
476        /// Contract reference.
477        contract_ref: String,
478    },
479    /// Manual override by operator (requires documentation).
480    ManualOverride {
481        /// Reason for the override.
482        reason: String,
483    },
484}
485
486// ── CalculationTrace ──────────────────────────────────────────────────────────
487
488/// Full audit record for how one [`SettlementPosition`] was computed.
489///
490/// Answers the question: *"Why is this amount on the invoice?"*
491///
492/// Every `CalculationTrace` carries the input values, the applied legal rules,
493/// intermediate results, and the tariff source. This enables:
494/// - Regulator audits (BNetzA §20 EnWG)
495/// - Operator review
496/// - LF dispute resolution
497/// - AI-assisted invoice explainability (MCP tools)
498#[derive(Debug, Clone, serde::Serialize)]
499pub struct CalculationTrace {
500    /// Human-readable explanation of this position.
501    ///
502    /// Example: `"Arbeit 1500 kWh × 3.5 ct/kWh = 52.50 EUR"`
503    pub explanation: String,
504    /// Input quantity used (before rounding).
505    pub input_quantity: Decimal,
506    /// Input unit price in EUR (before rounding, already converted from ct).
507    pub input_unit_price_eur: Decimal,
508    /// Intermediate result before rounding (qty × price).
509    pub gross_eur: Decimal,
510    /// Applied legal references (at least one required).
511    pub legal_refs: Vec<LegalReference>,
512    /// Source of the tariff rate.
513    pub tariff_source: Option<TariffSource>,
514    /// Any §14a reductions applied, expressed as a fraction (0.0–1.0).
515    ///
516    /// `None` when no regulatory reduction applies.
517    /// Example: `Some(Decimal::new(85, 2))` = 85% of full rate (15% reduction).
518    pub regulatory_reduction_factor: Option<Decimal>,
519    /// Notes on rounding applied.
520    ///
521    /// Example: `"rounded to 5 dp per StromNEV §17"`.
522    pub rounding_note: Option<&'static str>,
523}
524
525// ── SettlementWarning ─────────────────────────────────────────────────────────
526
527/// A non-blocking validation issue found during settlement calculation.
528///
529/// Warnings do not prevent the invoice from being generated but should be
530/// reviewed before dispatch. The service layer may choose to block dispatch
531/// on `Severity::Error` warnings.
532#[derive(Debug, Clone, serde::Serialize)]
533pub struct SettlementWarning {
534    /// Severity: informational, warning, or error.
535    pub severity: WarningSeverity,
536    /// Machine-readable warning code.
537    pub code: &'static str,
538    /// Human-readable description.
539    pub message: String,
540}
541
542/// Severity level for [`SettlementWarning`].
543#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
544pub enum WarningSeverity {
545    /// Informational — no action required.
546    Info,
547    /// Potential issue — review recommended before dispatch.
548    Warning,
549    /// Definite issue — should be resolved before dispatch.
550    Error,
551}
552
553// ── InvoicePosition ───────────────────────────────────────────────────────────
554
555/// Semantic kind of a billing position — used by the service layer to derive
556/// the correct `BdewArtikelnummer` for the BO4E `Rechnungsposition`.
557///
558/// `grid-billing` has no `rubo4e` dependency, so this enum is the bridge:
559/// the service layer maps `BillingPositionKind` → `BdewArtikelnummer` in
560/// `into_rechnung()`. Every position in every `SettlementResult` must carry
561/// a `kind` so the INVOIC `Rechnungsposition.artikelnummer` is never missing.
562///
563/// ## BDEW INVOIC AHB requirement
564///
565/// BDEW INVOIC AHBs (FV2025-10-01) mandate `artikelnummer` in every
566/// `SG28 PIA` line item. Missing or wrong Artikelnummern cause counterparty
567/// APERAK rejection. The `invoic-checker` checks 6 plausibility rules;
568/// Artikelnummer matching is part of the tariff-found rule (check 5).
569///
570/// ## Mapping to `BdewArtikelnummer`
571///
572/// | `BillingPositionKind` | `BdewArtikelnummer` | INVOIC AHB ref |
573/// |---|---|---|
574/// | `NneArbeit` | `Wirkarbeit` | PID 31002 (NN-Rechnung) Arbeit |
575/// | `NneArbeitHt` | `Wirkarbeit` | PID 31002 §14a Modul 2 HT |
576/// | `NneArbeitNt` | `Wirkarbeit` | PID 31002 §14a Modul 2 NT |
577/// | `NneArbeitModul1` | `Wirkarbeit` | PID 31002 §14a Modul 1 (rate reduced) |
578/// | `NneLeistung` | `Leistung` | PID 31002 RLM kW charge |
579/// | `NneGasGrundpreis` | `Grundpreis` | PID 31002 Gas monthly base fee |
580/// | `Konzessionsabgabe` | `Konzessionsabgabe` | PID 31002 KAV §2 |
581/// | `Mehrmenge` | `Mehrmenge` | PID 31005 positive imbalance |
582/// | `Mindermenge` | `Mindermenge` | PID 31005 negative imbalance (credit) |
583/// | `MsbGrundgebuehr` | `EntgeltEinbauBetriebWartungMesstechnik` | PID 31009 MSB monthly fee |
584/// | `Messdienstleistung` | `EntgeltMessungAblesung` | PID 31009 reading service |
585/// | `GasAwhSperrung` | `Sperrkosten` | PID 31011 AWH disconnection |
586/// | `GasAwhEntsprrung` | `Entsperrkosten` | PID 31011 AWH reconnection |
587/// | `GasAwhSonstige` | `EntgeltAbrechnung` | PID 31011 other AWH |
588/// | `Blindmehrarbeit` | `Blindmehrarbeit` | Reactive energy excess |
589#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
590pub enum BillingPositionKind {
591    /// Netznutzungsentgelt Arbeit — flat-rate active energy charge (kWh).
592    /// SLP or Gas. → `BdewArtikelnummer::Wirkarbeit`
593    NneArbeit,
594    /// §14a Modul 2 Hochlast (HT) Arbeit — time-variable higher-price band.
595    /// → `BdewArtikelnummer::Wirkarbeit`
596    NneArbeitHt,
597    /// §14a Modul 2 Niedertarif (NT) Arbeit — time-variable lower-price band.
598    /// → `BdewArtikelnummer::Wirkarbeit`
599    NneArbeitNt,
600    /// §14a Modul 1 Arbeit — flat percentage reduction applied to Arbeitspreis.
601    /// → `BdewArtikelnummer::Wirkarbeit` (same article, different rate)
602    NneArbeitModul1,
603    /// §14a Modul 3 Spotpreis-NNE — per-dispatch-interval variable rate position.
604    ///
605    /// One `InvoicePosition` is generated per dispatch interval from
606    /// `NneInput::sect14a_modul3_intervals`. Each carries a
607    /// `lastvariable_preisposition_json` with the BO4E `LastvariablePreisposition`
608    /// COM data (pricing formula parameters) for ERP-side validation and portal
609    /// display of the per-interval tariff breakdown.
610    ///
611    /// Regulatory basis: BNetzA BK6-22-300 Anlage 2 §3 — Spotpreis-Netzentgelt.
612    /// → `BdewArtikelnummer::Wirkarbeit`
613    NneArbeitModul3,
614    /// Netznutzungsentgelt Leistung — RLM peak demand charge (kW).
615    /// → `BdewArtikelnummer::Leistung`
616    NneLeistung,
617    /// Gas NNE monthly base fee (Grundpreis / Verrechnungspreis).
618    /// GasNEV §14. → `BdewArtikelnummer::Grundpreis`
619    NneGasGrundpreis,
620    /// Konzessionsabgabe — KAV §2 municipal concession fee.
621    /// → `BdewArtikelnummer::Konzessionsabgabe`
622    Konzessionsabgabe,
623    /// Mehrmengen — positive imbalance (actual > profiled).
624    /// PID 31005 GPKE (BK6-24-174) Teil 1 Kap. 8.4 / GaBi Gas 2.1 (BK7-24-01-008). → `BdewArtikelnummer::Mehrmenge`
625    Mehrmenge,
626    /// Mindermengen — negative imbalance credit note (actual < profiled).
627    /// PID 31005. → `BdewArtikelnummer::Mindermenge`
628    Mindermenge,
629    /// MSB Grundgebühr Messstellenbetrieb — monthly metering base fee.
630    /// MsbG §§6–7. → `BdewArtikelnummer::EntgeltEinbauBetriebWartungMesstechnik`
631    MsbGrundgebuehr,
632    /// Messdienstleistung — periodic reading service fee.
633    /// MsbG §2. → `BdewArtikelnummer::EntgeltMessungAblesung`
634    Messdienstleistung,
635    /// Gas AWH Sperrung — abrechnungswürdige Handlung disconnection.
636    /// BK7-24-01-009 §5.4. → `BdewArtikelnummer::Sperrkosten`
637    GasAwhSperrung,
638    /// Gas AWH Entsperrung — abrechnungswürdige Handlung reconnection.
639    /// BK7-24-01-009 §5.4. → `BdewArtikelnummer::Entsperrkosten`
640    GasAwhEntsprrung,
641    /// Gas AWH sonstige — other abrechnungswürdige Handlung.
642    /// BK7-24-01-009 §5.4. → `BdewArtikelnummer::EntgeltAbrechnung`
643    GasAwhSonstige,
644    /// Blindmehrarbeit — reactive energy excess charge.
645    /// StromNEV §18. → `BdewArtikelnummer::Blindmehrarbeit`
646    Blindmehrarbeit,
647    /// Aufschlag für besondere Netznutzung (§19 StromNEV-Umlage).
648    ///
649    /// Funds the reduced individual network charges granted under §19 Abs. 2
650    /// StromNEV. Rate depends on the Letztverbrauchergruppe (EnFG).
651    Sect19StromNevUmlage,
652    /// Offshore-Netzumlage (§17f EnWG).
653    ///
654    /// Funds offshore connection cost and the compensation owed to offshore
655    /// wind farms for unavailable connections.
656    OffshoreNetzumlage,
657    /// KWKG-Umlage (§26 KWKG).
658    ///
659    /// Funds the KWK-Zuschlag paid to CHP operators.
660    KwkgUmlage,
661    /// Entgelt für dezentrale Erzeugung — §18 StromNEV, under Abschmelzung
662    /// (GBK-25-02-1#1). A payment out, so its `net_eur` is negative.
663    DezentraleEinspeisung,
664    /// §19 Abs. 2 StromNEV individual-charge reduction over the Netzentgelt.
665    /// Negative: it takes the published charge down to the agreed fraction.
666    Sect19IndividuellesEntgelt,
667    /// Gas Kapazitätsentgelt — booked capacity at the price sheet's annual
668    /// rate, pro-rated over the period. §15 GasNEV.
669    GasKapazitaetsentgelt,
670}
671
672/// One line item in a grid settlement.
673///
674/// Carries raw numbers for the service layer to map into the required format
675/// (BO4E `Rechnungsposition`, EN16931 UBL, etc.).
676///
677/// Invariant: `net_eur == (quantity × unit_price_eur).round_dp(5)`.
678/// The pricing formula behind a §14a Modul 3 spot-priced position.
679///
680/// Modelled as a value object rather than a serialised BO4E document. The engine
681/// states *what the formula was*; translating that into
682/// `LastvariablePreisposition` — or into any other representation — is the
683/// adapter's job. Carrying BO4E JSON here would put schema knowledge inside the
684/// calculation, untyped and unvalidated, which is the coupling the crate exists
685/// to avoid.
686#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
687pub struct SpotPriceFormula {
688    /// What the price refers to — for Modul 3 always the metered energy.
689    pub reference: PriceReference,
690    /// The unit the price is expressed per.
691    pub unit: QuantityUnit,
692    /// How the rate was derived.
693    pub method: TariffCalculationMethod,
694    /// The rate steps that applied, in order.
695    pub steps: Vec<PriceStep>,
696}
697
698/// What a price refers to.
699#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
700pub enum PriceReference {
701    /// The metered energy quantity.
702    Energiemenge,
703    /// Contracted or metered capacity.
704    Leistung,
705}
706
707/// How a rate was derived.
708#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
709pub enum TariffCalculationMethod {
710    /// A published fixed rate.
711    Festpreis,
712    /// Derived from a spot-market price — §14a Modul 3, BK6-22-300 Anlage 2 §3.
713    Spotpreis,
714}
715
716/// One step of a rate schedule.
717#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
718pub struct PriceStep {
719    /// Lower bound of the step, inclusive.
720    pub from: Decimal,
721    /// Upper bound, exclusive; `None` for the open top step.
722    pub to: Option<Decimal>,
723    /// The rate in EUR per [`SpotPriceFormula::unit`].
724    pub unit_price_eur: Decimal,
725}
726
727/// One line of a settlement.
728///
729/// Carries no position number and no BDEW Artikel-ID: both are properties of the
730/// *document* that presents the settlement, not of the calculation. An adapter
731/// numbers the positions it renders and resolves article identifiers from the
732/// price sheet.
733#[derive(Debug, Clone, serde::Serialize)]
734pub struct SettlementPosition {
735    /// Human-readable description.
736    pub text: String,
737    /// Semantic kind — what was charged, independent of how it is coded.
738    pub kind: BillingPositionKind,
739    /// Metered or contracted quantity.
740    pub quantity: Decimal,
741    /// Unit of measure.
742    pub unit: QuantityUnit,
743    /// Unit price in EUR.
744    pub unit_price_eur: Decimal,
745    /// Net amount in EUR, rounded to 5 decimal places.
746    ///
747    /// May be negative for credit positions (Mindermengen, Gutschriften).
748    pub net_eur: Decimal,
749    /// The formula behind the rate, where one applied.
750    pub spot_price_formula: Option<SpotPriceFormula>,
751    /// Why this amount is what it is.
752    pub trace: CalculationTrace,
753}
754
755impl BillingPositionKind {
756    /// The BDEW Artikelnummer that codes this position, as its codelist name.
757    ///
758    /// Which article number applies depends on both what was charged and what
759    /// kind of settlement it appears in — Gas NNE keeps the classic `WIRKARBEIT`
760    /// code, while Strom NNE moved to Artikel-IDs under BK6-20-160 and carries
761    /// no Artikelnummer at all.
762    ///
763    /// Returned as the codelist *name* rather than a BO4E enum so that this
764    /// crate stays free of BO4E types. A consumer parses it into whatever it
765    /// renders — `rubo4e::current::BdewArtikelnummer` implements `FromStr` over
766    /// exactly these names.
767    ///
768    /// `None` means the position carries an Artikel-ID instead, resolved from
769    /// the price sheet by the renderer.
770    ///
771    /// Source: BDEW Codeliste der Artikelnummern und Artikel-IDs v5.6.
772    #[must_use]
773    pub fn artikelnummer(self, settlement_type: SettlementType) -> Option<&'static str> {
774        use BillingPositionKind as K;
775        use SettlementType as ST;
776        match (self, settlement_type) {
777            // Gas NNE keeps the classic codes — BK6-20-160 changed Strom only.
778            (
779                K::NneArbeit
780                | K::NneArbeitHt
781                | K::NneArbeitNt
782                | K::NneArbeitModul1
783                | K::NneArbeitModul3,
784                ST::NneGas,
785            ) => Some("WIRKARBEIT"),
786            (K::NneLeistung, ST::NneGas) => Some("LEISTUNG"),
787            (K::NneGasGrundpreis, _) => Some("GRUNDPREIS"),
788            // Strom NNE: the Artikel-ID replaces the Artikelnummer.
789            (
790                K::NneArbeit
791                | K::NneArbeitHt
792                | K::NneArbeitNt
793                | K::NneArbeitModul1
794                | K::NneArbeitModul3
795                | K::NneLeistung,
796                _,
797            ) => None,
798            (K::Konzessionsabgabe, _) => Some("KONZESSIONSABGABE"),
799            (K::Mehrmenge, _) => Some("MEHRMENGE"),
800            (K::Mindermenge, _) => Some("MINDERMENGE"),
801            (K::MsbGrundgebuehr, _) => Some("ENTGELT_EINBAU_BETRIEB_WARTUNG_MESSTECHNIK"),
802            (K::Messdienstleistung, _) => Some("ENTGELT_MESSUNG_ABLESUNG"),
803            // AWH Gas positions carry a 2-01-7-xxx Artikel-ID from the input.
804            (K::GasAwhSperrung | K::GasAwhEntsprrung | K::GasAwhSonstige, _) => None,
805            (K::Blindmehrarbeit, _) => Some("BLINDMEHRARBEIT"),
806            // Netzseitige Umlagen (EnFG). `OFFSHORE_HAFTUNGSUMLAGE` is the code's
807            // legacy name — the levy was renamed Offshore-Netzumlage, the article
808            // number was not.
809            (K::Sect19StromNevUmlage, _) => Some("PARAGRAF_19_STROM_NEV_UMLAGE"),
810            // Bilateral payment outside the INVOIC market processes — the
811            // codelist has no article number for it.
812            (K::DezentraleEinspeisung, _) => None,
813            // A reduction over Strom NNE positions, which carry Artikel-IDs.
814            (K::Sect19IndividuellesEntgelt, _) => None,
815            // Capacity is the gas Leistung analogue and keeps the classic code.
816            (K::GasKapazitaetsentgelt, ST::NneGas) => Some("LEISTUNG"),
817            (K::GasKapazitaetsentgelt, _) => None,
818            (K::OffshoreNetzumlage, _) => Some("OFFSHORE_HAFTUNGSUMLAGE"),
819            (K::KwkgUmlage, _) => Some("ABGABE_KWKG"),
820        }
821    }
822}
823
824// ── Arbeitspreis model ────────────────────────────────────────────────────────
825
826/// A §14a Modul 1 reduction factor — the fraction of the published rate paid.
827///
828/// A newtype because the range matters: `0.85` is a 15 % reduction, and a value
829/// outside `(0, 1]` is not a reduction at all. The unconstrained `Decimal` this
830/// replaces was range-checked in the validator and *not* in the engine, so a
831/// caller who skipped validation could multiply the tariff by 5.
832#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
833pub struct Reduktionsfaktor(Decimal);
834
835impl Reduktionsfaktor {
836    /// The regulatory default, BNetzA BK6-22-300 Anlage 2 — 85 % of the tariff.
837    pub const REGELFALL: Self = Self(rust_decimal::dec!(0.85));
838
839    /// Build a factor.
840    ///
841    /// # Errors
842    ///
843    /// Returns [`crate::error::BillingError::InvalidInput`] outside `(0, 1]`.
844    pub fn new(factor: Decimal) -> Result<Self, crate::error::BillingError> {
845        if factor <= Decimal::ZERO || factor > Decimal::ONE {
846            return Err(crate::error::BillingError::InvalidInput {
847                reason: format!("§14a Modul 1 reduction factor must be in (0, 1], got {factor}"),
848            });
849        }
850        Ok(Self(factor))
851    }
852
853    /// The factor as a fraction.
854    #[must_use]
855    pub const fn get(self) -> Decimal {
856        self.0
857    }
858}
859
860/// A metered quantity priced at a rate.
861#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
862pub struct MengePreis {
863    /// Metered energy in kWh.
864    pub menge_kwh: Decimal,
865    /// Rate in ct/kWh.
866    pub preis_ct_per_kwh: Decimal,
867}
868
869/// How the Arbeitspreis is structured, and whether §14a applies.
870///
871/// One enum rather than three independent field groups. The four variants are
872/// mutually exclusive **by construction**, which removes a whole class of defect:
873///
874/// - The four HT/NT fields were 2⁴ states of which two were valid. Setting three
875///   of them fell through to flat billing with no error — the invoice looked
876///   right and was billed on the wrong basis.
877/// - Modul 1 and Modul 3 could both be set. The engine applied the flat
878///   reduction *and* the per-interval rates, double-billing the same energy.
879/// - Modul 1 and Modul 2 could both be set; the engine silently preferred
880///   Modul 2 rather than rejecting the conflict.
881///
882/// Those were runtime warnings in a validator the engine never called. They are
883/// now unrepresentable.
884#[derive(Debug, Clone, PartialEq, serde::Serialize)]
885pub enum ArbeitspreisModell {
886    /// A single rate for all metered energy.
887    Einheitlich(MengePreis),
888
889    /// **§14a Modul 1** — the published rate reduced by a flat factor.
890    ///
891    /// BNetzA BK6-22-300 Anlage 2.
892    Modul1Pauschal {
893        /// The metered energy and its published rate, before reduction.
894        basis: MengePreis,
895        /// The fraction of that rate actually paid.
896        reduktion: Reduktionsfaktor,
897    },
898
899    /// **§14a Modul 2** — time-variable rates in a Hoch-/Niedertarif split.
900    ///
901    /// Both bands are required: a Modul 2 tariff has both, and permitting one
902    /// would reintroduce the partial state this type exists to prevent.
903    Modul2ZeitVariabel {
904        /// Hochtarif band.
905        ht: MengePreis,
906        /// Niedertarif band.
907        nt: MengePreis,
908    },
909
910    /// **§14a Modul 3** — a spot-derived rate per dispatch interval.
911    ///
912    /// BNetzA BK6-22-300 Anlage 2 §3. The rates arrive already derived; this
913    /// crate never queries a spot market.
914    Modul3Spotpreis {
915        /// The dispatch intervals, each with its own rate.
916        intervalle: Vec<Sect14aModul3Interval>,
917    },
918}
919
920impl ArbeitspreisModell {
921    /// Total metered energy across the model, in kWh.
922    ///
923    /// This is the base the Konzessionsabgabe and the network levies are charged
924    /// on, so it is derived here once rather than recomputed per levy.
925    #[must_use]
926    pub fn menge_kwh(&self) -> Decimal {
927        match self {
928            Self::Einheitlich(mp) | Self::Modul1Pauschal { basis: mp, .. } => mp.menge_kwh,
929            Self::Modul2ZeitVariabel { ht, nt } => ht.menge_kwh + nt.menge_kwh,
930            Self::Modul3Spotpreis { intervalle } => intervalle.iter().map(|i| i.menge_kwh).sum(),
931        }
932    }
933
934    /// The §14a module in play, if any.
935    #[must_use]
936    pub const fn sect14a_modul(&self) -> Option<Sect14aModule> {
937        match self {
938            Self::Einheitlich(_) => None,
939            Self::Modul1Pauschal { .. } => Some(Sect14aModule::Modul1),
940            Self::Modul2ZeitVariabel { .. } => Some(Sect14aModule::Modul2),
941            Self::Modul3Spotpreis { .. } => Some(Sect14aModule::Modul3),
942        }
943    }
944}
945
946// ── Paired inputs ─────────────────────────────────────────────────────────────
947
948/// An RLM demand charge — peak demand and its rate.
949///
950/// A pair, because billing one without the other is meaningless. The two used to
951/// be independent `Option`s checked at runtime in two separate places.
952#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
953pub struct Leistungspreis {
954    /// Peak demand in kW.
955    pub spitzenleistung_kw: Decimal,
956    /// Rate in EUR per kW.
957    pub preis_eur_per_kw: Decimal,
958}
959
960/// A Gas NNE Grundpreis — monthly rate and the months billed.
961#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
962pub struct Grundpreis {
963    /// Rate in EUR per month.
964    pub eur_per_month: Decimal,
965    /// Months in the billing period.
966    pub months: Decimal,
967}
968
969/// A Konzessionsabgabe — the rate together with the customer group it applies to.
970///
971/// Paired so the KAV §2 Höchstbetrag check can always run. They were independent
972/// `Option`s, and the ceiling check was skipped entirely when the group was
973/// absent — which is exactly when an over-charge is most likely to go unnoticed.
974#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
975pub struct Konzessionsabgabe {
976    /// Published rate in ct/kWh.
977    pub satz_ct_per_kwh: Decimal,
978    /// The KAV §2 customer group, which fixes the ceiling.
979    pub klasse: KaKundengruppe,
980}
981
982// ── SettlementPeriod ──────────────────────────────────────────────────────────
983
984/// The delivery period a settlement covers.
985///
986/// A validated pair rather than two loose dates. Every input struct previously
987/// carried `period_from` and `period_to` independently, and every calculation
988/// re-checked their ordering — five copies of the same guard, each able to be
989/// forgotten. Constructing this type is the check.
990///
991/// Both bounds are inclusive: a monthly period runs from the 1st to the last day
992/// of the month, matching how Netzentgelte are published and billed.
993#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
994pub struct SettlementPeriod {
995    from: time::Date,
996    to: time::Date,
997}
998
999impl SettlementPeriod {
1000    /// Build a period.
1001    ///
1002    /// # Errors
1003    ///
1004    /// Returns [`crate::error::BillingError::InvalidInput`] when `from` is after `to`. A
1005    /// zero-length period (`from == to`) is a valid single day.
1006    pub fn new(from: time::Date, to: time::Date) -> Result<Self, crate::error::BillingError> {
1007        if from > to {
1008            return Err(crate::error::BillingError::InvalidInput {
1009                reason: format!("period start {from} is after its end {to}"),
1010            });
1011        }
1012        Ok(Self { from, to })
1013    }
1014
1015    /// Start of the period, inclusive.
1016    #[must_use]
1017    pub const fn from(&self) -> time::Date {
1018        self.from
1019    }
1020
1021    /// End of the period, inclusive.
1022    #[must_use]
1023    pub const fn to(&self) -> time::Date {
1024        self.to
1025    }
1026
1027    /// Number of days covered, both bounds inclusive.
1028    #[must_use]
1029    pub fn days(&self) -> i64 {
1030        (self.to - self.from).whole_days() + 1
1031    }
1032}
1033
1034// ── SettlementResult ──────────────────────────────────────────────────────────
1035
1036/// What a settlement calculation produced.
1037///
1038/// This is the canonical output of every calculation in this crate. It answers
1039/// *what is owed and why*, and deliberately not *what the invoice looks like*:
1040/// invoice numbers, issue and due dates, Prüfidentifikatoren and position
1041/// numbering live on [`InvoiceDocument`], which an adapter builds around this.
1042///
1043/// The separation is what makes a settlement recomputable. The same period can
1044/// be settled twice — for a correction, a dispute, or an audit — and the two
1045/// results compared, without inventing a document each time.
1046///
1047/// ## Explainability
1048///
1049/// Every position carries a [`CalculationTrace`]; [`Self::all_legal_refs`]
1050/// collects the paragraphs the settlement rests on. `warnings` records what the
1051/// engine could not do, which is as much part of the result as the amounts.
1052#[derive(Debug, Clone, serde::Serialize)]
1053pub struct SettlementResult {
1054    /// What was settled.
1055    pub settlement_type: SettlementType,
1056    /// Where this settlement sits in the correction lifecycle.
1057    pub status: SettlementStatus,
1058    /// The delivery period.
1059    pub period: SettlementPeriod,
1060    /// The rules the calculation applied.
1061    pub regime: crate::regulatory::RegulatoryRegime,
1062    /// Commodity.
1063    pub sparte: Sparte,
1064    /// The metering location settled.
1065    pub malo_id: String,
1066    /// Sender MP-ID — Netzbetreiber, or MSB for a metering settlement.
1067    pub nb_mp_id: String,
1068    /// Recipient MP-ID — Lieferant, MSB, or MGV.
1069    pub counterparty_mp_id: String,
1070    /// The positions, in calculation order.
1071    pub positions: Vec<SettlementPosition>,
1072    /// Net total in EUR, rounded to 2 decimal places.
1073    pub total_eur: Decimal,
1074    /// What the engine could not do, or did with a caveat.
1075    pub warnings: Vec<SettlementWarning>,
1076}
1077
1078// ── InvoiceDocument ───────────────────────────────────────────────────────────
1079
1080/// A settlement presented as an invoice.
1081///
1082/// Everything here is a property of the document rather than of the calculation:
1083/// an invoice number, the dates it was issued and falls due, the
1084/// Prüfidentifikator that routes it, and the reference to whatever it corrects.
1085/// None of it affects what is owed.
1086///
1087/// Built by an adapter around a [`SettlementResult`]; the engine never produces
1088/// one, which is why the engine can be run without inventing an invoice number.
1089#[derive(Debug, Clone, serde::Serialize)]
1090pub struct InvoiceDocument {
1091    /// What the document presents.
1092    pub settlement: SettlementResult,
1093    /// BDEW Prüfidentifikator.
1094    pub pid: u32,
1095    /// Unique invoice reference.
1096    pub rechnungsnummer: String,
1097    /// The `rechnungsnummer` this corrects, if any.
1098    pub correction_of: Option<String>,
1099    /// Issue date.
1100    pub invoice_date: time::Date,
1101    /// Payment due date (Zahlungsziel, §271 BGB).
1102    pub due_date: time::Date,
1103}
1104
1105impl InvoiceDocument {
1106    /// Positions paired with their 1-based document numbers.
1107    ///
1108    /// Numbering is assigned here, at rendering time, rather than carried through
1109    /// the calculation as mutable state.
1110    pub fn numbered_positions(&self) -> impl Iterator<Item = (u32, &SettlementPosition)> {
1111        self.settlement
1112            .positions
1113            .iter()
1114            .enumerate()
1115            .map(|(i, p)| (u32::try_from(i + 1).unwrap_or(u32::MAX), p))
1116    }
1117}
1118
1119impl SettlementResult {
1120    /// Number of billing positions.
1121    #[must_use]
1122    pub fn positions_count(&self) -> usize {
1123        self.positions.len()
1124    }
1125
1126    /// `true` when the settlement has no warnings at `Warning` or `Error` severity.
1127    #[must_use]
1128    pub fn is_clean(&self) -> bool {
1129        !self
1130            .warnings
1131            .iter()
1132            .any(|w| w.severity >= WarningSeverity::Warning)
1133    }
1134
1135    /// All legal references cited across all positions (deduplicated by citation string).
1136    #[must_use]
1137    pub fn all_legal_refs(&self) -> Vec<String> {
1138        let mut seen = std::collections::HashSet::new();
1139        self.positions
1140            .iter()
1141            .flat_map(|p| p.trace.legal_refs.iter().map(|r| r.citation()))
1142            .filter(|c| seen.insert(c.clone()))
1143            .collect()
1144    }
1145
1146    /// Net total as computed from positions (re-summed for verification).
1147    ///
1148    /// Should equal `total_eur`. A mismatch indicates a calculation bug.
1149    #[must_use]
1150    pub fn recomputed_total(&self) -> Decimal {
1151        self.positions
1152            .iter()
1153            .map(|p| p.net_eur)
1154            .sum::<Decimal>()
1155            .round_dp(2)
1156    }
1157}
1158
1159// ── Input types ───────────────────────────────────────────────────────────────
1160
1161/// Input for NNE (Netznutzungsentgelt) invoice calculation.
1162///
1163/// Covers:
1164/// - **PID 31002** (NN-Rechnung) — NNE Strom and Gas (NB → LF, monthly network
1165///   usage billing). The Sparte is carried in the message content, not the PID.
1166///
1167/// For **RLM** (Leistungsmessung) meters:
1168/// - Set `spitzenleistung_kw` to the peak demand in kW.
1169/// - Set `leistungspreis_eur_per_kw` to the published tariff.
1170///
1171/// For **SLP** meters:
1172/// - Leave both fields as `None` (Arbeitspreisanteil only).
1173///
1174/// For **§14a Modul 2 time-variable NNE** (BNetzA BK6-22-300):
1175/// - Set `arbeitsmenge_ht_kwh` + `arbeitspreis_ht_ct_per_kwh` for Hochlast periods.
1176/// - Set `arbeitsmenge_nt_kwh` + `arbeitspreis_nt_ct_per_kwh` for Niedertarif periods.
1177/// - Leave `arbeitsmenge_kwh` / `arbeitspreis_ct_per_kwh` as the base fallback.
1178///
1179/// For Gas:
1180/// - The `arbeitsmenge_kwh` should already be converted from m³ using
1181///   `brennwert × zustandszahl` before being supplied here.
1182///   (edmd's `MeterBillingPeriod.arbeitsmenge_kwh` carries this converted value.)
1183#[derive(Debug, Clone)]
1184pub struct NneInput {
1185    /// 11-digit Marktlokations-ID.
1186    pub malo_id: String,
1187    /// Invoice sender — Netzbetreiber or Gasnetzbetreiber MP-ID.
1188    pub nb_mp_id: String,
1189    /// Invoice recipient — Lieferant MP-ID.
1190    pub lf_mp_id: String,
1191    /// The delivery period being settled.
1192    pub period: SettlementPeriod,
1193
1194    /// Letztverbrauchergruppe for the network levies (EnFG §§21 ff.).
1195    ///
1196    /// Determines which rate of the §19 StromNEV-, Offshore- and KWKG-Umlage
1197    /// applies at this Entnahmestelle.
1198    pub letztverbrauchergruppe: crate::umlagen::Letztverbrauchergruppe,
1199
1200    /// §19 StromNEV-Umlage in ct/kWh, overriding the tabled rate.
1201    ///
1202    /// `None` uses the statutory rate for the delivery year and group. Set it
1203    /// where an EnFG decision grants a rate the published schedule does not
1204    /// express.
1205    pub sect19_umlage_ct_per_kwh: Option<Decimal>,
1206    /// Offshore-Netzumlage in ct/kWh, overriding the tabled rate.
1207    pub offshore_umlage_ct_per_kwh: Option<Decimal>,
1208    /// KWKG-Umlage in ct/kWh, overriding the tabled rate.
1209    pub kwkg_umlage_ct_per_kwh: Option<Decimal>,
1210
1211    /// Optional tariff sheet identifier for audit tracing.
1212    ///
1213    /// When set, each position's `trace.tariff_source` references this sheet.
1214    pub tariff_sheet_id: Option<String>,
1215    /// Commodity — drives legal references (StromNEV vs GasNEV) and `SettlementType`.
1216    ///
1217    /// - `Sparte::Strom` (default) → `StromNEV §21` Arbeit, `StromNEV §17` Leistung,
1218    ///   `SettlementType::NneStrom`
1219    /// - `Sparte::Gas` → `GasNEV §14`, `SettlementType::NneGas`
1220    pub sparte: Sparte,
1221
1222    // ── §14a Modul 3 Spotpreis-NNE per-interval dispatch data ────────────────
1223    /// §14a Modul 3 (BNetzA BK6-22-300 Anlage 2 §3) per-dispatch-interval positions.
1224    ///
1225    /// Each entry represents one 15-min interval during which a spot-price-linked
1226    /// NNE rate applies. The caller fetches the EPEX Spot day-ahead price for each
1227    /// interval and applies the formula from `PreisblattNetznutzung.lastvariablePreispositionen`
1228    /// to derive `nne_rate_ct_per_kwh`. `grid-billing` receives pre-calculated rates —
1229    /// it never queries EPEX directly.
1230    ///
1231    /// **Empty (default)** when §14a Modul 3 does not apply to this MaLo.
1232    ///
1233    /// **Cannot be combined with `sect14a_modul1_reduction_factor`** — the validator
1234    /// returns `InvalidInput` when both are set.
1235    ///
1236    /// Each interval generates one `InvoicePosition` with
1237    /// `kind = NneArbeitModul3` and `lastvariable_preisposition_json` populated.
1238    #[doc = "§14a Modul 3 per-interval input data."]
1239    ///
1240    /// One value rather than twelve loose fields: the four shapes are mutually
1241    /// exclusive by construction.
1242    pub arbeitspreis: ArbeitspreisModell,
1243
1244    /// RLM demand charge — peak demand and its rate, or neither.
1245    pub leistungspreis: Option<Leistungspreis>,
1246
1247    /// Gas NNE Grundpreis. `None` for Strom, which has no separate Grundpreis.
1248    pub grundpreis: Option<Grundpreis>,
1249
1250    /// Konzessionsabgabe — rate and customer group together, so the KAV §2
1251    /// ceiling can always be checked.
1252    pub konzessionsabgabe: Option<Konzessionsabgabe>,
1253
1254    /// The Netzebene this metering point takes supply from.
1255    ///
1256    /// Netzentgelte are published per level, so the level is what makes a rate
1257    /// checkable against a price sheet. Recorded on the settlement and in the
1258    /// trace; it does not itself select a rate — this crate is given the rates.
1259    pub netzebene: Option<crate::netzebene::Netzebene>,
1260
1261    /// Annual peak demand in kW, where the metering point has one.
1262    ///
1263    /// Used with the annual energy to record the Benutzungsstundenzahl in the
1264    /// trace. This is the *annual* peak, which is not the same as the peak in
1265    /// the billing period — a monthly settlement carries the annual figure so
1266    /// the utilisation can be checked against the price sheet that priced it.
1267    pub jahreshoechstleistung_kw: Option<Decimal>,
1268
1269    /// Annual energy in kWh, where known.
1270    ///
1271    /// Pairs with `jahreshoechstleistung_kw` for the Benutzungsstundenzahl, and
1272    /// decides whether §17 Abs. 6 permits an Arbeitspreis-only tariff.
1273    pub jahresarbeit_kwh: Option<Decimal>,
1274
1275    /// An agreed §19 Abs. 2 StromNEV individual charge, where one exists.
1276    ///
1277    /// Applied as a reduction over the Arbeits- and Leistungspreis positions,
1278    /// with the statutory Mindestentgelt floor checked against the utilisation
1279    /// data above. The Konzessionsabgabe and the network levies are unaffected —
1280    /// the Netzbetreiber's lost revenue is compensated through the
1281    /// §19 StromNEV-Umlage, billed separately.
1282    pub sect19: Option<crate::sect19::Sect19Vereinbarung>,
1283
1284    /// A booked gas capacity, billed alongside the commodity charge.
1285    ///
1286    /// Gas only; §15 GasNEV. The annual rate is pro-rated over the settlement
1287    /// period by calendar days.
1288    pub gas_kapazitaet: Option<crate::gas::GasKapazitaet>,
1289}
1290
1291// ── Sect14aModul3Interval ─────────────────────────────────────────────────────
1292
1293/// One controlled dispatch interval for §14a Modul 3 (Spotpreis-Netzentgelt).
1294///
1295/// Each interval represents a 15-min period during which the DSO exercised load
1296/// control and the NNE rate is derived from the day-ahead spot price via the
1297/// formula published in `PreisblattNetznutzung.lastvariablePreispositionen`.
1298///
1299/// ## Calculation
1300///
1301/// `Einsatzkosten = menge_kwh × nne_rate_ct_per_kwh / 100`
1302///
1303/// The NB computes one `InvoicePosition` per interval, allowing the LF (and their
1304/// customers) to see the exact tariff breakdown for each dispatch event.
1305///
1306/// ## Caller responsibility
1307///
1308/// The caller (service layer) must:
1309/// 1. Fetch the EPEX Spot day-ahead price for each 15-min interval from `tarifbd`
1310///    or the `PreisblattNetznutzung` formula.
1311/// 2. Apply the formula from `lastvariablePreispositionen` to derive `nne_rate_ct_per_kwh`.
1312/// 3. Fetch `menge_kwh` from `edmd Lastgang` for the interval.
1313///
1314/// `grid-billing` receives pre-calculated rates — it does NOT query EPEX or `edmd`.
1315///
1316/// ## Regulatory basis
1317///
1318/// BNetzA BK6-22-300 Anlage 2 §3 — Modul 3: Spotpreis-Netzentgelt.
1319/// The NNE varies per 15-min interval based on the spot market price.
1320/// All controllable loads ≥ 3.7 kW registered under §14a must have Modul 1 at minimum;
1321/// Modul 3 is the opt-in premium variant (lower NNE when spot prices are low).
1322#[derive(Debug, Clone, PartialEq, serde::Serialize)]
1323pub struct Sect14aModul3Interval {
1324    /// UTC start of this controlled dispatch interval (ISO-8601).
1325    ///
1326    /// Typically the start of a 15-min settlement slot.
1327    pub period_from: time::OffsetDateTime,
1328    /// UTC end of this controlled dispatch interval (ISO-8601).
1329    ///
1330    /// Typically `period_from + 15 min`.
1331    pub period_to: time::OffsetDateTime,
1332    /// Energy consumption (or reduction) during this interval in kWh.
1333    ///
1334    /// Sourced from `edmd Lastgang` for the MaLo during the interval window.
1335    pub menge_kwh: Decimal,
1336    /// Effective NNE rate in **ct/kWh** for this interval.
1337    ///
1338    /// Derived from the `LastvariablePreisposition` formula applied to the
1339    /// applicable EPEX Spot day-ahead price. Pre-calculated by the caller.
1340    pub nne_rate_ct_per_kwh: Decimal,
1341    /// EPEX Spot day-ahead price in ct/kWh used to derive `nne_rate_ct_per_kwh`.
1342    ///
1343    /// Stored in the `CalculationTrace.explanation` for audit transparency.
1344    /// `None` when the rate was determined by a fixed formula without market reference.
1345    pub epex_spot_ct_per_kwh: Option<Decimal>,
1346}
1347
1348// ── MmmInput ──────────────────────────────────────────────────────────────────
1349
1350/// Input for Mehr-/Mindermengen (MMM) settlement invoice calculation.
1351///
1352/// Covers:
1353/// - **PID 31005** — MMM-Rechnung used for Mehr-/Mindermengen settlement between
1354///   NB and LF (Strom and Gas).
1355///
1356/// Mehr-/Mindermengen settle the difference between the LF's forecast profile
1357/// (SLP standard load profile) and the actual measured consumption.
1358///
1359/// - **Mehrmengen** (positive deviation): actual > profil → LF owes NB
1360/// - **Mindermengen** (negative deviation): actual < profil → NB owes LF
1361///
1362/// The settlement amount is the algebraic sum of both positions.  It can be
1363/// negative (i.e. a credit note from NB to LF) when Mindermengen dominate.
1364#[derive(Debug, Clone)]
1365pub struct MmmInput {
1366    /// 11-digit Marktlokations-ID.
1367    pub malo_id: String,
1368    /// Invoice sender — Netzbetreiber MP-ID.
1369    pub nb_mp_id: String,
1370    /// Invoice recipient — Lieferant MP-ID.
1371    pub lf_mp_id: String,
1372    /// The delivery period being settled.
1373    pub period: SettlementPeriod,
1374    /// Commodity — determines which Festlegung the legal references cite.
1375    ///
1376    /// - `Sparte::Strom` → `GPKE (BK6-24-174) Teil 1 Kap. 8.4`, `GPKE BK6-22-024`
1377    /// - `Sparte::Gas` → `GaBi Gas 2.1 (BK7-24-01-008)`, `GeLi Gas 3.0 (BK7-24-01-009)`
1378    pub sparte: Sparte,
1379    /// Actual measured consumption in kWh (from MSCONS / `MeterBillingPeriod`).
1380    pub actual_kwh: Decimal,
1381    /// Standard load profile (SLP) forecast consumption in kWh.
1382    pub profil_kwh: Decimal,
1383    /// Mehrmengen price in **ct/kWh** (from `PreisblattNetznutzung` MMM position).
1384    pub mehr_preis_ct_per_kwh: Decimal,
1385    /// Mindermengen price in **ct/kWh** (from `PreisblattNetznutzung` MMM position).
1386    pub minder_preis_ct_per_kwh: Decimal,
1387}
1388
1389// ── MsbInput ──────────────────────────────────────────────────────────────────
1390
1391/// Input for MSB (Messstellenbetreiber) invoice calculation.
1392///
1393/// Covers:
1394/// - **PID 31009** — MSB-Rechnung (NB → MSB, monthly metering service settlement)
1395///
1396/// The NB bills the MSB for the metering service period.  Positions:
1397/// 1. Grundgebühr Messstellenbetrieb — flat monthly base fee × billing months.
1398/// 2. Messdienstleistung — optional per-period measurement service fee.
1399#[derive(Debug, Clone)]
1400pub struct MsbInput {
1401    /// 11-digit Marktlokations-ID.
1402    pub malo_id: String,
1403    /// Invoice sender — Netzbetreiber MP-ID.
1404    pub nb_mp_id: String,
1405    /// Invoice recipient — Messstellenbetreiber MP-ID.
1406    pub msb_mp_id: String,
1407    /// The delivery period being settled.
1408    pub period: SettlementPeriod,
1409    /// Grundgebühr Messstellenbetrieb in **EUR/month** (from `PreisblattMessung`).
1410    pub grundgebuehr_eur_per_month: Decimal,
1411    /// Number of full calendar months in the billing period.
1412    pub billing_months: u32,
1413    /// Optional Messdienstleistung flat fee in **EUR** for the full period.
1414    ///
1415    /// `None` when the MSB provides only the meter, not a separate measurement service.
1416    pub messdienstleistung_eur: Option<Decimal>,
1417
1418    /// Which §30 MsbG case this metering point falls under.
1419    ///
1420    /// Fixes the Preisobergrenze the charge is checked against. `None` skips the
1421    /// check, which should be rare: a metering charge above the POG is an amount
1422    /// the customer is entitled to have refunded.
1423    pub messstellen_kategorie: Option<crate::msbg::MessstellenKategorie>,
1424
1425    /// Whose share of the metering charge this settlement bills.
1426    ///
1427    /// §30 MsbG splits the ceiling between the Netzbetreiber and the
1428    /// Letztverbraucher, so the applicable cap depends on who is being billed.
1429    pub entgeltschuldner: Option<crate::msbg::Entgeltschuldner>,
1430}
1431
1432// ── GasAwhInput ───────────────────────────────────────────────────────────────
1433
1434/// Input for GeLi Gas AWH Sperrprozesse settlement (PID 31011).
1435///
1436/// **PID 31011 — Rechnung sonstige Leistung (NB → LF)**
1437///
1438/// Bills the Lieferant (LFG/LFA) for abrechnungswürdige Handlungen (AWH)
1439/// performed by the GNB/VNB during the Sperrung/Entsperrung process.
1440/// Governed by BK7-24-01-009 §5.4 (GeLi Gas 3.0).
1441///
1442/// ## What counts as AWH
1443///
1444/// AWH are chargeable actions not included in the network tariff, triggered by
1445/// the LF through the Sperrung process. Typical AWH:
1446/// - `Sperrung` (disconnection)
1447/// - `Entsperrung` (reconnection)
1448/// - `Teilsperrung` (partial disconnection)
1449/// - `Unterbrechung Verfahren` (process interruption)
1450///
1451/// Each action type has a fixed price published in the `PreisblattNetznutzung`.
1452#[derive(Debug, Clone)]
1453pub struct GasAwhInput {
1454    /// 11-digit Marktlokations-ID.
1455    pub malo_id: String,
1456    /// Invoice sender — Gasnetzbetreiber (GNB/VNB) MP-ID.
1457    pub nb_mp_id: String,
1458    /// Invoice recipient — Lieferant Gas (LFG or LFA) MP-ID.
1459    pub lf_mp_id: String,
1460    /// The delivery period being settled.
1461    pub period: SettlementPeriod,
1462    /// Optional tariff sheet identifier for audit tracing.
1463    pub tariff_sheet_id: Option<String>,
1464    /// AWH line items: each chargeable action with count and unit price.
1465    ///
1466    /// At least one position is required.
1467    pub awh_positionen: Vec<AwhPositionInput>,
1468}
1469
1470/// One AWH action line item for [`GasAwhInput`].
1471///
1472/// ## Examples
1473///
1474/// ```rust
1475/// # use grid_billing::AwhPositionInput;
1476/// # use rust_decimal::dec;
1477/// let sperrung = AwhPositionInput {
1478///     beschreibung: "Sperrung Gaszähler".to_owned(),
1479///     anzahl: 1,
1480///     preis_eur: dec!(45.00),
1481///     artikel_id: Some("2-01-7-001".to_owned()),
1482/// };
1483/// ```
1484#[derive(Debug, Clone)]
1485pub struct AwhPositionInput {
1486    /// Human-readable action description, e.g. `"Sperrung Gaszähler"`.
1487    pub beschreibung: String,
1488    /// Number of executions of this action.
1489    pub anzahl: u32,
1490    /// Price per execution in **EUR** (from `PreisblattNetznutzung`).
1491    pub preis_eur: Decimal,
1492    /// BDEW Artikel-ID from section 3.2 of the Codeliste Artikelnummern v5.6.
1493    ///
1494    /// Standard values for Gas AWH Sperrprozesse (BK7-24-01-009 §5.4):
1495    /// - `"2-01-7-001"` — Unterbrechung der Anschlussnutzung (reguläre AZ)
1496    /// - `"2-01-7-002"` — Wiederherstellung der Anschlussnutzung (reguläre AZ)
1497    /// - `"2-01-7-003"` — Erfolglose Unterbrechung
1498    /// - `"2-01-7-004"` — Stornierung Unterbrechungsauftrag (bis Vortag)
1499    /// - `"2-01-7-005"` — Stornierung Unterbrechungsauftrag (am Sperrtag)
1500    /// - `"2-01-7-006"` — Wiederherstellung außerhalb regulärer AZ
1501    ///
1502    /// `None` for custom / non-standard AWH positions.
1503    pub artikel_id: Option<String>,
1504}
1505
1506// ── ValidationResult ─────────────────────────────────────────────────────────
1507
1508/// Result of pre-calculation input validation.
1509///
1510/// For NNE there is no separate validator: the invariants that mattered are
1511/// either unrepresentable — an inverted [`SettlementPeriod`], a half-set
1512/// [`Leistungspreis`], two §14a modules at once — or enforced inside
1513/// [`crate::settle_nne`] itself. A validator the engine did not call was how a
1514/// caller who skipped it got billed on the wrong basis with no error.
1515///
1516/// [`validate_mmm_input`], [`validate_msb_input`] and [`validate_gas_awh_input`]
1517/// remain for inputs whose engines accept looser shapes.
1518#[derive(Debug, Clone)]
1519pub struct ValidationResult {
1520    /// Whether the input passed all validation checks.
1521    pub is_valid: bool,
1522    /// All warnings and errors found. May contain [`WarningSeverity::Info`] items
1523    /// even when `is_valid = true`.
1524    pub warnings: Vec<SettlementWarning>,
1525}
1526
1527impl ValidationResult {
1528    /// Returns a clean (valid, no warnings) result.
1529    #[must_use]
1530    pub fn ok() -> Self {
1531        Self {
1532            is_valid: true,
1533            warnings: Vec::new(),
1534        }
1535    }
1536
1537    /// Appends a warning. `WarningSeverity::Error` marks the result invalid.
1538    pub fn push(&mut self, w: SettlementWarning) {
1539        if w.severity == WarningSeverity::Error {
1540            self.is_valid = false;
1541        }
1542        self.warnings.push(w);
1543    }
1544}
1545
1546/// Validate a [`MmmInput`] before calling [`crate::settle_mmm`].
1547#[must_use]
1548pub fn validate_mmm_input(input: &MmmInput) -> ValidationResult {
1549    let mut r = ValidationResult::ok();
1550    if input.period.from() >= input.period.to() {
1551        r.push(SettlementWarning {
1552            severity: WarningSeverity::Error,
1553            code: "INVALID_PERIOD",
1554            message: "period_from must be strictly before period_to".to_owned(),
1555        });
1556    }
1557    if input.mehr_preis_ct_per_kwh < Decimal::ZERO {
1558        r.push(SettlementWarning {
1559            severity: WarningSeverity::Warning,
1560            code: "NEGATIVE_MEHR_PREIS",
1561            message: format!(
1562                "mehr_preis_ct_per_kwh is negative: {}",
1563                input.mehr_preis_ct_per_kwh
1564            ),
1565        });
1566    }
1567    if input.minder_preis_ct_per_kwh < Decimal::ZERO {
1568        r.push(SettlementWarning {
1569            severity: WarningSeverity::Warning,
1570            code: "NEGATIVE_MINDER_PREIS",
1571            message: format!(
1572                "minder_preis_ct_per_kwh is negative: {}",
1573                input.minder_preis_ct_per_kwh
1574            ),
1575        });
1576    }
1577    r
1578}
1579
1580/// Validate a [`MsbInput`] before calling [`crate::settle_msb`].
1581#[must_use]
1582pub fn validate_msb_input(input: &MsbInput) -> ValidationResult {
1583    let mut r = ValidationResult::ok();
1584    if input.period.from() >= input.period.to() {
1585        r.push(SettlementWarning {
1586            severity: WarningSeverity::Error,
1587            code: "INVALID_PERIOD",
1588            message: "period_from must be strictly before period_to".to_owned(),
1589        });
1590    }
1591    if input.grundgebuehr_eur_per_month < Decimal::ZERO {
1592        r.push(SettlementWarning {
1593            severity: WarningSeverity::Error,
1594            code: "NEGATIVE_GRUNDGEBUEHR",
1595            message: format!(
1596                "grundgebuehr_eur_per_month is negative: {}",
1597                input.grundgebuehr_eur_per_month
1598            ),
1599        });
1600    }
1601    if input.billing_months == 0 {
1602        r.push(SettlementWarning {
1603            severity: WarningSeverity::Error,
1604            code: "ZERO_BILLING_MONTHS",
1605            message: "billing_months must be at least 1".to_owned(),
1606        });
1607    }
1608    r
1609}
1610
1611/// Validate a [`GasAwhInput`] before calling [`crate::settle_gas_awh`].
1612///
1613/// Checks that:
1614/// - `period_from < period_to`
1615/// - `awh_positionen` is non-empty
1616/// - All positions have `anzahl ≥ 1` and `preis_eur ≥ 0`
1617#[must_use]
1618pub fn validate_gas_awh_input(input: &GasAwhInput) -> ValidationResult {
1619    let mut r = ValidationResult::ok();
1620    if input.period.from() >= input.period.to() {
1621        r.push(SettlementWarning {
1622            severity: WarningSeverity::Error,
1623            code: "INVALID_PERIOD",
1624            message: "period_from must be strictly before period_to".to_owned(),
1625        });
1626    }
1627    if input.awh_positionen.is_empty() {
1628        r.push(SettlementWarning {
1629            severity: WarningSeverity::Error,
1630            code: "EMPTY_AWH_POSITIONEN",
1631            message: "awh_positionen must contain at least one position".to_owned(),
1632        });
1633    }
1634    for (i, awh) in input.awh_positionen.iter().enumerate() {
1635        if awh.anzahl == 0 {
1636            r.push(SettlementWarning {
1637                severity: WarningSeverity::Error,
1638                code: "ZERO_AWH_ANZAHL",
1639                message: format!("awh_positionen[{i}].anzahl must be ≥ 1"),
1640            });
1641        }
1642        if awh.preis_eur < Decimal::ZERO {
1643            r.push(SettlementWarning {
1644                severity: WarningSeverity::Error,
1645                code: "NEGATIVE_AWH_PREIS",
1646                message: format!(
1647                    "awh_positionen[{i}].preis_eur must be non-negative, got {}",
1648                    awh.preis_eur
1649                ),
1650            });
1651        }
1652    }
1653    r
1654}
1655
1656#[cfg(test)]
1657mod input_model_tests {
1658    use super::*;
1659    use rust_decimal::dec;
1660
1661    /// A reduction factor outside `(0, 1]` cannot be built.
1662    ///
1663    /// It used to be a bare `Decimal`, range-checked in a validator the engine
1664    /// did not call — so `settle_nne` would happily multiply the published
1665    /// tariff by 5.
1666    #[test]
1667    fn a_reduction_factor_must_actually_reduce() {
1668        assert!(Reduktionsfaktor::new(dec!(0.85)).is_ok());
1669        assert!(
1670            Reduktionsfaktor::new(dec!(1)).is_ok(),
1671            "no reduction is still valid"
1672        );
1673        assert!(
1674            Reduktionsfaktor::new(dec!(0)).is_err(),
1675            "zero is not a reduction"
1676        );
1677        assert!(Reduktionsfaktor::new(dec!(-0.5)).is_err());
1678        assert!(
1679            Reduktionsfaktor::new(dec!(5)).is_err(),
1680            "5x is not a reduction"
1681        );
1682        assert_eq!(Reduktionsfaktor::REGELFALL.get(), dec!(0.85));
1683    }
1684
1685    /// The charged energy is the same figure whichever model priced it.
1686    ///
1687    /// The Konzessionsabgabe and the three network levies are charged on it, and
1688    /// each used to recompute the base with its own `if has_tou` branch.
1689    #[test]
1690    fn every_model_reports_the_energy_it_priced() {
1691        let flat = ArbeitspreisModell::Einheitlich(MengePreis {
1692            menge_kwh: dec!(1000),
1693            preis_ct_per_kwh: dec!(3.5),
1694        });
1695        assert_eq!(flat.menge_kwh(), dec!(1000));
1696
1697        let tou = ArbeitspreisModell::Modul2ZeitVariabel {
1698            ht: MengePreis {
1699                menge_kwh: dec!(600),
1700                preis_ct_per_kwh: dec!(4.0),
1701            },
1702            nt: MengePreis {
1703                menge_kwh: dec!(400),
1704                preis_ct_per_kwh: dec!(1.5),
1705            },
1706        };
1707        assert_eq!(tou.menge_kwh(), dec!(1000), "HT + NT, not one of them");
1708
1709        let modul1 = ArbeitspreisModell::Modul1Pauschal {
1710            basis: MengePreis {
1711                menge_kwh: dec!(1000),
1712                preis_ct_per_kwh: dec!(3.5),
1713            },
1714            reduktion: Reduktionsfaktor::REGELFALL,
1715        };
1716        assert_eq!(
1717            modul1.menge_kwh(),
1718            dec!(1000),
1719            "the reduction changes the rate, not the energy"
1720        );
1721    }
1722
1723    /// Each model names its §14a module, and only one can be in play.
1724    #[test]
1725    fn a_model_carries_at_most_one_sect14a_module() {
1726        use Sect14aModule as M;
1727        let cases = [
1728            (
1729                ArbeitspreisModell::Einheitlich(MengePreis {
1730                    menge_kwh: dec!(1),
1731                    preis_ct_per_kwh: dec!(1),
1732                }),
1733                None,
1734            ),
1735            (
1736                ArbeitspreisModell::Modul1Pauschal {
1737                    basis: MengePreis {
1738                        menge_kwh: dec!(1),
1739                        preis_ct_per_kwh: dec!(1),
1740                    },
1741                    reduktion: Reduktionsfaktor::REGELFALL,
1742                },
1743                Some(M::Modul1),
1744            ),
1745            (
1746                ArbeitspreisModell::Modul2ZeitVariabel {
1747                    ht: MengePreis {
1748                        menge_kwh: dec!(1),
1749                        preis_ct_per_kwh: dec!(1),
1750                    },
1751                    nt: MengePreis {
1752                        menge_kwh: dec!(1),
1753                        preis_ct_per_kwh: dec!(1),
1754                    },
1755                },
1756                Some(M::Modul2),
1757            ),
1758            (
1759                ArbeitspreisModell::Modul3Spotpreis { intervalle: vec![] },
1760                Some(M::Modul3),
1761            ),
1762        ];
1763        for (model, expected) in cases {
1764            assert_eq!(model.sect14a_modul(), expected);
1765        }
1766    }
1767
1768    /// A period is ordered by construction; a single day is valid.
1769    #[test]
1770    fn a_period_cannot_be_inverted() {
1771        use time::macros::date;
1772        assert!(SettlementPeriod::new(date!(2026 - 01 - 31), date!(2026 - 01 - 01)).is_err());
1773        let one_day = SettlementPeriod::new(date!(2026 - 01 - 01), date!(2026 - 01 - 01))
1774            .expect("a single day is a period");
1775        assert_eq!(one_day.days(), 1);
1776        let january =
1777            SettlementPeriod::new(date!(2026 - 01 - 01), date!(2026 - 01 - 31)).expect("valid");
1778        assert_eq!(january.days(), 31, "both bounds are inclusive");
1779    }
1780}