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 (energy).
68    Kwh,
69    /// Kilowatts (demand / peak load).
70    Kw,
71    /// Calendar months.
72    Monat,
73}
74
75// ── SettlementType ────────────────────────────────────────────────────────────
76
77/// Which regulated settlement process produced this result.
78///
79/// Determines which BDEW PIDs are applicable and which regulatory references apply.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum SettlementType {
82    /// Netznutzungsentgelt (NNE) Strom — PID 31001 (NB → LF).
83    NneStrom,
84    /// Netznutzungsentgelt (NNE) Gas — PID 31005 (NB → LF, GasNEV).
85    NneGas,
86    /// NNE selbst ausgestellt (NB + LF = same entity) — PID 31006.
87    NneSelbstausstellt,
88    /// Mehr-/Mindermengen settlement Strom — PID 31002 (NB → LF).
89    MmmStrom,
90    /// Messstellenbetrieb settlement — PID 31009 (NB → MSB).
91    MsbRechnung,
92    /// GaBi Gas AWH Sperrprozesse settlement — PID 31011 (NB → LF).
93    GasAwhSperrung,
94    /// Redispatch 2.0 Einsatzkosten (NB → ÜNB, BK6-20-061).
95    RedispatchKostenblatt,
96}
97
98impl SettlementType {
99    /// Default BDEW PID for this settlement type.
100    ///
101    /// Callers may override the PID (e.g. `31005` for Gas NNE, `31006` for
102    /// selbstausstellt) after construction.
103    #[must_use]
104    pub fn default_pid(self) -> u32 {
105        match self {
106            Self::NneStrom => 31001,
107            Self::NneGas => 31005,
108            Self::NneSelbstausstellt => 31006,
109            Self::MmmStrom => 31002,
110            Self::MsbRechnung => 31009,
111            Self::GasAwhSperrung => 31011,
112            Self::RedispatchKostenblatt => 0, // no standard PID
113        }
114    }
115}
116
117// ── SettlementStatus ──────────────────────────────────────────────────────────
118
119/// Lifecycle status of a settlement result.
120///
121/// Settlements are never destroyed — every correction or cancellation creates
122/// a new result that references the original. This ensures an immutable audit trail.
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum SettlementStatus {
125    /// Initial calculation — no prior settlement exists for this period.
126    Initial,
127    /// Correction of a prior settlement (references `correction_of`).
128    Correction,
129    /// Cancellation of a prior settlement — all positions are negated.
130    Reversal,
131    /// Final settlement — no further corrections expected.
132    Final,
133}
134
135// ── LegalReference ────────────────────────────────────────────────────────────
136
137/// Regulatory citation that justifies a billing position or rate.
138///
139/// Every [`InvoicePosition`] should carry at least one `LegalReference`.
140/// This enables full auditability: any operator or regulator can trace
141/// exactly which paragraph, ruling, and version authorised each charge.
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub enum LegalReference {
144    /// StromNEV — Stromnetzentgeltverordnung (grid usage charges, Strom).
145    ///
146    /// Example: `StromNev { paragraph: "§17" }` for Leistungspreise.
147    StromNev {
148        /// Paragraph reference, e.g. `"§17"`, `"§21"`.
149        paragraph: &'static str,
150    },
151    /// GasNEV — Gasnetzentgeltverordnung (grid usage charges, Gas).
152    GasNev {
153        /// Paragraph reference, e.g. `"§14"`.
154        paragraph: &'static str,
155    },
156    /// KAV — Konzessionsabgabenverordnung (municipal concession fee).
157    ///
158    /// Example: `Kav { paragraph: "§2 Abs. 2" }`.
159    Kav {
160        /// Paragraph reference, e.g. `"§2 Abs. 2"`.
161        paragraph: &'static str,
162    },
163    /// §14a EnWG — Steuerbare Verbrauchseinrichtungen (controllable loads).
164    ///
165    /// Governs time-variable (ToU) NNE for heat pumps, EV chargers, etc.
166    Sect14aEnwg {
167        /// Module: 1, 2, or 3.
168        module: u8,
169    },
170    /// MessZV — Messzugangsverordnung (metering access).
171    MessZv {
172        /// Paragraph citation, e.g. `"§2"`, `"§17"`.
173        paragraph: &'static str,
174    },
175    /// MsbG — Messstellenbetriebsgesetz (metering point operation).
176    MsbG {
177        /// Paragraph citation, e.g. `"§§6–7"`.
178        paragraph: &'static str,
179    },
180    /// BNetzA decision (Beschluss).
181    ///
182    /// Example: `BnetzaDecision { reference: "BK6-22-300" }`.
183    BnetzaDecision {
184        /// Decision reference, e.g. `"BK6-22-300"`, `"BK6-24-174"`.
185        reference: &'static str,
186    },
187    /// BDEW application handbook (Anwendungshandbuch).
188    BdewAhb {
189        /// AHB reference, e.g. `"GPKE BK6-22-024"`.
190        reference: &'static str,
191    },
192    /// StromNZV — Stromnetzzugangsverordnung (grid access, Strom).
193    StromNzv {
194        /// Paragraph citation, e.g. `"§15"`.
195        paragraph: &'static str,
196    },
197    /// GasNZV — Gasnetzzugangsverordnung (grid access, Gas).
198    GasNzv {
199        /// Paragraph citation, e.g. `"§15"`.
200        paragraph: &'static str,
201    },
202    /// EnWG — Energiewirtschaftsgesetz (general energy law).
203    Enwg {
204        /// Paragraph citation, e.g. `"§14a"`.
205        paragraph: &'static str,
206    },
207    /// ARegV — Anreizregulierungsverordnung (incentive regulation).
208    ///
209    /// ARegV §§17–21 define the allowed NNE revenue caps and efficiency targets.
210    /// Relevant when documenting why a specific regulated tariff level was approved.
211    ARegV {
212        /// Paragraph citation, e.g. `"§17"`, `"§21"`.
213        paragraph: &'static str,
214    },
215}
216
217impl LegalReference {
218    /// Short human-readable citation string (German).
219    #[must_use]
220    pub fn citation(&self) -> String {
221        match self {
222            Self::StromNev { paragraph } => format!("StromNEV {paragraph}"),
223            Self::GasNev { paragraph } => format!("GasNEV {paragraph}"),
224            Self::Kav { paragraph } => format!("KAV {paragraph}"),
225            Self::Sect14aEnwg { module } => format!("§14a EnWG Modul {module}"),
226            Self::MessZv { paragraph } => format!("MessZV {paragraph}"),
227            Self::MsbG { paragraph } => format!("MsbG {paragraph}"),
228            Self::BnetzaDecision { reference } => format!("BNetzA {reference}"),
229            Self::BdewAhb { reference } => format!("BDEW {reference}"),
230            Self::StromNzv { paragraph } => format!("StromNZV {paragraph}"),
231            Self::GasNzv { paragraph } => format!("GasNZV {paragraph}"),
232            Self::Enwg { paragraph } => format!("EnWG {paragraph}"),
233            Self::ARegV { paragraph } => format!("ARegV {paragraph}"),
234        }
235    }
236}
237
238// ── TariffSource ──────────────────────────────────────────────────────────────
239
240/// Origin of the tariff rate applied in a settlement position.
241///
242/// Every rate used in a billing position must be traceable to a `TariffSource`.
243/// This enables operators and auditors to answer: *"Why was this rate used?"*
244#[derive(Debug, Clone, PartialEq, Eq)]
245pub enum TariffSource {
246    /// Rate from the published and approved `PreisblattNetznutzung` tariff sheet.
247    PublishedTariffSheet {
248        /// Tariff sheet identifier or version, e.g. `"Preisblatt 2025 Q1"`.
249        sheet_id: String,
250    },
251    /// Rate from a historical tariff (retroactive billing or correction).
252    HistoricalTariff {
253        /// Original valid_from date of the tariff.
254        valid_from: time::Date,
255    },
256    /// Regulatory rate mandated by a BNetzA decision.
257    RegulatoryTariff {
258        /// BNetzA decision reference.
259        decision_ref: &'static str,
260    },
261    /// Contract-specific rate negotiated between NB and customer.
262    ContractTariff {
263        /// Contract reference.
264        contract_ref: String,
265    },
266    /// Manual override by operator (requires documentation).
267    ManualOverride {
268        /// Reason for the override.
269        reason: String,
270    },
271}
272
273// ── CalculationTrace ──────────────────────────────────────────────────────────
274
275/// Full audit record for how one [`InvoicePosition`] was computed.
276///
277/// Answers the question: *"Why is this amount on the invoice?"*
278///
279/// Every `CalculationTrace` carries the input values, the applied legal rules,
280/// intermediate results, and the tariff source. This enables:
281/// - Regulator audits (BNetzA §20 EnWG)
282/// - Operator review
283/// - LF dispute resolution
284/// - AI-assisted invoice explainability (MCP tools)
285#[derive(Debug, Clone)]
286pub struct CalculationTrace {
287    /// Human-readable explanation of this position.
288    ///
289    /// Example: `"Arbeit 1500 kWh × 3.5 ct/kWh = 52.50 EUR"`
290    pub explanation: String,
291    /// Input quantity used (before rounding).
292    pub input_quantity: Decimal,
293    /// Input unit price in EUR (before rounding, already converted from ct).
294    pub input_unit_price_eur: Decimal,
295    /// Intermediate result before rounding (qty × price).
296    pub gross_eur: Decimal,
297    /// Applied legal references (at least one required).
298    pub legal_refs: Vec<LegalReference>,
299    /// Source of the tariff rate.
300    pub tariff_source: Option<TariffSource>,
301    /// Any §14a reductions applied, expressed as a fraction (0.0–1.0).
302    ///
303    /// `None` when no regulatory reduction applies.
304    /// Example: `Some(Decimal::new(85, 2))` = 85% of full rate (15% reduction).
305    pub regulatory_reduction_factor: Option<Decimal>,
306    /// Notes on rounding applied.
307    ///
308    /// Example: `"rounded to 5 dp per StromNEV §17"`.
309    pub rounding_note: Option<&'static str>,
310}
311
312// ── SettlementWarning ─────────────────────────────────────────────────────────
313
314/// A non-blocking validation issue found during settlement calculation.
315///
316/// Warnings do not prevent the invoice from being generated but should be
317/// reviewed before dispatch. The service layer may choose to block dispatch
318/// on `Severity::Error` warnings.
319#[derive(Debug, Clone)]
320pub struct SettlementWarning {
321    /// Severity: informational, warning, or error.
322    pub severity: WarningSeverity,
323    /// Machine-readable warning code.
324    pub code: &'static str,
325    /// Human-readable description.
326    pub message: String,
327}
328
329/// Severity level for [`SettlementWarning`].
330#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
331pub enum WarningSeverity {
332    /// Informational — no action required.
333    Info,
334    /// Potential issue — review recommended before dispatch.
335    Warning,
336    /// Definite issue — should be resolved before dispatch.
337    Error,
338}
339
340// ── InvoicePosition ───────────────────────────────────────────────────────────
341
342/// One line item in a grid settlement.
343///
344/// Carries raw numbers for the service layer to map into the required format
345/// (BO4E `Rechnungsposition`, EN16931 UBL, etc.).
346///
347/// Invariant: `net_eur == (quantity × unit_price_eur).round_dp(5)`.
348#[derive(Debug, Clone)]
349pub struct InvoicePosition {
350    /// 1-based sequence number.
351    pub number: u32,
352    /// Human-readable position description.
353    pub text: String,
354    /// Metered or contracted quantity.
355    pub quantity: Decimal,
356    /// Unit of measure.
357    pub unit: QuantityUnit,
358    /// Unit price in EUR (already converted from ct where applicable).
359    pub unit_price_eur: Decimal,
360    /// Net amount in EUR, rounded to 5 decimal places.
361    ///
362    /// May be negative for credit positions (Mindermengen, Gutschriften).
363    pub net_eur: Decimal,
364    /// Full audit trace for this position.
365    ///
366    /// Answers "why is this amount here?" and carries all legal references.
367    pub trace: CalculationTrace,
368}
369
370// ── GridSettlement ────────────────────────────────────────────────────────────
371
372/// Result of a grid settlement calculation — pure domain type, no BO4E coupling.
373///
374/// This is the canonical output of all calculation functions in `grid-billing`.
375/// The service layer (`netzbilanzd`, `invoicd`) converts it to `rubo4e::current::Rechnung`
376/// via a local `into_rechnung()` adapter.
377///
378/// ## Explainability
379///
380/// Every settlement carries a full [`CalculationTrace`] per position, applied
381/// [`LegalReference`]s, and the [`TariffSource`] for each rate. The `warnings`
382/// field surfaces any non-blocking validation issues.
383///
384/// ## Immutable correction chain
385///
386/// When correcting a prior settlement, set `status = SettlementStatus::Correction`
387/// and populate `correction_of` with the original settlement's `rechnungsnummer`.
388/// The original settlement is never mutated.
389///
390/// ## PID override
391///
392/// `pid` defaults to `SettlementType::default_pid()`. Override after construction:
393/// - `31005` for Gas NNE (NneStrom → NneGas)
394/// - `31006` for selbstausstellt NNE
395/// - `31011` for GeLi Gas AWH Sperrprozesse
396#[derive(Debug, Clone)]
397pub struct GridSettlement {
398    /// BDEW Prüfidentifikator — caller may override after construction.
399    pub pid: u32,
400    /// Settlement type.
401    pub settlement_type: SettlementType,
402    /// Lifecycle status.
403    pub status: SettlementStatus,
404    /// Unique invoice reference number.
405    pub rechnungsnummer: String,
406    /// If this is a correction, the `rechnungsnummer` of the original settlement.
407    pub correction_of: Option<String>,
408    /// Invoice issue date.
409    pub invoice_date: time::Date,
410    /// Payment due date (Zahlungsziel, §271 BGB).
411    pub due_date: time::Date,
412    /// Start of billing period (inclusive).
413    pub period_from: time::Date,
414    /// End of billing period (inclusive).
415    pub period_to: time::Date,
416    /// Sender MP-ID — Netzbetreiber (or MSB for PID 31009).
417    pub nb_mp_id: String,
418    /// Recipient MP-ID — Lieferant (NNE/MMM), MSB (PID 31009), or MGV (GaBi Gas).
419    ///
420    /// Maps to `rechnungsempfaenger` in the BO4E `Rechnung` built by the service layer.
421    /// Previously omitted, causing service-layer code to pass recipient IDs separately.
422    pub counterparty_mp_id: String,
423    /// Ordered billing positions (each with full calculation trace).
424    pub positions: Vec<InvoicePosition>,
425    /// Net total in EUR, rounded to 2 decimal places.
426    pub total_eur: Decimal,
427    /// Non-blocking validation warnings.
428    ///
429    /// Empty when the settlement is clean. The service layer should review
430    /// `Warning` and `Error` severity items before dispatch.
431    pub warnings: Vec<SettlementWarning>,
432}
433
434/// Backward-compatible alias so callers using the old name continue to compile.
435///
436/// `GridInvoice` was the original output type. New code should use [`GridSettlement`]
437/// which carries full calculation traces, legal references, and settlement metadata.
438pub type GridInvoice = GridSettlement;
439
440impl GridSettlement {
441    /// Number of billing positions.
442    #[must_use]
443    pub fn positions_count(&self) -> usize {
444        self.positions.len()
445    }
446
447    /// `true` when the settlement has no warnings at `Warning` or `Error` severity.
448    #[must_use]
449    pub fn is_clean(&self) -> bool {
450        !self
451            .warnings
452            .iter()
453            .any(|w| w.severity >= WarningSeverity::Warning)
454    }
455
456    /// All legal references cited across all positions (deduplicated by citation string).
457    #[must_use]
458    pub fn all_legal_refs(&self) -> Vec<String> {
459        let mut seen = std::collections::HashSet::new();
460        self.positions
461            .iter()
462            .flat_map(|p| p.trace.legal_refs.iter().map(|r| r.citation()))
463            .filter(|c| seen.insert(c.clone()))
464            .collect()
465    }
466
467    /// Net total as computed from positions (re-summed for verification).
468    ///
469    /// Should equal `total_eur`. A mismatch indicates a calculation bug.
470    #[must_use]
471    pub fn recomputed_total(&self) -> Decimal {
472        self.positions
473            .iter()
474            .map(|p| p.net_eur)
475            .sum::<Decimal>()
476            .round_dp(2)
477    }
478}
479
480// ── Input types ───────────────────────────────────────────────────────────────
481
482/// Input for NNE (Netznutzungsentgelt) invoice calculation.
483///
484/// Covers:
485/// - **PID 31001** — NNE Strom (NB → LF, monthly network usage billing)
486/// - **PID 31005** — NNE Gas (NB → LF, monthly gas network usage billing)
487///
488/// For **RLM** (Leistungsmessung) meters:
489/// - Set `spitzenleistung_kw` to the peak demand in kW.
490/// - Set `leistungspreis_eur_per_kw` to the published tariff.
491///
492/// For **SLP** meters:
493/// - Leave both fields as `None` (Arbeitspreisanteil only).
494///
495/// For **§14a Modul 2 time-variable NNE** (BNetzA BK6-22-300):
496/// - Set `arbeitsmenge_ht_kwh` + `arbeitspreis_ht_ct_per_kwh` for Hochlast periods.
497/// - Set `arbeitsmenge_nt_kwh` + `arbeitspreis_nt_ct_per_kwh` for Niedertarif periods.
498/// - Leave `arbeitsmenge_kwh` / `arbeitspreis_ct_per_kwh` as the base fallback.
499///
500/// For Gas:
501/// - The `arbeitsmenge_kwh` should already be converted from m³ using
502///   `brennwert × zustandszahl` before being supplied here.
503///   (`mako-edm` `MeterBillingPeriod.arbeitsmenge_kwh` carries this converted value.)
504#[derive(Debug, Clone)]
505pub struct NneInput {
506    /// 11-digit Marktlokations-ID.
507    pub malo_id: String,
508    /// Invoice sender — Netzbetreiber or Gasnetzbetreiber MP-ID.
509    pub nb_mp_id: String,
510    /// Invoice recipient — Lieferant MP-ID.
511    pub lf_mp_id: String,
512    /// Unique invoice number (operator-generated).
513    pub rechnungsnummer: String,
514    /// Start of billing period (inclusive, German local date).
515    pub period_from: time::Date,
516    /// End of billing period (inclusive, German local date).
517    pub period_to: time::Date,
518    /// Invoice issue date.
519    pub invoice_date: time::Date,
520    /// Payment due date (Zahlungsziel).
521    pub due_date: time::Date,
522    /// Total energy consumption in kWh for the billing period.
523    ///
524    /// For Gas: already converted from m³ (brennwert × zustandszahl × volume).
525    /// Used when HT/NT split is not available (SLP, Gas, or pre-§14a deployments).
526    pub arbeitsmenge_kwh: Decimal,
527    /// Published NNE Arbeitspreis in **ct/kWh** (from `PreisblattNetznutzung`).
528    /// Used as the single Arbeit rate when HT/NT split is absent.
529    pub arbeitspreis_ct_per_kwh: Decimal,
530
531    // ── §14a Modul 2 time-variable (ToU) NNE ─────────────────────────────────
532    // BNetzA BK6-22-300: mandatory for all controllable loads since 01.01.2024.
533    // When both fields below are non-None, the billing engine generates two
534    // separate Arbeit positions (HT + NT) instead of a single blended position.
535    // Source: `edmd` MeterBillingPeriod.arbeitsmenge_ht_kwh / .arbeitsmenge_nt_kwh.
536    /// Hochlast (HT) consumption in kWh — §14a Modul 2 periods (higher-price band).
537    /// `None` when ToU metering is not configured for this MaLo.
538    pub arbeitsmenge_ht_kwh: Option<Decimal>,
539    /// HT Arbeitspreis in ct/kWh (from `PreisblattNetznutzung.zeitvariablePreispositionen`).
540    /// Required when `arbeitsmenge_ht_kwh` is set.
541    pub arbeitspreis_ht_ct_per_kwh: Option<Decimal>,
542    /// Niedertarif (NT) consumption in kWh — §14a Modul 2 off-peak periods.
543    /// `None` when ToU metering is not configured for this MaLo.
544    pub arbeitsmenge_nt_kwh: Option<Decimal>,
545    /// NT Arbeitspreis in ct/kWh (from `PreisblattNetznutzung.zeitvariablePreispositionen`).
546    /// Required when `arbeitsmenge_nt_kwh` is set.
547    pub arbeitspreis_nt_ct_per_kwh: Option<Decimal>,
548
549    // ── RLM demand charge ─────────────────────────────────────────────────────
550    /// Peak demand in **kW** (`spitzenleistung_kw` from `MeterBillingPeriod`).
551    ///
552    /// `None` for SLP meters and Gas MaLos.
553    pub spitzenleistung_kw: Option<Decimal>,
554    /// Published NNE Leistungspreis in **EUR/kW** (from `PreisblattNetznutzung`).
555    ///
556    /// `None` when `spitzenleistung_kw` is `None`.
557    pub leistungspreis_eur_per_kw: Option<Decimal>,
558    /// Published Konzessionsabgabe rate in **ct/kWh** (from `PreisblattKonzessionsabgabe`).
559    ///
560    /// `None` when KA does not apply (Gas or exempt customer class).
561    pub ka_satz_ct_per_kwh: Option<Decimal>,
562
563    /// Optional tariff sheet identifier for audit tracing.
564    ///
565    /// When set, each position's `trace.tariff_source` references this sheet.
566    pub tariff_sheet_id: Option<String>,
567    /// Commodity — drives legal references (StromNEV vs GasNEV) and `SettlementType`.
568    ///
569    /// - `Sparte::Strom` (default) → `StromNEV §21` Arbeit, `StromNEV §17` Leistung,
570    ///   `SettlementType::NneStrom`
571    /// - `Sparte::Gas` → `GasNEV §14`, `SettlementType::NneGas`
572    pub sparte: Sparte,
573    /// KAV rate class applied to this metering point.
574    ///
575    /// Included in the calculation trace for KA positions so auditors can verify
576    /// the rate matches the correct KAV §2 tier. `None` when KA is absent.
577    pub ka_klasse: Option<KaKlasse>,
578}
579
580// ── MmmInput ──────────────────────────────────────────────────────────────────
581
582/// Input for Mehr-/Mindermengen (MMM) settlement invoice calculation.
583///
584/// Covers:
585/// - **PID 31002** — `MMM-Stornorechnung NNE Strom` used for Mehr-/Mindermengen
586///   settlement between NB and LF.
587///
588/// Mehr-/Mindermengen settle the difference between the LF's forecast profile
589/// (SLP standard load profile) and the actual measured consumption.
590///
591/// - **Mehrmengen** (positive deviation): actual > profil → LF owes NB
592/// - **Mindermengen** (negative deviation): actual < profil → NB owes LF
593///
594/// The settlement amount is the algebraic sum of both positions.  It can be
595/// negative (i.e. a credit note from NB to LF) when Mindermengen dominate.
596#[derive(Debug, Clone)]
597pub struct MmmInput {
598    /// 11-digit Marktlokations-ID.
599    pub malo_id: String,
600    /// Invoice sender — Netzbetreiber MP-ID.
601    pub nb_mp_id: String,
602    /// Invoice recipient — Lieferant MP-ID.
603    pub lf_mp_id: String,
604    /// Unique invoice number.
605    pub rechnungsnummer: String,
606    /// Start of billing period.
607    pub period_from: time::Date,
608    /// End of billing period.
609    pub period_to: time::Date,
610    /// Invoice issue date.
611    pub invoice_date: time::Date,
612    /// Payment due date.
613    pub due_date: time::Date,
614    /// Commodity — determines legal references (StromNZV vs GasNZV).
615    ///
616    /// - `Sparte::Strom` → `StromNZV §15`, `GPKE BK6-22-024`
617    /// - `Sparte::Gas` → `GasNZV §14`, `GeLi Gas BK7-24-01-009`
618    pub sparte: Sparte,
619    /// Actual measured consumption in kWh (from MSCONS / `MeterBillingPeriod`).
620    pub actual_kwh: Decimal,
621    /// Standard load profile (SLP) forecast consumption in kWh.
622    pub profil_kwh: Decimal,
623    /// Mehrmengen price in **ct/kWh** (from `PreisblattNetznutzung` MMM position).
624    pub mehr_preis_ct_per_kwh: Decimal,
625    /// Mindermengen price in **ct/kWh** (from `PreisblattNetznutzung` MMM position).
626    pub minder_preis_ct_per_kwh: Decimal,
627}
628
629// ── MsbInput ──────────────────────────────────────────────────────────────────
630
631/// Input for MSB (Messstellenbetreiber) invoice calculation.
632///
633/// Covers:
634/// - **PID 31009** — MSB-Rechnung (NB → MSB, monthly metering service settlement)
635///
636/// The NB bills the MSB for the metering service period.  Positions:
637/// 1. Grundgebühr Messstellenbetrieb — flat monthly base fee × billing months.
638/// 2. Messdienstleistung — optional per-period measurement service fee.
639#[derive(Debug, Clone)]
640pub struct MsbInput {
641    /// 11-digit Marktlokations-ID.
642    pub malo_id: String,
643    /// Invoice sender — Netzbetreiber MP-ID.
644    pub nb_mp_id: String,
645    /// Invoice recipient — Messstellenbetreiber MP-ID.
646    pub msb_mp_id: String,
647    /// Unique invoice number.
648    pub rechnungsnummer: String,
649    /// Start of billing period (inclusive, German local date).
650    pub period_from: time::Date,
651    /// End of billing period (inclusive, German local date).
652    pub period_to: time::Date,
653    /// Invoice issue date.
654    pub invoice_date: time::Date,
655    /// Payment due date.
656    pub due_date: time::Date,
657    /// Grundgebühr Messstellenbetrieb in **EUR/month** (from `PreisblattMessung`).
658    pub grundgebuehr_eur_per_month: Decimal,
659    /// Number of full calendar months in the billing period.
660    pub billing_months: u32,
661    /// Optional Messdienstleistung flat fee in **EUR** for the full period.
662    ///
663    /// `None` when the MSB provides only the meter, not a separate measurement service.
664    pub messdienstleistung_eur: Option<Decimal>,
665}
666
667// ── ValidationResult ─────────────────────────────────────────────────────────
668
669/// Result of pre-calculation input validation.
670///
671/// Validation runs **before** the calculation begins. A `ValidationResult`
672/// with `is_valid = false` should prevent calling `calculate_*` to avoid
673/// partial or incorrect results.
674///
675/// Use [`validate_nne_input`], [`validate_mmm_input`], or [`validate_msb_input`]
676/// to obtain a `ValidationResult` for your input.
677#[derive(Debug, Clone)]
678pub struct ValidationResult {
679    /// Whether the input passed all validation checks.
680    pub is_valid: bool,
681    /// All warnings and errors found. May contain [`WarningSeverity::Info`] items
682    /// even when `is_valid = true`.
683    pub warnings: Vec<SettlementWarning>,
684}
685
686impl ValidationResult {
687    /// Returns a clean (valid, no warnings) result.
688    #[must_use]
689    pub fn ok() -> Self {
690        Self {
691            is_valid: true,
692            warnings: Vec::new(),
693        }
694    }
695
696    /// Appends a warning. `WarningSeverity::Error` marks the result invalid.
697    pub fn push(&mut self, w: SettlementWarning) {
698        if w.severity == WarningSeverity::Error {
699            self.is_valid = false;
700        }
701        self.warnings.push(w);
702    }
703}
704
705/// Validate a [`NneInput`] before calling [`crate::calculate_nne_invoice`].
706///
707/// The calculation functions also validate hard constraints and return
708/// `Err(BillingError)`. This function additionally surfaces soft warnings
709/// (e.g. suspiciously negative prices) that would not prevent calculation
710/// but should be reviewed before dispatch.
711#[must_use]
712pub fn validate_nne_input(input: &NneInput) -> ValidationResult {
713    let mut r = ValidationResult::ok();
714    if input.period_from >= input.period_to {
715        r.push(SettlementWarning {
716            severity: WarningSeverity::Error,
717            code: "INVALID_PERIOD",
718            message: "period_from must be strictly before period_to".to_owned(),
719        });
720    }
721    if input.arbeitsmenge_kwh < Decimal::ZERO {
722        r.push(SettlementWarning {
723            severity: WarningSeverity::Error,
724            code: "NEGATIVE_CONSUMPTION",
725            message: format!("arbeitsmenge_kwh is negative: {}", input.arbeitsmenge_kwh),
726        });
727    }
728    if input.arbeitspreis_ct_per_kwh < Decimal::ZERO {
729        r.push(SettlementWarning {
730            severity: WarningSeverity::Warning,
731            code: "NEGATIVE_ARBEITSPREIS",
732            message: format!(
733                "arbeitspreis_ct_per_kwh is negative: {}",
734                input.arbeitspreis_ct_per_kwh
735            ),
736        });
737    }
738    if input.spitzenleistung_kw.is_some() != input.leistungspreis_eur_per_kw.is_some() {
739        r.push(SettlementWarning {
740            severity: WarningSeverity::Error,
741            code: "MISMATCHED_RLM_FIELDS",
742            message:
743                "spitzenleistung_kw and leistungspreis_eur_per_kw must both be set or both absent"
744                    .to_owned(),
745        });
746    }
747    if input.sparte == Sparte::Gas && input.spitzenleistung_kw.is_some() {
748        r.push(SettlementWarning {
749            severity: WarningSeverity::Warning,
750            code: "GAS_WITH_LEISTUNG",
751            message: "Gas NNE typically does not use Leistungspreis — verify tariff configuration"
752                .to_owned(),
753        });
754    }
755    r
756}
757
758/// Validate a [`MmmInput`] before calling [`crate::calculate_mmm_invoice`].
759#[must_use]
760pub fn validate_mmm_input(input: &MmmInput) -> ValidationResult {
761    let mut r = ValidationResult::ok();
762    if input.period_from >= input.period_to {
763        r.push(SettlementWarning {
764            severity: WarningSeverity::Error,
765            code: "INVALID_PERIOD",
766            message: "period_from must be strictly before period_to".to_owned(),
767        });
768    }
769    if input.mehr_preis_ct_per_kwh < Decimal::ZERO {
770        r.push(SettlementWarning {
771            severity: WarningSeverity::Warning,
772            code: "NEGATIVE_MEHR_PREIS",
773            message: format!(
774                "mehr_preis_ct_per_kwh is negative: {}",
775                input.mehr_preis_ct_per_kwh
776            ),
777        });
778    }
779    if input.minder_preis_ct_per_kwh < Decimal::ZERO {
780        r.push(SettlementWarning {
781            severity: WarningSeverity::Warning,
782            code: "NEGATIVE_MINDER_PREIS",
783            message: format!(
784                "minder_preis_ct_per_kwh is negative: {}",
785                input.minder_preis_ct_per_kwh
786            ),
787        });
788    }
789    r
790}
791
792/// Validate a [`MsbInput`] before calling [`crate::calculate_msb_invoice`].
793#[must_use]
794pub fn validate_msb_input(input: &MsbInput) -> ValidationResult {
795    let mut r = ValidationResult::ok();
796    if input.period_from >= input.period_to {
797        r.push(SettlementWarning {
798            severity: WarningSeverity::Error,
799            code: "INVALID_PERIOD",
800            message: "period_from must be strictly before period_to".to_owned(),
801        });
802    }
803    if input.grundgebuehr_eur_per_month < Decimal::ZERO {
804        r.push(SettlementWarning {
805            severity: WarningSeverity::Error,
806            code: "NEGATIVE_GRUNDGEBUEHR",
807            message: format!(
808                "grundgebuehr_eur_per_month is negative: {}",
809                input.grundgebuehr_eur_per_month
810            ),
811        });
812    }
813    if input.billing_months == 0 {
814        r.push(SettlementWarning {
815            severity: WarningSeverity::Error,
816            code: "ZERO_BILLING_MONTHS",
817            message: "billing_months must be at least 1".to_owned(),
818        });
819    }
820    r
821}