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 `crate::EuroAmount`
22//! newtype provides overflow-safe EUR arithmetic. No `f32`/`f64` appears anywhere
23//! in settlement calculations.
24
25use crate::rounding::RoundMoney;
26use rust_decimal::Decimal;
27
28// ── Sparte ────────────────────────────────────────────────────────────────────
29
30/// Commodity — Strom (electricity) or Gas.
31///
32/// Controls which legal references are applied to each settlement position:
33/// - `Strom` → `StromNEV`, BK6 Festlegungen
34/// - `Gas` → `GasNEV`, BK7 Festlegungen
35#[derive(
36 Debug,
37 Clone,
38 Copy,
39 PartialEq,
40 Eq,
41 PartialOrd,
42 Ord,
43 Hash,
44 Default,
45 serde::Serialize,
46 serde::Deserialize,
47)]
48pub enum Sparte {
49 /// Electricity (Strom). Default.
50 #[default]
51 Strom,
52 /// Natural gas (Gas).
53 Gas,
54}
55
56// ── Konzessionsabgabe (KAV §2) ────────────────────────────────────────────────
57
58/// Municipality size band for Konzessionsabgabe, per **KAV §2 Abs. 2**.
59///
60/// KAV bands Tarifkunden rates by the municipality's **inhabitant count**, not by
61/// the customer's annual consumption.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
63pub enum GemeindeGroesse {
64 /// bis 25 000 Einwohner.
65 Bis25k,
66 /// bis 100 000 Einwohner.
67 Bis100k,
68 /// bis 500 000 Einwohner.
69 Bis500k,
70 /// über 500 000 Einwohner.
71 Ueber500k,
72}
73
74/// Konzessionsabgabe customer group per **KAV §2**.
75///
76/// The Tarifkunde/Sondervertragskunde split is a **contract-type** test, not a
77/// consumption threshold: KAV §2 Abs. 3 applies to Sondervertragskunden whatever
78/// they consume, and Abs. 2 bands Tarifkunden by municipality size.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
80pub enum KaKundengruppe {
81 /// Tarifkunde — KAV §2 Abs. 2. Rate depends on [`GemeindeGroesse`].
82 ///
83 /// For gas, `nur_kochen_warmwasser` selects between the two Abs. 2 columns:
84 /// supply limited to cooking and hot water, or all other Tariflieferungen.
85 Tarifkunde {
86 /// Municipality size band.
87 gemeinde: GemeindeGroesse,
88 /// Gas only: supply limited to cooking/hot water. Ignored for Strom.
89 nur_kochen_warmwasser: bool,
90 },
91 /// Schwachlaststrom — KAV §2 Abs. 2. **Strom only**; gas has no such tier.
92 Schwachlast,
93 /// Sondervertragskunde — KAV §2 Abs. 3. Flat, independent of municipality size.
94 Sondervertragskunde,
95 /// No Konzessionsabgabe may be agreed or paid — KAV §2 Abs. 4 (Strom) resp.
96 /// Abs. 5 (Gas).
97 ///
98 /// The prohibitions are the Grenzpreisvergleich for Strom-Sondervertrags-
99 /// kunden (Abs. 4) and, for Gas, a Liefermenge über 5 Millionen kWh je Jahr
100 /// und Abnahmefall or a Durchschnittspreis unter dem Grenzpreis (Abs. 5).
101 /// §2 Abs. 7 is **not** one of them: it decides whether a
102 /// Niederspannungslieferung counts as a Tarif- or a Sondervertragslieferung
103 /// (30 kW in mindestens zwei Monaten **und** mehr als 30 000 kWh im Jahr),
104 /// and never that no Konzessionsabgabe is payable.
105 Exempt,
106}
107
108impl KaKundengruppe {
109 /// The KAV §2 **Höchstbetrag** in ct/kWh for this group and Sparte.
110 ///
111 /// Returns `None` for [`KaKundengruppe::Exempt`], and for
112 /// [`KaKundengruppe::Schwachlast`] on gas, which KAV does not provide.
113 ///
114 /// These are statutory **maxima**, not the agreed rate — a concession contract
115 /// may set anything up to them.
116 #[must_use]
117 pub fn hoechstsatz_ct_per_kwh(self, sparte: Sparte) -> Option<Decimal> {
118 let pick = |a: &str| Decimal::from_str_exact(a).ok();
119 match (self, sparte) {
120 (Self::Exempt, _) => None,
121 (Self::Schwachlast, Sparte::Strom) => pick("0.61"),
122 (Self::Schwachlast, Sparte::Gas) => None,
123 (Self::Sondervertragskunde, Sparte::Strom) => pick("0.11"),
124 (Self::Sondervertragskunde, Sparte::Gas) => pick("0.03"),
125 (Self::Tarifkunde { gemeinde, .. }, Sparte::Strom) => pick(match gemeinde {
126 GemeindeGroesse::Bis25k => "1.32",
127 GemeindeGroesse::Bis100k => "1.59",
128 GemeindeGroesse::Bis500k => "1.99",
129 GemeindeGroesse::Ueber500k => "2.39",
130 }),
131 (
132 Self::Tarifkunde {
133 gemeinde,
134 nur_kochen_warmwasser: true,
135 },
136 Sparte::Gas,
137 ) => pick(match gemeinde {
138 GemeindeGroesse::Bis25k => "0.51",
139 GemeindeGroesse::Bis100k => "0.61",
140 GemeindeGroesse::Bis500k => "0.77",
141 GemeindeGroesse::Ueber500k => "0.93",
142 }),
143 (
144 Self::Tarifkunde {
145 gemeinde,
146 nur_kochen_warmwasser: false,
147 },
148 Sparte::Gas,
149 ) => pick(match gemeinde {
150 GemeindeGroesse::Bis25k => "0.22",
151 GemeindeGroesse::Bis100k => "0.27",
152 GemeindeGroesse::Bis500k => "0.33",
153 GemeindeGroesse::Ueber500k => "0.40",
154 }),
155 }
156 }
157
158 /// Short label for the invoice position text.
159 ///
160 /// `sparte` is read only for [`Self::Exempt`], whose prohibition sits in a
161 /// different Absatz for each Sparte.
162 #[must_use]
163 pub const fn label(self, sparte: Sparte) -> &'static str {
164 match self {
165 Self::Tarifkunde { .. } => "KAV §2 Abs. 2 Tarifkunde",
166 Self::Schwachlast => "KAV §2 Abs. 2 Schwachlast",
167 Self::Sondervertragskunde => "KAV §2 Abs. 3 Sondervertragskunde",
168 Self::Exempt => match sparte {
169 Sparte::Strom => "KAV §2 Abs. 4 — keine Konzessionsabgabe zulässig",
170 Sparte::Gas => "KAV §2 Abs. 5 — keine Konzessionsabgabe zulässig",
171 },
172 }
173 }
174
175 /// The KAV paragraph that governs this group.
176 ///
177 /// Cited on the position, so the invoice states the rule it was actually
178 /// billed under: §2 Abs. 2 for a Tarifkunde, Abs. 3 for a
179 /// Sondervertragskunde, and — where no Konzessionsabgabe may be charged at
180 /// all — Abs. 4 for Strom, Abs. 5 for Gas.
181 #[must_use]
182 pub const fn kav_paragraph(self, sparte: Sparte) -> &'static str {
183 match self {
184 Self::Tarifkunde { .. } | Self::Schwachlast => "§2 Abs. 2",
185 Self::Sondervertragskunde => "§2 Abs. 3",
186 Self::Exempt => match sparte {
187 Sparte::Strom => "§2 Abs. 4",
188 Sparte::Gas => "§2 Abs. 5",
189 },
190 }
191 }
192}
193
194// ── QuantityUnit ──────────────────────────────────────────────────────────────
195
196/// Unit of measure for a settlement position quantity.
197#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
198pub enum QuantityUnit {
199 /// Kilowatt-hours (active energy).
200 Kwh,
201 /// Kilowatts (demand / peak load).
202 Kw,
203 /// Reactive energy (Blindarbeit) — kilovolt-ampere reactive hours.
204 ///
205 /// Used for reactive energy settlement positions per StromNEV §18.
206 Kvarh,
207 /// Reactive power (Blindleistung) — kilovolt-ampere reactive.
208 Kvar,
209 /// Calendar months.
210 Monat,
211 /// Calendar years — the unit of a charge whose published rate is annual and
212 /// whose quantity is the fraction of a year the settlement period covers.
213 Jahr,
214}
215
216// ── Sect14aModule ─────────────────────────────────────────────────────────────
217
218/// §14a EnWG module for steuerbare Verbrauchseinrichtungen (controllable loads).
219///
220/// Source: BNetzA BK6-22-300 (Beschluss 27.11.2023, in force 01.01.2024).
221///
222/// All three modules are **mandatory** for eligible controllable loads (heat pumps,
223/// EV chargers, battery storage ≥ 4.2 kW) registered with the NB. The LF/NB
224/// must offer at least Modul 1 to all eligible customers.
225#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
226pub enum Sect14aModule {
227 /// Modul 1 — **pauschale Reduzierung des Netzentgelts**.
228 ///
229 /// A flat reduction applied for the whole billing period, published by the
230 /// NB either as an annual EUR amount or as a factor on the rate. It needs no
231 /// additional metering, which is why it is the default where the connection
232 /// holder makes no choice. It is the one base module Modul 3 may be added to.
233 Modul1,
234 /// Modul 2 — **prozentuale Reduzierung des Arbeitspreises**.
235 ///
236 /// The Arbeitspreis of the Netzentgelt is reduced by a percentage for the
237 /// controllable device, which therefore needs its **own metering** — the
238 /// reduction attaches to that device's energy, not to the whole connection.
239 ///
240 /// An **alternative to Modul 1**, not an addition to it, and it takes no
241 /// Modul 3 (see [`Sect14aModule::combinable_with`]).
242 Modul2,
243 /// Modul 3 — **zeitvariable Netzentgelte**, available from 01.04.2025.
244 ///
245 /// Three Tarifstufen — Hochtarif, Standardtarif and Niedertarif — whose
246 /// windows the NB publishes in the UTILTS Zählzeitdefinition. Requires an
247 /// intelligent metering system. It may be combined with Modul 1 but **not**
248 /// with Modul 2.
249 Modul3,
250}
251
252impl Sect14aModule {
253 /// Canonical BNetzA decision reference for this module.
254 #[must_use]
255 pub fn bnentza_reference(self) -> &'static str {
256 "BK6-22-300"
257 }
258
259 /// Whether two **different** modules may be held at once.
260 ///
261 /// BK6-22-300 offers one base module and one optional addition. Modul 1 and
262 /// Modul 2 are the two forms the base takes — a pauschale reduction needing
263 /// no metering, or a percentage on the device's own Arbeitspreis — and the
264 /// Anschlussnutzer picks one. Modul 3 re-prices the Arbeitspreis over time,
265 /// so it composes with the pauschale Modul 1 and not with Modul 2, which
266 /// would reduce the same Arbeitspreis twice.
267 ///
268 /// `Modul 1 + Modul 3` is therefore the only pair, in either order. A module
269 /// paired with itself is not a combination and answers `false`.
270 #[must_use]
271 pub fn combinable_with(self, other: Self) -> bool {
272 matches!(
273 (self, other),
274 (Self::Modul1, Self::Modul3) | (Self::Modul3, Self::Modul1)
275 )
276 }
277
278 /// Display label for the module.
279 #[must_use]
280 pub fn label(self) -> &'static str {
281 match self {
282 Self::Modul1 => "§14a EnWG Modul 1 (pauschale Reduzierung)",
283 Self::Modul2 => "§14a EnWG Modul 2 (prozentuale Arbeitspreisreduzierung)",
284 Self::Modul3 => "§14a EnWG Modul 3 (zeitvariable Netzentgelte)",
285 }
286 }
287}
288
289// ── SettlementType ────────────────────────────────────────────────────────────
290
291/// Which regulated settlement process produced this result.
292///
293/// Determines which BDEW PIDs are applicable and which regulatory references apply.
294#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
295pub enum SettlementType {
296 /// Abschlagsrechnung Netznutzung — PID 31001 (NB → LF).
297 ///
298 /// A payment on account, not a settled period: it prices no energy and
299 /// carries **exactly one** Positionszeile (INVOIC AHB 1.0b, Änd-ID 26817 —
300 /// "Eine Abschlagsrechnung kann und muss genau eine Positionszeile
301 /// enthalten"). What settles it is the Abschlussrechnung that follows,
302 /// which deducts it by invoice number.
303 NneAbschlag,
304 /// Netznutzungsentgelt (NNE) Strom — PID 31002 (NN-Rechnung, NB → LF).
305 NneStrom,
306 /// Netznutzungsentgelt (NNE) Gas — PID 31002 (NN-Rechnung, NB → LF, GasNEV).
307 ///
308 /// NNE Strom and Gas share the INVOIC Prüfidentifikator 31002 (NN-Rechnung);
309 /// the Sparte is carried in the message content, not the PID. Keeping a
310 /// separate variant preserves the correct legal references (StromNEV vs
311 /// GasNEV) without conditional logic in call sites.
312 NneGas,
313 /// Mehr-/Mindermengen settlement Strom — PID 31005 (NB → LF, GPKE (BK6-24-174) Teil 1 Kap. 8.4).
314 MmmStrom,
315 /// Mehr-/Mindermengen settlement Gas — PID 31005 (NB → LF, GaBi Gas 2.1 (BK7-24-01-008)).
316 ///
317 /// Gas MMM settlement uses different legal references from Strom MMM:
318 /// `GaBi Gas 2.1 (BK7-24-01-008)` and `GeLi Gas 3.0 (BK7-24-01-009)`. Using a separate variant
319 /// ensures correct audit traces without conditional logic in call sites.
320 MmmGas,
321 /// Mehr-/Mindermengen Mehrmenge, selbst ausgestellte Rechnung (Lieferung) — PID 31006.
322 ///
323 /// Per INVOIC AHB §3.x, PID 31006 covers the Mehrmenge leg when the Mehr-/
324 /// Mindermenge is treated as a „Lieferung“ and the invoice is self-issued.
325 MmmSelbstausstellt,
326 /// Messstellenbetrieb settlement — PID 31009 (MSB → NB / LF / ESA).
327 MsbRechnung,
328 /// GaBi Gas AWH Sperrprozesse settlement — PID 31011 (NB → LF, BK7-24-01-009 §5.4).
329 ///
330 /// Rechnung sonstige Leistung: bills the LF (LFG/LFA) for abrechnungswürdige
331 /// Handlungen (AWH) performed by the GNB/VNB during Sperrung/Entsperrung.
332 GasAwhSperrung,
333 /// Redispatch 2.0 Einsatzkosten (NB → ÜNB, BK6-20-061).
334 RedispatchKostenblatt,
335 /// Entgelt für dezentrale Erzeugung — §18 StromNEV, NB → Anlagenbetreiber.
336 ///
337 /// A bilateral payment relationship, not an EDIFACT market process: it has
338 /// no Prüfidentifikator and is rendered as an ordinary commercial credit.
339 DezentraleEinspeisung,
340}
341
342impl SettlementType {
343 /// Default BDEW PID for this settlement type.
344 ///
345 /// Callers may override the PID after construction if needed.
346 #[must_use]
347 pub fn default_pid(self) -> u32 {
348 match self {
349 Self::NneAbschlag => 31001,
350 Self::NneStrom => 31002,
351 Self::NneGas => 31002,
352 Self::MmmStrom => 31005,
353 Self::MmmGas => 31005,
354 Self::MmmSelbstausstellt => 31006,
355 Self::MsbRechnung => 31009,
356 Self::GasAwhSperrung => 31011,
357 Self::RedispatchKostenblatt => 0, // no standard PID
358 // Bilateral NB → Anlagenbetreiber payment; not an EDIFACT process.
359 Self::DezentraleEinspeisung => 0,
360 }
361 }
362}
363
364// ── SettlementStatus ──────────────────────────────────────────────────────────
365
366/// Lifecycle status of a settlement result.
367///
368/// Settlements are never destroyed — a correction or cancellation produces a new
369/// result and leaves the original intact.
370///
371/// **Which document supersedes which is not recorded here.** The invoice numbers
372/// linking a correction to what it replaces live on
373/// [`InvoiceDocument::correction_of`], because the same pair of settlements can
374/// be presented under different invoice numbers. What *is* recorded here is
375/// [`SettlementResult::korrektur_grund`] — why the recalculation happened, which
376/// is a fact about the settlement rather than about the document.
377#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
378pub enum SettlementStatus {
379 /// Initial calculation — no prior settlement exists for this period.
380 Initial,
381 /// Correction of a prior settlement.
382 Correction,
383 /// Cancellation of a prior settlement — all positions are negated.
384 Reversal,
385 /// Final settlement — no further corrections expected.
386 Final,
387}
388
389// ── KorrekturGrund ────────────────────────────────────────────────────────────
390
391/// Why a settlement was recalculated.
392///
393/// A correction that cannot say why it happened is not an audit trail. The
394/// invoice numbers alone answer *what* was replaced; they never answer whether
395/// the meter was wrong, the tariff was wrong, or the law changed underneath —
396/// and those have different consequences. A retroactive regulatory change is a
397/// lawful recalculation; a Rechenfehler in the same period is a defect that
398/// should be counted and investigated.
399///
400/// Carried on every non-`Initial` [`SettlementResult`].
401#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
402#[cfg_attr(feature = "bo4e", derive(serde::Deserialize))]
403pub enum KorrekturGrund {
404 /// Corrected metering — a replaced or re-read value (§ 60 Abs. 1 MsbG:
405 /// the MSB owes the berechtigte Stellen *aufbereitete* data, and a
406 /// correction is how that duty is discharged once the first value was
407 /// wrong. Not Abs. 2, which only says where an iMS should do the
408 /// Aufbereitung and obliges nobody to form a value at all).
409 Messwertkorrektur,
410 /// The wrong tariff or price sheet version was applied.
411 Tarifkorrektur,
412 /// Master data was wrong — Netzebene, KA-Klasse, Konzessionsgemeinde.
413 Stammdatenkorrektur,
414 /// A regulatory change applies retroactively to a settled period.
415 RegulatorischeAenderung,
416 /// An arithmetic or logic error in the original settlement.
417 Rechenfehler,
418 /// A clearing result between the parties (Mehr-/Mindermengen, MaBiS).
419 Clearing,
420 /// Anything else — carry the detail in the settlement's warnings.
421 Sonstiges,
422}
423
424impl KorrekturGrund {
425 /// Stable machine-readable code for structured records and reporting.
426 #[must_use]
427 pub const fn code(self) -> &'static str {
428 match self {
429 Self::Messwertkorrektur => "MESSWERTKORREKTUR",
430 Self::Tarifkorrektur => "TARIFKORREKTUR",
431 Self::Stammdatenkorrektur => "STAMMDATENKORREKTUR",
432 Self::RegulatorischeAenderung => "REGULATORISCHE_AENDERUNG",
433 Self::Rechenfehler => "RECHENFEHLER",
434 Self::Clearing => "CLEARING",
435 Self::Sonstiges => "SONSTIGES",
436 }
437 }
438
439 /// Whether this reason indicates a defect in the original settlement rather
440 /// than a lawful recalculation.
441 ///
442 /// Separating the two is the point of recording the reason: a rising count
443 /// of `Rechenfehler` is an engineering signal, while a rising count of
444 /// `RegulatorischeAenderung` is not.
445 #[must_use]
446 pub const fn indicates_defect(self) -> bool {
447 matches!(self, Self::Rechenfehler | Self::Stammdatenkorrektur)
448 }
449}
450
451// ── LegalReference ────────────────────────────────────────────────────────────
452
453/// Regulatory citation that justifies a billing position or rate.
454///
455/// Every [`SettlementPosition`] should carry at least one `LegalReference`.
456/// This enables full auditability: any operator or regulator can trace
457/// exactly which paragraph, ruling, and version authorised each charge.
458#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
459pub enum LegalReference {
460 /// StromNEV — Stromnetzentgeltverordnung (grid usage charges, Strom).
461 ///
462 /// Example: `StromNev { paragraph: "§17" }` for Leistungspreise.
463 StromNev {
464 /// Paragraph reference, e.g. `"§17"`, `"§21"`.
465 paragraph: &'static str,
466 },
467 /// GasNEV — Gasnetzentgeltverordnung (grid usage charges, Gas).
468 GasNev {
469 /// Paragraph reference, e.g. `"§14"`.
470 paragraph: &'static str,
471 },
472 /// KAV — Konzessionsabgabenverordnung (municipal concession fee).
473 ///
474 /// Example: `Kav { paragraph: "§2 Abs. 2" }`.
475 Kav {
476 /// Paragraph reference, e.g. `"§2 Abs. 2"`.
477 paragraph: &'static str,
478 },
479 /// KWKG — Kraft-Wärme-Kopplungsgesetz.
480 ///
481 /// Example: `Kwkg { paragraph: "§26" }` for the KWKG-Umlage.
482 Kwkg {
483 /// Paragraph citation, e.g. `"§26"`.
484 paragraph: &'static str,
485 },
486 /// EnFG — Energiefinanzierungsgesetz.
487 ///
488 /// Governs which Letztverbrauchergruppe an Entnahmestelle falls into and so
489 /// which rate of a network levy applies.
490 EnFG {
491 /// Paragraph citation, e.g. `"§§21 ff."`.
492 paragraph: &'static str,
493 },
494 /// UStG — Umsatzsteuergesetz.
495 ///
496 /// Cited where the tax treatment is itself part of what the position claims:
497 /// an Anzahlung under §14 Abs. 5, a reverse charge under §13b.
498 Ustg {
499 /// Paragraph citation, e.g. `"§14 Abs. 5"`.
500 paragraph: &'static str,
501 },
502 /// §14a EnWG — Steuerbare Verbrauchseinrichtungen (controllable loads).
503 ///
504 /// Governs time-variable (ToU) NNE for heat pumps, EV chargers, etc.
505 Sect14aEnwg {
506 /// Module: Modul1 (flat reduction), Modul2 (HT/NT), or Modul3 (spot).
507 module: Sect14aModule,
508 },
509 /// MsbG — Messstellenbetriebsgesetz (metering point operation).
510 MsbG {
511 /// Paragraph citation, e.g. `"§§6–7"`.
512 paragraph: &'static str,
513 },
514 /// BNetzA decision (Beschluss).
515 ///
516 /// Example: `BnetzaDecision { reference: "BK6-22-300" }`.
517 BnetzaDecision {
518 /// Decision reference, e.g. `"BK6-22-300"`, `"BK6-24-174"`.
519 reference: &'static str,
520 },
521 /// BDEW application handbook (Anwendungshandbuch).
522 BdewAhb {
523 /// AHB reference, e.g. `"GPKE BK6-22-024"`.
524 reference: &'static str,
525 },
526 /// StromNZV — Stromnetzzugangsverordnung.
527 ///
528 /// **Außer Kraft mit Ablauf des 31.12.2025** (Art. 15 Abs. 4 des Gesetzes
529 /// v. 22.12.2023, BGBl. 2023 I Nr. 405). Valid only for Lieferzeiträume up
530 /// to that date; the successor competence is §20 Abs. 3 EnWG, exercised
531 /// through the BK6 Festlegungen. [`LegalReference::citation`] appends the
532 /// expiry so an archived invoice stays self-explanatory.
533 StromNzv {
534 /// Paragraph citation, e.g. `"§13 Abs. 3"`.
535 paragraph: &'static str,
536 },
537 /// GasNZV — Gasnetzzugangsverordnung 2010.
538 ///
539 /// **Außer Kraft mit Ablauf des 31.12.2025** (Art. 15 Abs. 6 des Gesetzes
540 /// v. 22.12.2023, BGBl. 2023 I Nr. 405). Succeeded by KARLA Gas 2.0
541 /// (BK7-24-01-007), GaBi Gas 2.1 (BK7-24-01-008), GeLi Gas 3.0
542 /// (BK7-24-01-009) and ZuBio (BK7-24-01-010), all in force 01.01.2026.
543 GasNzv {
544 /// Paragraph citation, e.g. `"§25"`.
545 paragraph: &'static str,
546 },
547 /// EnWG — Energiewirtschaftsgesetz (general energy law).
548 Enwg {
549 /// Paragraph citation, e.g. `"§14a"`.
550 paragraph: &'static str,
551 },
552 /// ARegV — Anreizregulierungsverordnung (incentive regulation).
553 ///
554 /// ARegV §§17–21 define the allowed NNE revenue caps and efficiency targets.
555 /// Relevant when documenting why a specific regulated tariff level was approved.
556 ARegV {
557 /// Paragraph citation, e.g. `"§17"`, `"§21"`.
558 paragraph: &'static str,
559 },
560}
561
562impl LegalReference {
563 /// Short human-readable citation string (German).
564 #[must_use]
565 pub fn citation(&self) -> String {
566 match self {
567 Self::StromNev { paragraph } => format!("StromNEV {paragraph}"),
568 Self::GasNev { paragraph } => format!("GasNEV {paragraph}"),
569 Self::Kav { paragraph } => format!("KAV {paragraph}"),
570 Self::Ustg { paragraph } => format!("UStG {paragraph}"),
571 Self::Kwkg { paragraph } => format!("KWKG {paragraph}"),
572 Self::EnFG { paragraph } => format!("EnFG {paragraph}"),
573 Self::Sect14aEnwg { module } => format!("§14a EnWG {}", module.label()),
574 Self::MsbG { paragraph } => format!("MsbG {paragraph}"),
575 Self::BnetzaDecision { reference } => format!("BNetzA {reference}"),
576 Self::BdewAhb { reference } => format!("BDEW {reference}"),
577 Self::StromNzv { paragraph } => {
578 format!("StromNZV {paragraph} (außer Kraft seit 01.01.2026)")
579 }
580 Self::GasNzv { paragraph } => {
581 format!("GasNZV {paragraph} (außer Kraft seit 01.01.2026)")
582 }
583 Self::Enwg { paragraph } => format!("EnWG {paragraph}"),
584 Self::ARegV { paragraph } => format!("ARegV {paragraph}"),
585 }
586 }
587}
588
589// ── TariffSource ──────────────────────────────────────────────────────────────
590
591/// Origin of the tariff rate applied in a settlement position.
592///
593/// Every rate used in a billing position must be traceable to a `TariffSource`.
594/// This enables operators and auditors to answer: *"Why was this rate used?"*
595#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
596pub enum TariffSource {
597 /// Rate from the published and approved `PreisblattNetznutzung` tariff sheet.
598 PublishedTariffSheet {
599 /// Tariff sheet identifier or version, e.g. `"Preisblatt 2025 Q1"`.
600 sheet_id: String,
601 },
602 /// Rate from a historical tariff (retroactive billing or correction).
603 HistoricalTariff {
604 /// Original valid_from date of the tariff.
605 valid_from: time::Date,
606 },
607 /// Regulatory rate mandated by a BNetzA decision.
608 RegulatoryTariff {
609 /// BNetzA decision reference.
610 decision_ref: &'static str,
611 },
612 /// Contract-specific rate negotiated between NB and customer.
613 ContractTariff {
614 /// Contract reference.
615 contract_ref: String,
616 },
617 /// Manual override by operator (requires documentation).
618 ManualOverride {
619 /// Reason for the override.
620 reason: String,
621 },
622}
623
624// ── CalculationTrace ──────────────────────────────────────────────────────────
625
626/// Full audit record for how one [`SettlementPosition`] was computed.
627///
628/// Answers the question: *"Why is this amount on the invoice?"*
629///
630/// Every `CalculationTrace` carries the input values, the applied legal rules,
631/// intermediate results, and the tariff source. This enables:
632/// - Regulator audits (BNetzA §20 EnWG)
633/// - Operator review
634/// - LF dispute resolution
635/// - AI-assisted invoice explainability (MCP tools)
636#[derive(Debug, Clone, serde::Serialize)]
637pub struct CalculationTrace {
638 /// Human-readable explanation of this position.
639 ///
640 /// Example: `"Arbeit 1500 kWh × 3.5 ct/kWh = 52.50 EUR"`
641 pub explanation: String,
642 /// Input quantity used (before rounding).
643 pub input_quantity: Decimal,
644 /// Input unit price in EUR (before rounding, already converted from ct).
645 pub input_unit_price_eur: Decimal,
646 /// Intermediate result before rounding (qty × price).
647 pub gross_eur: Decimal,
648 /// Applied legal references (at least one required).
649 pub legal_refs: Vec<LegalReference>,
650 /// Source of the tariff rate.
651 pub tariff_source: Option<TariffSource>,
652 /// Any §14a reductions applied, expressed as a fraction (0.0–1.0).
653 ///
654 /// `None` when no regulatory reduction applies.
655 /// Example: `Some(Decimal::new(85, 2))` = 85% of full rate (15% reduction).
656 pub regulatory_reduction_factor: Option<Decimal>,
657 /// Notes on rounding applied.
658 ///
659 /// Example: `"rounded to 5 dp per StromNEV §17"`.
660 pub rounding_note: Option<&'static str>,
661}
662
663// ── SettlementWarning ─────────────────────────────────────────────────────────
664
665/// A non-blocking validation issue found during settlement calculation.
666///
667/// Warnings do not prevent the invoice from being generated but should be
668/// reviewed before dispatch. The service layer may choose to block dispatch
669/// on `Severity::Error` warnings.
670#[derive(Debug, Clone, serde::Serialize)]
671pub struct SettlementWarning {
672 /// Severity: informational, warning, or error.
673 pub severity: WarningSeverity,
674 /// Machine-readable warning code.
675 pub code: &'static str,
676 /// Human-readable description.
677 pub message: String,
678}
679
680/// Severity level for [`SettlementWarning`].
681#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
682pub enum WarningSeverity {
683 /// Informational — no action required.
684 Info,
685 /// Potential issue — review recommended before dispatch.
686 Warning,
687 /// Definite issue — should be resolved before dispatch.
688 Error,
689}
690
691// ── InvoicePosition ───────────────────────────────────────────────────────────
692
693/// Semantic kind of a billing position — used by the service layer to derive
694/// the correct `BdewArtikelnummer` for the BO4E `Rechnungsposition`.
695///
696/// `grid-billing` has no `rubo4e` dependency, so this enum is the bridge:
697/// the service layer maps `BillingPositionKind` → `BdewArtikelnummer` in
698/// `into_rechnung()`. Every position in every `SettlementResult` must carry
699/// a `kind` so the INVOIC `Rechnungsposition.artikelnummer` is never missing.
700///
701/// ## BDEW INVOIC AHB requirement
702///
703/// BDEW INVOIC AHBs (FV2025-10-01) mandate `artikelnummer` in every
704/// `SG28 PIA` line item. Missing or wrong Artikelnummern cause counterparty
705/// APERAK rejection. The `invoic-checker` checks 6 plausibility rules;
706/// Artikelnummer matching is part of the tariff-found rule (check 5).
707///
708/// ## Mapping to `BdewArtikelnummer`
709///
710/// | `BillingPositionKind` | `BdewArtikelnummer` | INVOIC AHB ref |
711/// |---|---|---|
712/// | `NneArbeit` | `Wirkarbeit` | PID 31002 (NN-Rechnung) Arbeit |
713/// | `NneArbeitHt` | `Wirkarbeit` | PID 31002 §14a Modul 3 HT |
714/// | `NneArbeitSt` | `Wirkarbeit` | PID 31002 §14a Modul 3 ST |
715/// | `NneArbeitNt` | `Wirkarbeit` | PID 31002 §14a Modul 3 NT |
716/// | `NneArbeitModul2` | `Wirkarbeit` | PID 31002 §14a Modul 2 (rate reduced) |
717/// | `NneArbeitModul1` | `Wirkarbeit` | PID 31002 §14a Modul 1 (rate reduced) |
718/// | `NneLeistung` | `Leistung` | PID 31002 RLM kW charge |
719/// | `NneGasGrundpreis` | `Grundpreis` | PID 31002 Gas monthly base fee |
720/// | `Konzessionsabgabe` | `Konzessionsabgabe` | PID 31002 KAV §2 |
721/// | `Mehrmenge` | `Mehrmenge` | PID 31005 positive imbalance |
722/// | `Mindermenge` | `Mindermenge` | PID 31005 negative imbalance (credit) |
723/// | `MsbGrundgebuehr` | `EntgeltEinbauBetriebWartungMesstechnik` | PID 31009 MSB monthly fee |
724/// | `Messdienstleistung` | `EntgeltMessungAblesung` | PID 31009 reading service |
725/// | `GasAwhSperrung` | `Sperrkosten` | PID 31011 AWH disconnection |
726/// | `GasAwhEntsperrung` | `Entsperrkosten` | PID 31011 AWH reconnection |
727/// | `GasAwhSonstige` | `EntgeltAbrechnung` | PID 31011 other AWH |
728/// | `Blindmehrarbeit` | `Blindmehrarbeit` | Reactive energy excess |
729#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
730pub enum BillingPositionKind {
731 /// The single line of an Abschlagsrechnung — a payment on account.
732 ///
733 /// Carries an amount and nothing else: no quantity, no unit price, because
734 /// an Abschlag prices no energy. What it is *for* is the delivery period on
735 /// the settlement.
736 NneAbschlag,
737 /// Netznutzungsentgelt Arbeit — flat-rate active energy charge (kWh).
738 /// SLP or Gas. → `BdewArtikelnummer::Wirkarbeit`
739 NneArbeit,
740 /// §14a Modul 3 Hochtarif (HT) Arbeit — zeitvariables Netzentgelt, high band.
741 /// → `BdewArtikelnummer::Wirkarbeit`
742 NneArbeitHt,
743 /// §14a Modul 3 Standardtarif (ST) Arbeit — zeitvariables Netzentgelt, middle band.
744 /// → `BdewArtikelnummer::Wirkarbeit`
745 NneArbeitSt,
746 /// §14a Modul 3 Niedertarif (NT) Arbeit — zeitvariables Netzentgelt, low band.
747 /// → `BdewArtikelnummer::Wirkarbeit`
748 NneArbeitNt,
749 /// §14a Modul 1 Arbeit — pauschale Reduzierung applied to the Arbeitspreis.
750 /// → `BdewArtikelnummer::Wirkarbeit` (same article, different rate)
751 NneArbeitModul1,
752 /// §14a Modul 2 Arbeit — prozentuale Reduzierung of the device's Arbeitspreis.
753 /// → `BdewArtikelnummer::Wirkarbeit` (same article, different rate)
754 NneArbeitModul2,
755 /// §14a Modul 3 Spotpreis-NNE — per-dispatch-interval variable rate position.
756 ///
757 /// One `InvoicePosition` is generated per dispatch interval from
758 /// `NneInput::sect14a_modul3_intervals`. Each carries a
759 /// `lastvariable_preisposition_json` with the BO4E `LastvariablePreisposition`
760 /// COM data (pricing formula parameters) for ERP-side validation and portal
761 /// display of the per-interval tariff breakdown.
762 ///
763 /// Regulatory basis: BNetzA BK6-22-300 Anlage 2 §3 — Spotpreis-Netzentgelt.
764 /// → `BdewArtikelnummer::Wirkarbeit`
765 NneArbeitModul3,
766 /// Netznutzungsentgelt Leistung — RLM peak demand charge (kW).
767 /// → `BdewArtikelnummer::Leistung`
768 NneLeistung,
769 /// Gas NNE monthly base fee (Grundpreis / Verrechnungspreis).
770 /// GasNEV §14. → `BdewArtikelnummer::Grundpreis`
771 NneGasGrundpreis,
772 /// Konzessionsabgabe — KAV §2 municipal concession fee.
773 /// → `BdewArtikelnummer::Konzessionsabgabe`
774 Konzessionsabgabe,
775 /// Mehrmengen — positive imbalance (actual > profiled).
776 /// PID 31005 GPKE (BK6-24-174) Teil 1 Kap. 8.4 / GaBi Gas 2.1 (BK7-24-01-008). → `BdewArtikelnummer::Mehrmenge`
777 Mehrmenge,
778 /// Mindermengen — negative imbalance credit note (actual < profiled).
779 /// PID 31005. → `BdewArtikelnummer::Mindermenge`
780 Mindermenge,
781 /// MSB Grundgebühr Messstellenbetrieb — monthly metering base fee.
782 /// MsbG §§6–7. → `BdewArtikelnummer::EntgeltEinbauBetriebWartungMesstechnik`
783 MsbGrundgebuehr,
784 /// Einbau und Betrieb einer Steuerungseinrichtung am Netzanschlusspunkt.
785 /// MsbG §30 Abs. 2. → `BdewArtikelnummer::EntgeltEinbauBetriebWartungMesstechnik`
786 MsbSteuereinrichtung,
787 /// Messdienstleistung — periodic reading service fee.
788 /// MsbG §2. → `BdewArtikelnummer::EntgeltMessungAblesung`
789 Messdienstleistung,
790 /// Gas AWH Sperrung — abrechnungswürdige Handlung disconnection.
791 /// BK7-24-01-009 §5.4. → `BdewArtikelnummer::Sperrkosten`
792 GasAwhSperrung,
793 /// Gas AWH Entsperrung — abrechnungswürdige Handlung reconnection.
794 /// BK7-24-01-009 §5.4. → `BdewArtikelnummer::Entsperrkosten`
795 GasAwhEntsperrung,
796 /// Gas AWH sonstige — other abrechnungswürdige Handlung.
797 /// BK7-24-01-009 §5.4. → `BdewArtikelnummer::EntgeltAbrechnung`
798 GasAwhSonstige,
799 /// Blindmehrarbeit — reactive energy beyond the free share.
800 ///
801 /// Charged from the Netzbetreiber's published Preisblatt; StromNEV §17
802 /// governs how those Netzentgelte are formed. **Not** §18, which is the
803 /// Entgelt für dezentrale Erzeugung, and not §19, which is Sonderformen der
804 /// Netznutzung. → `BdewArtikelnummer::Blindmehrarbeit`
805 Blindmehrarbeit,
806 /// Aufschlag für besondere Netznutzung (§19 StromNEV-Umlage).
807 ///
808 /// Funds the reduced individual network charges granted under §19 Abs. 2
809 /// StromNEV. Rate depends on the Letztverbrauchergruppe (EnFG).
810 Sect19StromNevUmlage,
811 /// Offshore-Netzumlage (§17f EnWG).
812 ///
813 /// Funds offshore connection cost and the compensation owed to offshore
814 /// wind farms for unavailable connections.
815 OffshoreNetzumlage,
816 /// KWKG-Umlage (§26 KWKG).
817 ///
818 /// Funds the KWK-Zuschlag paid to CHP operators.
819 KwkgUmlage,
820 /// Entgelt für dezentrale Erzeugung — §18 StromNEV, under Abschmelzung
821 /// (GBK-25-02-1#1). A payment out, so its `net_eur` is negative.
822 DezentraleEinspeisung,
823 /// §19 Abs. 2 StromNEV individual-charge reduction over the Netzentgelt.
824 /// Negative: it takes the published charge down to the agreed fraction.
825 Sect19IndividuellesEntgelt,
826 /// Gas Kapazitätsentgelt — booked capacity at the price sheet's annual
827 /// rate, pro-rated over the period. §15 GasNEV.
828 GasKapazitaetsentgelt,
829}
830
831impl BillingPositionKind {
832 /// Every variant, so a guard can walk the whole enum instead of a list that
833 /// drifts. A wildcard-free `match` in the crate's tests anchors it: adding a
834 /// variant without listing it here fails to compile.
835 pub const ALL: [Self; 26] = [
836 Self::NneAbschlag,
837 Self::NneArbeit,
838 Self::NneArbeitHt,
839 Self::NneArbeitSt,
840 Self::NneArbeitNt,
841 Self::NneArbeitModul1,
842 Self::NneArbeitModul2,
843 Self::NneArbeitModul3,
844 Self::NneLeistung,
845 Self::NneGasGrundpreis,
846 Self::Konzessionsabgabe,
847 Self::Mehrmenge,
848 Self::Mindermenge,
849 Self::MsbGrundgebuehr,
850 Self::MsbSteuereinrichtung,
851 Self::Messdienstleistung,
852 Self::GasAwhSperrung,
853 Self::GasAwhEntsperrung,
854 Self::GasAwhSonstige,
855 Self::Blindmehrarbeit,
856 Self::Sect19StromNevUmlage,
857 Self::OffshoreNetzumlage,
858 Self::KwkgUmlage,
859 Self::DezentraleEinspeisung,
860 Self::Sect19IndividuellesEntgelt,
861 Self::GasKapazitaetsentgelt,
862 ];
863
864 /// Anchors `ALL` to the enum: the `match` has no wildcard, so a new
865 /// variant breaks the build here rather than silently escaping every guard
866 /// that walks `ALL`.
867 #[cfg(test)]
868 pub(crate) const fn is_exhaustive(self) -> bool {
869 match self {
870 Self::NneAbschlag
871 | Self::NneArbeit
872 | Self::NneArbeitHt
873 | Self::NneArbeitSt
874 | Self::NneArbeitNt
875 | Self::NneArbeitModul1
876 | Self::NneArbeitModul2
877 | Self::NneArbeitModul3
878 | Self::NneLeistung
879 | Self::NneGasGrundpreis
880 | Self::Konzessionsabgabe
881 | Self::Mehrmenge
882 | Self::Mindermenge
883 | Self::MsbGrundgebuehr
884 | Self::MsbSteuereinrichtung
885 | Self::Messdienstleistung
886 | Self::GasAwhSperrung
887 | Self::GasAwhEntsperrung
888 | Self::GasAwhSonstige
889 | Self::Blindmehrarbeit
890 | Self::Sect19StromNevUmlage
891 | Self::OffshoreNetzumlage
892 | Self::KwkgUmlage
893 | Self::DezentraleEinspeisung
894 | Self::Sect19IndividuellesEntgelt
895 | Self::GasKapazitaetsentgelt => true,
896 }
897 }
898}
899
900/// One line item in a grid settlement.
901///
902/// Carries raw numbers for the service layer to map into the required format
903/// (BO4E `Rechnungsposition`, EN16931 UBL, etc.).
904///
905/// Invariant: `net_eur == (quantity × unit_price_eur).round_kfm(5)`.
906/// The pricing formula behind a §14a Modul 3 spot-priced position.
907///
908/// Modelled as a value object rather than a serialised BO4E document. The engine
909/// states *what the formula was*; translating that into
910/// `LastvariablePreisposition` — or into any other representation — is the
911/// adapter's job. Carrying BO4E JSON here would put schema knowledge inside the
912/// calculation, untyped and unvalidated, which is the coupling the crate exists
913/// to avoid.
914#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
915pub struct SpotPriceFormula {
916 /// What the price refers to — for Modul 3 always the metered energy.
917 pub reference: PriceReference,
918 /// The unit the price is expressed per.
919 pub unit: QuantityUnit,
920 /// How the rate was derived.
921 pub method: TariffCalculationMethod,
922 /// The rate steps that applied, in order.
923 pub steps: Vec<PriceStep>,
924}
925
926/// What a price refers to.
927#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
928pub enum PriceReference {
929 /// The metered energy quantity.
930 Energiemenge,
931 /// Contracted or metered capacity.
932 Leistung,
933}
934
935/// How a rate was derived.
936#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
937pub enum TariffCalculationMethod {
938 /// A published fixed rate.
939 Festpreis,
940 /// Derived from a spot-market price — §14a Modul 3, BK6-22-300 Anlage 2 §3.
941 Spotpreis,
942}
943
944/// One step of a rate schedule.
945#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
946pub struct PriceStep {
947 /// Lower bound of the step, inclusive.
948 pub from: Decimal,
949 /// Upper bound, exclusive; `None` for the open top step.
950 pub to: Option<Decimal>,
951 /// The rate in EUR per [`SpotPriceFormula::unit`].
952 pub unit_price_eur: Decimal,
953}
954
955/// One line of a settlement.
956///
957/// Carries no position number and no BDEW Artikel-ID: both are properties of the
958/// *document* that presents the settlement, not of the calculation. An adapter
959/// numbers the positions it renders and resolves article identifiers from the
960/// price sheet.
961#[derive(Debug, Clone, serde::Serialize)]
962pub struct SettlementPosition {
963 /// Human-readable description.
964 pub text: String,
965 /// Semantic kind — what was charged, independent of how it is coded.
966 pub kind: BillingPositionKind,
967 /// Metered or contracted quantity.
968 pub quantity: Decimal,
969 /// Unit of measure.
970 pub unit: QuantityUnit,
971 /// Unit price in EUR.
972 pub unit_price_eur: Decimal,
973 /// Net amount in EUR, rounded to 5 decimal places.
974 ///
975 /// May be negative for credit positions (Mindermengen, Gutschriften).
976 pub net_eur: Decimal,
977 /// The formula behind the rate, where one applied.
978 pub spot_price_formula: Option<SpotPriceFormula>,
979 /// Why this amount is what it is.
980 pub trace: CalculationTrace,
981}
982
983impl BillingPositionKind {
984 /// The BDEW Artikelnummer that codes this position, as its codelist name.
985 ///
986 /// Which article number applies depends on both what was charged and what
987 /// kind of settlement it appears in — Gas NNE keeps the classic `WIRKARBEIT`
988 /// code, while Strom NNE moved to Artikel-IDs under BK6-20-160 and carries
989 /// no Artikelnummer at all.
990 ///
991 /// Returned as the codelist *name* rather than a BO4E enum so that this
992 /// crate stays free of BO4E types. A consumer parses it into whatever it
993 /// renders — `rubo4e::current::BdewArtikelnummer` implements `FromStr` over
994 /// exactly these names.
995 ///
996 /// `None` means the position carries an Artikel-ID instead, resolved from
997 /// the price sheet by the renderer.
998 ///
999 /// Source: BDEW Codeliste der Artikelnummern und Artikel-IDs v5.6.
1000 #[must_use]
1001 pub fn artikelnummer(self, settlement_type: SettlementType) -> Option<&'static str> {
1002 use BillingPositionKind as K;
1003 use SettlementType as ST;
1004 match (self, settlement_type) {
1005 // An Abschlag prices nothing, so it carries no Artikelnummer: the
1006 // codelist names charges, and a payment on account is not one.
1007 (K::NneAbschlag, _) => None,
1008 // Gas NNE keeps the classic codes — BK6-20-160 changed Strom only.
1009 (
1010 K::NneArbeit
1011 | K::NneArbeitHt
1012 | K::NneArbeitSt
1013 | K::NneArbeitNt
1014 | K::NneArbeitModul1
1015 | K::NneArbeitModul2
1016 | K::NneArbeitModul3,
1017 ST::NneGas,
1018 ) => Some("WIRKARBEIT"),
1019 (K::NneLeistung, ST::NneGas) => Some("LEISTUNG"),
1020 (K::NneGasGrundpreis, _) => Some("GRUNDPREIS"),
1021 // Strom NNE: the Artikel-ID replaces the Artikelnummer.
1022 (
1023 K::NneArbeit
1024 | K::NneArbeitHt
1025 | K::NneArbeitSt
1026 | K::NneArbeitNt
1027 | K::NneArbeitModul1
1028 | K::NneArbeitModul2
1029 | K::NneArbeitModul3
1030 | K::NneLeistung,
1031 _,
1032 ) => None,
1033 (K::Konzessionsabgabe, _) => Some("KONZESSIONSABGABE"),
1034 (K::Mehrmenge, _) => Some("MEHRMENGE"),
1035 (K::Mindermenge, _) => Some("MINDERMENGE"),
1036 (K::MsbGrundgebuehr | K::MsbSteuereinrichtung, _) => {
1037 Some("ENTGELT_EINBAU_BETRIEB_WARTUNG_MESSTECHNIK")
1038 }
1039 (K::Messdienstleistung, _) => Some("ENTGELT_MESSUNG_ABLESUNG"),
1040 // AWH Gas positions carry a 2-01-7-xxx Artikel-ID from the input.
1041 (K::GasAwhSperrung | K::GasAwhEntsperrung | K::GasAwhSonstige, _) => None,
1042 (K::Blindmehrarbeit, _) => Some("BLINDMEHRARBEIT"),
1043 // Netzseitige Umlagen (EnFG). `OFFSHORE_HAFTUNGSUMLAGE` is the code's
1044 // legacy name — the levy was renamed Offshore-Netzumlage, the article
1045 // number was not.
1046 (K::Sect19StromNevUmlage, _) => Some("PARAGRAF_19_STROM_NEV_UMLAGE"),
1047 // Bilateral payment outside the INVOIC market processes — the
1048 // codelist has no article number for it.
1049 (K::DezentraleEinspeisung, _) => None,
1050 // A reduction over Strom NNE positions, which carry Artikel-IDs.
1051 (K::Sect19IndividuellesEntgelt, _) => None,
1052 // Capacity is the gas Leistung analogue and keeps the classic code.
1053 (K::GasKapazitaetsentgelt, ST::NneGas) => Some("LEISTUNG"),
1054 (K::GasKapazitaetsentgelt, _) => None,
1055 (K::OffshoreNetzumlage, _) => Some("OFFSHORE_HAFTUNGSUMLAGE"),
1056 (K::KwkgUmlage, _) => Some("ABGABE_KWKG"),
1057 }
1058 }
1059}
1060
1061// ── Arbeitspreis model ────────────────────────────────────────────────────────
1062
1063/// A §14a **Modul 2** reduction factor — the fraction of the published
1064/// Arbeitspreis actually paid.
1065///
1066/// Modul 2 is the *prozentuale* reduction; Modul 1 is the flat annual pauschale
1067/// and carries no factor at all (see [`ArbeitspreisModell::Modul1Pauschal`]).
1068///
1069/// A newtype because the range matters: `"0.85"` is a 15 % reduction, and a
1070/// value outside `(0, 1]` is not a reduction at all. It travels as a JSON
1071/// **string** like every other `Decimal` — see the architecture page's
1072/// *Quantities and money on the wire*. The unconstrained `Decimal` this
1073/// replaces was range-checked in the validator and *not* in the engine, so a
1074/// caller who skipped validation could multiply the tariff by 5.
1075#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
1076pub struct Reduktionsfaktor(Decimal);
1077
1078impl Reduktionsfaktor {
1079 /// A commonly published factor — 85 % of the tariff, i.e. a 15 % reduction.
1080 ///
1081 /// Not a statutory rate: BK8-22/010-A leaves the Modul-2 percentage to each
1082 /// Netzbetreiber's published Preisblatt, so this is a convenience default
1083 /// and never a substitute for the operator's own figure.
1084 pub const REGELFALL: Self = Self(rust_decimal::dec!(0.85));
1085
1086 /// Build a factor.
1087 ///
1088 /// # Errors
1089 ///
1090 /// Returns [`crate::error::BillingError::InvalidInput`] outside `(0, 1]`.
1091 pub fn new(factor: Decimal) -> Result<Self, crate::error::BillingError> {
1092 if factor <= Decimal::ZERO || factor > Decimal::ONE {
1093 return Err(crate::error::BillingError::InvalidInput {
1094 reason: format!("§14a Modul 2 reduction factor must be in (0, 1], got {factor}"),
1095 });
1096 }
1097 Ok(Self(factor))
1098 }
1099
1100 /// The factor as a fraction.
1101 #[must_use]
1102 pub const fn get(self) -> Decimal {
1103 self.0
1104 }
1105}
1106
1107/// Deserialising goes through [`Reduktionsfaktor::new`], so a factor that
1108/// arrives over the wire is range-checked exactly like one built in process.
1109///
1110/// Deriving it would have reintroduced the unconstrained `Decimal` this newtype
1111/// exists to prevent: a request body carrying `5` would then multiply the
1112/// Arbeitspreis by five with no error anywhere.
1113impl<'de> serde::Deserialize<'de> for Reduktionsfaktor {
1114 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
1115 let raw = <Decimal as serde::Deserialize>::deserialize(d)?;
1116 Self::new(raw).map_err(serde::de::Error::custom)
1117 }
1118}
1119
1120/// A §14a **Modul 1** Jahresanteil — the fraction of a year the settlement
1121/// period covers, by which the Netzbetreiber's annual pauschale is credited.
1122///
1123/// A newtype for the same reason as [`Reduktionsfaktor`]: the range carries the
1124/// meaning. Outside `(0, 1]` the figure is not a share of a year — `1` on a
1125/// monthly run credits the whole annual pauschale twelve times over the year,
1126/// and a negative value turns the credit into a charge against the customer.
1127///
1128/// The range is necessary and not sufficient: `1` is a valid share of a year and
1129/// the wrong one for a month. [`crate::settle_nne`] therefore also measures the
1130/// share against the settlement period and warns when the two disagree.
1131///
1132/// It travels as a JSON **string** like every other `Decimal` on this crate's
1133/// wire.
1134#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
1135pub struct Jahresanteil(Decimal);
1136
1137impl Jahresanteil {
1138 /// A twelfth — one calendar month of a year.
1139 ///
1140 /// One twelfth to the full precision `Decimal` carries. A shorter constant
1141 /// under-credits: at six decimal places twelve monthly credits of a 120 EUR
1142 /// pauschale come to 119.99952 EUR, and the operator keeps the difference on
1143 /// every plant, every year.
1144 pub const MONAT: Self = Self(rust_decimal::dec!(0.0833333333333333333333333333));
1145
1146 /// A whole year.
1147 pub const JAHR: Self = Self(Decimal::ONE);
1148
1149 /// Build a share of a year.
1150 ///
1151 /// # Errors
1152 ///
1153 /// Returns [`crate::error::BillingError::InvalidInput`] outside `(0, 1]`.
1154 pub fn new(anteil: Decimal) -> Result<Self, crate::error::BillingError> {
1155 if anteil <= Decimal::ZERO || anteil > Decimal::ONE {
1156 return Err(crate::error::BillingError::InvalidInput {
1157 reason: format!("§14a Modul 1 Jahresanteil must be in (0, 1], got {anteil}"),
1158 });
1159 }
1160 Ok(Self(anteil))
1161 }
1162
1163 /// The share as a fraction of a year.
1164 #[must_use]
1165 pub const fn get(self) -> Decimal {
1166 self.0
1167 }
1168}
1169
1170/// Deserialising goes through [`Jahresanteil::new`], so a share that arrives
1171/// over the wire is range-checked exactly like one built in process.
1172impl<'de> serde::Deserialize<'de> for Jahresanteil {
1173 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
1174 let raw = <Decimal as serde::Deserialize>::deserialize(d)?;
1175 Self::new(raw).map_err(serde::de::Error::custom)
1176 }
1177}
1178
1179/// A metered quantity priced at a rate.
1180#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1181pub struct MengePreis {
1182 /// Metered energy in kWh.
1183 pub menge_kwh: Decimal,
1184 /// Rate in ct/kWh.
1185 pub preis_ct_per_kwh: Decimal,
1186}
1187
1188/// How the Arbeitspreis is structured, and whether §14a applies.
1189///
1190/// One enum rather than three independent field groups. The five variants —
1191/// `Einheitlich`, the three §14a modules and `SpotpreisNetzentgelt` — are
1192/// mutually exclusive **by construction**, which removes a whole class of defect:
1193///
1194/// - The four HT/NT fields were 2⁴ states of which two were valid. Setting three
1195/// of them fell through to flat billing with no error — the invoice looked
1196/// right and was billed on the wrong basis.
1197/// - Modul 1 and Modul 3 could both be set. The engine applied the flat
1198/// reduction *and* the per-interval rates, double-billing the same energy.
1199/// - Modul 1 and Modul 2 could both be set; the engine silently preferred
1200/// Modul 2 rather than rejecting the conflict.
1201///
1202/// Those were runtime warnings in a validator the engine never called. They are
1203/// now unrepresentable.
1204#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
1205pub enum ArbeitspreisModell {
1206 /// A single rate for all metered energy.
1207 Einheitlich(MengePreis),
1208
1209 /// **§14a Modul 1** — pauschale Reduzierung des Netzentgelts.
1210 ///
1211 /// A **flat annual amount** the Netzbetreiber publishes, credited pro rata
1212 /// for the settlement period. It does not scale with consumption — that is
1213 /// what makes it *pauschal*, and what distinguishes it from
1214 /// [`Self::Modul2ProzentualeReduzierung`], which reduces the Arbeitspreis by
1215 /// a percentage. The two were structurally identical here once; a factor on
1216 /// the Arbeitspreis is Modul 2's mechanism wearing Modul 1's name.
1217 ///
1218 /// Needs no additional metering, which is why it is the default where the
1219 /// connection holder makes no choice.
1220 Modul1Pauschal {
1221 /// The energy delivered in the period, at its published rate. Billed in
1222 /// full — Modul 1 does not touch the Arbeitspreis.
1223 basis: MengePreis,
1224 /// The Netzbetreiber's published annual pauschale, in EUR per year.
1225 ///
1226 /// A published amount, so it is non-negative; [`crate::settle_nne`]
1227 /// refuses a negative one, which would turn the credit into a charge.
1228 pauschale_eur_pro_jahr: Decimal,
1229 /// The fraction of a year this settlement period covers, so the annual
1230 /// pauschale is credited pro rata.
1231 jahresanteil: Jahresanteil,
1232 },
1233
1234 /// **§14a Modul 2** — the Arbeitspreis reduced by a percentage.
1235 ///
1236 /// The reduction attaches to the controllable device's own metered energy,
1237 /// so `basis` carries that device's consumption rather than the whole
1238 /// connection's.
1239 Modul2ProzentualeReduzierung {
1240 /// The device's metered energy and its published rate, before reduction.
1241 basis: MengePreis,
1242 /// The fraction of that rate actually paid.
1243 reduktion: Reduktionsfaktor,
1244 },
1245
1246 /// **§14a Modul 3** — zeitvariable Netzentgelte in three Tarifstufen.
1247 ///
1248 /// All three bands are required: BK6-22-300 defines Hochtarif, Standardtarif
1249 /// and Niedertarif, and permitting a subset would reintroduce the partial
1250 /// state this type exists to prevent. A band with no energy carries
1251 /// `menge_kwh = 0` rather than being omitted.
1252 Modul3ZeitVariabel {
1253 /// Hochtarif band.
1254 ht: MengePreis,
1255 /// Standardtarif band.
1256 st: MengePreis,
1257 /// Niedertarif band.
1258 nt: MengePreis,
1259 },
1260
1261 /// A spot-derived NNE rate per dispatch interval.
1262 ///
1263 /// **Not a §14a module.** BK6-22-300 defines exactly three, none of which is
1264 /// spot-linked; this models a Netzentgelt whose rate follows the spot price
1265 /// under the NB's own `PreisblattNetznutzung` formula. The rates arrive
1266 /// already derived — this crate never queries a spot market.
1267 SpotpreisNetzentgelt {
1268 /// The dispatch intervals, each with its own rate.
1269 intervalle: Vec<SpotpreisInterval>,
1270 },
1271}
1272
1273impl ArbeitspreisModell {
1274 /// Total metered energy across the model, in kWh.
1275 ///
1276 /// This is the base the Konzessionsabgabe and the network levies are charged
1277 /// on, so it is derived here once rather than recomputed per levy.
1278 #[must_use]
1279 pub fn menge_kwh(&self) -> Decimal {
1280 match self {
1281 Self::Einheitlich(mp) | Self::Modul1Pauschal { basis: mp, .. } => mp.menge_kwh,
1282 Self::Modul2ProzentualeReduzierung { basis, .. } => basis.menge_kwh,
1283 Self::Modul3ZeitVariabel { ht, st, nt } => ht.menge_kwh + st.menge_kwh + nt.menge_kwh,
1284 Self::SpotpreisNetzentgelt { intervalle } => {
1285 intervalle.iter().map(|i| i.menge_kwh).sum()
1286 }
1287 }
1288 }
1289
1290 /// The §14a module in play, if any.
1291 #[must_use]
1292 pub const fn sect14a_modul(&self) -> Option<Sect14aModule> {
1293 match self {
1294 Self::Einheitlich(_) => None,
1295 Self::Modul1Pauschal { .. } => Some(Sect14aModule::Modul1),
1296 Self::Modul2ProzentualeReduzierung { .. } => Some(Sect14aModule::Modul2),
1297 Self::Modul3ZeitVariabel { .. } => Some(Sect14aModule::Modul3),
1298 // A spot-linked Netzentgelt is the NB's own price model, not one of
1299 // the three modules BK6-22-300 defines.
1300 Self::SpotpreisNetzentgelt { .. } => None,
1301 }
1302 }
1303}
1304
1305// ── Blindarbeit ───────────────────────────────────────────────────────────────
1306
1307/// Reactive energy and the terms on which its excess is charged.
1308///
1309/// A Netzbetreiber supplies a *free share* of reactive energy alongside the
1310/// active energy delivered, and charges only what exceeds it (Blindmehrarbeit).
1311/// The customary boundary is a power factor of cos φ 0,9 — reactive energy up to
1312/// **tan φ ≈ 0,4843** of the active energy — though many Preisblätter round that
1313/// to a flat 50 %, and some set different shares for inductive and capacitive
1314/// draw.
1315///
1316/// The share is therefore an **input**, not a constant: it is a term of the
1317/// Netzbetreiber's price sheet, and hard-coding one would bill some networks
1318/// wrongly. [`Blindarbeit::COS_PHI_0_9`] is the documented default.
1319#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1320pub struct Blindarbeit {
1321 /// Reactive energy drawn in the period, in kvarh.
1322 pub blindarbeit_kvarh: Decimal,
1323 /// Free share of the active energy, as a fraction.
1324 ///
1325 /// `0.4843` for cos φ 0,9; `0.5` where the Preisblatt rounds it.
1326 pub freigrenze_anteil: Decimal,
1327 /// Price per excess kvarh, in ct/kvarh, from the Preisblatt.
1328 pub preis_ct_per_kvarh: Decimal,
1329}
1330
1331impl Blindarbeit {
1332 /// tan φ at cos φ 0,9 — the customary free share, to 4 dp.
1333 pub const COS_PHI_0_9: Decimal = rust_decimal::dec!(0.4843);
1334
1335 /// The chargeable excess in kvarh for the given active energy.
1336 ///
1337 /// Zero when the draw stays inside the free share; never negative, because
1338 /// an unused allowance is not a credit.
1339 #[must_use]
1340 pub fn mehrarbeit_kvarh(&self, wirkarbeit_kwh: Decimal) -> Decimal {
1341 let frei = wirkarbeit_kwh * self.freigrenze_anteil;
1342 (self.blindarbeit_kvarh - frei).max(Decimal::ZERO)
1343 }
1344}
1345
1346// ── Paired inputs ─────────────────────────────────────────────────────────────
1347
1348/// An RLM demand charge — peak demand and its rate.
1349///
1350/// A pair, because billing one without the other is meaningless — which two
1351/// independent `Option`s cannot express.
1352#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1353pub struct Leistungspreis {
1354 /// The Höchstleistung in kW the price is charged against.
1355 ///
1356 /// Which peak that is follows [`system`](Self::system): the
1357 /// Jahreshöchstleistung „im Abrechnungsjahr" under §17 Abs. 2 Satz 2
1358 /// StromNEV, the month's own Höchstleistung under a Monatsleistungspreis.
1359 pub spitzenleistung_kw: Decimal,
1360 /// The Leistungspreis in EUR per kW, per the unit
1361 /// [`system`](Self::system) names — EUR/kW·a for a Jahresleistungspreis,
1362 /// EUR/kW·Monat for a Monatsleistungspreis.
1363 pub preis_eur_per_kw: Decimal,
1364 /// Which Leistungspreissystem the price sheet states.
1365 ///
1366 /// Defaults to [`LeistungspreisSystem::Jahr`], the only one StromNEV itself
1367 /// defines.
1368 #[serde(default)]
1369 pub system: LeistungspreisSystem,
1370}
1371
1372/// Which Leistungspreissystem a price sheet bills under.
1373///
1374/// §17 Abs. 2 Satz 1 StromNEV builds the Netzentgelt „aus einem
1375/// Jahresleistungspreis in Euro pro Kilowatt und einem Arbeitspreis in Cent pro
1376/// Kilowattstunde", and Satz 2 states the entgelt: „Das Jahresleistungsentgelt
1377/// ist das Produkt aus dem jeweiligen Jahresleistungspreis und der
1378/// Jahreshöchstleistung in Kilowatt der jeweiligen Entnahme im Abrechnungsjahr."
1379/// It is a product of two figures and nothing else — the ordinance sets no
1380/// day-count convention for it, so there is no pro-rating to apply.
1381///
1382/// Abs. 8 nevertheless lets a Netzbetreiber offer Tagesleistungspreise for
1383/// Landstrom „neben einem Jahres- und **Monatsleistungspreissystem**", which
1384/// presupposes that a Monatsleistungspreissystem exists. StromNEV does not
1385/// define it; it is published in the Netzbetreiber's own Preisblatt as a
1386/// EUR/kW·Monat price against the month's Höchstleistung, paired with a higher
1387/// Arbeitspreis. A settlement bills whichever of the two the price sheet states,
1388/// and this enum is where it says which.
1389#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
1390#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
1391pub enum LeistungspreisSystem {
1392 /// §17 Abs. 2 Satz 2 StromNEV — Jahresleistungspreis × Jahreshöchstleistung
1393 /// im Abrechnungsjahr.
1394 ///
1395 /// The whole Abrechnungsjahr's Leistungsentgelt, so a settlement period
1396 /// shorter than the year bills a year of demand.
1397 /// [`crate::settle_nne`] warns when the two disagree rather than inventing a
1398 /// share the price sheet does not publish.
1399 #[default]
1400 Jahr,
1401 /// A Preisblatt Monatsleistungspreis — EUR/kW·Monat against the period's own
1402 /// Höchstleistung, for the months the period covers.
1403 ///
1404 /// This is the system §17 Abs. 8 StromNEV names alongside the annual one.
1405 Monat {
1406 /// Months billed. A monthly settlement is `1`.
1407 monate: Decimal,
1408 },
1409}
1410
1411/// A Gas NNE Grundpreis — monthly rate and the months billed.
1412#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1413pub struct Grundpreis {
1414 /// Rate in EUR per month.
1415 pub eur_per_month: Decimal,
1416 /// Months in the billing period.
1417 pub months: Decimal,
1418}
1419
1420/// The § 2 Abs. 7 KAV facts that classify a Niederspannungslieferung.
1421///
1422/// „Unbeschadet des § 1 Abs. 3 und 4 gelten Stromlieferungen aus dem
1423/// Niederspannungsnetz (bis 1 Kilovolt) konzessionsabgabenrechtlich als
1424/// Lieferungen an Tarifkunden, es sei denn, die gemessene Leistung des Kunden
1425/// überschreitet in mindestens zwei Monaten des Abrechnungsjahres 30 Kilowatt
1426/// **und** der Jahresverbrauch beträgt mehr als 30.000 Kilowattstunden."
1427///
1428/// Both limbs must be met for the point to be a Sondervertragslieferung, and
1429/// that decides between a 1,32-ct and a 0,11-ct ceiling — so the classification
1430/// is derived from the facts rather than taken from the caller's own label.
1431///
1432/// The reference is the **einzelne Betriebsstätte oder Abnahmestelle** (Satz 2).
1433/// Netzbetreiber and Gemeinde may agree lower figures (Satz 4); where they have,
1434/// state them here.
1435#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1436pub struct NiederspannungsEinstufung {
1437 /// Months of the Abrechnungsjahr in which the **gemessene** Leistung
1438 /// exceeded the Leistungswert — the statutory 30 kW, or the lower figure
1439 /// [`Self::leistungsgrenze_kw`] names. Counted by the caller, because only
1440 /// it holds the per-month series.
1441 pub monate_ueber_leistungsgrenze: u32,
1442 /// Jahresverbrauch at this Betriebsstätte or Abnahmestelle, in kWh.
1443 ///
1444 /// Satz 3 excludes Lieferungen nach §§ 7 und 9 BTOElt and Lieferungen im
1445 /// Rahmen von Sonderabkommen für lastschwache Zeiten from this figure; those
1446 /// are priced under Abs. 2 Nr. 1a and Abs. 3 in their own right.
1447 pub jahresverbrauch_kwh: Decimal,
1448 /// The agreed Leistungswert in kW, where Netzbetreiber and Gemeinde set one
1449 /// below the statutory 30 (Satz 4). Recorded on the settlement so the
1450 /// month count above can be read against the figure it was counted at;
1451 /// `None` means [`Self::LEISTUNGSGRENZE_KW`].
1452 pub leistungsgrenze_kw: Option<Decimal>,
1453 /// The agreed Jahresverbrauchsmenge in kWh, where one below the statutory
1454 /// 30 000 was set (Satz 4). `None` uses the statute's figure.
1455 pub verbrauchsgrenze_kwh: Option<Decimal>,
1456}
1457
1458impl NiederspannungsEinstufung {
1459 /// „überschreitet in mindestens zwei Monaten des Abrechnungsjahres 30
1460 /// Kilowatt" — the statutory Leistungswert.
1461 pub const LEISTUNGSGRENZE_KW: Decimal = rust_decimal::dec!(30);
1462 /// „und der Jahresverbrauch beträgt mehr als 30.000 Kilowattstunden".
1463 pub const VERBRAUCHSGRENZE_KWH: Decimal = rust_decimal::dec!(30_000);
1464 /// „in mindestens zwei Monaten des Abrechnungsjahres".
1465 pub const MINDESTMONATE: u32 = 2;
1466
1467 /// Whether § 2 Abs. 7 makes this a Sondervertragslieferung.
1468 ///
1469 /// Both limbs, because the statute joins them with „und": a point drawing
1470 /// 40 kW in one month of the year, or 200 kW every month on 20 000 kWh, is
1471 /// still a Tariflieferung.
1472 #[must_use]
1473 pub fn ist_sondervertragslieferung(&self) -> bool {
1474 let verbrauchsgrenze = self
1475 .verbrauchsgrenze_kwh
1476 .unwrap_or(Self::VERBRAUCHSGRENZE_KWH);
1477 self.monate_ueber_leistungsgrenze >= Self::MINDESTMONATE
1478 && self.jahresverbrauch_kwh > verbrauchsgrenze
1479 }
1480
1481 /// The group § 2 Abs. 7 puts this metering point in.
1482 #[must_use]
1483 pub fn klasse(&self, gemeinde: GemeindeGroesse) -> KaKundengruppe {
1484 if self.ist_sondervertragslieferung() {
1485 KaKundengruppe::Sondervertragskunde
1486 } else {
1487 KaKundengruppe::Tarifkunde {
1488 gemeinde,
1489 nur_kochen_warmwasser: false,
1490 }
1491 }
1492 }
1493}
1494
1495/// The § 2 Abs. 4 / Abs. 5 Nr. 2 KAV Grenzpreisvergleich, as facts.
1496///
1497/// Both Absätze forbid a Konzessionsabgabe outright — „dürfen
1498/// Konzessionsabgaben … nicht vereinbart oder gezahlt werden" — for a
1499/// Sondervertragskunde whose Durchschnittspreis im Kalenderjahr lies below the
1500/// Grenzpreis. Neither figure is derivable here: the Strom Grenzpreis is the
1501/// Durchschnittserlös the amtliche Statistik published for the *vorletzte*
1502/// Kalenderjahr, and the customer's own Durchschnittspreis is measured
1503/// „unter Einschluß des Netznutzungsentgelts" over the whole supply. Both are
1504/// supplied.
1505#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1506pub struct Grenzpreisvergleich {
1507 /// The customer's Durchschnittspreis im Kalenderjahr, in ct/kWh, ohne USt.
1508 pub durchschnittspreis_ct_per_kwh: Decimal,
1509 /// The applicable Grenzpreis, in ct/kWh, ohne USt.
1510 pub grenzpreis_ct_per_kwh: Decimal,
1511}
1512
1513impl Grenzpreisvergleich {
1514 /// Whether the Verordnung forbids a Konzessionsabgabe on this supply.
1515 ///
1516 /// „unter dem … Durchschnittserlös … liegt" — strictly below, so a price
1517 /// exactly at the Grenzpreis still admits one.
1518 #[must_use]
1519 pub fn verbietet_konzessionsabgabe(&self) -> bool {
1520 self.durchschnittspreis_ct_per_kwh < self.grenzpreis_ct_per_kwh
1521 }
1522}
1523
1524/// § 2 Abs. 5 Nr. 1 KAV — the Gas Grenzmenge per Jahr und Abnahmefall.
1525///
1526/// Above it no Konzessionsabgabe may be agreed or paid for a
1527/// Sondervertragskunde. Netzbetreiber and Gemeinde may agree a lower
1528/// Grenzmenge (Satz 3).
1529pub const KAV_GAS_GRENZMENGE_KWH: Decimal = rust_decimal::dec!(5_000_000);
1530
1531/// A Konzessionsabgabe — the rate together with the customer group it applies to.
1532///
1533/// Paired so the KAV § 2 Höchstbetrag check can always run: independent
1534/// `Option`s let the ceiling check be skipped exactly when an over-charge is
1535/// most likely to go unnoticed.
1536#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1537pub struct Konzessionsabgabe {
1538 /// Published rate in ct/kWh.
1539 pub satz_ct_per_kwh: Decimal,
1540 /// The KAV §2 customer group, which fixes the ceiling.
1541 pub klasse: KaKundengruppe,
1542 /// § 2 Abs. 7 KAV — the facts that classify a Niederspannungslieferung.
1543 ///
1544 /// `None` where the supply is not aus dem Niederspannungsnetz, or where the
1545 /// per-month Leistung is not held; the stated [`Self::klasse`] then stands
1546 /// unchecked.
1547 #[serde(default)]
1548 pub niederspannung: Option<NiederspannungsEinstufung>,
1549 /// § 2 Abs. 4 (Strom) / Abs. 5 Nr. 2 (Gas) KAV — the Grenzpreisvergleich.
1550 ///
1551 /// `None` leaves it unchecked, which is what a Tarifkunde needs: both
1552 /// Absätze speak only of Sondervertragskunden.
1553 #[serde(default)]
1554 pub grenzpreis: Option<Grenzpreisvergleich>,
1555}
1556
1557// ── SettlementPeriod ──────────────────────────────────────────────────────────
1558
1559/// The delivery period a settlement covers.
1560///
1561/// A validated pair rather than two loose dates: constructing the type is the
1562/// ordering check, so no calculation carries its own copy of it.
1563///
1564/// Both bounds are inclusive: a monthly period runs from the 1st to the last day
1565/// of the month, matching how Netzentgelte are published and billed.
1566#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
1567pub struct SettlementPeriod {
1568 from: time::Date,
1569 to: time::Date,
1570}
1571
1572impl SettlementPeriod {
1573 /// Build a period.
1574 ///
1575 /// # Errors
1576 ///
1577 /// Returns [`crate::error::BillingError::InvalidInput`] when `from` is after `to`. A
1578 /// zero-length period (`from == to`) is a valid single day.
1579 pub fn new(from: time::Date, to: time::Date) -> Result<Self, crate::error::BillingError> {
1580 if from > to {
1581 return Err(crate::error::BillingError::InvalidInput {
1582 reason: format!("period start {from} is after its end {to}"),
1583 });
1584 }
1585 Ok(Self { from, to })
1586 }
1587
1588 /// Start of the period, inclusive.
1589 #[must_use]
1590 pub const fn from(&self) -> time::Date {
1591 self.from
1592 }
1593
1594 /// End of the period, inclusive.
1595 #[must_use]
1596 pub const fn to(&self) -> time::Date {
1597 self.to
1598 }
1599
1600 /// Number of days covered, both bounds inclusive.
1601 #[must_use]
1602 pub fn days(&self) -> i64 {
1603 (self.to - self.from).whole_days() + 1
1604 }
1605
1606 /// The share of a year the period covers.
1607 ///
1608 /// Each calendar year the period touches contributes its own days over its
1609 /// own length, so a period running 15 December 2023 to 14 January 2024 is
1610 /// 17/365 + 14/366 and not 31 days over either year alone. Taking the
1611 /// divisor from the starting year mis-scales every period that straddles a
1612 /// leap-year boundary, and the error is silent because the figure still
1613 /// looks like a plausible fraction.
1614 ///
1615 /// A full calendar year returns exactly `1`.
1616 #[must_use]
1617 pub fn jahresanteil(&self) -> Decimal {
1618 let mut anteil = Decimal::ZERO;
1619 for jahr in self.from.year()..=self.to.year() {
1620 let Ok(jahresbeginn) = time::Date::from_ordinal_date(jahr, 1) else {
1621 continue;
1622 };
1623 let jahrestage = time::util::days_in_year(jahr);
1624 let Ok(jahresende) = time::Date::from_ordinal_date(jahr, jahrestage) else {
1625 continue;
1626 };
1627 let von = self.from.max(jahresbeginn);
1628 let bis = self.to.min(jahresende);
1629 if von > bis {
1630 continue;
1631 }
1632 let tage = (bis - von).whole_days() + 1;
1633 anteil += Decimal::from(tage) / Decimal::from(jahrestage);
1634 }
1635 anteil
1636 }
1637}
1638
1639// ── SettlementResult ──────────────────────────────────────────────────────────
1640
1641/// What a settlement calculation produced.
1642///
1643/// This is the canonical output of every calculation in this crate. It answers
1644/// *what is owed and why*, and deliberately not *what the invoice looks like*:
1645/// invoice numbers, issue and due dates, Prüfidentifikatoren and position
1646/// numbering live on [`InvoiceDocument`], which an adapter builds around this.
1647///
1648/// The separation is what makes a settlement recomputable. The same period can
1649/// be settled twice — for a correction, a dispute, or an audit — and the two
1650/// results compared, without inventing a document each time.
1651///
1652/// ## Explainability
1653///
1654/// Every position carries a [`CalculationTrace`]; [`Self::all_legal_refs`]
1655/// collects the paragraphs the settlement rests on. `warnings` records what the
1656/// engine could not do, which is as much part of the result as the amounts.
1657#[derive(Debug, Clone, serde::Serialize)]
1658pub struct SettlementResult {
1659 /// What was settled.
1660 pub settlement_type: SettlementType,
1661 /// Where this settlement sits in the correction lifecycle.
1662 pub status: SettlementStatus,
1663 /// Why this settlement was recalculated.
1664 ///
1665 /// `None` for an `Initial` settlement and required for every other status —
1666 /// see [`SettlementResult::lineage_is_consistent`].
1667 pub korrektur_grund: Option<KorrekturGrund>,
1668 /// The delivery period.
1669 pub period: SettlementPeriod,
1670 /// The rules the calculation applied.
1671 pub regime: crate::regulatory::RegulatoryRegime,
1672 /// Commodity.
1673 pub sparte: Sparte,
1674 /// The metering location settled.
1675 pub malo_id: String,
1676 /// Sender MP-ID — the party issuing the invoice.
1677 ///
1678 /// The Netzbetreiber for NNE/MMM, and the **Messstellenbetreiber** for a
1679 /// MSB-Rechnung (PID 31009). Named for the role it plays, not for one of
1680 /// the roles that can fill it.
1681 pub sender_mp_id: String,
1682 /// Recipient MP-ID — the party being billed (LF, NB, MSB, MGV or ESA).
1683 pub recipient_mp_id: String,
1684 /// The positions, in calculation order.
1685 pub positions: Vec<SettlementPosition>,
1686 /// Net total in EUR, rounded to 2 decimal places.
1687 pub total_eur: Decimal,
1688 /// The Umsatzsteuer on that net total.
1689 ///
1690 /// §14 Abs. 4 Nr. 8 UStG requires the rate and the amount on every invoice,
1691 /// or a note saying why neither is stated. A settlement that carries only a
1692 /// net figure cannot be rendered as a lawful Rechnung, and the recipient
1693 /// gets no Vorsteuerabzug from one.
1694 pub steuer: crate::umsatzsteuer::Steuerausweis,
1695 /// What the engine could not do, or did with a caveat.
1696 pub warnings: Vec<SettlementWarning>,
1697}
1698
1699impl SettlementResult {
1700 /// Whether the lifecycle status and the recorded reason agree.
1701 ///
1702 /// An `Initial` settlement corrects nothing and must carry no reason; every
1703 /// other status is a recalculation and must say why. A `Correction` with no
1704 /// reason is the state this check exists to catch — it looks like a complete
1705 /// settlement and answers none of the questions an audit asks of one.
1706 #[must_use]
1707 pub const fn lineage_is_consistent(&self) -> bool {
1708 match self.status {
1709 SettlementStatus::Initial => self.korrektur_grund.is_none(),
1710 _ => self.korrektur_grund.is_some(),
1711 }
1712 }
1713
1714 /// Whether this settlement records a defect in an earlier one.
1715 ///
1716 /// Distinguishes an engineering signal from a lawful recalculation — see
1717 /// [`KorrekturGrund::indicates_defect`].
1718 #[must_use]
1719 pub fn corrects_a_defect(&self) -> bool {
1720 self.korrektur_grund
1721 .is_some_and(KorrekturGrund::indicates_defect)
1722 }
1723}
1724
1725// ── Abschlagsverrechnung ──────────────────────────────────────────────────────
1726
1727/// An Abschlagsrechnung a later invoice deducts.
1728///
1729/// The INVOIC AHB puts these in the Summenteil, not among the positions:
1730/// `SG50 MOA+113` carries the **gross** amount already paid, `SG51 RFF+AFL` the
1731/// invoice number it was billed under and `SG51 DTM+3` that invoice's date. They
1732/// therefore reduce what is *owed*, never the net or the tax — §14 Abs. 5 UStG
1733/// taxes the Anzahlung when it is received, so the Abschlussrechnung does not
1734/// tax it a second time.
1735///
1736/// Two rules from the AHB travel with this type:
1737///
1738/// - **\[526\]** — the amount stated must equal the referenced Abschlagsrechnung's
1739/// own Rechnungsbetrag. A deduction that does not match what was billed is a
1740/// deduction the counterparty will reject.
1741/// - **\[519\]** — a *stornierte* Abschlagsrechnung is not listed. It was
1742/// reversed, so nothing was paid on it, and deducting it would credit money
1743/// that never moved.
1744#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1745pub struct Abschlagsverrechnung {
1746 /// The Abschlagsrechnung's invoice number (`SG51 RFF+AFL`).
1747 pub rechnungsnummer: String,
1748 /// That invoice's date (`SG51 DTM+3`).
1749 pub rechnungsdatum: time::Date,
1750 /// The **gross** amount already billed on it (`SG50 MOA+113`, inkl. USt.).
1751 pub betrag_brutto_eur: Decimal,
1752}
1753
1754// ── InvoiceDocument ───────────────────────────────────────────────────────────
1755
1756/// A settlement presented as an invoice.
1757///
1758/// Everything here is a property of the document rather than of the calculation:
1759/// an invoice number, the dates it was issued and falls due, the
1760/// Prüfidentifikator that routes it, and the reference to whatever it corrects.
1761/// None of it affects what is owed.
1762///
1763/// Built by an adapter around a [`SettlementResult`]; the engine never produces
1764/// one, which is why the engine can be run without inventing an invoice number.
1765#[derive(Debug, Clone, serde::Serialize)]
1766pub struct InvoiceDocument {
1767 /// What the document presents.
1768 pub settlement: SettlementResult,
1769 /// BDEW Prüfidentifikator.
1770 pub pid: u32,
1771 /// Unique invoice reference.
1772 pub rechnungsnummer: String,
1773 /// The `rechnungsnummer` this corrects, if any.
1774 pub correction_of: Option<String>,
1775 /// Issue date.
1776 pub invoice_date: time::Date,
1777 /// Payment due date (Zahlungsziel, §271 BGB).
1778 pub due_date: time::Date,
1779 /// The billing cadence — `IMD+7081` on the wire.
1780 ///
1781 /// A document fact, not a calculation one: an NNE settlement is the same
1782 /// arithmetic whether it is billed monthly, per Turnus or as the
1783 /// Abschlussrechnung that closes a year. `None` leaves the field unset
1784 /// rather than guessing a rhythm nothing supports.
1785 pub cadence: Option<Rechnungscharakter>,
1786 /// Abschlagsrechnungen this document settles, deducted from what is owed.
1787 ///
1788 /// Empty on an Abschlagsrechnung itself and on every document that has no
1789 /// payments on account to reconcile.
1790 pub abschlaege: Vec<Abschlagsverrechnung>,
1791}
1792
1793/// The billing cadence of a Netznutzungsrechnung — `IMD+7081`.
1794///
1795/// Named for what the AHB calls it. The set is closed: these are the codes the
1796/// INVOIC AHB 1.0b permits for a Netznutzungsrechnung, and inventing a rhythm
1797/// outside them would put a claim on the wire the standard does not carry.
1798#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1799pub enum Rechnungscharakter {
1800 /// `ABS` — a payment on account (PID 31001).
1801 Abschlagsrechnung,
1802 /// `ABR` — the invoice that closes a period and settles its Abschläge.
1803 Abschlussrechnung,
1804 /// `JVR` — the periodic invoice of a billing cycle.
1805 Turnusrechnung,
1806 /// `MVR` — a monthly invoice.
1807 Monatsrechnung,
1808 /// `ZVR` — an invoice between two Turnus invoices.
1809 Zwischenrechnung,
1810}
1811
1812impl InvoiceDocument {
1813 /// Positions paired with their 1-based document numbers.
1814 ///
1815 /// Numbering is assigned here, at rendering time, rather than carried through
1816 /// the calculation as mutable state.
1817 pub fn numbered_positions(&self) -> impl Iterator<Item = (u32, &SettlementPosition)> {
1818 self.settlement
1819 .positions
1820 .iter()
1821 .enumerate()
1822 .map(|(i, p)| (u32::try_from(i + 1).unwrap_or(u32::MAX), p))
1823 }
1824}
1825
1826impl SettlementResult {
1827 /// Number of billing positions.
1828 #[must_use]
1829 pub fn positions_count(&self) -> usize {
1830 self.positions.len()
1831 }
1832
1833 /// `true` when the settlement has no warnings at `Warning` or `Error` severity.
1834 #[must_use]
1835 pub fn is_clean(&self) -> bool {
1836 !self
1837 .warnings
1838 .iter()
1839 .any(|w| w.severity >= WarningSeverity::Warning)
1840 }
1841
1842 /// All legal references cited across all positions (deduplicated by citation string).
1843 #[must_use]
1844 pub fn all_legal_refs(&self) -> Vec<String> {
1845 let mut seen = std::collections::HashSet::new();
1846 self.positions
1847 .iter()
1848 .flat_map(|p| p.trace.legal_refs.iter().map(|r| r.citation()))
1849 .filter(|c| seen.insert(c.clone()))
1850 .collect()
1851 }
1852
1853 /// Net total as computed from positions (re-summed for verification).
1854 ///
1855 /// Should equal `total_eur`. A mismatch indicates a calculation bug.
1856 #[must_use]
1857 pub fn recomputed_total(&self) -> Decimal {
1858 self.positions
1859 .iter()
1860 .map(|p| p.net_eur)
1861 .sum::<Decimal>()
1862 .round_kfm(2)
1863 }
1864}
1865
1866// ── Input types ───────────────────────────────────────────────────────────────
1867
1868/// Input for NNE (Netznutzungsentgelt) invoice calculation.
1869///
1870/// Covers:
1871/// - **PID 31002** (NN-Rechnung) — NNE Strom and Gas (NB → LF, monthly network
1872/// usage billing). The Sparte is carried in the message content, not the PID.
1873///
1874/// For **RLM** (Leistungsmessung) meters:
1875/// - Set `spitzenleistung_kw` to the peak demand in kW.
1876/// - Set `leistungspreis_eur_per_kw` to the published tariff.
1877///
1878/// For **SLP** meters:
1879/// - Leave both fields as `None` (Arbeitspreisanteil only).
1880///
1881/// For **§14a Modul 2 time-variable NNE** (BNetzA BK6-22-300):
1882/// - Set `arbeitsmenge_ht_kwh` + `arbeitspreis_ht_ct_per_kwh` for Hochlast periods.
1883/// - Set `arbeitsmenge_nt_kwh` + `arbeitspreis_nt_ct_per_kwh` for Niedertarif periods.
1884/// - Leave `arbeitsmenge_kwh` / `arbeitspreis_ct_per_kwh` as the base fallback.
1885///
1886/// For Gas:
1887/// - The `arbeitsmenge_kwh` should already be converted from m³ using
1888/// `brennwert × zustandszahl` before being supplied here.
1889/// (edmd's `MeterBillingPeriod.arbeitsmenge_kwh` carries this converted value.)
1890#[derive(Debug, Clone)]
1891pub struct NneInput {
1892 /// 11-digit Marktlokations-ID.
1893 pub malo_id: String,
1894 /// Invoice sender — Netzbetreiber or Gasnetzbetreiber MP-ID.
1895 pub nb_mp_id: String,
1896 /// Invoice recipient — Lieferant MP-ID.
1897 pub lf_mp_id: String,
1898 /// The delivery period being settled.
1899 pub period: SettlementPeriod,
1900
1901 /// Letztverbrauchergruppe for the network levies (EnFG §§21 ff.).
1902 ///
1903 /// Determines which rate of the §19 StromNEV-, Offshore- and KWKG-Umlage
1904 /// applies at this Entnahmestelle.
1905 pub letztverbrauchergruppe: crate::umlagen::Letztverbrauchergruppe,
1906
1907 /// kWh already consumed at this Entnahmestelle earlier in the same calendar
1908 /// year, for the EnFG 1-GWh boundary.
1909 ///
1910 /// The B′/C′ rates are published for quantities *über* 1 000 000 kWh a year,
1911 /// so a settlement covering one period of that year cannot tell which side
1912 /// of the boundary its quantity falls on without knowing what came before.
1913 /// `None` is read as zero — the start of the year — which puts the first
1914 /// Gigawattstunde on the full rate. That is the direction that over-bills
1915 /// rather than under-bills, and the settlement says so in a warning.
1916 ///
1917 /// Ignored for groups A′ and Befreit, which have no boundary.
1918 pub enfg_jahresvorverbrauch_kwh: Option<Decimal>,
1919
1920 /// §19 StromNEV-Umlage in ct/kWh, overriding the tabled rate.
1921 ///
1922 /// `None` uses the statutory rate for the delivery year and group. Set it
1923 /// where an EnFG decision grants a rate the published schedule does not
1924 /// express.
1925 pub sect19_umlage_ct_per_kwh: Option<Decimal>,
1926 /// Offshore-Netzumlage in ct/kWh, overriding the tabled rate.
1927 pub offshore_umlage_ct_per_kwh: Option<Decimal>,
1928 /// KWKG-Umlage in ct/kWh, overriding the tabled rate.
1929 pub kwkg_umlage_ct_per_kwh: Option<Decimal>,
1930
1931 /// Reactive energy and its Preisblatt terms.
1932 ///
1933 /// `None` = the network does not charge Blindmehrarbeit at this location, or
1934 /// the reactive energy was not metered.
1935 pub blindarbeit: Option<Blindarbeit>,
1936
1937 /// Optional tariff sheet identifier for audit tracing.
1938 ///
1939 /// When set, each position's `trace.tariff_source` references this sheet.
1940 pub tariff_sheet_id: Option<String>,
1941 /// Commodity — drives legal references (StromNEV vs GasNEV) and `SettlementType`.
1942 ///
1943 /// - `Sparte::Strom` (default) → `StromNEV §21` Arbeit, `StromNEV §17` Leistung,
1944 /// `SettlementType::NneStrom`
1945 /// - `Sparte::Gas` → `GasNEV §14`, `SettlementType::NneGas`
1946 pub sparte: Sparte,
1947
1948 // ── §14a Modul 3 Spotpreis-NNE per-interval dispatch data ────────────────
1949 /// §14a Modul 3 (BNetzA BK6-22-300 Anlage 2 §3) per-dispatch-interval positions.
1950 ///
1951 /// Each entry represents one 15-min interval during which a spot-price-linked
1952 /// NNE rate applies. The caller fetches the EPEX Spot day-ahead price for each
1953 /// interval and applies the formula from `PreisblattNetznutzung.lastvariablePreispositionen`
1954 /// to derive `nne_rate_ct_per_kwh`. `grid-billing` receives pre-calculated rates —
1955 /// it never queries EPEX directly.
1956 ///
1957 /// **Empty (default)** when no spot-linked Netzentgelt applies to this MaLo.
1958 ///
1959 /// Selecting this model excludes every other `ArbeitspreisModell` by
1960 /// construction — the enum holds one at a time.
1961 ///
1962 /// Each interval generates one `InvoicePosition` with
1963 /// `kind = NneArbeitModul3` and `lastvariable_preisposition_json` populated.
1964 #[doc = "Spot-linked Netzentgelt per-interval input data."]
1965 ///
1966 /// One value rather than twelve loose fields: the four shapes are mutually
1967 /// exclusive by construction.
1968 pub arbeitspreis: ArbeitspreisModell,
1969
1970 /// RLM demand charge — peak demand and its rate, or neither.
1971 pub leistungspreis: Option<Leistungspreis>,
1972
1973 /// Gas NNE Grundpreis. `None` for Strom, which has no separate Grundpreis.
1974 pub grundpreis: Option<Grundpreis>,
1975
1976 /// Konzessionsabgabe — rate and customer group together, so the KAV §2
1977 /// ceiling can always be checked.
1978 pub konzessionsabgabe: Option<Konzessionsabgabe>,
1979
1980 /// The Netzebene this metering point takes supply from.
1981 ///
1982 /// Netzentgelte are published per level, so the level is what makes a rate
1983 /// checkable against a price sheet. Recorded on the settlement and in the
1984 /// trace; it does not itself select a rate — this crate is given the rates.
1985 pub netzebene: Option<crate::netzebene::Netzebene>,
1986
1987 /// Annual peak demand in kW, where the metering point has one.
1988 ///
1989 /// Used with the annual energy to record the Benutzungsstundenzahl in the
1990 /// trace. This is the *annual* peak, which is not the same as the peak in
1991 /// the billing period — a monthly settlement carries the annual figure so
1992 /// the utilisation can be checked against the price sheet that priced it.
1993 pub jahreshoechstleistung_kw: Option<Decimal>,
1994
1995 /// Annual energy in kWh, where known.
1996 ///
1997 /// Pairs with `jahreshoechstleistung_kw` for the Benutzungsstundenzahl, and
1998 /// decides whether §17 Abs. 6 permits an Arbeitspreis-only tariff.
1999 pub jahresarbeit_kwh: Option<Decimal>,
2000
2001 /// An agreed §19 Abs. 2 StromNEV individual charge, where one exists.
2002 ///
2003 /// Applied as a reduction over the Arbeits- and Leistungspreis positions,
2004 /// with the statutory Mindestentgelt floor checked against the utilisation
2005 /// data above. The Konzessionsabgabe and the network levies are unaffected —
2006 /// the Netzbetreiber's lost revenue is compensated through the
2007 /// §19 StromNEV-Umlage, billed separately.
2008 pub sect19: Option<crate::sect19::Sect19Vereinbarung>,
2009
2010 /// A booked gas capacity, billed alongside the commodity charge.
2011 ///
2012 /// Gas only; §15 GasNEV. The annual rate is pro-rated over the settlement
2013 /// period by calendar days.
2014 pub gas_kapazitaet: Option<crate::gas::GasKapazitaet>,
2015}
2016
2017// ── SpotpreisInterval ─────────────────────────────────────────────────────
2018
2019/// One dispatch interval for a spot-linked Netzentgelt (not a §14a module).
2020///
2021/// Each interval represents a 15-min period during which the DSO exercised load
2022/// control and the NNE rate is derived from the day-ahead spot price via the
2023/// formula published in `PreisblattNetznutzung.lastvariablePreispositionen`.
2024///
2025/// ## Calculation
2026///
2027/// `Einsatzkosten = menge_kwh × nne_rate_ct_per_kwh / 100`
2028///
2029/// The NB computes one `InvoicePosition` per interval, allowing the LF (and their
2030/// customers) to see the exact tariff breakdown for each dispatch event.
2031///
2032/// ## Caller responsibility
2033///
2034/// The caller (service layer) must:
2035/// 1. Fetch the EPEX Spot day-ahead price for each 15-min interval from `productd`
2036/// or the `PreisblattNetznutzung` formula.
2037/// 2. Apply the formula from `lastvariablePreispositionen` to derive `nne_rate_ct_per_kwh`.
2038/// 3. Fetch `menge_kwh` from `edmd Lastgang` for the interval.
2039///
2040/// `grid-billing` receives pre-calculated rates — it does NOT query EPEX or `edmd`.
2041///
2042/// ## Regulatory basis
2043///
2044/// **Not a §14a module.** BK6-22-300 defines exactly three — Modul 1 (pauschale
2045/// Reduzierung), Modul 2 (prozentuale Arbeitspreisreduzierung) and Modul 3
2046/// (zeitvariable Netzentgelte in three Tarifstufen) — and none of them is
2047/// spot-linked. See [`ArbeitspreisModell::SpotpreisNetzentgelt`], which states
2048/// the same thing.
2049///
2050/// This models a Netzentgelt whose rate follows the spot price under the
2051/// Netzbetreiber's own `PreisblattNetznutzung` formula: the NNE varies per
2052/// 15-minute interval. The rates arrive already derived — this crate never
2053/// queries a spot market.
2054#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
2055pub struct SpotpreisInterval {
2056 /// UTC start of this controlled dispatch interval (ISO-8601).
2057 ///
2058 /// Typically the start of a 15-min settlement slot.
2059 #[serde(with = "time::serde::rfc3339")]
2060 pub period_from: time::OffsetDateTime,
2061 /// UTC end of this controlled dispatch interval (ISO-8601).
2062 ///
2063 /// Typically `period_from + 15 min`.
2064 #[serde(with = "time::serde::rfc3339")]
2065 pub period_to: time::OffsetDateTime,
2066 /// Energy consumption (or reduction) during this interval in kWh.
2067 ///
2068 /// Sourced from `edmd Lastgang` for the MaLo during the interval window.
2069 pub menge_kwh: Decimal,
2070 /// Effective NNE rate in **ct/kWh** for this interval.
2071 ///
2072 /// Derived from the `LastvariablePreisposition` formula applied to the
2073 /// applicable EPEX Spot day-ahead price. Pre-calculated by the caller.
2074 pub nne_rate_ct_per_kwh: Decimal,
2075 /// EPEX Spot day-ahead price in ct/kWh used to derive `nne_rate_ct_per_kwh`.
2076 ///
2077 /// Stored in the `CalculationTrace.explanation` for audit transparency.
2078 /// `None` when the rate was determined by a fixed formula without market reference.
2079 pub epex_spot_ct_per_kwh: Option<Decimal>,
2080}
2081
2082// ── MmmInput ──────────────────────────────────────────────────────────────────
2083
2084/// Input for Mehr-/Mindermengen (MMM) settlement invoice calculation.
2085///
2086/// Covers:
2087/// - **PID 31005** — MMM-Rechnung used for Mehr-/Mindermengen settlement between
2088/// NB and LF (Strom and Gas).
2089///
2090/// Mehr-/Mindermengen settle the difference between the LF's forecast profile
2091/// (SLP standard load profile) and the actual measured consumption.
2092///
2093/// - **Mehrmengen** (positive deviation): actual > profil → LF owes NB
2094/// - **Mindermengen** (negative deviation): actual < profil → NB owes LF
2095///
2096/// The settlement amount is the algebraic sum of both positions. It can be
2097/// negative (i.e. a credit note from NB to LF) when Mindermengen dominate.
2098#[derive(Debug, Clone)]
2099pub struct MmmInput {
2100 /// 11-digit Marktlokations-ID.
2101 pub malo_id: String,
2102 /// Invoice sender — Netzbetreiber MP-ID.
2103 pub nb_mp_id: String,
2104 /// Invoice recipient — Lieferant MP-ID.
2105 pub lf_mp_id: String,
2106 /// The delivery period being settled.
2107 pub period: SettlementPeriod,
2108 /// Commodity — determines which Festlegung the legal references cite.
2109 ///
2110 /// - `Sparte::Strom` → `GPKE (BK6-24-174) Teil 1 Kap. 8.4`, `GPKE BK6-22-024`
2111 /// - `Sparte::Gas` → `GaBi Gas 2.1 (BK7-24-01-008)`, `GeLi Gas 3.0 (BK7-24-01-009)`
2112 pub sparte: Sparte,
2113 /// Actual measured consumption in kWh (from MSCONS / `MeterBillingPeriod`).
2114 pub actual_kwh: Decimal,
2115 /// Standard load profile (SLP) forecast consumption in kWh.
2116 pub profil_kwh: Decimal,
2117 /// Mehrmengen price in **ct/kWh** (from `PreisblattNetznutzung` MMM position).
2118 pub mehr_preis_ct_per_kwh: Decimal,
2119 /// Mindermengen price in **ct/kWh** (from `PreisblattNetznutzung` MMM position).
2120 pub minder_preis_ct_per_kwh: Decimal,
2121 /// Who holds §3g Wiederverkäufer status, evidenced by a *USt 1 TH*.
2122 ///
2123 /// A Mehr-/Mindermenge is a **Lieferung** of electricity or gas, not a
2124 /// network service, so §13b Abs. 2 Nr. 5 Buchst. b UStG can shift the tax to
2125 /// the recipient. The condition differs by Sparte — electricity needs both
2126 /// parties, gas needs the recipient — which is why this is a status rather
2127 /// than a `reverse_charge: bool` the caller has to reason out.
2128 pub wiederverkaeufer: crate::umsatzsteuer::Wiederverkaeuferstatus,
2129 /// The receiving party issues this invoice itself (Gutschriftverfahren).
2130 ///
2131 /// PID 31006 (Strom) / 31008 (Gas) is the Mehrmenge leg written by the
2132 /// party that would otherwise receive it, which the AHB marks as
2133 /// *Selbstausgestellt* rather than *Handelsrechnung*. That distinction is
2134 /// on the wire (`IMD+7081` and the Rechnungsart), so it has to come from
2135 /// the settlement rather than be stamped on the document afterwards:
2136 /// labelling an ordinary [`SettlementType::MmmStrom`] with PID 31006
2137 /// produces a message that states Handelsrechnung under a
2138 /// Selbstausstellung Prüfidentifikator.
2139 pub selbstausgestellt: bool,
2140}
2141
2142// ── AbschlagInput ─────────────────────────────────────────────────────────────
2143
2144/// Input for an Abschlagsrechnung Netznutzung (PID 31001).
2145///
2146/// A payment on account. There is no metered quantity and no Arbeitspreis: the
2147/// Netzbetreiber asks for an amount against a period it has not settled yet, and
2148/// the Abschlussrechnung that follows deducts it by invoice number.
2149///
2150/// How the amount is arrived at — a share of last year's Turnusrechnung, a
2151/// forecast from the Jahresarbeit, a figure agreed in the
2152/// Lieferantenrahmenvertrag — is the operator's judgement, not arithmetic this
2153/// crate can check. [`Self::grundlage`] records which of them it was, so an
2154/// auditor can see the basis rather than infer it from a bare number.
2155#[derive(Debug, Clone)]
2156pub struct AbschlagInput {
2157 /// 11-digit Marktlokations-ID.
2158 pub malo_id: String,
2159 /// Invoice sender — the Netzbetreiber.
2160 pub nb_mp_id: String,
2161 /// Invoice recipient — the Lieferant.
2162 pub lf_mp_id: String,
2163 /// The period the payment is on account of.
2164 pub period: SettlementPeriod,
2165 /// Commodity.
2166 pub sparte: Sparte,
2167 /// The **net** amount requested, in EUR.
2168 ///
2169 /// Net rather than gross: the tax is stated separately on the invoice, as
2170 /// §14 Abs. 4 Nr. 8 UStG requires of an Anzahlungsrechnung like any other.
2171 pub betrag_netto_eur: Decimal,
2172 /// How the amount was arrived at.
2173 pub grundlage: AbschlagGrundlage,
2174}
2175
2176/// How an Abschlag's amount was arrived at.
2177///
2178/// Recorded rather than computed. The engine cannot check a forecast, but an
2179/// audit can ask which basis was used, and an invoice that answers "a share of
2180/// the prior Turnusrechnung" is defensible where a bare figure is not.
2181#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2182pub enum AbschlagGrundlage {
2183 /// A share of the previous settled period's invoice.
2184 Vorjahresverbrauch,
2185 /// A forecast of the period being paid for.
2186 Prognose,
2187 /// A figure fixed in the Lieferantenrahmenvertrag.
2188 Vereinbarung,
2189}
2190
2191impl AbschlagGrundlage {
2192 /// Short label for the position text and the trace.
2193 #[must_use]
2194 pub const fn label(self) -> &'static str {
2195 match self {
2196 Self::Vorjahresverbrauch => "auf Basis des Vorjahresverbrauchs",
2197 Self::Prognose => "auf Basis einer Verbrauchsprognose",
2198 Self::Vereinbarung => "gemäß Vereinbarung im Lieferantenrahmenvertrag",
2199 }
2200 }
2201}
2202
2203// ── MsbInput ──────────────────────────────────────────────────────────────────
2204
2205/// Input for MSB (Messstellenbetreiber) invoice calculation.
2206///
2207/// Covers:
2208/// - **PID 31009** — MSB-Rechnung (MSB → NB / LF / ESA, monthly metering
2209/// service settlement; Strom only)
2210///
2211/// The NB bills the MSB for the metering service period. Positions:
2212/// 1. Grundgebühr Messstellenbetrieb — flat monthly base fee × billing months.
2213/// 2. Messdienstleistung — optional per-period measurement service fee.
2214/// 3. Steuerungseinrichtung am Netzanschlusspunkt — optional, §30 Abs. 2 MsbG.
2215#[derive(Debug, Clone)]
2216pub struct MsbInput {
2217 /// 11-digit Marktlokations-ID.
2218 pub malo_id: String,
2219 /// Invoice **sender** — the Messstellenbetreiber.
2220 ///
2221 /// PID 31009 is issued *by* the MSB in all seven of its Anwendungsfälle; it
2222 /// is never sent to one. See [`MsbRechnungsempfaenger`].
2223 pub msb_mp_id: String,
2224 /// Invoice **recipient** — who is billed, and in which market role.
2225 pub empfaenger: MsbRechnungsempfaenger,
2226 /// The delivery period being settled.
2227 pub period: SettlementPeriod,
2228 /// The Sparte of the metering point.
2229 ///
2230 /// §30 MsbG prices metering, not energy, so this changes no arithmetic — but
2231 /// it is what the invoice states, and a service that stores one Sparte on
2232 /// the draft while the settlement carries another cannot answer which is
2233 /// right.
2234 pub sparte: Sparte,
2235 /// Grundgebühr Messstellenbetrieb in **EUR/month** (from `PreisblattMessung`).
2236 pub grundgebuehr_eur_per_month: Decimal,
2237 /// Number of full calendar months in the billing period.
2238 pub billing_months: u32,
2239 /// Optional Messdienstleistung flat fee in **EUR** for the full period.
2240 ///
2241 /// `None` when the MSB provides only the meter, not a separate measurement service.
2242 pub messdienstleistung_eur: Option<Decimal>,
2243
2244 /// Einbau und Betrieb einer Steuerungseinrichtung am Netzanschlusspunkt in
2245 /// **EUR/month** — §30 Abs. 2 MsbG, where the gMSB equipped the metering
2246 /// point under §29 Abs. 1 Nr. 2.
2247 ///
2248 /// It is charged „zusätzlich zu den nach den Absätzen 1 und 5 zulässigen
2249 /// Preisobergrenzen" and carries a ceiling of its own, so it is stated apart
2250 /// from the Grundgebühr: folded into that figure it would consume Abs. 1
2251 /// headroom it does not belong to, and its own 50-EUR cap could not be
2252 /// checked at all.
2253 pub steuereinrichtung_eur_per_month: Option<Decimal>,
2254
2255 /// Which §30 MsbG case this metering point falls under.
2256 ///
2257 /// Fixes the Preisobergrenze the charge is checked against. `None` skips the
2258 /// check, which should be rare: a metering charge above the POG is an amount
2259 /// the customer is entitled to have refunded.
2260 pub messstellen_kategorie: Option<crate::msbg::MessstellenKategorie>,
2261
2262 /// Whose share of the metering charge this settlement bills.
2263 ///
2264 /// §30 MsbG splits the ceiling between the Netzbetreiber and the
2265 /// Letztverbraucher, so the applicable cap depends on who is being billed.
2266 pub entgeltschuldner: Option<crate::msbg::Entgeltschuldner>,
2267}
2268
2269/// Recipient of a MSB-Rechnung (PID 31009).
2270///
2271/// The *Anwendungsübersicht der Prüfidentifikatoren* 4.0 (01.04.2026) lists
2272/// seven Anwendungsfälle for 31009, and the sender is the **MSB** in every one:
2273///
2274/// | Prozessbeschreibung | von | an |
2275/// |---|---|---|
2276/// | GPKE Teil 3 | MSB | NB |
2277/// | GPKE Teil 3 | MSB | LF |
2278/// | WiM Strom Teil 1 | MSB (am Objekt Marktlokation) | LF |
2279/// | WiM Strom Teil 1 | MSB (am Objekt Marktlokation) | NB |
2280/// | WiM Strom Teil 2 | MSB | ESA |
2281/// | AWH Prozesse zur Änderung der Technik an Lokationen | MSB | NB |
2282/// | AWH Prozesse zur Änderung der Technik an Lokationen | MSB | LF |
2283///
2284/// So the recipient varies across three market roles while the sender does not,
2285/// which is why it is modelled as a role plus an MP-ID rather than a bare
2286/// `nb_mp_id`. 31009 is Strom-only — the overview marks Sparte Gas `--`.
2287///
2288/// Distinct from [`crate::msbg::Entgeltschuldner`], which selects the §30 MsbG
2289/// Preisobergrenze (whose *share* of the ceiling applies) rather than who
2290/// receives the invoice. The LF commonly receives an invoice for the
2291/// Letztverbraucher share under the Rechnungsabwicklung über den LF, so the two
2292/// axes do not coincide.
2293#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2294pub struct MsbRechnungsempfaenger {
2295 /// Which market role receives the invoice.
2296 pub rolle: MsbEmpfaengerRolle,
2297 /// The recipient's 13-digit MP-ID.
2298 pub mp_id: String,
2299}
2300
2301/// Market role a MSB-Rechnung (PID 31009) may be addressed to.
2302#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2303pub enum MsbEmpfaengerRolle {
2304 /// Netzbetreiber — GPKE Teil 3, WiM Strom Teil 1, AWH Technikänderung.
2305 Netzbetreiber,
2306 /// Lieferant — GPKE Teil 3, WiM Strom Teil 1, AWH Technikänderung
2307 /// (Rechnungsabwicklung des MSB über den LF).
2308 Lieferant,
2309 /// Energieserviceanbieter — WiM Strom Teil 2.
2310 Energieserviceanbieter,
2311}
2312
2313impl MsbEmpfaengerRolle {
2314 /// BDEW role code as it appears in the NAD segment.
2315 #[must_use]
2316 pub const fn code(self) -> &'static str {
2317 match self {
2318 Self::Netzbetreiber => "NB",
2319 Self::Lieferant => "LF",
2320 Self::Energieserviceanbieter => "ESA",
2321 }
2322 }
2323}
2324
2325// ── GasAwhInput ───────────────────────────────────────────────────────────────
2326
2327/// Input for GeLi Gas AWH Sperrprozesse settlement (PID 31011).
2328///
2329/// **PID 31011 — Rechnung sonstige Leistung (NB → LF)**
2330///
2331/// Bills the Lieferant (LFG/LFA) for abrechnungswürdige Handlungen (AWH)
2332/// performed by the GNB/VNB during the Sperrung/Entsperrung process.
2333/// Governed by BK7-24-01-009 §5.4 (GeLi Gas 3.0).
2334///
2335/// ## What counts as AWH
2336///
2337/// AWH are chargeable actions not included in the network tariff, triggered by
2338/// the LF through the Sperrung process. Typical AWH:
2339/// - `Sperrung` (disconnection)
2340/// - `Entsperrung` (reconnection)
2341/// - `Teilsperrung` (partial disconnection)
2342/// - `Unterbrechung Verfahren` (process interruption)
2343///
2344/// Each action type has a fixed price published in the `PreisblattNetznutzung`.
2345#[derive(Debug, Clone)]
2346pub struct GasAwhInput {
2347 /// 11-digit Marktlokations-ID.
2348 pub malo_id: String,
2349 /// Invoice sender — Gasnetzbetreiber (GNB/VNB) MP-ID.
2350 pub nb_mp_id: String,
2351 /// Invoice recipient — Lieferant Gas (LFG or LFA) MP-ID.
2352 pub lf_mp_id: String,
2353 /// The delivery period being settled.
2354 pub period: SettlementPeriod,
2355 /// Optional tariff sheet identifier for audit tracing.
2356 pub tariff_sheet_id: Option<String>,
2357 /// AWH line items: each chargeable action with count and unit price.
2358 ///
2359 /// At least one position is required.
2360 pub awh_positionen: Vec<AwhPositionInput>,
2361}
2362
2363/// One AWH action line item for [`GasAwhInput`].
2364///
2365/// ## Examples
2366///
2367/// ```rust
2368/// # use grid_billing::AwhPositionInput;
2369/// # use rust_decimal::dec;
2370/// let sperrung = AwhPositionInput {
2371/// beschreibung: "Sperrung Gaszähler".to_owned(),
2372/// anzahl: 1,
2373/// preis_eur: dec!(45.00),
2374/// artikel_id: Some("2-01-7-001".to_owned()),
2375/// };
2376/// ```
2377#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
2378pub struct AwhPositionInput {
2379 /// Human-readable action description, e.g. `"Sperrung Gaszähler"`.
2380 pub beschreibung: String,
2381 /// Number of executions of this action.
2382 pub anzahl: u32,
2383 /// Price per execution in **EUR** (from `PreisblattNetznutzung`).
2384 pub preis_eur: Decimal,
2385 /// BDEW Artikel-ID from section 3.2 of the Codeliste Artikelnummern v5.6.
2386 ///
2387 /// Standard values for Gas AWH Sperrprozesse (BK7-24-01-009 §5.4):
2388 /// - `"2-01-7-001"` — Unterbrechung der Anschlussnutzung (reguläre AZ)
2389 /// - `"2-01-7-002"` — Wiederherstellung der Anschlussnutzung (reguläre AZ)
2390 /// - `"2-01-7-003"` — Erfolglose Unterbrechung
2391 /// - `"2-01-7-004"` — Stornierung Unterbrechungsauftrag (bis Vortag)
2392 /// - `"2-01-7-005"` — Stornierung Unterbrechungsauftrag (am Sperrtag)
2393 /// - `"2-01-7-006"` — Wiederherstellung außerhalb regulärer AZ
2394 ///
2395 /// `None` for custom / non-standard AWH positions.
2396 pub artikel_id: Option<String>,
2397}
2398
2399// ── ValidationResult ─────────────────────────────────────────────────────────
2400
2401/// Result of pre-calculation input validation.
2402///
2403/// For NNE there is no separate validator: the invariants that mattered are
2404/// either unrepresentable — an inverted [`SettlementPeriod`], a half-set
2405/// [`Leistungspreis`], two §14a modules at once — or enforced inside
2406/// [`crate::settle_nne`] itself. A validator the engine did not call was how a
2407/// caller who skipped it got billed on the wrong basis with no error.
2408///
2409/// [`validate_mmm_input`], [`validate_msb_input`] and [`validate_gas_awh_input`]
2410/// remain for inputs whose engines accept looser shapes.
2411#[derive(Debug, Clone)]
2412pub struct ValidationResult {
2413 /// Whether the input passed all validation checks.
2414 pub is_valid: bool,
2415 /// All warnings and errors found. May contain [`WarningSeverity::Info`] items
2416 /// even when `is_valid = true`.
2417 pub warnings: Vec<SettlementWarning>,
2418}
2419
2420impl ValidationResult {
2421 /// Returns a clean (valid, no warnings) result.
2422 #[must_use]
2423 pub fn ok() -> Self {
2424 Self {
2425 is_valid: true,
2426 warnings: Vec::new(),
2427 }
2428 }
2429
2430 /// Appends a warning. `WarningSeverity::Error` marks the result invalid.
2431 pub fn push(&mut self, w: SettlementWarning) {
2432 if w.severity == WarningSeverity::Error {
2433 self.is_valid = false;
2434 }
2435 self.warnings.push(w);
2436 }
2437}
2438
2439/// Validate a [`MmmInput`] before calling [`crate::settle_mmm`].
2440#[must_use]
2441pub fn validate_mmm_input(input: &MmmInput) -> ValidationResult {
2442 let mut r = ValidationResult::ok();
2443 if input.period.from() >= input.period.to() {
2444 r.push(SettlementWarning {
2445 severity: WarningSeverity::Error,
2446 code: "INVALID_PERIOD",
2447 message: "period_from must be strictly before period_to".to_owned(),
2448 });
2449 }
2450 if input.mehr_preis_ct_per_kwh < Decimal::ZERO {
2451 r.push(SettlementWarning {
2452 severity: WarningSeverity::Warning,
2453 code: "NEGATIVE_MEHR_PREIS",
2454 message: format!(
2455 "mehr_preis_ct_per_kwh is negative: {}",
2456 input.mehr_preis_ct_per_kwh
2457 ),
2458 });
2459 }
2460 if input.minder_preis_ct_per_kwh < Decimal::ZERO {
2461 r.push(SettlementWarning {
2462 severity: WarningSeverity::Warning,
2463 code: "NEGATIVE_MINDER_PREIS",
2464 message: format!(
2465 "minder_preis_ct_per_kwh is negative: {}",
2466 input.minder_preis_ct_per_kwh
2467 ),
2468 });
2469 }
2470 r
2471}
2472
2473/// Validate a [`MsbInput`] before calling [`crate::settle_msb`].
2474#[must_use]
2475pub fn validate_msb_input(input: &MsbInput) -> ValidationResult {
2476 let mut r = ValidationResult::ok();
2477 if input.period.from() >= input.period.to() {
2478 r.push(SettlementWarning {
2479 severity: WarningSeverity::Error,
2480 code: "INVALID_PERIOD",
2481 message: "period_from must be strictly before period_to".to_owned(),
2482 });
2483 }
2484 if input.grundgebuehr_eur_per_month < Decimal::ZERO {
2485 r.push(SettlementWarning {
2486 severity: WarningSeverity::Error,
2487 code: "NEGATIVE_GRUNDGEBUEHR",
2488 message: format!(
2489 "grundgebuehr_eur_per_month is negative: {}",
2490 input.grundgebuehr_eur_per_month
2491 ),
2492 });
2493 }
2494 if input.billing_months == 0 {
2495 r.push(SettlementWarning {
2496 severity: WarningSeverity::Error,
2497 code: "ZERO_BILLING_MONTHS",
2498 message: "billing_months must be at least 1".to_owned(),
2499 });
2500 }
2501 r
2502}
2503
2504/// Validate a [`GasAwhInput`] before calling [`crate::settle_gas_awh`].
2505///
2506/// Checks that:
2507/// - `period_from < period_to`
2508/// - `awh_positionen` is non-empty
2509/// - All positions have `anzahl ≥ 1` and `preis_eur ≥ 0`
2510#[must_use]
2511pub fn validate_gas_awh_input(input: &GasAwhInput) -> ValidationResult {
2512 let mut r = ValidationResult::ok();
2513 if input.period.from() >= input.period.to() {
2514 r.push(SettlementWarning {
2515 severity: WarningSeverity::Error,
2516 code: "INVALID_PERIOD",
2517 message: "period_from must be strictly before period_to".to_owned(),
2518 });
2519 }
2520 if input.awh_positionen.is_empty() {
2521 r.push(SettlementWarning {
2522 severity: WarningSeverity::Error,
2523 code: "EMPTY_AWH_POSITIONEN",
2524 message: "awh_positionen must contain at least one position".to_owned(),
2525 });
2526 }
2527 for (i, awh) in input.awh_positionen.iter().enumerate() {
2528 if awh.anzahl == 0 {
2529 r.push(SettlementWarning {
2530 severity: WarningSeverity::Error,
2531 code: "ZERO_AWH_ANZAHL",
2532 message: format!("awh_positionen[{i}].anzahl must be ≥ 1"),
2533 });
2534 }
2535 if awh.preis_eur < Decimal::ZERO {
2536 r.push(SettlementWarning {
2537 severity: WarningSeverity::Error,
2538 code: "NEGATIVE_AWH_PREIS",
2539 message: format!(
2540 "awh_positionen[{i}].preis_eur must be non-negative, got {}",
2541 awh.preis_eur
2542 ),
2543 });
2544 }
2545 }
2546 r
2547}
2548
2549#[cfg(test)]
2550mod input_model_tests {
2551
2552 /// A factor arriving over the wire is range-checked, not merely parsed.
2553 ///
2554 /// The whole point of the newtype is that an out-of-range value cannot
2555 /// exist; a derived `Deserialize` would have let one in through a request
2556 /// body and multiplied the Arbeitspreis by it.
2557 #[test]
2558 fn a_wire_reduktionsfaktor_is_range_checked() {
2559 // A `Decimal` is a JSON string on the wire, so the factor is too — a
2560 // float cannot carry 0.85 exactly and this one multiplies a tariff.
2561 let ok: Reduktionsfaktor = serde_json::from_str(r#""0.85""#).expect("in range");
2562 assert_eq!(ok.get(), dec!(0.85));
2563
2564 for bad in [r#""0""#, r#""-0.5""#, r#""1.01""#, r#""5""#] {
2565 assert!(
2566 serde_json::from_str::<Reduktionsfaktor>(bad).is_err(),
2567 "{bad} must be refused"
2568 );
2569 }
2570 // The boundary is inclusive at 1 — no reduction is still a valid factor.
2571 assert!(serde_json::from_str::<Reduktionsfaktor>(r#""1""#).is_ok());
2572 // A bare number is refused before the range is even considered.
2573 assert!(serde_json::from_str::<Reduktionsfaktor>("0.85").is_err());
2574 }
2575
2576 /// The Arbeitspreis model round-trips, so a settlement input can be stored
2577 /// and recomputed rather than a rendered document being edited in place.
2578 #[test]
2579 fn the_arbeitspreis_model_round_trips() {
2580 let model = ArbeitspreisModell::Modul3ZeitVariabel {
2581 ht: MengePreis {
2582 menge_kwh: dec!(600),
2583 preis_ct_per_kwh: dec!(4.2),
2584 },
2585 st: MengePreis {
2586 menge_kwh: dec!(100),
2587 preis_ct_per_kwh: dec!(3.0),
2588 },
2589 nt: MengePreis {
2590 menge_kwh: dec!(400),
2591 preis_ct_per_kwh: dec!(1.5),
2592 },
2593 };
2594 let json = serde_json::to_string(&model).expect("serialize");
2595 let back: ArbeitspreisModell = serde_json::from_str(&json).expect("deserialize");
2596 assert_eq!(back, model);
2597 }
2598
2599 use super::*;
2600 use rust_decimal::dec;
2601
2602 /// A reduction factor outside `(0, 1]` cannot be built.
2603 ///
2604 /// The type is the check. A bare `Decimal` range-checked in a validator
2605 /// leaves `settle_nne` free to multiply the published tariff by 5 whenever
2606 /// the engine does not call it.
2607 #[test]
2608 fn a_reduction_factor_must_actually_reduce() {
2609 assert!(Reduktionsfaktor::new(dec!(0.85)).is_ok());
2610 assert!(
2611 Reduktionsfaktor::new(dec!(1)).is_ok(),
2612 "no reduction is still valid"
2613 );
2614 assert!(
2615 Reduktionsfaktor::new(dec!(0)).is_err(),
2616 "zero is not a reduction"
2617 );
2618 assert!(Reduktionsfaktor::new(dec!(-0.5)).is_err());
2619 assert!(
2620 Reduktionsfaktor::new(dec!(5)).is_err(),
2621 "5x is not a reduction"
2622 );
2623 assert_eq!(Reduktionsfaktor::REGELFALL.get(), dec!(0.85));
2624 }
2625
2626 /// The charged energy is the same figure whichever model priced it.
2627 ///
2628 /// The Konzessionsabgabe and the three network levies are all charged on it,
2629 /// so they read one figure rather than each deriving its own.
2630 #[test]
2631 fn every_model_reports_the_energy_it_priced() {
2632 let flat = ArbeitspreisModell::Einheitlich(MengePreis {
2633 menge_kwh: dec!(1000),
2634 preis_ct_per_kwh: dec!(3.5),
2635 });
2636 assert_eq!(flat.menge_kwh(), dec!(1000));
2637
2638 let tou = ArbeitspreisModell::Modul3ZeitVariabel {
2639 ht: MengePreis {
2640 menge_kwh: dec!(600),
2641 preis_ct_per_kwh: dec!(4.0),
2642 },
2643 st: MengePreis {
2644 menge_kwh: dec!(0),
2645 preis_ct_per_kwh: dec!(0),
2646 },
2647 nt: MengePreis {
2648 menge_kwh: dec!(400),
2649 preis_ct_per_kwh: dec!(1.5),
2650 },
2651 };
2652 assert_eq!(tou.menge_kwh(), dec!(1000), "HT + NT, not one of them");
2653
2654 let modul1 = ArbeitspreisModell::Modul1Pauschal {
2655 basis: MengePreis {
2656 menge_kwh: dec!(1000),
2657 preis_ct_per_kwh: dec!(3.5),
2658 },
2659 pauschale_eur_pro_jahr: dec!(120),
2660 jahresanteil: Jahresanteil::MONAT,
2661 };
2662 assert_eq!(
2663 modul1.menge_kwh(),
2664 dec!(1000),
2665 "the reduction changes the rate, not the energy"
2666 );
2667 }
2668
2669 /// Each model names its §14a module, and only one can be in play.
2670 #[test]
2671 fn a_model_carries_at_most_one_sect14a_module() {
2672 use Sect14aModule as M;
2673 let cases = [
2674 (
2675 ArbeitspreisModell::Einheitlich(MengePreis {
2676 menge_kwh: dec!(1),
2677 preis_ct_per_kwh: dec!(1),
2678 }),
2679 None,
2680 ),
2681 (
2682 ArbeitspreisModell::Modul1Pauschal {
2683 basis: MengePreis {
2684 menge_kwh: dec!(1),
2685 preis_ct_per_kwh: dec!(1),
2686 },
2687 pauschale_eur_pro_jahr: dec!(120),
2688 jahresanteil: Jahresanteil::MONAT,
2689 },
2690 Some(M::Modul1),
2691 ),
2692 (
2693 ArbeitspreisModell::Modul3ZeitVariabel {
2694 ht: MengePreis {
2695 menge_kwh: dec!(1),
2696 preis_ct_per_kwh: dec!(1),
2697 },
2698 st: MengePreis {
2699 menge_kwh: dec!(0),
2700 preis_ct_per_kwh: dec!(0),
2701 },
2702 nt: MengePreis {
2703 menge_kwh: dec!(1),
2704 preis_ct_per_kwh: dec!(1),
2705 },
2706 },
2707 Some(M::Modul3),
2708 ),
2709 (
2710 // Not a §14a module at all — BK6-22-300 defines exactly three,
2711 // none of them spot-linked.
2712 ArbeitspreisModell::SpotpreisNetzentgelt { intervalle: vec![] },
2713 None,
2714 ),
2715 ];
2716 for (model, expected) in cases {
2717 assert_eq!(model.sect14a_modul(), expected);
2718 }
2719 }
2720
2721 /// BK6-22-300 numbers the three modules in a specific way, and this project
2722 /// had them shuffled: the time-variable model was labelled Modul 2 and a
2723 /// spot-linked Netzentgelt was labelled Modul 3.
2724 ///
2725 /// Getting this wrong prints the wrong statutory module on a real invoice
2726 /// and makes the LF-side and NB-side engines disagree about the same
2727 /// connection, so it is pinned here rather than left to a doc comment.
2728 #[test]
2729 fn the_modules_are_numbered_as_bk6_22_300_defines_them() {
2730 use Sect14aModule as M;
2731 assert!(M::Modul1.label().contains("pauschale Reduzierung"));
2732 assert!(
2733 M::Modul2
2734 .label()
2735 .contains("prozentuale Arbeitspreisreduzierung")
2736 );
2737 assert!(M::Modul3.label().contains("zeitvariable Netzentgelte"));
2738 }
2739
2740 /// `Modul 1 + Modul 3` is the only pair BK6-22-300 offers.
2741 ///
2742 /// Modul 1 and Modul 2 are the two forms of the *base* module and the
2743 /// Anschlussnutzer picks one; Modul 3 adds to the pauschale Modul 1 and not
2744 /// to Modul 2, which re-prices the same Arbeitspreis.
2745 #[test]
2746 fn modul_1_and_modul_3_are_the_only_combination() {
2747 use Sect14aModule as M;
2748 assert!(M::Modul1.combinable_with(M::Modul3));
2749 assert!(M::Modul3.combinable_with(M::Modul1));
2750
2751 assert!(!M::Modul2.combinable_with(M::Modul3));
2752 assert!(!M::Modul3.combinable_with(M::Modul2));
2753 assert!(
2754 !M::Modul1.combinable_with(M::Modul2),
2755 "Modul 2 is an alternative to Modul 1, not an addition to it"
2756 );
2757 assert!(!M::Modul2.combinable_with(M::Modul1));
2758
2759 for m in [M::Modul1, M::Modul2, M::Modul3] {
2760 assert!(!m.combinable_with(m), "{m:?} with itself is not a pair");
2761 }
2762 }
2763
2764 /// A period is ordered by construction; a single day is valid.
2765 #[test]
2766 fn a_period_cannot_be_inverted() {
2767 use time::macros::date;
2768 assert!(SettlementPeriod::new(date!(2026 - 01 - 31), date!(2026 - 01 - 01)).is_err());
2769 let one_day = SettlementPeriod::new(date!(2026 - 01 - 01), date!(2026 - 01 - 01))
2770 .expect("a single day is a period");
2771 assert_eq!(one_day.days(), 1);
2772 let january =
2773 SettlementPeriod::new(date!(2026 - 01 - 01), date!(2026 - 01 - 31)).expect("valid");
2774 assert_eq!(january.days(), 31, "both bounds are inclusive");
2775 }
2776}
2777
2778#[cfg(test)]
2779mod korrektur_grund_tests {
2780 use super::*;
2781
2782 /// A correction that cannot say why it happened is not an audit trail.
2783 ///
2784 /// The invoice numbers answer *what* was replaced; only the reason
2785 /// distinguishes a lawful retroactive recalculation from a defect in the
2786 /// original settlement, and those have different consequences.
2787 #[test]
2788 fn a_reason_is_required_for_every_recalculation() {
2789 let mut r = sample_result(SettlementStatus::Initial, None);
2790 assert!(
2791 r.lineage_is_consistent(),
2792 "an initial settlement corrects nothing"
2793 );
2794
2795 r.status = SettlementStatus::Correction;
2796 assert!(
2797 !r.lineage_is_consistent(),
2798 "a correction with no reason must be detectable"
2799 );
2800
2801 r.korrektur_grund = Some(KorrekturGrund::Tarifkorrektur);
2802 assert!(r.lineage_is_consistent());
2803 }
2804
2805 /// An initial settlement carrying a correction reason is equally inconsistent.
2806 #[test]
2807 fn an_initial_settlement_carries_no_reason() {
2808 let r = sample_result(
2809 SettlementStatus::Initial,
2810 Some(KorrekturGrund::Rechenfehler),
2811 );
2812 assert!(!r.lineage_is_consistent());
2813 }
2814
2815 /// Separating defects from lawful recalculations is the point of the field:
2816 /// a rising Rechenfehler count is an engineering signal, a rising
2817 /// RegulatorischeAenderung count is not.
2818 #[test]
2819 fn only_some_reasons_indicate_a_defect() {
2820 assert!(KorrekturGrund::Rechenfehler.indicates_defect());
2821 assert!(KorrekturGrund::Stammdatenkorrektur.indicates_defect());
2822 assert!(!KorrekturGrund::RegulatorischeAenderung.indicates_defect());
2823 assert!(!KorrekturGrund::Messwertkorrektur.indicates_defect());
2824 assert!(!KorrekturGrund::Clearing.indicates_defect());
2825 }
2826
2827 /// The codes are stable — they reach reporting and structured records.
2828 #[test]
2829 fn the_codes_are_stable() {
2830 assert_eq!(
2831 KorrekturGrund::Messwertkorrektur.code(),
2832 "MESSWERTKORREKTUR"
2833 );
2834 assert_eq!(
2835 KorrekturGrund::RegulatorischeAenderung.code(),
2836 "REGULATORISCHE_AENDERUNG"
2837 );
2838 }
2839
2840 fn sample_result(
2841 status: SettlementStatus,
2842 korrektur_grund: Option<KorrekturGrund>,
2843 ) -> SettlementResult {
2844 SettlementResult {
2845 settlement_type: SettlementType::NneStrom,
2846 status,
2847 korrektur_grund,
2848 period: SettlementPeriod::new(
2849 time::macros::date!(2026 - 01 - 01),
2850 time::macros::date!(2026 - 01 - 31),
2851 )
2852 .expect("valid period"),
2853 regime: crate::regulatory::RegulatoryRegime::for_period(
2854 time::macros::date!(2026 - 01 - 01),
2855 time::macros::date!(2026 - 01 - 31),
2856 ),
2857 sparte: Sparte::Strom,
2858 malo_id: "51238696012".to_owned(),
2859 sender_mp_id: "9900000000001".to_owned(),
2860 recipient_mp_id: "9900000000002".to_owned(),
2861 positions: Vec::new(),
2862 total_eur: rust_decimal::Decimal::ZERO,
2863 steuer: crate::umsatzsteuer::Steuerausweis {
2864 kategorie: crate::umsatzsteuer::TaxCategory::Standard,
2865 satz_prozent: crate::umsatzsteuer::REGELSTEUERSATZ,
2866 bemessungsgrundlage_eur: rust_decimal::Decimal::ZERO,
2867 steuer_eur: rust_decimal::Decimal::ZERO,
2868 hinweis: None,
2869 rechtsgrundlage: "§12 Abs. 1 UStG",
2870 },
2871 warnings: Vec::new(),
2872 }
2873 }
2874}
2875
2876#[cfg(test)]
2877mod position_kind_tests {
2878 use super::BillingPositionKind as K;
2879
2880 /// `K::ALL` really is every variant.
2881 ///
2882 /// The `const fn` it calls matches without a wildcard, so a new kind that is
2883 /// not listed in `ALL` breaks this file's build — which is what keeps every
2884 /// guard walking `ALL` honest.
2885 #[test]
2886 fn the_kind_catalogue_is_complete() {
2887 assert!(K::ALL.iter().all(|k| k.is_exhaustive()));
2888 for (i, a) in K::ALL.iter().enumerate() {
2889 assert!(!K::ALL[i + 1..].contains(a), "ALL lists {a:?} twice");
2890 }
2891 }
2892}
2893
2894#[cfg(test)]
2895mod blindarbeit_tests {
2896 use super::*;
2897 use rust_decimal::dec;
2898
2899 /// cos φ 0,9 is the customary boundary: reactive energy up to tan φ ≈ 0,4843
2900 /// of the active energy travels with it and is not charged.
2901 #[test]
2902 fn draw_inside_the_free_share_costs_nothing() {
2903 let b = Blindarbeit {
2904 blindarbeit_kvarh: dec!(400),
2905 freigrenze_anteil: Blindarbeit::COS_PHI_0_9,
2906 preis_ct_per_kvarh: dec!(2.0),
2907 };
2908 // 1 000 kWh × 0,4843 = 484,3 kvarh free; 400 stays inside it.
2909 assert_eq!(b.mehrarbeit_kvarh(dec!(1000)), Decimal::ZERO);
2910 }
2911
2912 /// Only the excess is chargeable.
2913 #[test]
2914 fn only_the_excess_is_charged() {
2915 let b = Blindarbeit {
2916 blindarbeit_kvarh: dec!(600),
2917 freigrenze_anteil: Blindarbeit::COS_PHI_0_9,
2918 preis_ct_per_kvarh: dec!(2.0),
2919 };
2920 assert_eq!(b.mehrarbeit_kvarh(dec!(1000)), dec!(115.7));
2921 }
2922
2923 /// An unused allowance is not a credit — the excess floors at zero.
2924 #[test]
2925 fn an_unused_allowance_is_never_negative() {
2926 let b = Blindarbeit {
2927 blindarbeit_kvarh: dec!(10),
2928 freigrenze_anteil: Blindarbeit::COS_PHI_0_9,
2929 preis_ct_per_kvarh: dec!(2.0),
2930 };
2931 assert_eq!(b.mehrarbeit_kvarh(dec!(5000)), Decimal::ZERO);
2932 }
2933
2934 /// The share is a term of the Preisblatt, not a constant: many networks
2935 /// round cos φ 0,9 to a flat 50 %, and billing them at 0,4843 overcharges.
2936 #[test]
2937 fn the_free_share_follows_the_preisblatt() {
2938 let rounded = Blindarbeit {
2939 blindarbeit_kvarh: dec!(600),
2940 freigrenze_anteil: dec!(0.5),
2941 preis_ct_per_kvarh: dec!(2.0),
2942 };
2943 assert_eq!(rounded.mehrarbeit_kvarh(dec!(1000)), dec!(100.0));
2944
2945 let exact = Blindarbeit {
2946 freigrenze_anteil: Blindarbeit::COS_PHI_0_9,
2947 ..rounded
2948 };
2949 assert!(
2950 exact.mehrarbeit_kvarh(dec!(1000)) > rounded.mehrarbeit_kvarh(dec!(1000)),
2951 "the tighter cos φ 0,9 share charges more than a rounded 50 %"
2952 );
2953 }
2954
2955 /// Blindmehrarbeit rests on the Netzbetreiber's Preisblatt under StromNEV
2956 /// §17 — not §18 (dezentrale Erzeugung) and not §19 (Sonderformen).
2957 #[test]
2958 fn the_position_kind_maps_to_the_bdew_artikelnummer() {
2959 assert_eq!(
2960 BillingPositionKind::Blindmehrarbeit.artikelnummer(SettlementType::NneStrom),
2961 Some("BLINDMEHRARBEIT")
2962 );
2963 }
2964}