grid-billing
Deterministic, regulation-aware German grid settlement engine — NNE, KA, MMM, MSB, and GeLi Gas AWH Sperrprozesse (PIDs 31001, 31002, 31005, 31006, 31009, 31011).
What this crate does
grid-billing computes BDEW INVOIC billing positions with full explainability:
- NNE Strom (PID 31001) — flat-rate Arbeit, Leistung (RLM), Konzessionsabgabe
- NNE Gas (PID 31005) — GasNEV §14 legal basis, auto-set when
Sparte::Gas - §14a Modul 2 ToU — mandatory HT/NT Arbeit split for controllable loads (BNetzA BK6-22-300)
- Selbst ausgestellte NNE (PID 31006) — LF runs the identical formula (§20 MessZV)
- MMM Strom (PID 31002) — Mehr-/Mindermengensaldo, StromNZV §15
- MMM Gas (PID 31002 via GasNZV §14) — Gas imbalance with GeLi Gas legal basis
- MSB-Rechnung (PID 31009) — Grundgebühr Messstellenbetrieb + optional Messdienstleistung
- GeLi Gas AWH Sperrprozesse (PID 31011) — abrechnungswürdige Handlungen (BK7-24-01-009 §5.4)
- Reversal (Stornorechnung) —
calculate_reversal()negates any prior settlement immutably
All calculations are pure functions — zero I/O, zero async, no side effects.
All monetary arithmetic uses rust_decimal::Decimal via billing::EuroAmount — no f64 anywhere.
Architecture
Settlement flow
NneInput / MmmInput / MsbInput / GasAwhInput
│
▼
validate_*_input() ← optional pre-check: ValidationResult
│
▼
calculate_*_invoice() ← pure, deterministic, no I/O
│
▼
GridSettlement {
pid, settlement_type, status,
rechnungsnummer, correction_of,
nb_mp_id, counterparty_mp_id, ← auto-populated from input
positions: Vec<InvoicePosition {
text, kind, artikel_id, ← BDEW Artikelnummer bridge
quantity, unit, unit_price_eur, net_eur,
trace: CalculationTrace { ← "why is this amount here?"
explanation,
legal_refs: Vec<LegalReference>, ← StromNEV §17, KAV §2, §14a Modul 2…
tariff_source: Option<TariffSource>,
gross_eur, regulatory_reduction_factor, …
}
}>,
total_eur,
warnings: Vec<SettlementWarning>,
}
│
▼ (service-layer concern — grid-billing has no rubo4e dep)
kind_to_artikelnummer(pos.kind, settlement_type) → BdewArtikelnummer
│
▼
into_rechnung(&settlement) → rubo4e::current::Rechnung {
rechnungspositionen[].artikelnummer ← Gas/MMM/KA classic codes
rechnungspositionen[].artikel_id ← NNE Strom / AWH Gas
}
│
▼
InvoicCheckEngine::check(pid, &nb_mp_id, &rechnung, …)
│
▼
invoice_drafts (PostgreSQL) → AS4 dispatch
BDEW Artikelnummern architecture
The service layer owns the BDEW Artikelnummer mapping. grid-billing stays free
of rubo4e:
flowchart LR
calc["grid_billing\ncalculate_*_invoice()"]
pos["InvoicePosition\n.kind: BillingPositionKind\n.artikel_id: Option<String>"]
svc["Service layer\nkind_to_artikelnummer()"]
bo4e["Rechnungsposition\n.artikelnummer ← Gas/MMM/KA\n.artikel_id ← NNE Strom/AWH Gas"]
calc --> pos --> svc --> bo4e
note1["BK6-20-160:\nNNE Strom replaced\nartikelnummer → artikel_id\nfrom PreisblattNetznutzung"]
note2["BDEW Codeliste v5.6:\nGas NNE/MMM/KA use\nclassic 9990001… codes\nAWH: 2-01-7-001/002"]
note1 -.->|Strom| bo4e
note2 -.->|Gas| bo4e
Responsibility split
grid-billing has zero dependency on rubo4e. BO4E conversion lives exclusively in the
service layer, keeping this crate publishable to crates.io without pulling in internal workspace crates.
| Responsibility | Where |
|---|---|
| Settlement math + legal refs | grid-billing |
BO4E Rechnung conversion |
netzbilanzd::into_rechnung() / invoicd::into_rechnung() |
| INVOIC plausibility checks 1–6 | invoic-checker |
| EDIFACT serialization + AS4 dispatch | makod |
Domain types
GridSettlement — canonical output
// Backward-compatible alias — existing code using GridInvoice continues to compile:
pub type GridInvoice = GridSettlement;
Helper methods on GridSettlement:
| Method | Returns | Description |
|---|---|---|
is_clean() |
bool |
true when no Warning/Error severity items in warnings |
recomputed_total() |
Decimal |
Re-sums positions — should equal total_eur (regression guard) |
all_legal_refs() |
Vec<String> |
Deduplicated citation strings across all positions |
positions_count() |
usize |
Number of billing positions |
InvoicePosition with CalculationTrace
Every position carries a full audit record so any amount can be explained without
re-running the calculation. The kind field drives the BDEW Artikelnummer mapping
in the service layer, and artikel_id carries the new-format article code where applicable
(e.g. AWH Gas: "2-01-7-001", NNE Strom: populated from PreisblattNetznutzung):
LegalReference
.citation() returns a short German-language string (e.g. "StromNEV §17",
"§14a EnWG Modul 2 (HT/NT variable)", "ARegV §17").
Sect14aModule
Sect14aModule::Modul1.label() = "§14a EnWG Modul 1 (pauschale Reduzierung)";
.bnentza_reference() = "BK6-22-300" for all three modules.
TariffSource
Sparte — commodity dispatch
Sparte is required on NneInput and MmmInput. The calculation automatically
selects the correct legal references, SettlementType, and default PID — no
manual r.pid = 31005 override needed for standard Gas paths.
SettlementType
SettlementType::default_pid() returns the standard PID for the type.
MmmGas and MmmStrom share PID 31002 but carry different legal references.
BillingPositionKind — BDEW Artikelnummern bridge
BillingPositionKind is the rubo4e-free type carried by every InvoicePosition.kind.
The service layer maps it to rubo4e::current::BdewArtikelnummer in into_rechnung().
NNE Strom (PIDs 31001/31006): BK6-20-160 replaced classic
artikelnummercodes withartikel_idfrom the BNetzA Netznutzungspreisblatt. The service layer (netzbilanzd,invoicd) populatesRechnungsposition.artikel_idfrom the tariff sheet for those positions;kind_to_artikelnummer()returnsNonefor Strom NNE. Gas NNE, MMM, Konzessionsabgabe still use classicarticlenummercodes.
Source: BDEW Codeliste Artikelnummern und Artikel-ID v5.6 (valid 01.09.2025).
KaKlasse — KAV rate tier
When ka_klasse is set, the KA position text and trace include the tier so
auditors can verify the rate matches the correct KAV §2 band without looking up
the underlying master data.
Who uses this library
| Consumer | Role | Use case |
|---|---|---|
netzbilanzd |
NB | Generate INVOIC 31001/31002/31005/31009/31011 to LF/MSB/LFG |
invoicd |
LF | §20 MessZV selbstausstellen PID 31006 — same formula, LF-initiated |
Quick start
[]
= { = "0.10" }
= "1"
= "0.3"
NNE flat-rate (SLP, Strom)
use ;
use Decimal;
use date;
let settlement = calculate_nne_invoice.expect;
// settlement.total_eur = 52.50 + 1.65 = 54.15 EUR
assert_eq!;
// counterparty_mp_id is auto-populated from lf_mp_id:
assert_eq!;
// Every position is self-explanatory:
for pos in &settlement.positions
NNE Gas (GasNEV §14)
use ;
// Only Sparte changes — GasNEV §14 legal refs and PID 31005 are automatic:
let settlement = calculate_nne_invoice.unwrap;
assert_eq!;
§14a Modul 2 ToU (HT/NT split, mandatory since 2024-01-01)
use ;
let settlement = calculate_nne_invoice.unwrap;
// HT: 600×4.20ct=25.20; NT: 400×1.50ct=6.00; KA: 1000×1.32ct=13.20 → total 44.40 EUR
assert_eq!; // HT + NT + KA
assert!;
§14a Modul 1 (flat percentage reduction, mandatory offer since 2024-01-01)
use ;
use dec;
// BK6-22-300 Anlage 2: default reduction factor = 0.85 (customer pays 85% of full rate).
// The NB may publish a different approved value in their PreisblattNetznutzung.
let settlement = calculate_nne_invoice.unwrap;
// 1500 × 0.035 × 0.85 = 44.625 → 44.62 EUR (MidpointNearestEven)
assert!;
assert!;
Gas NNE with Grundpreis (GasNEV monthly standing charge)
let settlement = calculate_nne_invoice.unwrap;
// Positions: Grundpreis (15.00) + Arbeit (54.00) = 69.00 EUR
assert_eq!;
assert!;
GeLi Gas AWH Sperrprozesse (PID 31011)
use ;
let settlement = calculate_gas_awh_invoice.unwrap;
assert_eq!;
assert_eq!;
// Both positions cite BK7-24-01-009 §5.4
assert!;
Correction lifecycle (reversal + replacement pair)
use ;
let original = calculate_nne_invoice.unwrap;
let corrected = calculate_nne_invoice.unwrap;
let = calculate_correction;
assert_eq!;
assert_eq!;
assert_eq!;
assert_eq!;
use ;
use date;
let original = calculate_nne_invoice.unwrap;
let storno = calculate_reversal;
assert_eq!;
assert_eq!;
Pre-calculation validation
use ;
let input = NneInput ;
let v = validate_nne_input;
if !v.is_valid
let settlement = calculate_nne_invoice.unwrap;
Service-layer conversion to BO4E Rechnung
// In netzbilanzd/src/billing.rs — grid-billing itself has no rubo4e dep:
use ;
use ;
Generated invoice types
| PID | Description | Direction | Sparte |
|---|---|---|---|
| 31001 | NNE Strom | NB → LF | Strom |
| 31002 | MMM Strom | NB → LF | Strom |
| 31002 | MMM Gas | GNB → LFG | Gas |
| 31005 | NNE Gas | GNB → LFG | Gas (auto via Sparte::Gas) |
| 31006 | Selbst ausgestellte NNE | LF | Strom |
| 31009 | MSB-Rechnung | NB → MSB | both |
| 31011 | AWH Sperrprozesse Gas | GNB → LFG | Gas |
Billing position reference
NNE
| # | Position text | Unit | kind |
Condition | Legal basis | Artikelnummer |
|---|---|---|---|---|---|---|
| 1 | Netznutzung Arbeit |
kWh | NneArbeit |
flat / SLP | StromNEV §21 (Strom) · GasNEV §14 (Gas) | Wirkarbeit (Gas); artikel_id (Strom) |
| 1 | Netznutzung Arbeit §14a Modul 1 (85% Reduzierung) |
kWh | NneArbeitModul1 |
sect14a_modul1_reduction_factor set |
§14a EnWG Modul 1 · BK6-22-300 | same as NneArbeit |
| 1+2 | Netznutzung Arbeit HT (§14a Modul 2) + NT |
kWh | NneArbeitHt / NneArbeitNt |
HT + NT both set | §14a EnWG Modul 2 · BK6-22-300 | same as NneArbeit |
| opt | Netzentgelt Grundpreis Gas |
Monat | NneGasGrundpreis |
nne_grundpreis_eur_per_month set |
GasNEV §14 | Grundpreis |
| next | Netznutzung Leistung |
kW | NneLeistung |
spitzenleistung_kw set (RLM) |
StromNEV §17 | Leistung (Gas); artikel_id (Strom) |
| last | Konzessionsabgabe[tier] |
kWh | Konzessionsabgabe |
ka_satz_ct_per_kwh set |
KAV §2 Abs. 2 | Konzessionsabgabe |
MMM
| # | Position text | kind |
Artikelnummer | Condition |
|---|---|---|---|---|
| 1 | Mehrmengen |
Mehrmenge |
Mehrmenge |
actual > profil |
| 2 | Mindermengen (Gutschrift) |
Mindermenge |
Mindermenge |
profil > actual |
MSB
| # | Position text | kind |
Artikelnummer | Condition |
|---|---|---|---|---|
| 1 | Grundgebühr Messstellenbetrieb |
MsbGrundgebuehr |
EntgeltEinbauBetriebWartungMesstechnik |
Always |
| 2 | Messdienstleistung |
Messdienstleistung |
EntgeltMessungAblesung |
messdienstleistung_eur set |
AWH Gas Sperrprozesse (PID 31011)
| # | Position text | artikel_id |
Condition |
|---|---|---|---|
| any | Sperrung Gaszähler |
2-01-7-001 |
Unterbrechung reguläre AZ |
| any | Entsperrung Gaszähler |
2-01-7-002 |
Wiederherstellung reguläre AZ |
| any | Erfolglose Unterbrechung |
2-01-7-003 |
Sperrung failed |
| any | Stornierung Sperrauftrag (Vortag) |
2-01-7-004 |
Cancelled day before |
| any | Stornierung Sperrauftrag (Sperrtag) |
2-01-7-005 |
Cancelled same day |
| any | Entsperrung außerhalb AZ |
2-01-7-006 |
Out of hours |
Source: BDEW Codeliste Artikelnummern und Artikel-ID v5.6, Section 3.2 (valid 01.09.2025).
Design invariants
| Invariant | Detail |
|---|---|
| No floating-point money | rust_decimal::Decimal throughout; billing::EuroAmount for overflow guard. No f64. |
| No rubo4e dependency | Returns GridSettlement; service layer owns into_rechnung(). |
counterparty_mp_id auto-populated |
lf_mp_id (NNE/MMM) or msb_mp_id (PID 31009) copied automatically. |
Sparte drives settlement type |
Sparte::Gas → SettlementType::NneGas, GasNEV §14, PID 31005. No manual override needed. |
| Every position cites regulation | trace.legal_refs is non-empty for every position. Enables BNetzA audit without re-calculation. |
| Artikelnummer on every position | InvoicePosition.kind → BdewArtikelnummer via kind_to_artikelnummer() in service layer. Never empty. |
MmmGas ≠ MmmStrom |
Separate SettlementType variants ensure correct legal refs (GasNZV §14 vs StromNZV §15) per position. |
| Immutable correction chain | calculate_reversal() mirrors positions, sets status = Reversal, links via correction_of. Original never mutated. |
calculate_correction() pair |
Returns (reversal, replacement) — both get status set atomically; caller dispatches both. |
| Pure functions | All calculate_* functions are sync with no side effects. |
recomputed_total guard |
debug_assert_eq!(result.total_eur, result.recomputed_total()) inside every calculate_* — catches rounding bugs in debug builds. |
| --- | --- |
| No floating-point money | rust_decimal::Decimal throughout; billing::EuroAmount for overflow guard. No f64. |
| No rubo4e dependency | Returns GridSettlement; service layer owns into_rechnung(). |
counterparty_mp_id auto-populated |
lf_mp_id (NNE/MMM) or msb_mp_id (PID 31009) copied automatically — service layer always has the recipient. |
Sparte drives settlement type |
Sparte::Gas → SettlementType::NneGas, GasNEV §14, PID 31005. No manual override needed. |
| Every position cites regulation | trace.legal_refs is non-empty for every position. Enables BNetzA audit without re-calculation. |
| Immutable correction chain | calculate_reversal() mirrors positions, sets status = Reversal, links via correction_of. Original never mutated. |
| Pure functions | All calculate_* functions are sync with no side effects. |
| Decimal-only input | All rates via Decimal::from_str_exact. Never Decimal::try_from(f64). |
See also
invoic-checker— validates the generatedRechnungin the service layernetzbilanzd— NB billing service that callsgrid-billinginvoicd— LF service usinggrid-billingfor selbstausstellen- Operator guide → netzbilanzd
grid-billing computes BDEW INVOIC billing positions for:
- NNE (Netznutzungsentgelt) — flat-rate or §14a Modul 2 ToU (HT/NT split)
- KA (Konzessionsabgabe) — §17 StromNZV, included as separate position
- MMM (Mehr-/Mindermengensaldo) — actual vs. SLP profile deviation, credit when Mindermengen dominate
- MSB-Rechnung — metering service fee (NB → MSB, PID 31009)
All calculations are pure functions — zero I/O, zero async, no side effects.
All monetary arithmetic uses EuroAmount = i64 × 10⁻⁵ EUR — no f64 anywhere in the billing path.