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