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 → GridSettlement → Invoice Adapter → BO4E → EDIFACT
9//! ```
10//!
11//! [`GridSettlement`] is the canonical output. It carries every billing 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 `GridSettlement` 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`, `StromNZV`, BK6 decisions
33/// - `Gas` → `GasNEV`, `GasNZV`, BK7 decisions
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub enum Sparte {
36    /// Electricity (Strom). Default.
37    #[default]
38    Strom,
39    /// Natural gas (Gas).
40    Gas,
41}
42
43// ── KaKlasse ──────────────────────────────────────────────────────────────────
44
45/// KAV §2 concession fee rate class.
46///
47/// Different annual consumption bands attract different KAV rates per
48/// KAV §2 Abs. 2. Providing the class makes each position's audit trace
49/// self-explanatory: auditors can verify the rate matches the class.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum KaKlasse {
52    /// Tarifkunde ≤ 25 MWh/a — residential (highest rate tier).
53    TarifkundeLow,
54    /// Tarifkunde > 25 MWh/a and ≤ 150 MWh/a — commercial.
55    TarifkundeMedium,
56    /// Sonderkunde / Industriekunde > 150 MWh/a.
57    SonderkundeHigh,
58    /// Exempt from KA (hospitals, water utilities, §2 Abs. 7 KAV).
59    Exempt,
60}
61
62// ── QuantityUnit ──────────────────────────────────────────────────────────────
63
64/// Unit of measure for a settlement position quantity.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum QuantityUnit {
67    /// Kilowatt-hours (active energy).
68    Kwh,
69    /// Kilowatts (demand / peak load).
70    Kw,
71    /// Reactive energy (Blindarbeit) — kilovolt-ampere reactive hours.
72    ///
73    /// Used for reactive energy settlement positions per StromNEV §18.
74    Kvarh,
75    /// Reactive power (Blindleistung) — kilovolt-ampere reactive.
76    Kvar,
77    /// Calendar months.
78    Monat,
79}
80
81// ── Sect14aModule ─────────────────────────────────────────────────────────────
82
83/// §14a EnWG module for steuerbare Verbrauchseinrichtungen (controllable loads).
84///
85/// Source: BNetzA BK6-22-300 (Beschluss 27.11.2023, in force 01.01.2024).
86///
87/// All three modules are **mandatory** for eligible controllable loads (heat pumps,
88/// EV chargers, battery storage ≥ 4.2 kW) registered with the NB. The LF/NB
89/// must offer at least Modul 1 to all eligible customers.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum Sect14aModule {
92    /// Modul 1 — pauschale Reduzierung (flat reduction).
93    ///
94    /// The NB applies a fixed percentage reduction to the Arbeitspreis (or
95    /// Arbeitspreis + Leistungspreis) for the entire billing period.
96    /// Reduction factor = 85 % (i.e. customer pays 85 % of full rate) per BK6-22-300
97    /// Anlage 2. The NB may set a different approved rate in their tariff sheet.
98    ///
99    /// Equivalent UTILTS segment: `CCI+ZG6 CAV+Z28:::0.85` (multiplier).
100    Modul1,
101    /// Modul 2 — variable Netzentgelte (time-variable, HT/NT split).
102    ///
103    /// Two Arbeitspreis tiers: Hochlast (HT, higher price) and Niedertarif (NT,
104    /// lower price). Periods are defined in the UTILTS Zählzeitdefinition published
105    /// by the NB. Required for iMSys meters with quarter-hour metering.
106    Modul2,
107    /// Modul 3 — Spotpreis-Netzentgelt (dynamic, spot-price linked).
108    ///
109    /// NNE follows the intraday or day-ahead electricity spot price. The calculation
110    /// basis is the `PreisblattNetznutzung.spotpreisNetzentgelt` formula defined by
111    /// the NB. Requires smart meter (iMSys) with 15-min resolution.
112    ///
113    /// Note: Modul 3 rates are not yet calculable from static inputs alone —
114    /// populate `regulatory_reduction_factor` in the trace with the effective
115    /// period-average rate when using this module.
116    Modul3,
117}
118
119impl Sect14aModule {
120    /// Canonical BNetzA decision reference for this module.
121    #[must_use]
122    pub fn bnentza_reference(self) -> &'static str {
123        "BK6-22-300"
124    }
125
126    /// Display label for the module.
127    #[must_use]
128    pub fn label(self) -> &'static str {
129        match self {
130            Self::Modul1 => "§14a EnWG Modul 1 (pauschale Reduzierung)",
131            Self::Modul2 => "§14a EnWG Modul 2 (HT/NT variable)",
132            Self::Modul3 => "§14a EnWG Modul 3 (Spotpreis)",
133        }
134    }
135}
136
137// ── SettlementType ────────────────────────────────────────────────────────────
138
139/// Which regulated settlement process produced this result.
140///
141/// Determines which BDEW PIDs are applicable and which regulatory references apply.
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub enum SettlementType {
144    /// Netznutzungsentgelt (NNE) Strom — PID 31001 (NB → LF).
145    NneStrom,
146    /// Netznutzungsentgelt (NNE) Gas — PID 31005 (NB → LF, GasNEV).
147    NneGas,
148    /// NNE selbst ausgestellt (NB + LF = same entity) — PID 31006.
149    NneSelbstausstellt,
150    /// Mehr-/Mindermengen settlement Strom — PID 31002 (NB → LF, StromNZV §15).
151    MmmStrom,
152    /// Mehr-/Mindermengen settlement Gas — PID 31002 (NB → LF, GasNZV §14).
153    ///
154    /// Gas MMM settlement uses different legal references from Strom MMM:
155    /// `GasNZV §14` and `GeLi Gas BK7-24-01-009`. Using a separate variant
156    /// ensures correct audit traces without conditional logic in call sites.
157    MmmGas,
158    /// Messstellenbetrieb settlement — PID 31009 (NB → MSB).
159    MsbRechnung,
160    /// GaBi Gas AWH Sperrprozesse settlement — PID 31011 (NB → LF, BK7-24-01-009 §5.4).
161    ///
162    /// Rechnung sonstige Leistung: bills the LF (LFG/LFA) for abrechnungswürdige
163    /// Handlungen (AWH) performed by the GNB/VNB during Sperrung/Entsperrung.
164    GasAwhSperrung,
165    /// Redispatch 2.0 Einsatzkosten (NB → ÜNB, BK6-20-061).
166    RedispatchKostenblatt,
167}
168
169impl SettlementType {
170    /// Default BDEW PID for this settlement type.
171    ///
172    /// Callers may override the PID after construction if needed.
173    #[must_use]
174    pub fn default_pid(self) -> u32 {
175        match self {
176            Self::NneStrom => 31001,
177            Self::NneGas => 31005,
178            Self::NneSelbstausstellt => 31006,
179            Self::MmmStrom => 31002,
180            Self::MmmGas => 31002,
181            Self::MsbRechnung => 31009,
182            Self::GasAwhSperrung => 31011,
183            Self::RedispatchKostenblatt => 0, // no standard PID
184        }
185    }
186}
187
188// ── SettlementStatus ──────────────────────────────────────────────────────────
189
190/// Lifecycle status of a settlement result.
191///
192/// Settlements are never destroyed — every correction or cancellation creates
193/// a new result that references the original. This ensures an immutable audit trail.
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
195pub enum SettlementStatus {
196    /// Initial calculation — no prior settlement exists for this period.
197    Initial,
198    /// Correction of a prior settlement (references `correction_of`).
199    Correction,
200    /// Cancellation of a prior settlement — all positions are negated.
201    Reversal,
202    /// Final settlement — no further corrections expected.
203    Final,
204}
205
206// ── LegalReference ────────────────────────────────────────────────────────────
207
208/// Regulatory citation that justifies a billing position or rate.
209///
210/// Every [`InvoicePosition`] should carry at least one `LegalReference`.
211/// This enables full auditability: any operator or regulator can trace
212/// exactly which paragraph, ruling, and version authorised each charge.
213#[derive(Debug, Clone, PartialEq, Eq)]
214pub enum LegalReference {
215    /// StromNEV — Stromnetzentgeltverordnung (grid usage charges, Strom).
216    ///
217    /// Example: `StromNev { paragraph: "§17" }` for Leistungspreise.
218    StromNev {
219        /// Paragraph reference, e.g. `"§17"`, `"§21"`.
220        paragraph: &'static str,
221    },
222    /// GasNEV — Gasnetzentgeltverordnung (grid usage charges, Gas).
223    GasNev {
224        /// Paragraph reference, e.g. `"§14"`.
225        paragraph: &'static str,
226    },
227    /// KAV — Konzessionsabgabenverordnung (municipal concession fee).
228    ///
229    /// Example: `Kav { paragraph: "§2 Abs. 2" }`.
230    Kav {
231        /// Paragraph reference, e.g. `"§2 Abs. 2"`.
232        paragraph: &'static str,
233    },
234    /// §14a EnWG — Steuerbare Verbrauchseinrichtungen (controllable loads).
235    ///
236    /// Governs time-variable (ToU) NNE for heat pumps, EV chargers, etc.
237    Sect14aEnwg {
238        /// Module: Modul1 (flat reduction), Modul2 (HT/NT), or Modul3 (spot).
239        module: Sect14aModule,
240    },
241    /// MessZV — Messzugangsverordnung (metering access).
242    MessZv {
243        /// Paragraph citation, e.g. `"§2"`, `"§17"`.
244        paragraph: &'static str,
245    },
246    /// MsbG — Messstellenbetriebsgesetz (metering point operation).
247    MsbG {
248        /// Paragraph citation, e.g. `"§§6–7"`.
249        paragraph: &'static str,
250    },
251    /// BNetzA decision (Beschluss).
252    ///
253    /// Example: `BnetzaDecision { reference: "BK6-22-300" }`.
254    BnetzaDecision {
255        /// Decision reference, e.g. `"BK6-22-300"`, `"BK6-24-174"`.
256        reference: &'static str,
257    },
258    /// BDEW application handbook (Anwendungshandbuch).
259    BdewAhb {
260        /// AHB reference, e.g. `"GPKE BK6-22-024"`.
261        reference: &'static str,
262    },
263    /// StromNZV — Stromnetzzugangsverordnung (grid access, Strom).
264    StromNzv {
265        /// Paragraph citation, e.g. `"§15"`.
266        paragraph: &'static str,
267    },
268    /// GasNZV — Gasnetzzugangsverordnung (grid access, Gas).
269    GasNzv {
270        /// Paragraph citation, e.g. `"§15"`.
271        paragraph: &'static str,
272    },
273    /// EnWG — Energiewirtschaftsgesetz (general energy law).
274    Enwg {
275        /// Paragraph citation, e.g. `"§14a"`.
276        paragraph: &'static str,
277    },
278    /// ARegV — Anreizregulierungsverordnung (incentive regulation).
279    ///
280    /// ARegV §§17–21 define the allowed NNE revenue caps and efficiency targets.
281    /// Relevant when documenting why a specific regulated tariff level was approved.
282    ARegV {
283        /// Paragraph citation, e.g. `"§17"`, `"§21"`.
284        paragraph: &'static str,
285    },
286}
287
288impl LegalReference {
289    /// Short human-readable citation string (German).
290    #[must_use]
291    pub fn citation(&self) -> String {
292        match self {
293            Self::StromNev { paragraph } => format!("StromNEV {paragraph}"),
294            Self::GasNev { paragraph } => format!("GasNEV {paragraph}"),
295            Self::Kav { paragraph } => format!("KAV {paragraph}"),
296            Self::Sect14aEnwg { module } => format!("§14a EnWG {}", module.label()),
297            Self::MessZv { paragraph } => format!("MessZV {paragraph}"),
298            Self::MsbG { paragraph } => format!("MsbG {paragraph}"),
299            Self::BnetzaDecision { reference } => format!("BNetzA {reference}"),
300            Self::BdewAhb { reference } => format!("BDEW {reference}"),
301            Self::StromNzv { paragraph } => format!("StromNZV {paragraph}"),
302            Self::GasNzv { paragraph } => format!("GasNZV {paragraph}"),
303            Self::Enwg { paragraph } => format!("EnWG {paragraph}"),
304            Self::ARegV { paragraph } => format!("ARegV {paragraph}"),
305        }
306    }
307}
308
309// ── TariffSource ──────────────────────────────────────────────────────────────
310
311/// Origin of the tariff rate applied in a settlement position.
312///
313/// Every rate used in a billing position must be traceable to a `TariffSource`.
314/// This enables operators and auditors to answer: *"Why was this rate used?"*
315#[derive(Debug, Clone, PartialEq, Eq)]
316pub enum TariffSource {
317    /// Rate from the published and approved `PreisblattNetznutzung` tariff sheet.
318    PublishedTariffSheet {
319        /// Tariff sheet identifier or version, e.g. `"Preisblatt 2025 Q1"`.
320        sheet_id: String,
321    },
322    /// Rate from a historical tariff (retroactive billing or correction).
323    HistoricalTariff {
324        /// Original valid_from date of the tariff.
325        valid_from: time::Date,
326    },
327    /// Regulatory rate mandated by a BNetzA decision.
328    RegulatoryTariff {
329        /// BNetzA decision reference.
330        decision_ref: &'static str,
331    },
332    /// Contract-specific rate negotiated between NB and customer.
333    ContractTariff {
334        /// Contract reference.
335        contract_ref: String,
336    },
337    /// Manual override by operator (requires documentation).
338    ManualOverride {
339        /// Reason for the override.
340        reason: String,
341    },
342}
343
344// ── CalculationTrace ──────────────────────────────────────────────────────────
345
346/// Full audit record for how one [`InvoicePosition`] was computed.
347///
348/// Answers the question: *"Why is this amount on the invoice?"*
349///
350/// Every `CalculationTrace` carries the input values, the applied legal rules,
351/// intermediate results, and the tariff source. This enables:
352/// - Regulator audits (BNetzA §20 EnWG)
353/// - Operator review
354/// - LF dispute resolution
355/// - AI-assisted invoice explainability (MCP tools)
356#[derive(Debug, Clone)]
357pub struct CalculationTrace {
358    /// Human-readable explanation of this position.
359    ///
360    /// Example: `"Arbeit 1500 kWh × 3.5 ct/kWh = 52.50 EUR"`
361    pub explanation: String,
362    /// Input quantity used (before rounding).
363    pub input_quantity: Decimal,
364    /// Input unit price in EUR (before rounding, already converted from ct).
365    pub input_unit_price_eur: Decimal,
366    /// Intermediate result before rounding (qty × price).
367    pub gross_eur: Decimal,
368    /// Applied legal references (at least one required).
369    pub legal_refs: Vec<LegalReference>,
370    /// Source of the tariff rate.
371    pub tariff_source: Option<TariffSource>,
372    /// Any §14a reductions applied, expressed as a fraction (0.0–1.0).
373    ///
374    /// `None` when no regulatory reduction applies.
375    /// Example: `Some(Decimal::new(85, 2))` = 85% of full rate (15% reduction).
376    pub regulatory_reduction_factor: Option<Decimal>,
377    /// Notes on rounding applied.
378    ///
379    /// Example: `"rounded to 5 dp per StromNEV §17"`.
380    pub rounding_note: Option<&'static str>,
381}
382
383// ── SettlementWarning ─────────────────────────────────────────────────────────
384
385/// A non-blocking validation issue found during settlement calculation.
386///
387/// Warnings do not prevent the invoice from being generated but should be
388/// reviewed before dispatch. The service layer may choose to block dispatch
389/// on `Severity::Error` warnings.
390#[derive(Debug, Clone)]
391pub struct SettlementWarning {
392    /// Severity: informational, warning, or error.
393    pub severity: WarningSeverity,
394    /// Machine-readable warning code.
395    pub code: &'static str,
396    /// Human-readable description.
397    pub message: String,
398}
399
400/// Severity level for [`SettlementWarning`].
401#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
402pub enum WarningSeverity {
403    /// Informational — no action required.
404    Info,
405    /// Potential issue — review recommended before dispatch.
406    Warning,
407    /// Definite issue — should be resolved before dispatch.
408    Error,
409}
410
411// ── InvoicePosition ───────────────────────────────────────────────────────────
412
413/// Semantic kind of a billing position — used by the service layer to derive
414/// the correct `BdewArtikelnummer` for the BO4E `Rechnungsposition`.
415///
416/// `grid-billing` has no `rubo4e` dependency, so this enum is the bridge:
417/// the service layer maps `BillingPositionKind` → `BdewArtikelnummer` in
418/// `into_rechnung()`. Every position in every `GridSettlement` must carry
419/// a `kind` so the INVOIC `Rechnungsposition.artikelnummer` is never missing.
420///
421/// ## BDEW INVOIC AHB requirement
422///
423/// BDEW INVOIC AHBs (FV2025-10-01) mandate `artikelnummer` in every
424/// `SG28 PIA` line item. Missing or wrong Artikelnummern cause counterparty
425/// APERAK rejection. The `invoic-checker` checks 6 plausibility rules;
426/// Artikelnummer matching is part of the tariff-found rule (check 5).
427///
428/// ## Mapping to `BdewArtikelnummer`
429///
430/// | `BillingPositionKind` | `BdewArtikelnummer` | INVOIC AHB ref |
431/// |---|---|---|
432/// | `NneArbeit` | `Wirkarbeit` | PID 31001/31005/31006 Arbeit |
433/// | `NneArbeitHt` | `Wirkarbeit` | PID 31001 §14a Modul 2 HT |
434/// | `NneArbeitNt` | `Wirkarbeit` | PID 31001 §14a Modul 2 NT |
435/// | `NneArbeitModul1` | `Wirkarbeit` | PID 31001 §14a Modul 1 (rate reduced) |
436/// | `NneLeistung` | `Leistung` | PID 31001/31005 RLM kW charge |
437/// | `NneGasGrundpreis` | `Grundpreis` | PID 31005 monthly base fee |
438/// | `Konzessionsabgabe` | `Konzessionsabgabe` | PID 31001/31006 KAV §2 |
439/// | `Mehrmenge` | `Mehrmenge` | PID 31002 positive imbalance |
440/// | `Mindermenge` | `Mindermenge` | PID 31002 negative imbalance (credit) |
441/// | `MsbGrundgebuehr` | `EntgeltEinbauBetriebWartungMesstechnik` | PID 31009 MSB monthly fee |
442/// | `Messdienstleistung` | `EntgeltMessungAblesung` | PID 31009 reading service |
443/// | `GasAwhSperrung` | `Sperrkosten` | PID 31011 AWH disconnection |
444/// | `GasAwhEntsprrung` | `Entsperrkosten` | PID 31011 AWH reconnection |
445/// | `GasAwhSonstige` | `EntgeltAbrechnung` | PID 31011 other AWH |
446/// | `Blindmehrarbeit` | `Blindmehrarbeit` | Reactive energy excess |
447#[derive(Debug, Clone, Copy, PartialEq, Eq)]
448pub enum BillingPositionKind {
449    /// Netznutzungsentgelt Arbeit — flat-rate active energy charge (kWh).
450    /// SLP or Gas. → `BdewArtikelnummer::Wirkarbeit`
451    NneArbeit,
452    /// §14a Modul 2 Hochlast (HT) Arbeit — time-variable higher-price band.
453    /// → `BdewArtikelnummer::Wirkarbeit`
454    NneArbeitHt,
455    /// §14a Modul 2 Niedertarif (NT) Arbeit — time-variable lower-price band.
456    /// → `BdewArtikelnummer::Wirkarbeit`
457    NneArbeitNt,
458    /// §14a Modul 1 Arbeit — flat percentage reduction applied to Arbeitspreis.
459    /// → `BdewArtikelnummer::Wirkarbeit` (same article, different rate)
460    NneArbeitModul1,
461    /// §14a Modul 3 Spotpreis-NNE — per-dispatch-interval variable rate position.
462    ///
463    /// One `InvoicePosition` is generated per dispatch interval from
464    /// `NneInput::sect14a_modul3_intervals`. Each carries a
465    /// `lastvariable_preisposition_json` with the BO4E `LastvariablePreisposition`
466    /// COM data (pricing formula parameters) for ERP-side validation and portal
467    /// display of the per-interval tariff breakdown.
468    ///
469    /// Regulatory basis: BNetzA BK6-22-300 Anlage 2 §3 — Spotpreis-Netzentgelt.
470    /// → `BdewArtikelnummer::Wirkarbeit`
471    NneArbeitModul3,
472    /// Netznutzungsentgelt Leistung — RLM peak demand charge (kW).
473    /// → `BdewArtikelnummer::Leistung`
474    NneLeistung,
475    /// Gas NNE monthly base fee (Grundpreis / Verrechnungspreis).
476    /// GasNEV §14. → `BdewArtikelnummer::Grundpreis`
477    NneGasGrundpreis,
478    /// Konzessionsabgabe — KAV §2 municipal concession fee.
479    /// → `BdewArtikelnummer::Konzessionsabgabe`
480    Konzessionsabgabe,
481    /// Mehrmengen — positive imbalance (actual > profiled).
482    /// PID 31002 StromNZV §15 / GasNZV §14. → `BdewArtikelnummer::Mehrmenge`
483    Mehrmenge,
484    /// Mindermengen — negative imbalance credit note (actual < profiled).
485    /// PID 31002. → `BdewArtikelnummer::Mindermenge`
486    Mindermenge,
487    /// MSB Grundgebühr Messstellenbetrieb — monthly metering base fee.
488    /// MsbG §§6–7. → `BdewArtikelnummer::EntgeltEinbauBetriebWartungMesstechnik`
489    MsbGrundgebuehr,
490    /// Messdienstleistung — periodic reading service fee.
491    /// MessZV §2. → `BdewArtikelnummer::EntgeltMessungAblesung`
492    Messdienstleistung,
493    /// Gas AWH Sperrung — abrechnungswürdige Handlung disconnection.
494    /// BK7-24-01-009 §5.4. → `BdewArtikelnummer::Sperrkosten`
495    GasAwhSperrung,
496    /// Gas AWH Entsperrung — abrechnungswürdige Handlung reconnection.
497    /// BK7-24-01-009 §5.4. → `BdewArtikelnummer::Entsperrkosten`
498    GasAwhEntsprrung,
499    /// Gas AWH sonstige — other abrechnungswürdige Handlung.
500    /// BK7-24-01-009 §5.4. → `BdewArtikelnummer::EntgeltAbrechnung`
501    GasAwhSonstige,
502    /// Blindmehrarbeit — reactive energy excess charge.
503    /// StromNEV §18. → `BdewArtikelnummer::Blindmehrarbeit`
504    Blindmehrarbeit,
505}
506
507/// One line item in a grid settlement.
508///
509/// Carries raw numbers for the service layer to map into the required format
510/// (BO4E `Rechnungsposition`, EN16931 UBL, etc.).
511///
512/// Invariant: `net_eur == (quantity × unit_price_eur).round_dp(5)`.
513#[derive(Debug, Clone)]
514pub struct InvoicePosition {
515    /// 1-based sequence number.
516    pub number: u32,
517    /// Human-readable position description.
518    pub text: String,
519    /// Semantic position kind — used by the service layer to set
520    /// `Rechnungsposition.artikelnummer` (BDEW INVOIC AHB requirement).
521    pub kind: BillingPositionKind,
522    /// Metered or contracted quantity.
523    pub quantity: Decimal,
524    /// Unit of measure.
525    pub unit: QuantityUnit,
526    /// Unit price in EUR (already converted from ct where applicable).
527    pub unit_price_eur: Decimal,
528    /// Net amount in EUR, rounded to 5 decimal places.
529    ///
530    /// May be negative for credit positions (Mindermengen, Gutschriften).
531    pub net_eur: Decimal,
532    /// BDEW Artikel-ID for positions that use the new format (post-BK6-20-160).
533    ///
534    /// Used for:
535    /// - **NNE Strom** (GPKE PIDs 31001/31006): BNetzA Netznutzungspreisblatt article IDs
536    ///   (e.g. `"1-02-5-001"` for NS Grundpreis-/Arbeitspreissystem Arbeitspreis).
537    ///   The service layer populates this from the `PreisblattNetznutzung` Artikel-ID.
538    /// - **AWH Gas Sperrprozesse** (PID 31011): `"2-01-7-001"` (Sperrung) / `"2-01-7-002"` (Entsperrung)
539    ///   populated automatically by `calculate_gas_awh_invoice()`.
540    /// - **MSB** (PID 31009): `"4-02-0-xxx"` format from section 3.5 of the codelist,
541    ///   populated by the service layer from the product/Konfigurationsprodukt code.
542    ///
543    /// For Gas NNE, MMM, Konzessionsabgabe and Blindmehrarbeit, the classic
544    /// `artikelnummer` (via `BillingPositionKind`) takes precedence; this field is `None`.
545    ///
546    /// Source: BDEW Codeliste Artikelnummern und Artikel-ID v5.6 (valid 01.09.2025).
547    pub artikel_id: Option<String>,
548    /// §14a Modul 3: serialized `LastvariablePreisposition` BO4E COM for this position.
549    ///
550    /// Set when `kind == NneArbeitModul3`. Carries the pricing formula parameters
551    /// (SPOTPREIS method, `preisreferenz`, `preisBezugseinheit`) from the
552    /// `PreisblattNetznutzung.lastvariablePreispositionen` used to derive `unit_price_eur`.
553    ///
554    /// Stored as raw `serde_json::Value` so that `grid-billing` remains rubo4e-free.
555    /// The service layer (`netzbilanzd`, `billingd`) can deserialize to
556    /// `rubo4e::current::LastvariablePreisposition` for `Rechnungsposition.zusatzAttribute`
557    /// embedding, enabling ERP-side validation and portal display of the per-dispatch
558    /// tariff breakdown.
559    ///
560    /// `None` for all other position kinds.
561    pub lastvariable_preisposition_json: Option<serde_json::Value>,
562    /// Full audit trace for this position.
563    ///
564    /// Answers "why is this amount here?" and carries all legal references.
565    pub trace: CalculationTrace,
566}
567
568// ── GridSettlement ────────────────────────────────────────────────────────────
569
570/// Result of a grid settlement calculation — pure domain type, no BO4E coupling.
571///
572/// This is the canonical output of all calculation functions in `grid-billing`.
573/// The service layer (`netzbilanzd`, `invoicd`) converts it to `rubo4e::current::Rechnung`
574/// via a local `into_rechnung()` adapter.
575///
576/// ## Explainability
577///
578/// Every settlement carries a full [`CalculationTrace`] per position, applied
579/// [`LegalReference`]s, and the [`TariffSource`] for each rate. The `warnings`
580/// field surfaces any non-blocking validation issues.
581///
582/// ## Immutable correction chain
583///
584/// When correcting a prior settlement, set `status = SettlementStatus::Correction`
585/// and populate `correction_of` with the original settlement's `rechnungsnummer`.
586/// The original settlement is never mutated.
587///
588/// ## PID override
589///
590/// `pid` defaults to `SettlementType::default_pid()`. Override after construction:
591/// - `31005` for Gas NNE (NneStrom → NneGas)
592/// - `31006` for selbstausstellt NNE
593/// - `31011` for GeLi Gas AWH Sperrprozesse
594#[derive(Debug, Clone)]
595pub struct GridSettlement {
596    /// BDEW Prüfidentifikator — caller may override after construction.
597    pub pid: u32,
598    /// Settlement type.
599    pub settlement_type: SettlementType,
600    /// Lifecycle status.
601    pub status: SettlementStatus,
602    /// Unique invoice reference number.
603    pub rechnungsnummer: String,
604    /// If this is a correction, the `rechnungsnummer` of the original settlement.
605    pub correction_of: Option<String>,
606    /// Invoice issue date.
607    pub invoice_date: time::Date,
608    /// Payment due date (Zahlungsziel, §271 BGB).
609    pub due_date: time::Date,
610    /// Start of billing period (inclusive).
611    pub period_from: time::Date,
612    /// End of billing period (inclusive).
613    pub period_to: time::Date,
614    /// Sender MP-ID — Netzbetreiber (or MSB for PID 31009).
615    pub nb_mp_id: String,
616    /// Recipient MP-ID — Lieferant (NNE/MMM), MSB (PID 31009), or MGV (GaBi Gas).
617    ///
618    /// Maps to `rechnungsempfaenger` in the BO4E `Rechnung` built by the service layer.
619    /// Previously omitted, causing service-layer code to pass recipient IDs separately.
620    pub counterparty_mp_id: String,
621    /// Ordered billing positions (each with full calculation trace).
622    pub positions: Vec<InvoicePosition>,
623    /// Net total in EUR, rounded to 2 decimal places.
624    pub total_eur: Decimal,
625    /// Non-blocking validation warnings.
626    ///
627    /// Empty when the settlement is clean. The service layer should review
628    /// `Warning` and `Error` severity items before dispatch.
629    pub warnings: Vec<SettlementWarning>,
630}
631
632/// Backward-compatible alias so callers using the old name continue to compile.
633///
634/// `GridInvoice` was the original output type. New code should use [`GridSettlement`]
635/// which carries full calculation traces, legal references, and settlement metadata.
636pub type GridInvoice = GridSettlement;
637
638impl GridSettlement {
639    /// Number of billing positions.
640    #[must_use]
641    pub fn positions_count(&self) -> usize {
642        self.positions.len()
643    }
644
645    /// `true` when the settlement has no warnings at `Warning` or `Error` severity.
646    #[must_use]
647    pub fn is_clean(&self) -> bool {
648        !self
649            .warnings
650            .iter()
651            .any(|w| w.severity >= WarningSeverity::Warning)
652    }
653
654    /// All legal references cited across all positions (deduplicated by citation string).
655    #[must_use]
656    pub fn all_legal_refs(&self) -> Vec<String> {
657        let mut seen = std::collections::HashSet::new();
658        self.positions
659            .iter()
660            .flat_map(|p| p.trace.legal_refs.iter().map(|r| r.citation()))
661            .filter(|c| seen.insert(c.clone()))
662            .collect()
663    }
664
665    /// Net total as computed from positions (re-summed for verification).
666    ///
667    /// Should equal `total_eur`. A mismatch indicates a calculation bug.
668    #[must_use]
669    pub fn recomputed_total(&self) -> Decimal {
670        self.positions
671            .iter()
672            .map(|p| p.net_eur)
673            .sum::<Decimal>()
674            .round_dp(2)
675    }
676}
677
678// ── Input types ───────────────────────────────────────────────────────────────
679
680/// Input for NNE (Netznutzungsentgelt) invoice calculation.
681///
682/// Covers:
683/// - **PID 31001** — NNE Strom (NB → LF, monthly network usage billing)
684/// - **PID 31005** — NNE Gas (NB → LF, monthly gas network usage billing)
685///
686/// For **RLM** (Leistungsmessung) meters:
687/// - Set `spitzenleistung_kw` to the peak demand in kW.
688/// - Set `leistungspreis_eur_per_kw` to the published tariff.
689///
690/// For **SLP** meters:
691/// - Leave both fields as `None` (Arbeitspreisanteil only).
692///
693/// For **§14a Modul 2 time-variable NNE** (BNetzA BK6-22-300):
694/// - Set `arbeitsmenge_ht_kwh` + `arbeitspreis_ht_ct_per_kwh` for Hochlast periods.
695/// - Set `arbeitsmenge_nt_kwh` + `arbeitspreis_nt_ct_per_kwh` for Niedertarif periods.
696/// - Leave `arbeitsmenge_kwh` / `arbeitspreis_ct_per_kwh` as the base fallback.
697///
698/// For Gas:
699/// - The `arbeitsmenge_kwh` should already be converted from m³ using
700///   `brennwert × zustandszahl` before being supplied here.
701///   (`mako-edm` `MeterBillingPeriod.arbeitsmenge_kwh` carries this converted value.)
702#[derive(Debug, Clone)]
703pub struct NneInput {
704    /// 11-digit Marktlokations-ID.
705    pub malo_id: String,
706    /// Invoice sender — Netzbetreiber or Gasnetzbetreiber MP-ID.
707    pub nb_mp_id: String,
708    /// Invoice recipient — Lieferant MP-ID.
709    pub lf_mp_id: String,
710    /// Unique invoice number (operator-generated).
711    pub rechnungsnummer: String,
712    /// Start of billing period (inclusive, German local date).
713    pub period_from: time::Date,
714    /// End of billing period (inclusive, German local date).
715    pub period_to: time::Date,
716    /// Invoice issue date.
717    pub invoice_date: time::Date,
718    /// Payment due date (Zahlungsziel).
719    pub due_date: time::Date,
720    /// Total energy consumption in kWh for the billing period.
721    ///
722    /// For Gas: already converted from m³ (brennwert × zustandszahl × volume).
723    /// Used when HT/NT split is not available (SLP, Gas, or pre-§14a deployments).
724    pub arbeitsmenge_kwh: Decimal,
725    /// Published NNE Arbeitspreis in **ct/kWh** (from `PreisblattNetznutzung`).
726    /// Used as the single Arbeit rate when HT/NT split is absent.
727    pub arbeitspreis_ct_per_kwh: Decimal,
728
729    // ── §14a Modul 2 time-variable (ToU) NNE ─────────────────────────────────
730    // BNetzA BK6-22-300: mandatory for all controllable loads since 01.01.2024.
731    // When both fields below are non-None, the billing engine generates two
732    // separate Arbeit positions (HT + NT) instead of a single blended position.
733    // Source: `edmd` MeterBillingPeriod.arbeitsmenge_ht_kwh / .arbeitsmenge_nt_kwh.
734    /// Hochlast (HT) consumption in kWh — §14a Modul 2 periods (higher-price band).
735    /// `None` when ToU metering is not configured for this MaLo.
736    pub arbeitsmenge_ht_kwh: Option<Decimal>,
737    /// HT Arbeitspreis in ct/kWh (from `PreisblattNetznutzung.zeitvariablePreispositionen`).
738    /// Required when `arbeitsmenge_ht_kwh` is set.
739    pub arbeitspreis_ht_ct_per_kwh: Option<Decimal>,
740    /// Niedertarif (NT) consumption in kWh — §14a Modul 2 off-peak periods.
741    /// `None` when ToU metering is not configured for this MaLo.
742    pub arbeitsmenge_nt_kwh: Option<Decimal>,
743    /// NT Arbeitspreis in ct/kWh (from `PreisblattNetznutzung.zeitvariablePreispositionen`).
744    /// Required when `arbeitsmenge_nt_kwh` is set.
745    pub arbeitspreis_nt_ct_per_kwh: Option<Decimal>,
746
747    // ── RLM demand charge ─────────────────────────────────────────────────────
748    /// Peak demand in **kW** (`spitzenleistung_kw` from `MeterBillingPeriod`).
749    ///
750    /// `None` for SLP meters and Gas MaLos.
751    pub spitzenleistung_kw: Option<Decimal>,
752    /// Published NNE Leistungspreis in **EUR/kW** (from `PreisblattNetznutzung`).
753    ///
754    /// `None` when `spitzenleistung_kw` is `None`.
755    pub leistungspreis_eur_per_kw: Option<Decimal>,
756    /// Published Konzessionsabgabe rate in **ct/kWh** (from `PreisblattKonzessionsabgabe`).
757    ///
758    /// `None` when KA does not apply (Gas or exempt customer class).
759    pub ka_satz_ct_per_kwh: Option<Decimal>,
760
761    // ── §14a Modul 1 flat reduction ───────────────────────────────────────────
762    /// §14a Modul 1 (BNetzA BK6-22-300): rate multiplier applied to the Arbeit
763    /// positions (and Leistung if applicable) before billing.
764    ///
765    /// This is the fraction of the full rate that the customer pays — a reduction
766    /// factor of `0.85` means the customer pays 85 % of the published tariff
767    /// (15 % reduction). The regulatory default per BK6-22-300 Anlage 2 is 0.85;
768    /// the NB may publish a different approved value in the `PreisblattNetznutzung`.
769    ///
770    /// `None` when §14a Modul 1 does not apply to this MaLo. Must not be set
771    /// together with ToU HT/NT fields (Modul 2) — the validator rejects the
772    /// combination.
773    ///
774    /// # Example
775    ///
776    /// Full rate: 3.5 ct/kWh, reduction factor: 0.85 → billed rate: 2.975 ct/kWh
777    pub sect14a_modul1_reduction_factor: Option<Decimal>,
778
779    // ── Gas NNE monthly base fee ──────────────────────────────────────────────
780    /// Gas NNE monthly base fee (Grundpreis / Verrechnungspreis) in **EUR/month**.
781    ///
782    /// Per GasNEV, gas network charges may include a fixed monthly standing charge
783    /// in addition to the commodity-linked Arbeitspreis.  Supply the approved
784    /// `PreisblattNetznutzung.grundpreis_eur_per_month` value here.
785    ///
786    /// `None` for Strom NNE (Strom does not have a separate Grundpreis).
787    /// `None` when the Gas tariff is purely commodity-based (no Grundpreis).
788    pub nne_grundpreis_eur_per_month: Option<Decimal>,
789    /// Number of months to apply the `nne_grundpreis_eur_per_month` to.
790    ///
791    /// Defaults to `None` (= 0 months, no Grundpreis position generated).
792    /// Required when `nne_grundpreis_eur_per_month` is set.
793    pub nne_grundpreis_months: Option<u32>,
794
795    /// Optional tariff sheet identifier for audit tracing.
796    ///
797    /// When set, each position's `trace.tariff_source` references this sheet.
798    pub tariff_sheet_id: Option<String>,
799    /// Commodity — drives legal references (StromNEV vs GasNEV) and `SettlementType`.
800    ///
801    /// - `Sparte::Strom` (default) → `StromNEV §21` Arbeit, `StromNEV §17` Leistung,
802    ///   `SettlementType::NneStrom`
803    /// - `Sparte::Gas` → `GasNEV §14`, `SettlementType::NneGas`
804    pub sparte: Sparte,
805    /// KAV rate class applied to this metering point.
806    ///
807    /// Included in the calculation trace for KA positions so auditors can verify
808    /// the rate matches the correct KAV §2 tier. `None` when KA is absent.
809    pub ka_klasse: Option<KaKlasse>,
810
811    // ── §14a Modul 3 Spotpreis-NNE per-interval dispatch data ────────────────
812    /// §14a Modul 3 (BNetzA BK6-22-300 Anlage 2 §3) per-dispatch-interval positions.
813    ///
814    /// Each entry represents one 15-min interval during which a spot-price-linked
815    /// NNE rate applies. The caller fetches the EPEX Spot day-ahead price for each
816    /// interval and applies the formula from `PreisblattNetznutzung.lastvariablePreispositionen`
817    /// to derive `nne_rate_ct_per_kwh`. `grid-billing` receives pre-calculated rates —
818    /// it never queries EPEX directly.
819    ///
820    /// **Empty (default)** when §14a Modul 3 does not apply to this MaLo.
821    ///
822    /// **Cannot be combined with `sect14a_modul1_reduction_factor`** — the validator
823    /// returns `InvalidInput` when both are set.
824    ///
825    /// Each interval generates one `InvoicePosition` with
826    /// `kind = NneArbeitModul3` and `lastvariable_preisposition_json` populated.
827    #[doc = "§14a Modul 3 per-interval input data."]
828    pub sect14a_modul3_intervals: Vec<Sect14aModul3Interval>,
829}
830
831// ── Sect14aModul3Interval ─────────────────────────────────────────────────────
832
833/// One controlled dispatch interval for §14a Modul 3 (Spotpreis-Netzentgelt).
834///
835/// Each interval represents a 15-min period during which the DSO exercised load
836/// control and the NNE rate is derived from the day-ahead spot price via the
837/// formula published in `PreisblattNetznutzung.lastvariablePreispositionen`.
838///
839/// ## Calculation
840///
841/// `Einsatzkosten = menge_kwh × nne_rate_ct_per_kwh / 100`
842///
843/// The NB computes one `InvoicePosition` per interval, allowing the LF (and their
844/// customers) to see the exact tariff breakdown for each dispatch event.
845///
846/// ## Caller responsibility
847///
848/// The caller (service layer) must:
849/// 1. Fetch the EPEX Spot day-ahead price for each 15-min interval from `tarifbd`
850///    or the `PreisblattNetznutzung` formula.
851/// 2. Apply the formula from `lastvariablePreispositionen` to derive `nne_rate_ct_per_kwh`.
852/// 3. Fetch `menge_kwh` from `edmd Lastgang` for the interval.
853///
854/// `grid-billing` receives pre-calculated rates — it does NOT query EPEX or `edmd`.
855///
856/// ## Regulatory basis
857///
858/// BNetzA BK6-22-300 Anlage 2 §3 — Modul 3: Spotpreis-Netzentgelt.
859/// The NNE varies per 15-min interval based on the spot market price.
860/// All controllable loads ≥ 3.7 kW registered under §14a must have Modul 1 at minimum;
861/// Modul 3 is the opt-in premium variant (lower NNE when spot prices are low).
862#[derive(Debug, Clone)]
863pub struct Sect14aModul3Interval {
864    /// UTC start of this controlled dispatch interval (ISO-8601).
865    ///
866    /// Typically the start of a 15-min settlement slot.
867    pub period_from: time::OffsetDateTime,
868    /// UTC end of this controlled dispatch interval (ISO-8601).
869    ///
870    /// Typically `period_from + 15 min`.
871    pub period_to: time::OffsetDateTime,
872    /// Energy consumption (or reduction) during this interval in kWh.
873    ///
874    /// Sourced from `edmd Lastgang` for the MaLo during the interval window.
875    pub menge_kwh: Decimal,
876    /// Effective NNE rate in **ct/kWh** for this interval.
877    ///
878    /// Derived from the `LastvariablePreisposition` formula applied to the
879    /// applicable EPEX Spot day-ahead price. Pre-calculated by the caller.
880    pub nne_rate_ct_per_kwh: Decimal,
881    /// EPEX Spot day-ahead price in ct/kWh used to derive `nne_rate_ct_per_kwh`.
882    ///
883    /// Stored in the `CalculationTrace.explanation` for audit transparency.
884    /// `None` when the rate was determined by a fixed formula without market reference.
885    pub epex_spot_ct_per_kwh: Option<Decimal>,
886}
887
888// ── MmmInput ──────────────────────────────────────────────────────────────────
889
890/// Input for Mehr-/Mindermengen (MMM) settlement invoice calculation.
891///
892/// Covers:
893/// - **PID 31002** — `MMM-Stornorechnung NNE Strom` used for Mehr-/Mindermengen
894///   settlement between NB and LF.
895///
896/// Mehr-/Mindermengen settle the difference between the LF's forecast profile
897/// (SLP standard load profile) and the actual measured consumption.
898///
899/// - **Mehrmengen** (positive deviation): actual > profil → LF owes NB
900/// - **Mindermengen** (negative deviation): actual < profil → NB owes LF
901///
902/// The settlement amount is the algebraic sum of both positions.  It can be
903/// negative (i.e. a credit note from NB to LF) when Mindermengen dominate.
904#[derive(Debug, Clone)]
905pub struct MmmInput {
906    /// 11-digit Marktlokations-ID.
907    pub malo_id: String,
908    /// Invoice sender — Netzbetreiber MP-ID.
909    pub nb_mp_id: String,
910    /// Invoice recipient — Lieferant MP-ID.
911    pub lf_mp_id: String,
912    /// Unique invoice number.
913    pub rechnungsnummer: String,
914    /// Start of billing period.
915    pub period_from: time::Date,
916    /// End of billing period.
917    pub period_to: time::Date,
918    /// Invoice issue date.
919    pub invoice_date: time::Date,
920    /// Payment due date.
921    pub due_date: time::Date,
922    /// Commodity — determines legal references (StromNZV vs GasNZV).
923    ///
924    /// - `Sparte::Strom` → `StromNZV §15`, `GPKE BK6-22-024`
925    /// - `Sparte::Gas` → `GasNZV §14`, `GeLi Gas BK7-24-01-009`
926    pub sparte: Sparte,
927    /// Actual measured consumption in kWh (from MSCONS / `MeterBillingPeriod`).
928    pub actual_kwh: Decimal,
929    /// Standard load profile (SLP) forecast consumption in kWh.
930    pub profil_kwh: Decimal,
931    /// Mehrmengen price in **ct/kWh** (from `PreisblattNetznutzung` MMM position).
932    pub mehr_preis_ct_per_kwh: Decimal,
933    /// Mindermengen price in **ct/kWh** (from `PreisblattNetznutzung` MMM position).
934    pub minder_preis_ct_per_kwh: Decimal,
935}
936
937// ── MsbInput ──────────────────────────────────────────────────────────────────
938
939/// Input for MSB (Messstellenbetreiber) invoice calculation.
940///
941/// Covers:
942/// - **PID 31009** — MSB-Rechnung (NB → MSB, monthly metering service settlement)
943///
944/// The NB bills the MSB for the metering service period.  Positions:
945/// 1. Grundgebühr Messstellenbetrieb — flat monthly base fee × billing months.
946/// 2. Messdienstleistung — optional per-period measurement service fee.
947#[derive(Debug, Clone)]
948pub struct MsbInput {
949    /// 11-digit Marktlokations-ID.
950    pub malo_id: String,
951    /// Invoice sender — Netzbetreiber MP-ID.
952    pub nb_mp_id: String,
953    /// Invoice recipient — Messstellenbetreiber MP-ID.
954    pub msb_mp_id: String,
955    /// Unique invoice number.
956    pub rechnungsnummer: String,
957    /// Start of billing period (inclusive, German local date).
958    pub period_from: time::Date,
959    /// End of billing period (inclusive, German local date).
960    pub period_to: time::Date,
961    /// Invoice issue date.
962    pub invoice_date: time::Date,
963    /// Payment due date.
964    pub due_date: time::Date,
965    /// Grundgebühr Messstellenbetrieb in **EUR/month** (from `PreisblattMessung`).
966    pub grundgebuehr_eur_per_month: Decimal,
967    /// Number of full calendar months in the billing period.
968    pub billing_months: u32,
969    /// Optional Messdienstleistung flat fee in **EUR** for the full period.
970    ///
971    /// `None` when the MSB provides only the meter, not a separate measurement service.
972    pub messdienstleistung_eur: Option<Decimal>,
973}
974
975// ── GasAwhInput ───────────────────────────────────────────────────────────────
976
977/// Input for GeLi Gas AWH Sperrprozesse settlement (PID 31011).
978///
979/// **PID 31011 — Rechnung sonstige Leistung (NB → LF)**
980///
981/// Bills the Lieferant (LFG/LFA) for abrechnungswürdige Handlungen (AWH)
982/// performed by the GNB/VNB during the Sperrung/Entsperrung process.
983/// Governed by BK7-24-01-009 §5.4 (GeLi Gas 3.0).
984///
985/// ## What counts as AWH
986///
987/// AWH are chargeable actions not included in the network tariff, triggered by
988/// the LF through the Sperrung process. Typical AWH:
989/// - `Sperrung` (disconnection)
990/// - `Entsperrung` (reconnection)
991/// - `Teilsperrung` (partial disconnection)
992/// - `Unterbrechung Verfahren` (process interruption)
993///
994/// Each action type has a fixed price published in the `PreisblattNetznutzung`.
995#[derive(Debug, Clone)]
996pub struct GasAwhInput {
997    /// 11-digit Marktlokations-ID.
998    pub malo_id: String,
999    /// Invoice sender — Gasnetzbetreiber (GNB/VNB) MP-ID.
1000    pub nb_mp_id: String,
1001    /// Invoice recipient — Lieferant Gas (LFG or LFA) MP-ID.
1002    pub lf_mp_id: String,
1003    /// Unique invoice number (operator-generated).
1004    pub rechnungsnummer: String,
1005    /// Start of billing period (inclusive, German local date).
1006    pub period_from: time::Date,
1007    /// End of billing period (inclusive, German local date).
1008    pub period_to: time::Date,
1009    /// Invoice issue date.
1010    pub invoice_date: time::Date,
1011    /// Payment due date (Zahlungsziel, §271 BGB).
1012    pub due_date: time::Date,
1013    /// Optional tariff sheet identifier for audit tracing.
1014    pub tariff_sheet_id: Option<String>,
1015    /// AWH line items: each chargeable action with count and unit price.
1016    ///
1017    /// At least one position is required.
1018    pub awh_positionen: Vec<AwhPositionInput>,
1019}
1020
1021/// One AWH action line item for [`GasAwhInput`].
1022///
1023/// ## Examples
1024///
1025/// ```rust
1026/// # use grid_billing::AwhPositionInput;
1027/// # use rust_decimal_macros::dec;
1028/// let sperrung = AwhPositionInput {
1029///     beschreibung: "Sperrung Gaszähler".to_owned(),
1030///     anzahl: 1,
1031///     preis_eur: dec!(45.00),
1032///     artikel_id: Some("2-01-7-001".to_owned()),
1033/// };
1034/// ```
1035#[derive(Debug, Clone)]
1036pub struct AwhPositionInput {
1037    /// Human-readable action description, e.g. `"Sperrung Gaszähler"`.
1038    pub beschreibung: String,
1039    /// Number of executions of this action.
1040    pub anzahl: u32,
1041    /// Price per execution in **EUR** (from `PreisblattNetznutzung`).
1042    pub preis_eur: Decimal,
1043    /// BDEW Artikel-ID from section 3.2 of the Codeliste Artikelnummern v5.6.
1044    ///
1045    /// Standard values for Gas AWH Sperrprozesse (BK7-24-01-009 §5.4):
1046    /// - `"2-01-7-001"` — Unterbrechung der Anschlussnutzung (reguläre AZ)
1047    /// - `"2-01-7-002"` — Wiederherstellung der Anschlussnutzung (reguläre AZ)
1048    /// - `"2-01-7-003"` — Erfolglose Unterbrechung
1049    /// - `"2-01-7-004"` — Stornierung Unterbrechungsauftrag (bis Vortag)
1050    /// - `"2-01-7-005"` — Stornierung Unterbrechungsauftrag (am Sperrtag)
1051    /// - `"2-01-7-006"` — Wiederherstellung außerhalb regulärer AZ
1052    ///
1053    /// `None` for custom / non-standard AWH positions.
1054    pub artikel_id: Option<String>,
1055}
1056
1057// ── ValidationResult ─────────────────────────────────────────────────────────
1058
1059/// Result of pre-calculation input validation.
1060///
1061/// Validation runs **before** the calculation begins. A `ValidationResult`
1062/// with `is_valid = false` should prevent calling `calculate_*` to avoid
1063/// partial or incorrect results.
1064///
1065/// Use [`validate_nne_input`], [`validate_mmm_input`], or [`validate_msb_input`]
1066/// to obtain a `ValidationResult` for your input.
1067#[derive(Debug, Clone)]
1068pub struct ValidationResult {
1069    /// Whether the input passed all validation checks.
1070    pub is_valid: bool,
1071    /// All warnings and errors found. May contain [`WarningSeverity::Info`] items
1072    /// even when `is_valid = true`.
1073    pub warnings: Vec<SettlementWarning>,
1074}
1075
1076impl ValidationResult {
1077    /// Returns a clean (valid, no warnings) result.
1078    #[must_use]
1079    pub fn ok() -> Self {
1080        Self {
1081            is_valid: true,
1082            warnings: Vec::new(),
1083        }
1084    }
1085
1086    /// Appends a warning. `WarningSeverity::Error` marks the result invalid.
1087    pub fn push(&mut self, w: SettlementWarning) {
1088        if w.severity == WarningSeverity::Error {
1089            self.is_valid = false;
1090        }
1091        self.warnings.push(w);
1092    }
1093}
1094
1095/// Validate a [`NneInput`] before calling [`crate::calculate_nne_invoice`].
1096///
1097/// The calculation functions also validate hard constraints and return
1098/// `Err(BillingError)`. This function additionally surfaces soft warnings
1099/// (e.g. suspiciously negative prices) that would not prevent calculation
1100/// but should be reviewed before dispatch.
1101#[must_use]
1102pub fn validate_nne_input(input: &NneInput) -> ValidationResult {
1103    let mut r = ValidationResult::ok();
1104    if input.period_from >= input.period_to {
1105        r.push(SettlementWarning {
1106            severity: WarningSeverity::Error,
1107            code: "INVALID_PERIOD",
1108            message: "period_from must be strictly before period_to".to_owned(),
1109        });
1110    }
1111    if input.arbeitsmenge_kwh < Decimal::ZERO {
1112        r.push(SettlementWarning {
1113            severity: WarningSeverity::Error,
1114            code: "NEGATIVE_CONSUMPTION",
1115            message: format!("arbeitsmenge_kwh is negative: {}", input.arbeitsmenge_kwh),
1116        });
1117    }
1118    if input.arbeitspreis_ct_per_kwh < Decimal::ZERO {
1119        r.push(SettlementWarning {
1120            severity: WarningSeverity::Warning,
1121            code: "NEGATIVE_ARBEITSPREIS",
1122            message: format!(
1123                "arbeitspreis_ct_per_kwh is negative: {}",
1124                input.arbeitspreis_ct_per_kwh
1125            ),
1126        });
1127    }
1128    if input.spitzenleistung_kw.is_some() != input.leistungspreis_eur_per_kw.is_some() {
1129        r.push(SettlementWarning {
1130            severity: WarningSeverity::Error,
1131            code: "MISMATCHED_RLM_FIELDS",
1132            message:
1133                "spitzenleistung_kw and leistungspreis_eur_per_kw must both be set or both absent"
1134                    .to_owned(),
1135        });
1136    }
1137    if input.sparte == Sparte::Gas && input.spitzenleistung_kw.is_some() {
1138        r.push(SettlementWarning {
1139            severity: WarningSeverity::Warning,
1140            code: "GAS_WITH_LEISTUNG",
1141            message: "Gas NNE typically does not use Leistungspreis — verify tariff configuration"
1142                .to_owned(),
1143        });
1144    }
1145    // Partial HT/NT field consistency: all four must be set or all four absent.
1146    let tou_count = [
1147        input.arbeitsmenge_ht_kwh.is_some(),
1148        input.arbeitspreis_ht_ct_per_kwh.is_some(),
1149        input.arbeitsmenge_nt_kwh.is_some(),
1150        input.arbeitspreis_nt_ct_per_kwh.is_some(),
1151    ]
1152    .iter()
1153    .filter(|&&b| b)
1154    .count();
1155    if tou_count > 0 && tou_count < 4 {
1156        r.push(SettlementWarning {
1157            severity: WarningSeverity::Error,
1158            code: "PARTIAL_TOU_FIELDS",
1159            message: format!(
1160                "§14a Modul 2 ToU: {tou_count} of 4 HT/NT fields set — all four must be provided or all absent"
1161            ),
1162        });
1163    }
1164    // §14a Modul 1 and Modul 2 are mutually exclusive.
1165    if input.sect14a_modul1_reduction_factor.is_some() && tou_count == 4 {
1166        r.push(SettlementWarning {
1167            severity: WarningSeverity::Error,
1168            code: "MODUL1_AND_MODUL2_CONFLICT",
1169            message:
1170                "sect14a_modul1_reduction_factor (Modul 1) cannot be combined with HT/NT ToU fields (Modul 2)"
1171                    .to_owned(),
1172        });
1173    }
1174    // §14a Modul 1 and Modul 3 are mutually exclusive.
1175    if input.sect14a_modul1_reduction_factor.is_some() && !input.sect14a_modul3_intervals.is_empty()
1176    {
1177        r.push(SettlementWarning {
1178            severity: WarningSeverity::Error,
1179            code: "MODUL1_AND_MODUL3_CONFLICT",
1180            message:
1181                "sect14a_modul1_reduction_factor (Modul 1) cannot be combined with sect14a_modul3_intervals (Modul 3)"
1182                    .to_owned(),
1183        });
1184    }
1185    // §14a Modul 3 interval sanity checks.
1186    for (i, interval) in input.sect14a_modul3_intervals.iter().enumerate() {
1187        if interval.period_from >= interval.period_to {
1188            r.push(SettlementWarning {
1189                severity: WarningSeverity::Error,
1190                code: "MODUL3_INTERVAL_PERIOD_INVALID",
1191                message: format!(
1192                    "sect14a_modul3_intervals[{i}]: period_from must be before period_to"
1193                ),
1194            });
1195        }
1196        if interval.menge_kwh < Decimal::ZERO {
1197            r.push(SettlementWarning {
1198                severity: WarningSeverity::Warning,
1199                code: "MODUL3_INTERVAL_NEGATIVE_MENGE",
1200                message: format!(
1201                    "sect14a_modul3_intervals[{i}]: menge_kwh is negative ({}) — verify Lastgang data",
1202                    interval.menge_kwh
1203                ),
1204            });
1205        }
1206        if interval.nne_rate_ct_per_kwh < Decimal::ZERO {
1207            r.push(SettlementWarning {
1208                severity: WarningSeverity::Warning,
1209                code: "MODUL3_INTERVAL_NEGATIVE_RATE",
1210                message: format!(
1211                    "sect14a_modul3_intervals[{i}]: nne_rate_ct_per_kwh is negative ({}) — \
1212                     Modul 3 rates should be ≥ 0; verify spot formula",
1213                    interval.nne_rate_ct_per_kwh
1214                ),
1215            });
1216        }
1217    }
1218    if let Some(factor) = input.sect14a_modul1_reduction_factor
1219        && (factor <= Decimal::ZERO || factor > Decimal::ONE)
1220    {
1221        r.push(SettlementWarning {
1222            severity: WarningSeverity::Error,
1223            code: "INVALID_MODUL1_FACTOR",
1224            message: format!("sect14a_modul1_reduction_factor must be in (0, 1], got {factor}"),
1225        });
1226    }
1227    // Gas Grundpreis: both fields must be set together.
1228    if input.nne_grundpreis_eur_per_month.is_some() != input.nne_grundpreis_months.is_some() {
1229        r.push(SettlementWarning {
1230            severity: WarningSeverity::Error,
1231            code: "GRUNDPREIS_MONTHS_MISMATCH",
1232            message:
1233                "nne_grundpreis_eur_per_month and nne_grundpreis_months must both be set or both absent"
1234                    .to_owned(),
1235        });
1236    }
1237    r
1238}
1239
1240/// Validate a [`MmmInput`] before calling [`crate::calculate_mmm_invoice`].
1241#[must_use]
1242pub fn validate_mmm_input(input: &MmmInput) -> ValidationResult {
1243    let mut r = ValidationResult::ok();
1244    if input.period_from >= input.period_to {
1245        r.push(SettlementWarning {
1246            severity: WarningSeverity::Error,
1247            code: "INVALID_PERIOD",
1248            message: "period_from must be strictly before period_to".to_owned(),
1249        });
1250    }
1251    if input.mehr_preis_ct_per_kwh < Decimal::ZERO {
1252        r.push(SettlementWarning {
1253            severity: WarningSeverity::Warning,
1254            code: "NEGATIVE_MEHR_PREIS",
1255            message: format!(
1256                "mehr_preis_ct_per_kwh is negative: {}",
1257                input.mehr_preis_ct_per_kwh
1258            ),
1259        });
1260    }
1261    if input.minder_preis_ct_per_kwh < Decimal::ZERO {
1262        r.push(SettlementWarning {
1263            severity: WarningSeverity::Warning,
1264            code: "NEGATIVE_MINDER_PREIS",
1265            message: format!(
1266                "minder_preis_ct_per_kwh is negative: {}",
1267                input.minder_preis_ct_per_kwh
1268            ),
1269        });
1270    }
1271    r
1272}
1273
1274/// Validate a [`MsbInput`] before calling [`crate::calculate_msb_invoice`].
1275#[must_use]
1276pub fn validate_msb_input(input: &MsbInput) -> ValidationResult {
1277    let mut r = ValidationResult::ok();
1278    if input.period_from >= input.period_to {
1279        r.push(SettlementWarning {
1280            severity: WarningSeverity::Error,
1281            code: "INVALID_PERIOD",
1282            message: "period_from must be strictly before period_to".to_owned(),
1283        });
1284    }
1285    if input.grundgebuehr_eur_per_month < Decimal::ZERO {
1286        r.push(SettlementWarning {
1287            severity: WarningSeverity::Error,
1288            code: "NEGATIVE_GRUNDGEBUEHR",
1289            message: format!(
1290                "grundgebuehr_eur_per_month is negative: {}",
1291                input.grundgebuehr_eur_per_month
1292            ),
1293        });
1294    }
1295    if input.billing_months == 0 {
1296        r.push(SettlementWarning {
1297            severity: WarningSeverity::Error,
1298            code: "ZERO_BILLING_MONTHS",
1299            message: "billing_months must be at least 1".to_owned(),
1300        });
1301    }
1302    r
1303}
1304
1305/// Validate a [`GasAwhInput`] before calling [`crate::calculate_gas_awh_invoice`].
1306///
1307/// Checks that:
1308/// - `period_from < period_to`
1309/// - `awh_positionen` is non-empty
1310/// - All positions have `anzahl ≥ 1` and `preis_eur ≥ 0`
1311#[must_use]
1312pub fn validate_gas_awh_input(input: &GasAwhInput) -> ValidationResult {
1313    let mut r = ValidationResult::ok();
1314    if input.period_from >= input.period_to {
1315        r.push(SettlementWarning {
1316            severity: WarningSeverity::Error,
1317            code: "INVALID_PERIOD",
1318            message: "period_from must be strictly before period_to".to_owned(),
1319        });
1320    }
1321    if input.awh_positionen.is_empty() {
1322        r.push(SettlementWarning {
1323            severity: WarningSeverity::Error,
1324            code: "EMPTY_AWH_POSITIONEN",
1325            message: "awh_positionen must contain at least one position".to_owned(),
1326        });
1327    }
1328    for (i, awh) in input.awh_positionen.iter().enumerate() {
1329        if awh.anzahl == 0 {
1330            r.push(SettlementWarning {
1331                severity: WarningSeverity::Error,
1332                code: "ZERO_AWH_ANZAHL",
1333                message: format!("awh_positionen[{i}].anzahl must be ≥ 1"),
1334            });
1335        }
1336        if awh.preis_eur < Decimal::ZERO {
1337            r.push(SettlementWarning {
1338                severity: WarningSeverity::Error,
1339                code: "NEGATIVE_AWH_PREIS",
1340                message: format!(
1341                    "awh_positionen[{i}].preis_eur must be non-negative, got {}",
1342                    awh.preis_eur
1343                ),
1344            });
1345        }
1346    }
1347    r
1348}