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}