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}