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