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