# grid-billing
> Deterministic, regulation-aware German grid settlement engine —
> NNE, KA, MMM, MSB, and GeLi Gas AWH Sperrprozesse
> (Prüfidentifikatoren 31001, 31002, 31005, 31006, 31009, 31011 — the five-digit
> BDEW codes that name each billing Anwendungsfall).
[](https://crates.io/crates/grid-billing)
## Regulatory ceilings and structure
### KAV §2 — Konzessionsabgabe
The Höchstbeträge are checked on every settlement, and each position cites the
paragraph its group is actually capped under: **§2 Abs. 2** for Tarifkunden and
Schwachlast, **Abs. 3** for Sondervertragskunden, and **Abs. 4** (Strom) resp.
**Abs. 5** (Gas) where the Verordnung forbids a Konzessionsabgabe altogether.
The rates themselves are undated because the statute has not changed them since
the Euro conversion — the annual reductions people remember were the §3
transitional phase-down, which completed long ago.
### MsbG §30 — Preisobergrenzen für den Messstellenbetrieb
Every figure §30 states is **brutto jährlich**.
| > 6 000 – ≤ 10 000 kWh | 80 € | 40 € | 120 € |
| > 10 000 – ≤ 20 000 kWh · steuerbare VE · > 7 – ≤ 15 kW | 80 € | 50 € | 130 € |
| > 20 000 – ≤ 50 000 kWh · > 15 – ≤ 25 kW | 80 € | 110 € | 190 € |
| > 50 000 – ≤ 100 000 kWh · > 25 – ≤ 100 kW | 80 € | 140 € | 220 € |
| > 100 000 kWh · > 100 kW | 80 € | angemessenes Entgelt | — |
§30 Abs. 3 (optionaler Einbau) is 30 € each, 60 € total.
Two conversions before the comparison means anything. The monthly charge is
**annualised** — billing a year in instalments does not raise the cap — and it is
**grossed up** at the Umsatzsteuer rate of the delivery period, because the
ceiling is gross and the settlement's figures are net. Comparing net against
gross would grant the whole tax rate as headroom: at 19 % a charge could sit
18.9 % above the statutory maximum unreported. A gross charge above the ceiling
raises `MSB_ABOVE_MSBG_POG`, and the finding states both figures.
What is measured is the **Grundgebühr together with the Messdienstleistung**.
§30 caps the „Entgelt für den Messstellenbetrieb", and §3 Abs. 2 Nr. 1 MsbG puts
the Messung inside it — Messstellenbetrieb umfasst die „Gewährleistung einer
mess- und eichrechtskonformen Messung […] einschließlich der
Messwertaufbereitung"; §17 Abs. 7 Satz 1 StromNEV states it from the other side
(„ein Entgelt für den Messstellenbetrieb, zu dem auch die Messung gehört").
Testing the Grundgebühr alone would let a settlement clear any ceiling by moving
part of the charge into the second position. The Messdienstleistung is a flat fee
for the whole period, so it is spread over `billing_months` before annualising.
The **Steuerungseinrichtung am Netzanschlusspunkt** is the exception, and it is
`steuereinrichtung_eur_per_month`'s own position. §30 Abs. 2 charges it
„zusätzlich zu den nach den Absätzen 1 und 5 zulässigen Preisobergrenzen" and
caps it at 50 € brutto a year per party, so it is measured against that figure
and left out of the Abs. 1 comparison — folded into the Grundgebühr it would take
headroom the Absatz does not grant, and an excess raises
`MSB_STEUEREINRICHTUNG_ABOVE_MSBG_POG`.
**§ 2 Abs. 7 is a classification rule, and it is checked too.** A
Niederspannungslieferung counts as a Tariflieferung unless the gemessene Leistung
exceeds 30 kW in mindestens zwei Monaten des Abrechnungsjahres **und** the
Jahresverbrauch exceeds 30 000 kWh. `Konzessionsabgabe::niederspannung` carries
those two facts (with the Satz-4 lower figures where a Gemeinde agreed them), and
a stated `klasse` that contradicts them raises
`KA_GRUPPE_WIDERSPRICHT_KAV_ABS7` — the two ceilings are 1,32 ct and 0,11 ct
apart, so the label is worth checking.
**Two rules forbid a Konzessionsabgabe outright**, and a ceiling cannot catch
either: § 2 Abs. 4 (Strom) resp. Abs. 5 Nr. 2 (Gas) where a Sondervertragskunde's
Durchschnittspreis lies under the Grenzpreis (`Grenzpreisvergleich` →
`KA_UNTER_GRENZPREIS`), and § 2 Abs. 5 Nr. 1 above 5 Millionen kWh Gas je Jahr
und Abnahmefall (read off `jahresarbeit_kwh` → `KA_GAS_UEBER_GRENZMENGE`).
The band is **derived, not named**. Each §30 Abs. 1 Nummer is a disjunction over
facts about the metering point, so `MessstellenKategorie::Pflichteinbau` carries
`PflichtEinstufung { jahresverbrauch_kwh, installierte_leistung_kw,
steuerbare_verbrauchseinrichtung }` and `PflichtEinstufung::band` walks the Nummern
top down — a point meeting several takes the highest, and a settlement cannot pick
its own ceiling. With no fact at all the tightest applies: a Pflichteinbaufall
exists only above 6 000 kWh (§29 Abs. 1), so Nr. 5 is the catalogue's floor.
**The schedule is dated.** Abs. 1 and Abs. 3 state their figures „für die Zeit ab
dem 1. Januar 2025", so `preisobergrenze_eur_per_jahr` takes the period's end and
answers `Preisobergrenze::VorSchedule` below it — a correction settled today for a
2024 period is governed by rules mako does not carry, and reporting it against
these would call a lawful charge an excess (`MSB_POG_VOR_SCHEDULE`). The three
answers are distinct on purpose: `Betrag` is a ceiling, `Angemessen` is Abs. 1
Nr. 1's *angemessenes jährliches Entgelt* above 100 000 kWh, and `VorSchedule` is
"not this schedule's period". Abs. 6 lets a BNetzA Festlegung nach § 33 replace
Abs. 1 bis 3 from a date it names; none has issued.
**Which Jahresstromverbrauch picks the band is Abs. 4's answer, not the
caller's choice**: the average of the last three erfasste Jahresverbrauchswerte,
the Netzbetreiber's Jahresverbrauchsprognose until three exist, re-checked
annually. `PflichtEinstufung::jahresverbrauch_kwh` is that figure — one year's
reading is not.
### §17 StromNEV — Netzebene and Benutzungsstundenzahl
`Netzebene` covers the seven levels, distinguishing network levels from
transformation levels. It is **recorded, not applied**: Netzentgelte are
published per level, so the level is what makes a rate checkable against a price
sheet, but this crate is given rates rather than resolving them.
The same holds for the Benutzungsstundenzahl (annual energy ÷ annual peak). It
does not appear in §17 as a threshold — it is the convention by which a price
sheet publishes two rate pairs — so it goes into the trace rather than selecting
anything. Zero peak yields `None`, not zero.
What *is* enforced is §17 Abs. 6: an Arbeitspreis-only tariff is permitted only
in Niederspannung up to 100 000 kWh a year. Billing without a Leistungspreis
outside that raises `ARBEITSPREIS_ONLY_OUTSIDE_SECT17_ABS6`.
`Leistungspreis::system` names which Leistungspreissystem the price sheet states,
and the two are billed differently.
`LeistungspreisSystem::Jahr` is §17 Abs. 2 Satz 2: „Das Jahresleistungsentgelt
ist das Produkt aus dem jeweiligen Jahresleistungspreis und der
Jahreshöchstleistung in Kilowatt der jeweiligen Entnahme im Abrechnungsjahr." Two
figures multiplied, and §17 states no day-count convention to scale them by — so
the rate is billed as published. A settlement period that is not the
Abrechnungsjahr raises `JAHRESLEISTUNGSPREIS_UNTERJAEHRIG` rather than being
silently pro-rated into a Zwölftelung no price sheet publishes.
`LeistungspreisSystem::Monat` is the Monatsleistungspreissystem §17 Abs. 8
presupposes when it offers Tagesleistungspreise for Landstrom „neben einem
Jahres- und Monatsleistungspreissystem". StromNEV does not define it; the
Netzbetreiber's Preisblatt publishes it as a €/kW·Monat rate against the period's
Höchstleistung. It is billed for the months stated, and a month count that does
not match the period raises `MONATSLEISTUNGSPREIS_MONATE_MISMATCH` — a twelvefold
over-charge multiplies out perfectly, so only the count can catch it.
### §19 Abs. 2 StromNEV — individuelle Netzentgelte
Both forms are settled, with the statutory floors — which are in the ordinance
text itself, not only in the BK4-22-089 methodology:
| Atypische Netznutzung (Satz 1) | peak in the low-load windows (BNetzA-approved) | 20 % |
| Intensive Netznutzung (Satz 2) | ≥ 7 000 h **and** > 10 GWh | 20 % |
| | ≥ 7 500 h | 15 % |
| | ≥ 8 000 h | 10 % |
`Sect19Vereinbarung` carries the agreed fraction; the engine applies it as a
reduction over the Arbeits- and Leistungspreis positions **only** — the
Konzessionsabgabe and the levies are untouched, because the Netzbetreiber's lost
revenue is recovered through the §19-Umlage billed separately. An agreement
below the floor raises `SECT19_BELOW_MINDESTENTGELT`; a Satz 2 agreement whose
utilisation data does not qualify raises `SECT19_BANDLAST_CRITERIA_NOT_MET`; one
naming neither a Genehmigung (Satz 5) nor an Anzeige (Satz 7) raises
`SECT19_OHNE_GENEHMIGUNG`.
The two Satz 2 thresholds read differently: the Benutzungsstundenzahl must
*„mindestens 7 000 Stunden im Jahr erreichen"*, the Stromverbrauch must *„zehn
Gigawattstunden übersteigen"* — so exactly 10 GWh does not qualify. The agreed
fraction is a reduction, and outside `(0, 1]` it is refused as
`BillingError::InvalidInput`.
### §18 StromNEV — Entgelte für dezentrale Erzeugung, under Abschmelzung
`settle_dezentrale_einspeisung` pays the plant operator the avoided upstream
costs, at the factor Festlegung **GBK-25-02-1#1** (17.02.2026) leaves standing:
| to 30.06.2026 | 1.00 |
| 01.07.2026 – 31.12.2027 | 0.50 |
| 2028 | 0.25 |
| from 2029 | 0.00 |
The Tenor cuts in three steps (50 % from 01.07.2026, 50 % from 01.01.2027, 75 %
from 01.01.2028) — the annual averages fall by 25 points a year, which is the
decision's own cross-check. A period crossing a step is **refused**, not
averaged; an EEG-funded plant is refused outright (§18 Abs. 1 Satz 4 Nr. 1 —
the payment would be unlawful).
### Gas — Druckstufen and Kapazitätsprodukte (§15 GasNEV)
`Druckstufe` (Hoch-/Mittel-/Niederdruck) is the gas analogue of the Strom
Netzebene; `GasKapazitaet` bills a booked capacity at the price sheet's annual
rate, pro-rated by calendar days, distinguishing feste from unterbrechbarer
Kapazität — the latter cites §15 Abs. 5, and its discount stays where the
ordinance leaves it: on the price sheet, not in this crate.
## Invalid inputs are unrepresentable
`NneInput`'s cross-field rules live in the types, not in a validator a caller
could forget:
| Exactly one Arbeitspreis form (einheitlich, Modul 1 pauschal, Modul 2 prozentual, Modul 3 zeitvariabel, or spot-linked) | `ArbeitspreisModell` — one variant at a time; each replaces the flat position, so the same energy is never billed twice |
| `Modul 1 + Modul 3` is the only combination BK6-22-300 offers | `ArbeitspreisModell` holds one variant at a time, so every pair is unrepresentable — which is right for Modul 1/Modul 2 (alternative base modules) and for Modul 2/Modul 3, and is a limitation for the Modul 1 + Modul 3 pair the Festlegung permits. See `Sect14aModule::combinable_with` |
| Reduction factors in `(0, 1]` | `Reduktionsfaktor` enforces the range at construction |
| Leistungspreis needs both peak and rate, and names its Leistungspreissystem | `Leistungspreis` — a pair plus `LeistungspreisSystem` |
| Grundpreis needs both rate and months | `Grundpreis` — a pair |
| KAV Höchstbetrag is always checked | `Konzessionsabgabe` pairs the rate with its `KaKundengruppe` |
| Period ordering | `SettlementPeriod` — constructing it is the check |
What the types cannot express — negative energy, empty or inverted Modul 3
intervals — `settle_nne` enforces itself and returns `Err`. There is no
separate NNE validator: `settle_nne` is pure and cheap, run it and read
`warnings`. `validate_mmm_input` / `validate_msb_input` /
`validate_gas_awh_input` exist for the settlement types whose engines accept
looser shapes.
## Settlement, not invoice
The engine calculates **what is owed and why**. It does not know what the invoice
looks like:
```
Input → Validation → Settlement Engine → SettlementResult → InvoiceDocument → BO4E → EDIFACT
```
`SettlementResult` carries the positions, totals, warnings, the applied
`RegulatoryRegime` and a `CalculationTrace` per position. `InvoiceDocument`
carries everything that is a property of the *document* — invoice number, issue
and due dates, the Prüfidentifikator that routes it, the reference to what it
supersedes — and is built by an adapter around a settlement.
The separation is what makes a settlement recomputable: the same period can be
settled twice, for a correction or a dispute or an audit, and the two results
compared, without inventing an invoice number each time.
Position numbering follows the same rule. `InvoiceDocument::numbered_positions()`
assigns 1-based numbers at rendering time; the engine carries no counter.
## No BO4E inside the engine
`SpotPriceFormula` states the pricing formula behind a §14a Modul 3 rate as a
typed value — reference, unit, method, steps — never a `serde_json::Value` carrying
a hand-built BO4E COM. That keeps BO4E *schema knowledge* out of the engine: the
`bo4e` bridge builds the COM from the value object, and the default build pulls
in no `serde_json` at all — it arrives only with that feature.
## SettlementPeriod
A validated pair, not two loose `period_from` / `period_to` dates each calculation
would have to re-check for ordering. Constructing `SettlementPeriod` *is* the check,
so an inverted period is unrepresentable rather than rejected at every call site.
## Regulatory regime
German network-charge law is several timelines, each turning over on its own date:
| Netzzugang | 31.12.2025 | §20 Abs. 3 EnWG via BNetzA Festlegungen (GPKE BK6-24-174, GaBi Gas 2.1) |
| Entgeltbildung | 31.12.2028 | BNetzA framework Festlegung *AgNeS*, replacing StromNEV and ARegV |
| Umlagen | annually | ÜNB publication each October |
[`RegulatoryRegime`](src/regulatory.rs) resolves those dates **once**, at the edge;
every calculation then matches on an enum. Scattering `if period_to <= date`
through the engine is how a rule change becomes a bug — each site has to be found
and each has to agree. Adding the AgNeS turnover is a new variant the compiler
forces every deciding site to handle.
The regime can also be supplied explicitly, so a historical settlement is
reproduced under the rules that applied then rather than under today's calendar.
A period crossing a turnover raises `REGIME_TURNOVER_IN_PERIOD`: different rules
govern its start and its end, so it should be split rather than half-billed.
## Explainability
Every position carries a `CalculationTrace` — the inputs used, the paragraphs
applied, the tariff source, the reduction factor, the rounding. `SettlementResult`
additionally exposes `all_legal_refs()`, deduplicated across positions.
These types are `Serialize`, and the service adapters emit them as BO4E
`ZusatzAttribut`e (`mako:calculation_trace` per position,
`mako:legal_references` and `mako:settlement_warnings` per settlement). BO4E has
no field for a calculation trace and inventing one would break the schema; a
`ZusatzAttribut` is the sanctioned place for what a standard does not model.
This matters because the settlement value itself is dropped once the Rechnung is
stored — the attribute is the only surviving record of *why* an amount is what it
is, and it is what a §20 EnWG audit or an LF dispute is answered from.
## Netzseitige Umlagen
Three levies ride on the network charge rather than the commodity, and a Strom
NNE invoice carries all three:
| Aufschlag für besondere Netznutzung (§19 StromNEV-Umlage) | §19 Abs. 2 StromNEV | A′ 1.559 · B′ 0.050 · C′ 0.025 ct/kWh |
| Offshore-Netzumlage | §17f EnWG | 0.941 ct/kWh |
| KWKG-Umlage | §26 KWKG | 0.446 ct/kWh |
B′ and C′ are published „für Strommengen über 1 000 000 kWh" at one Entnahmestelle,
so they are a **tranche, not a rate**: the year's first Gigawattstunde carries A′
whatever the group. `NneInput::enfg_jahresvorverbrauch_kwh` places the period against
that annual boundary, and one straddling it bills two §19-Aufschlag positions.
Omitted, the period is billed as though it opened the year — the over-billing
direction — with `ENFG_VORVERBRAUCH_MISSING`.
Rates are set annually by the ÜNB and published by 25 October for the following
year. They are held as a year-indexed series in [`umlagen`](src/umlagen.rs) so a
correction reopening an earlier period bills it at the rate that applied then —
a single configured scalar cannot express two years at once. `NneInput` carries
a per-levy override for the cases an EnFG decision does not fit the published
schedule.
### Letztverbrauchergruppen (EnFG §§21 ff.)
The Energiefinanzierungsgesetz replaced the older per-levy privilege rules with
one scheme. `Letztverbrauchergruppe` selects the band: **A′** is the full levy
and covers the first 1 GWh at an Entnahmestelle; **B′** and **C′** apply above
that, C′ for energy-intensive undertakings; **Befreit** (§21 EnFG) is zero
rather than reduced, and emits no line at all.
Only the §19 StromNEV-Umlage is published as an explicit A′/B′/C′ schedule. The
other two publish the non-privileged rate, with privileges granted per
Entnahmestelle — supply those through the override.
A year the series does not cover yields **no** rate rather than a neighbouring
year's, and the levy is omitted with an `UMLAGE_RATE_MISSING` warning. Billing
2027 at the 2026 rate would be wrong by an amount nobody notices until the ÜNB
reconciliation.
## Regulatory baseline (2026)
**StromNZV and GasNZV ceased to apply with the end of 31.12.2025** — Art. 15
Abs. 4 (Strom) and Abs. 6 (Gas) of the Gesetz v. 22.12.2023, BGBl. 2023 I Nr. 405.
The successor competence is **§20 Abs. 3 EnWG**, exercised through BNetzA
Festlegungen:
| Mehr-/Mindermengen Strom | StromNZV §13 Abs. 3 | GPKE (BK6-24-174) Teil 1 Kap. 8.4 |
| Mehr-/Mindermengen Gas | GasNZV §25 | GaBi Gas 2.1 (BK7-24-01-008) |
| Standardlastprofile Strom | StromNZV §12 | GPKE (BK6-24-174), "Profilverfahren" |
| Standardlastprofile Gas | GasNZV §24 | GaBi Gas 2.1 (BK7-24-01-008) |
| Bilanzkreisabrechnung Strom | StromNZV §4 | MaBiS (Anlage 3 zu BK6-24-174) |
| Konzessionsabgabe | **KAV §2** (unchanged) | KAV §2 |
`settle_mmm` picks its legal references from `period_to`, so a
settlement for a 2025 period still cites the ordinance that governed it and one
for 2026 does not. `LegalReference::citation` appends "(außer Kraft seit
01.01.2026)" to a repealed ordinance, keeping archived invoices self-explanatory.
Konzessionsabgabe is governed by the KAV plus §48 EnWG — not by StromNZV §17
or GasNZV §7, which concern balancing-group and network-access matters.
## Mehr-/Mindermengen sign convention
Both quantities are named from the **network operator's** side, which inverts the
intuitive reading. GPKE Kap. 8.4 Nr. 3:
> Unterschreitet die Summe der in einem Zeitraum ermittelten elektrischen Arbeit
> die Summe der Arbeit, die den bilanzierten Profilen zu Grunde gelegt wurde
> (ungewollte Mehrmenge), so vergütet der Netzbetreiber dem Lieferanten oder dem
> Kunden diese Differenzmenge.
| measured **<** profiled | ungewollte **Mehrmenge** | NB vergütet → **credit** |
| measured **>** profiled | ungewollte **Mindermenge** | NB stellt in Rechnung → **charge** |
GaBi Gas 2.1 states the same for gas: the Ausspeisenetzbetreiber *nimmt
Mehrmengen entgegen* and *liefert Mindermengen*. Consuming below the profile
leaves surplus energy the network absorbed — that surplus is the Mehrmenge, and
it is reimbursed.
## Konzessionsabgabe (KAV §2)
`KaKundengruppe` models the two orthogonal tests KAV actually applies:
Tarifkunde vs Sondervertragskunde is a **contract-type** test, and Tarifkunden
rates band on **municipality inhabitants**, not on annual consumption.
| Tarifkunde, Gemeinde ≤ 25 000 Einw. | 1.32 | 0.51 (Kochen/Warmwasser) · 0.22 (übrige) |
| ≤ 100 000 | 1.59 | 0.61 · 0.27 |
| ≤ 500 000 | 1.99 | 0.77 · 0.33 |
| > 500 000 | 2.39 | 0.93 · 0.40 |
| Schwachlast (Strom only) | 0.61 | — |
| Sondervertragskunde | 0.11 | 0.03 |
These are **Höchstbeträge**, so `settle_nne` emits
`KA_ABOVE_KAV_MAXIMUM` when the agreed rate exceeds the ceiling for the group,
and `KA_CHARGED_WHILE_EXEMPT` when a rate is applied to a §2 Abs. 7 exemption.
## What this crate does
`grid-billing` computes BDEW INVOIC billing positions with full explainability:
- **NNE Strom** (PID 31002, NN-Rechnung) — flat-rate Arbeit, Leistung (RLM), Konzessionsabgabe
- **NNE Gas** (PID 31002, NN-Rechnung) — GasNEV §14 legal basis, auto-set when `Sparte::Gas`
- **§14a modules** — Modul 1 (pauschale Reduzierung), Modul 2 (prozentuale Reduzierung des Arbeitspreises), Modul 3 (zeitvariable Netzentgelte HT/ST/NT, opt-in since 01.04.2025) — BNetzA BK6-22-300 / BK8-22/010-A
- **MMM Strom** (PID 31005) — Mehr-/Mindermengensaldo, GPKE (BK6-24-174) Teil 1 Kap. 8.4
- **MMM Gas** (PID 31005) — Gas imbalance, GaBi Gas 2.1 (BK7-24-01-008)
- **NNE Gas** (PID 31002) — GasNEV §14 Arbeits-/Grundpreis and §15 Kapazitätsentgelt
- **Abschlagsrechnung** (PID 31001) — a payment on account: one Positionszeile, no quantity, no
Arbeitspreis (INVOIC AHB 1.0b Änd-ID 26817). The invoice that settles the period deducts it
from what is **owed** via `InvoiceDocument::abschlaege`, never from the net or the tax, because
§14 Abs. 5 UStG taxed the Anzahlung when it was received
- **MMM Mehrmenge selbst ausgestellt** (PID 31006) — Mehr-/Mindermenge als Lieferung, self-issued (INVOIC AHB Selbstausstellung)
- **MSB-Rechnung** (PID 31009) — Grundgebühr Messstellenbetrieb + optional Messdienstleistung
- **GeLi Gas AWH Sperrprozesse** (PID 31011) — abrechnungswürdige Handlungen (BK7-24-01-009 §5.4)
- **§13a EnWG Redispatch-Vergütung** — `redispatch_verguetung()` computes the angemessene Vergütung per activation (entgangene Einnahmen + zusätzliche − ersparte Aufwendungen; `eeg_entgangene_einnahmen()` for the Nr. 5 EEG basis)
- **Reversal (Stornorechnung)** — `reverse()` negates any prior settlement immutably; the sign goes on the unit price, so each position still multiplies out
- **Umsatzsteuer** — every settlement states its tax (§14 Abs. 4 Nr. 8 UStG). Network services
are 19 % and never reverse-charged (UStAE 13b.3a excludes them by name); a Mehr-/Mindermenge is
a *Lieferung* and takes the §13b Abs. 2 Nr. 5 Buchst. b reverse charge on the asymmetric
condition the statute sets — electricity needs both parties to hold §3g status, gas needs the
recipient alone. A delivery period straddling a rate change is refused rather than billed at
one of the two.
All calculations are **pure functions** — zero I/O, zero async, no side effects.
All monetary arithmetic uses `rust_decimal::Decimal` via `EuroAmount` — no `f64` anywhere.
## Architecture
### Settlement flow
```
NneInput / MmmInput / MsbInput / GasAwhInput
│
▼
validate_*_input() ← optional pre-check: ValidationResult
│
▼
settle_*() ← pure, deterministic, no I/O
│
▼
SettlementResult {
settlement_type, status, period, regime, sparte,
malo_id, sender_mp_id, recipient_mp_id,
positions: Vec<SettlementPosition {
text, kind, ← what was charged
quantity, unit, unit_price_eur, net_eur,
spot_price_formula, ← the formula behind the rate, as a value
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>,
}
│
▼ (adapter — this is where document identity enters)
InvoiceDocument { settlement, pid, rechnungsnummer, invoice_date, due_date }
│
▼ (feature `bo4e` — off by default)
grid_billing::bo4e::into_rechnung(&document) → rubo4e::current::Rechnung {
rechnungspositionen[].positionsnummer ← assigned here
rechnungspositionen[].artikelnummer ← via kind.artikelnummer()
rechnungstyp ← Netznutzungsrechnung (NNE + MMM only)
netznutzungrechnungsart ← Handels-/Selbstausgestellt
netznutzungrechnungstyp ← Mehrmindermengenrechnung (MMM only)
}
│
▼
InvoicCheckEngine::check(pid, &sender_mp_id, &rechnung, …)
│
▼
invoice_drafts (PostgreSQL) → AS4 dispatch
```
### Netznutzungsrechnung typing
`into_rechnung` marks the document so a consumer can recognise it without
inspecting positions:
| `rechnungstyp` | Abschlag, NNE Strom/Gas, MMM Strom/Gas, MMM selbst ausgestellt | `Netznutzungsrechnung` |
| `netznutzungrechnungsart` | the same six | `Selbstausgestellt` for PID 31006, else `Handelsrechnung` |
| `netznutzungrechnungstyp` | MMM · Abschlag · anything with a stated cadence | `Mehrmindermengenrechnung` · `Abschlagsrechnung` · the cadence |
The other four settlement types this engine produces — MSB-Rechnung (31009),
Gas-AWH Sperrung (31011), Redispatch Kostenblatt, dezentrale Einspeisung (§18
StromNEV) — are **left untyped on purpose**. They are not network-use invoices,
and typing them as one would assert something the AHB does not.
`netznutzungrechnungstyp` is the one field two different things decide. The
Mehr-/Mindermengen codes follow from the settlement, and so does
`Abschlagsrechnung`, because PID 31001 *is* that document. The rest —
Turnus-, Monats-, Abschluss-, Zwischenrechnung — describe the billing
**cadence**, which the same NNE computation does not carry: it is billed
monthly or annually depending on contract. So it rides on
`InvoiceDocument::cadence`, and with neither present the field stays unset
rather than guessing a rhythm nothing supports.
### BDEW Artikelnummern architecture
Which article number applies to which position in which settlement is domain
logic, so it lives here — `BillingPositionKind::artikelnummer(settlement_type)`
returns the BDEW **codelist name** as a `&'static str`, a plain string that costs
the core engine no BO4E type. The `bo4e` bridge parses it into the enum
(`bo4e::kind_to_artikelnummer`); a consumer rendering something else parses it
its own way.
```mermaid
flowchart LR
calc["grid_billing<br/>settle_*()"]
pos["SettlementPosition<br/>.kind: BillingPositionKind<br/>.trace: CalculationTrace"]
bridge["feature bo4e<br/>kind_to_artikelnummer()"]
bo4e["Rechnungsposition<br/>.artikelnummer ← Gas/MMM/KA<br/>.artikel_id ← NNE Strom/AWH Gas"]
calc --> pos --> bridge --> bo4e
note1["BK6-20-160:<br/>NNE Strom replaced<br/>artikelnummer → artikel_id<br/>from PreisblattNetznutzung"]
note2["BDEW Codeliste v5.6:<br/>Gas NNE/MMM/KA use<br/>classic 9990001… codes<br/>AWH: 2-01-7-001/002"]
note1 -.->|Strom| bo4e
note2 -.->|Gas| bo4e
```
`artikelnummer` answers `None` where the position carries an **Artikel-ID**
instead — every Strom NNE position since BK6-20-160, the AWH Gas `2-01-7-xxx`
positions, and the Abschlag, which prices nothing. The renderer resolves those
from the price sheet or the input.
### Responsibility split
| Settlement math + legal refs | `grid-billing` |
| BO4E `Rechnung` conversion | `grid_billing::bo4e` — **feature `bo4e`**, off by default |
| INVOIC plausibility checks | `invoic-checker` |
| EDIFACT serialization + AS4 dispatch | `makod` |
The feature is off by default so the settlement engine stays publishable and
`rubo4e`-free for a consumer that renders something else; `netzbilanzd` and
`invoicd` both enable it.
| *(default)* | `billing`, `rust_decimal`, `serde`, `thiserror`, `time` | the settlement engine |
| `bo4e` | `rubo4e`, `serde_json` | `bo4e::into_rechnung`, `kind_to_artikelnummer`, `rechnungstyp_for`, `netznutzungrechnungsart_for`, `netznutzungrechnungstyp_for` |
## Domain types
### `SettlementResult` — canonical output
```rust
pub struct SettlementResult {
pub settlement_type: SettlementType, // NneStrom | NneGas | MmmStrom | MsbRechnung | …
pub status: SettlementStatus, // Initial | Correction | Reversal | Final
pub korrektur_grund: Option<KorrekturGrund>, // why — None only for Initial
pub period: SettlementPeriod, // validated pair, both bounds inclusive
pub regime: RegulatoryRegime, // the rules this calculation applied
pub sparte: Sparte,
pub malo_id: String,
pub sender_mp_id: String, // NB, or MSB for a MSB-Rechnung (31009)
pub recipient_mp_id: String, // LF, NB, MSB, MGV or ESA
pub positions: Vec<SettlementPosition>,
pub total_eur: Decimal, // rounded to 2 dp
pub steuer: Steuerausweis, // §14 Abs. 4 Nr. 8 UStG — rate, amount, reverse-charge note
pub warnings: Vec<SettlementWarning>,
}
```
### `InvoiceDocument` — the settlement presented as an invoice
```rust
pub struct InvoiceDocument {
pub settlement: SettlementResult,
pub pid: u32, // BDEW Prüfidentifikator — routes the document
pub rechnungsnummer: String,
pub correction_of: Option<String>, // what this supersedes
pub invoice_date: time::Date,
pub due_date: time::Date,
pub cadence: Option<Rechnungscharakter>, // IMD+7081 — a document fact
pub abschlaege: Vec<Abschlagsverrechnung>, // deducted from what is owed, not from the net
}
```
Nothing on `InvoiceDocument` affects what is owed. `numbered_positions()` assigns
the 1-based document numbering at render time.
Helper methods on `SettlementResult`:
| `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 settlement positions |
### `SettlementPosition` 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. A position carries **no** position number and **no**
Artikel-ID: both are properties of the *document* that presents the settlement,
not of the calculation — an adapter numbers the positions it renders and resolves
Artikel-IDs (AWH Gas `2-01-7-xxx`, NNE Strom from the `PreisblattNetznutzung`)
from the price sheet:
```rust
pub struct SettlementPosition {
pub text: String, // e.g. "Netznutzung Arbeit HT (§14a Modul 3)"
pub kind: BillingPositionKind, // what was charged
pub quantity: Decimal, // rounded to 3 dp
pub unit: QuantityUnit, // Kwh | Kw | Kvarh | Kvar | Monat | Jahr
pub unit_price_eur: Decimal, // rounded to 6 dp
pub net_eur: Decimal, // quantity × unit_price_eur, rounded to 5 dp
pub spot_price_formula: Option<SpotPriceFormula>, // the formula behind the rate
pub trace: CalculationTrace,
}
// No position number and no Artikel-ID: both are properties of the document that
// presents the settlement, not of the calculation.
pub struct CalculationTrace {
/// Human-readable explanation, e.g.:
/// "1500.000 kWh × 0.035000 EUR/kWh = 52.50000 EUR"
pub explanation: String,
pub input_quantity: Decimal,
pub input_unit_price_eur: Decimal,
pub gross_eur: Decimal, // qty × price before rounding
pub legal_refs: Vec<LegalReference>, // at least one, always
pub tariff_source: Option<TariffSource>, // where the rate came from
pub regulatory_reduction_factor: Option<Decimal>, // §14a Modul 2 factor (0–1)
pub rounding_note: Option<&'static str>,
}
```
### `LegalReference`
```rust
pub enum LegalReference {
StromNev { paragraph: &'static str }, // "§21" Arbeit, "§17" Leistung
GasNev { paragraph: &'static str }, // "§14"
Kav { paragraph: &'static str }, // "§2 Abs. 2"
Kwkg { paragraph: &'static str }, // "§26" KWKG-Umlage
Ustg { paragraph: &'static str }, // "§14 Abs. 5" Anzahlung, "§13b" reverse charge
EnFG { paragraph: &'static str }, // "§§21 ff." Letztverbrauchergruppe
Sect14aEnwg { module: Sect14aModule }, // Modul1 | Modul2 | Modul3
MsbG { paragraph: &'static str }, // "§§6–7"
BnetzaDecision { reference: &'static str }, // "BK6-22-300"
BdewAhb { reference: &'static str }, // "GPKE BK6-22-024"
StromNzv { paragraph: &'static str }, // "§13 Abs. 3" — außer Kraft seit 01.01.2026
GasNzv { paragraph: &'static str }, // "§25" — außer Kraft seit 01.01.2026
Enwg { paragraph: &'static str }, // "§14a"
ARegV { paragraph: &'static str }, // "§17" incentive regulation
}
```
`.citation()` returns a short German-language string (e.g. `"StromNEV §17"`,
`"KAV §2 Abs. 2"`, `"ARegV §17"`). Repealed ordinances carry their expiry:
`StromNzv`/`GasNzv` append `"(außer Kraft seit 01.01.2026)"`.
### `Sect14aModule`
```rust
pub enum Sect14aModule {
Modul1, // pauschale Reduzierung des Netzentgelts — flat, needs no extra metering
Modul2, // prozentuale Reduzierung des Arbeitspreises — needs the device's own metering
Modul3, // zeitvariable Netzentgelte HT/ST/NT (from 01.04.2025) — Zählzeitdefinition
// from UTILTS, requires an iMSys; also the Spotpreis-Netzentgelt variant
}
```
`Sect14aModule::Modul1.label()` = `"§14a EnWG Modul 1 (pauschale Reduzierung)"`;
`.bnentza_reference()` = `"BK6-22-300"` for all three modules.
### `TariffSource`
```rust
pub enum TariffSource {
PublishedTariffSheet { sheet_id: String },
HistoricalTariff { valid_from: time::Date },
RegulatoryTariff { decision_ref: &'static str },
ContractTariff { contract_ref: String },
ManualOverride { reason: String },
}
```
### `Sparte` — commodity dispatch
```rust
#[derive(Default)]
pub enum Sparte {
#[default]
Strom, // → StromNEV §21, SettlementType::NneStrom, PID 31002 (NN-Rechnung)
Gas, // → GasNEV §14, SettlementType::NneGas, PID 31002 (NN-Rechnung)
}
```
`Sparte` is required on `NneInput` and `MmmInput`. The calculation automatically
selects the correct legal references and `SettlementType` (from which
`default_pid()` yields the PID) — the caller sets no PID for standard Gas paths.
### `SettlementType`
```rust
pub enum SettlementType {
NneAbschlag, // PID 31001 — Abschlagsrechnung Netznutzung (NB → LF)
NneStrom, // PID 31002 — NN-Rechnung Strom (NB → LF)
NneGas, // PID 31002 — NN-Rechnung Gas (GNB → LFG)
MmmStrom, // PID 31005 — MMM Strom, GPKE (BK6-24-174) Teil 1 Kap. 8.4
MmmGas, // PID 31005 — MMM Gas, GaBi Gas 2.1 (BK7-24-01-008) (separate to ensure correct legal refs)
MmmSelbstausstellt,// PID 31006 — MMM Mehrmenge, selbst ausgestellte Rechnung (Lieferung)
MsbRechnung, // PID 31009 — MSB-Rechnung (MSB → NB / LF / ESA)
GasAwhSperrung, // PID 31011 — AWH Sperrprozesse Gas (GNB → LFG)
RedispatchKostenblatt, // no standard PID — Redispatch 2.0 Einsatzkosten (NB → ÜNB)
DezentraleEinspeisung, // no standard PID — §18 StromNEV, NB → Anlagenbetreiber (bilateral)
}
```
`SettlementType::default_pid()` returns the standard PID for the type; it is `0`
for `RedispatchKostenblatt` and `DezentraleEinspeisung`, which are not EDIFACT
market processes. `MmmGas` and `MmmStrom` share PID 31005, and `NneGas` and
`NneStrom` share 31002, but each pair carries different legal references.
### `BillingPositionKind` — BDEW Artikelnummern bridge
`BillingPositionKind` is the rubo4e-free type carried by every `SettlementPosition.kind`.
`bo4e::kind_to_artikelnummer` maps it to `rubo4e::current::BdewArtikelnummer`
under the `bo4e` feature; the core engine only ever holds the codelist name.
```rust
pub enum BillingPositionKind {
NneAbschlag, // the single line of an Abschlagsrechnung — no article number
NneArbeit, // Wirkarbeit (9990001 00026 9)
NneArbeitHt, // Wirkarbeit (9990001 00026 9) — §14a Modul 3 Hochtarif
NneArbeitSt, // Wirkarbeit (9990001 00026 9) — §14a Modul 3 Standardtarif
NneArbeitNt, // Wirkarbeit (9990001 00026 9) — §14a Modul 3 Niedertarif
NneArbeitModul1, // Wirkarbeit (9990001 00026 9) — Modul 1 Arbeit + pauschale credit
NneArbeitModul2, // Wirkarbeit (9990001 00026 9) — Modul 2 reduced Arbeitspreis
NneArbeitModul3, // Wirkarbeit — §14a Modul 3 spot, one position per dispatch interval
NneLeistung, // Leistung (9990001 00005 3)
NneGasGrundpreis, // Grundpreis (9990001 00008 7)
Konzessionsabgabe, // Konzessionsabgabe(9990001 00041 7)
Mehrmenge, // Mehrmenge (9990001 00074 8)
Mindermenge, // Mindermenge (9990001 00075 6)
MsbGrundgebuehr, // EntgeltEinbauBetriebWartungMesstechnik (9990001 00061 5)
Messdienstleistung, // EntgeltMessungAblesung (9990001 00062 3)
GasAwhSperrung, // Sperrkosten — Artikel-ID "2-01-7-001" (BK7-24-01-009 §5.4)
GasAwhEntsperrung, // Entsperrkosten — Artikel-ID "2-01-7-002"
GasAwhSonstige, // Artikel-ID from AwhPositionInput.artikel_id
Blindmehrarbeit, // Blindmehrarbeit (9990001 00047 5)
Sect19StromNevUmlage,// §19 StromNEV-Umlage — artikelnummer PARAGRAF_19_STROM_NEV_UMLAGE
OffshoreNetzumlage, // §17f EnWG — artikelnummer OFFSHORE_HAFTUNGSUMLAGE (legacy code name)
KwkgUmlage, // §26 KWKG — artikelnummer ABGABE_KWKG
DezentraleEinspeisung, // §18 StromNEV payment out (negative rate); no article number
Sect19IndividuellesEntgelt, // §19 Abs. 2 StromNEV reduction over the Netzentgelt; no article number
GasKapazitaetsentgelt, // §15 GasNEV booked capacity — Leistung on Gas
}
```
> **NNE Strom (PID 31002):** BK6-20-160 replaced classic `artikelnummer` codes
> with `artikel_id` from the BNetzA Netznutzungspreisblatt. The service layer
> (`netzbilanzd`, `invoicd`) populates `Rechnungsposition.artikel_id` from the tariff
> sheet for those positions; `BillingPositionKind::artikelnummer(settlement_type)`
> returns `None` for Strom NNE. Gas NNE, MMM, Konzessionsabgabe still use classic
> Artikelnummer codes.
Source: BDEW Codeliste Artikelnummern und Artikel-ID v5.6 (valid 01.09.2025).
### `KaKundengruppe` / `GemeindeGroesse` — KAV §2 classifier
KAV applies two orthogonal tests: contract type (Tarifkunde vs
Sondervertragskunde), and — for Tarifkunden — municipality size, not annual
consumption.
```rust
pub enum KaKundengruppe {
Tarifkunde { // KAV §2 Abs. 2 — rate bands on municipality size
gemeinde: GemeindeGroesse,
nur_kochen_warmwasser: bool, // Gas only: cooking/hot-water column vs übrige; ignored for Strom
},
Schwachlast, // KAV §2 Abs. 2 — Strom only; gas has no such tier
Sondervertragskunde, // KAV §2 Abs. 3 — flat, independent of municipality size
Exempt, // KAV §2 Abs. 4 (Strom) / Abs. 5 (Gas) — keine KA zulässig
}
pub enum GemeindeGroesse {
Bis25k, // bis 25 000 Einwohner
Bis100k, // bis 100 000
Bis500k, // bis 500 000
Ueber500k, // über 500 000
}
```
`KaKundengruppe::hoechstsatz_ct_per_kwh(sparte)` returns the statutory KAV §2
Höchstbetrag (or `None` for `Exempt`, and for `Schwachlast` on Gas).
`.kav_paragraph(sparte)` returns the paragraph the group is governed by
(`"§2 Abs. 2"`, `"§2 Abs. 3"`, or — where no Konzessionsabgabe may be charged at
all — `"§2 Abs. 4"` for Strom and `"§2 Abs. 5"` for Gas) and `.label(sparte)` the
position text. The group is carried on `Konzessionsabgabe.klasse`, so the ceiling
check always has what it needs: `settle_nne` emits `KA_ABOVE_KAV_MAXIMUM` when
the agreed rate exceeds the ceiling, and `KA_CHARGED_WHILE_EXEMPT` when a rate is
applied where the Verordnung forbids one.
The two prohibitions are Abs. 4 and Abs. 5, not Abs. 7. Abs. 4 forbids a
Konzessionsabgabe for Strom-Sondervertragskunden below the Grenzpreis, Abs. 5 for
Gas above 5 Millionen kWh je Jahr und Abnahmefall or below the Gas-Grenzpreis.
**Abs. 7 is a classification rule**: a Niederspannungslieferung counts as a
Tariflieferung unless the gemessene Leistung exceeds 30 kW in at least two months
of the Abrechnungsjahr *and* the Jahresverbrauch exceeds 30 000 kWh. Deciding
that is the caller's, which is why the group arrives with the rate.
## Who uses this library
| `netzbilanzd` | **NB** (and **MSB** for 31009) | Generate INVOIC 31001/31002/31005/31011 to LF/LFG, and 31009 from the MSB to NB/LF/ESA |
| `invoicd` | **LF** | INVOIC PID 31006, selbst ausgestellt — same formula, LF-initiated |
## Quick start
```bash
cargo add grid-billing
cargo add rust_decimal time
```
### NNE flat-rate (SLP, Strom)
```rust,no_run
use grid_billing::{NneInput, Sparte, SettlementType, settle_nne};
use grid_billing::types::{
ArbeitspreisModell, MengePreis, Konzessionsabgabe, KaKundengruppe, SettlementPeriod,
};
use grid_billing::umlagen::Letztverbrauchergruppe;
use rust_decimal::Decimal;
use time::macros::date;
fn d(s: &str) -> Decimal { Decimal::from_str_exact(s).unwrap() }
let settlement = settle_nne(&NneInput {
malo_id: "51238696012".into(),
nb_mp_id: "9900357000004".into(),
lf_mp_id: "9900012345678".into(),
// The delivery period is a validated pair — inverted bounds are unrepresentable.
period: SettlementPeriod::new(date!(2026-01-01), date!(2026-01-31)).unwrap(),
// Letztverbrauchergruppe drives the network-levy rates (EnFG §§21 ff.).
letztverbrauchergruppe: Letztverbrauchergruppe::A,
// A′ has no EnFG 1-GWh boundary, so the year to date does not place it.
enfg_jahresvorverbrauch_kwh: None,
// Exactly one Arbeitspreis form — here a single flat rate.
arbeitspreis: ArbeitspreisModell::Einheitlich(MengePreis {
menge_kwh: d("1500"),
preis_ct_per_kwh: d("3.5"),
}),
leistungspreis: None, // SLP — no RLM demand charge
grundpreis: None, // Strom has no separate Grundpreis
konzessionsabgabe: Some(Konzessionsabgabe {
satz_ct_per_kwh: d("0.11"),
klasse: KaKundengruppe::Sondervertragskunde,
}),
sparte: Sparte::Strom,
tariff_sheet_id: Some("Preisblatt-NNE-2026-Q1".into()),
netzebene: None,
jahreshoechstleistung_kw: None,
jahresarbeit_kwh: None,
sect19: None, // no §19 Abs. 2 individual charge
blindarbeit: None, // no reactive-energy charge
gas_kapazitaet: None,
sect19_umlage_ct_per_kwh: None, // use the tabled rate for the delivery year/group
offshore_umlage_ct_per_kwh: None,
kwkg_umlage_ct_per_kwh: None,
}).expect("valid NNE input");
// The settlement carries what was settled, not a PID — invoice number, dates and
// the Prüfidentifikator are properties of InvoiceDocument. SettlementType maps to
// the standard PID:
assert_eq!(settlement.settlement_type, SettlementType::NneStrom);
assert_eq!(settlement.settlement_type.default_pid(), 31002);
// recipient_mp_id is auto-populated from lf_mp_id:
assert_eq!(settlement.recipient_mp_id, "9900012345678");
// A Strom NNE settlement also carries the three netzseitige Umlagen (§19 StromNEV,
// Offshore, KWKG) alongside the Arbeit and Konzessionsabgabe positions.
for pos in &settlement.positions {
println!("{}: {}", pos.text, pos.trace.explanation);
for lr in &pos.trace.legal_refs {
println!(" → {}", lr.citation());
}
}
```
### NNE Gas (GasNEV §14)
```rust,no_run
use grid_billing::{NneInput, Sparte, SettlementType, settle_nne};
use grid_billing::types::{ArbeitspreisModell, MengePreis};
// Only Sparte changes — GasNEV §14 legal refs and SettlementType::NneGas are automatic:
let settlement = settle_nne(&NneInput {
sparte: Sparte::Gas, // ← drives GasNEV §14 + NneGas (PID 31002)
arbeitspreis: ArbeitspreisModell::Einheitlich(MengePreis {
menge_kwh: d("3000"), // already kWh_Hs from edmd gas conversion
preis_ct_per_kwh: d("1.80"),
}),
konzessionsabgabe: None, // KA typically not applicable for Gas
grundpreis: None,
leistungspreis: None,
// … other identity + levy-override fields, all None …
}).unwrap();
assert_eq!(settlement.settlement_type, SettlementType::NneGas);
assert_eq!(settlement.settlement_type.default_pid(), 31002);
```
### §14a Modul 3 — zeitvariable Netzentgelte (HT/ST/NT, opt-in since 2025-04-01)
```rust,no_run
use grid_billing::{NneInput, Sparte, settle_nne};
use grid_billing::types::{
ArbeitspreisModell, MengePreis, Konzessionsabgabe, KaKundengruppe, GemeindeGroesse,
};
let settlement = settle_nne(&NneInput {
// Modul 3 requires all three bands; the enum makes the flat/ToU states exclusive.
arbeitspreis: ArbeitspreisModell::Modul3ZeitVariabel {
ht: MengePreis { menge_kwh: d("600"), preis_ct_per_kwh: d("4.20") },
st: MengePreis { menge_kwh: d("250"), preis_ct_per_kwh: d("2.80") },
nt: MengePreis { menge_kwh: d("400"), preis_ct_per_kwh: d("1.50") },
},
konzessionsabgabe: Some(Konzessionsabgabe {
satz_ct_per_kwh: d("1.32"),
// The group fixes the KAV §2 ceiling and annotates the position for audit.
klasse: KaKundengruppe::Tarifkunde {
gemeinde: GemeindeGroesse::Bis25k,
nur_kochen_warmwasser: false,
},
}),
sparte: Sparte::Strom,
tariff_sheet_id: Some("Preisblatt-14a-2026".into()),
leistungspreis: None,
grundpreis: None,
// … identity + levy-override fields …
}).unwrap();
// Positions: HT + ST + NT Arbeit, the three netzseitige Umlagen, and Konzessionsabgabe.
### §14a Modul 1 — pauschale Reduzierung (offered since 2024-01-01)
Modul 1 is a **flat annual amount**, credited pro rata for the settlement period.
It does not scale with consumption — that is what makes it *pauschal*, and what
separates it from Modul 2, which reduces the Arbeitspreis by a percentage. The
energy is billed at the full Arbeitspreis and the credit sits beside it as its
own position, so the invoice shows both.
```rust,no_run
use grid_billing::{Jahresanteil, NneInput, Sparte, settle_nne};
use grid_billing::types::{ArbeitspreisModell, MengePreis};
use rust_decimal::{Decimal, dec};
let settlement = settle_nne(&NneInput {
// Modul 1 is a variant of ArbeitspreisModell, so it cannot coexist with a
// flat rate or the Modul 3 bands — the conflict is unrepresentable.
arbeitspreis: ArbeitspreisModell::Modul1Pauschal {
basis: MengePreis { menge_kwh: d("1500"), preis_ct_per_kwh: d("3.5") },
// The NB's published annual amount, and the share of a year this
// period covers.
pauschale_eur_pro_jahr: dec!(120.00),
jahresanteil: Jahresanteil::MONAT,
},
sparte: Sparte::Strom,
leistungspreis: None,
grundpreis: None,
konzessionsabgabe: None,
// … other fields …
}).unwrap();
// Two positions: 1500 kWh × 0.035 = 52.50 EUR Arbeit, billed in full, and the
// credit beside it — 0.083333 of a year × −120.00 EUR/Jahr = −9.99996 EUR.
```
### Gas NNE with Grundpreis (GasNEV monthly standing charge)
```rust,no_run
use grid_billing::{NneInput, Sparte, settle_nne};
use grid_billing::types::{ArbeitspreisModell, MengePreis, Grundpreis};
let settlement = settle_nne(&NneInput {
sparte: Sparte::Gas,
arbeitspreis: ArbeitspreisModell::Einheitlich(MengePreis {
menge_kwh: d("3000"),
preis_ct_per_kwh: d("1.80"),
}),
// Grundpreis pairs the monthly rate with the months billed — one without the
// other is meaningless, so they travel together.
grundpreis: Some(Grundpreis {
eur_per_month: d("15.00"), // monthly base fee from PreisblattNetznutzung
months: d("1"),
}),
leistungspreis: None,
konzessionsabgabe: None,
// … other fields …
}).unwrap();
// Gas carries no netzseitige Umlagen: Grundpreis (15.00) + Arbeit (54.00) = 69.00 EUR
assert_eq!(settlement.positions.len(), 2);
assert!(settlement.positions[0].text.contains("Grundpreis"));
```
### GeLi Gas AWH Sperrprozesse (PID 31011)
```rust,no_run
use grid_billing::{GasAwhInput, AwhPositionInput, SettlementType, settle_gas_awh};
use grid_billing::types::SettlementPeriod;
use time::macros::date;
let settlement = settle_gas_awh(&GasAwhInput {
malo_id: "51238696012".into(),
nb_mp_id: "9900357000004".into(),
lf_mp_id: "9900012345678".into(),
period: SettlementPeriod::new(date!(2026-01-01), date!(2026-01-31)).unwrap(),
tariff_sheet_id: Some("Preisblatt-AWH-2026".into()),
awh_positionen: vec![
AwhPositionInput {
beschreibung: "Sperrung Gaszähler".into(),
anzahl: 1,
preis_eur: d("45.00"),
artikel_id: Some("2-01-7-001".to_owned()), // BDEW Codeliste v5.6 §3.2
},
AwhPositionInput {
beschreibung: "Entsperrung Gaszähler".into(),
anzahl: 1,
preis_eur: d("45.00"),
artikel_id: Some("2-01-7-002".to_owned()),
},
],
}).unwrap();
// Invoice number and dates live on InvoiceDocument, not on the settlement:
assert_eq!(settlement.settlement_type, SettlementType::GasAwhSperrung);
assert_eq!(settlement.settlement_type.default_pid(), 31011);
assert_eq!(settlement.total_eur, d("90.00"));
// Both positions cite BK7-24-01-009 §5.4
### Correction lifecycle (reversal + replacement pair)
Two different facts are recorded in two different places, and the split is
deliberate:
- **What was replaced** — invoice numbers — lives on the `InvoiceDocument`.
The same pair of settlements can be presented under different invoice numbers,
so the chain is a property of the documents exchanged.
- **Why the recalculation happened** — `KorrekturGrund` — lives on the
`SettlementResult`. That is a fact about the settlement, and the invoice
numbers never answer it: they cannot say whether the meter was wrong, the
tariff was wrong, or the law changed underneath. Those have different
consequences, so `reverse()` and `correct()` require the reason.
```rust,no_run
use grid_billing::{settle_nne, correct, KorrekturGrund, SettlementStatus};
let original = settle_nne(&nne_input).unwrap();
let corrected = settle_nne(&corrected_input).unwrap();
let (reversal, replacement) =
correct(&original, corrected, KorrekturGrund::Tarifkorrektur);
assert_eq!(reversal.status, SettlementStatus::Reversal);
assert_eq!(reversal.total_eur, -original.total_eur);
assert_eq!(replacement.status, SettlementStatus::Correction);
assert_eq!(replacement.korrektur_grund, Some(KorrekturGrund::Tarifkorrektur));
assert!(replacement.lineage_is_consistent());
```
| `Messwertkorrektur` | replaced or re-read metering (§ 60 Abs. 1 MsbG — the Aufbereitungs- und Übermittlungspflicht the corrected value discharges) | no |
| `Tarifkorrektur` | wrong tariff or price-sheet version applied | no |
| `Stammdatenkorrektur` | wrong Netzebene, KA-Klasse or Konzessionsgemeinde | **yes** |
| `RegulatorischeAenderung` | a regulatory change applies retroactively | no |
| `Rechenfehler` | arithmetic or logic error in the original | **yes** |
| `Clearing` | a clearing result between the parties (MMM, MaBiS) | no |
| `Sonstiges` | anything else — detail rides in the warnings | no |
`indicates_defect()` separates the two: a rising `Rechenfehler` count is an
engineering signal, a rising `RegulatorischeAenderung` count is not.
`lineage_is_consistent()` catches the state this exists to prevent — a
`Correction` with no reason, which looks like a complete settlement and answers
none of the questions an audit asks of one.
```rust,no_run
use grid_billing::{settle_nne, reverse, KorrekturGrund, SettlementStatus};
let original = settle_nne(&/* … NneInput … */).unwrap();
// reverse() mirrors every position with the sign flipped. It takes the reason
// too — the storno invoice number and dates belong to the InvoiceDocument, but
// *why* the settlement was withdrawn is a fact about the settlement.
let storno = reverse(&original, KorrekturGrund::Messwertkorrektur);
assert_eq!(storno.status, SettlementStatus::Reversal);
assert_eq!(storno.total_eur, -original.total_eur);
```
### Pre-calculation validation
```rust,no_run
use grid_billing::{MmmInput, validate_mmm_input};
let input = MmmInput { /* … */ };
let v = validate_mmm_input(&input);
if !v.is_valid {
for w in &v.warnings {
eprintln!("[{}] {}", w.code, w.message);
}
return;
}
let settlement = grid_billing::settle_mmm(&input).unwrap();
```
(`settle_nne` validates inline — malformed NNE input returns `Err` directly;
`validate_mmm_input` / `validate_msb_input` / `validate_gas_awh_input` exist
for the settlement types where a pre-flight warning list is useful.)
**Running them is not optional.** `ValidationResult::is_valid` goes false on any
`Error`-severity finding, and for a long time nothing read it: the engine
computed the finding, attached it to the result, and the service billed the
settlement anyway. An MSB input with `billing_months = 0` produced a Grundgebühr
of nothing; a Gas-AWH input with no positions produced an empty invoice — both
sent to a counterparty who then had to dispute them back. `netzbilanzd` and
`invoicd` now refuse an invalid input before settling, naming the finding.
### Conversion to a BO4E `Rechnung` (feature `bo4e`)
```rust,no_run
use grid_billing::{InvoiceDocument, bo4e};
let rechnung: rubo4e::current::Rechnung = bo4e::into_rechnung(&document);
```
`into_rechnung` takes the **document**, not the settlement: `rechnungsnummer`,
`rechnungsdatum` and `faelligkeitsdatum` are document facts, and the 1-based
`positionsnummer` is assigned here rather than carried through the calculation.
Each position's `artikelnummer` comes from `kind_to_artikelnummer`, which is
`None` where an Artikel-ID applies instead; `einzelpreis` is rounded to 6 dp and
`gesamtpreis` to 5, the same figures the settlement states.
The trace travels with the position it explains, as a `mako:calculation_trace`
ZusatzAttribut, alongside settlement-level `mako:legal_references` and
`mako:settlement_warnings`. Once the Rechnung is stored the `SettlementResult`
is dropped, so those attributes are the only surviving record of why an amount
is what it is — and what a §20 EnWG audit or an LF dispute is answered from.
## Generated invoice types
| 31001 | Abschlagsrechnung Netznutzung | NB → LF | both |
| 31002 | NN-Rechnung Strom (Netznutzung) | NB → LF | Strom |
| 31002 | NN-Rechnung Gas (Netznutzung) | GNB → LFG | Gas (auto via `Sparte::Gas`) |
| 31005 | MMM-Rechnung (Mehr-/Mindermengensaldo) | NB → LF | both |
| 31006 | MMM Mehrmenge, selbst ausgestellt | LF | both |
| 31009 | MSB-Rechnung | **MSB → NB / LF / ESA** | Strom |
| 31011 | AWH Sperrprozesse Gas | GNB → LFG | Gas |
## Billing position reference
### NNE
| 1 | `Netznutzung Arbeit` | kWh | `NneArbeit` | `arbeitspreis: ArbeitspreisModell::Einheitlich` | StromNEV §21 (Strom) · GasNEV §14 (Gas) | `Wirkarbeit` (Gas); `artikel_id` (Strom) |
| 1–2 | `Netznutzung Arbeit (§14a Modul 1)` + `§14a Modul 1 pauschale Reduzierung` | kWh · Jahr | `NneArbeitModul1` | `arbeitspreis: ArbeitspreisModell::Modul1Pauschal` | §14a EnWG Modul 1 · BK6-22-300 | same as NneArbeit |
| 1–3 | `Netznutzung Arbeit HT/ST/NT (§14a Modul 3)` | kWh | `NneArbeitHt` / `NneArbeitSt` / `NneArbeitNt` | `arbeitspreis: ArbeitspreisModell::Modul3ZeitVariabel` | §14a EnWG Modul 3 · BK6-22-300 | same as NneArbeit |
| opt | `Netzentgelt Grundpreis Gas` | Monat | `NneGasGrundpreis` | `grundpreis` set | GasNEV §14 | `Grundpreis` |
| next | `Netznutzung Leistung` | kW | `NneLeistung` | `leistungspreis` set (RLM) — the Jahresleistungspreis pro-rated by calendar days | StromNEV §17 Abs. 2 | `Leistung` (Gas); `artikel_id` (Strom) |
| next | `Blindmehrarbeit` | kvarh | `Blindmehrarbeit` | `blindarbeit` set **and** the draw exceeds the free share | StromNEV §17 (Preisblatt) | `Blindmehrarbeit` |
| last | `Konzessionsabgabe[tier]` | kWh | `Konzessionsabgabe` | `konzessionsabgabe` set | KAV §2 Abs. 2 | `Konzessionsabgabe` |
#### Blindmehrarbeit
A Netzbetreiber supplies a *free share* of reactive energy alongside the active
energy and charges only what exceeds it. The customary boundary is a power factor
of cos φ 0,9 — reactive energy up to **tan φ ≈ 0,4843** of the active energy —
but many Preisblätter round that to a flat 50 %, and some set separate shares for
inductive and capacitive draw.
The share is therefore an **input**, not a constant: it is a term of the price
sheet, and hard-coding one would bill some networks wrongly.
`Blindarbeit::COS_PHI_0_9` is the documented default.
```rust,no_run
use grid_billing::{Blindarbeit, NneInput};
use rust_decimal::dec;
let blindarbeit = Some(Blindarbeit {
blindarbeit_kvarh: dec!(600),
freigrenze_anteil: Blindarbeit::COS_PHI_0_9,
preis_ct_per_kvarh: dec!(2.0),
});
// 1 000 kWh active → 484,3 kvarh free → 115,7 kvarh charged.
```
An unused allowance is never a credit — the excess floors at zero. The charge
rests on the Netzbetreiber's published Preisblatt, formed under **StromNEV §17**;
it is not §18 (Entgelt für dezentrale Erzeugung) and not §19 (Sonderformen der
Netznutzung).
### MMM
Both positions are always emitted; the one that does not apply carries a zero
quantity. The sign convention is GPKE's, from the network operator's side:
measured **below** profile is an ungewollte Mehrmenge the NB reimburses, and
measured above it is an ungewollte Mindermenge the NB invoices.
| 1 | `Mehrmengen (Gutschrift)` | `Mehrmenge` | `Mehrmenge` | `actual < profil` — the Mehrmengenpreis is negated, so the position multiplies out |
| 2 | `Mindermengen` | `Mindermenge` | `Mindermenge` | `actual > profil` |
### MSB
| 1 | `Grundgebühr Messstellenbetrieb` | `MsbGrundgebuehr` | `EntgeltEinbauBetriebWartungMesstechnik` | Always |
| 2 | `Messdienstleistung` | `Messdienstleistung` | `EntgeltMessungAblesung` | `messdienstleistung_eur` set |
### AWH Gas Sperrprozesse (PID 31011)
| 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
| **No floating-point money** | `rust_decimal::Decimal` throughout; `EuroAmount` for overflow guard. No `f64`. |
| **rubo4e is opt-in** | The engine returns `SettlementResult` and depends on no BO4E type; `bo4e::into_rechnung()` ships behind the off-by-default `bo4e` feature. |
| **`recipient_mp_id` auto-populated** | `lf_mp_id` (NNE/MMM) or `empfaenger.mp_id` (PID 31009) copied automatically; `sender_mp_id` is the NB, or the **MSB** for 31009. |
| **`Sparte` drives settlement type** | `Sparte::Gas` → `SettlementType::NneGas`, `GasNEV §14`. NN-Rechnung is PID 31002 for both Sparten — the Sparte rides on `Rechnung.sparte`, not on the Prüfidentifikator. |
| **Every position cites regulation** | `trace.legal_refs` is non-empty for every position. Enables BNetzA audit without re-calculation. |
| **Artikelnummer decided in this crate** | `BillingPositionKind::artikelnummer(settlement_type)` returns the codelist name, or `None` where the position carries an Artikel-ID instead (Strom NNE, AWH Gas, Abschlag). The decision is never left to the renderer. |
| **`MmmGas` ≠ `MmmStrom`** | Separate `SettlementType` variants ensure correct legal refs (`GaBi Gas 2.1 (BK7-24-01-008)` vs `GPKE (BK6-24-174) Teil 1 Kap. 8.4`) per position. |
| **Immutable correction chain** | `reverse()` mirrors positions, sets `status = Reversal`, links via `correction_of`. Original never mutated. |
| **`correct()` pair** | Returns `(reversal, replacement)` — both get status set atomically; caller dispatches both. |
| **Abschläge reduce `zuZahlen` only** | `gesamtnetto` and `gesamtsteuer` stand; §14 Abs. 5 UStG taxes an Anzahlung on receipt, so the settling invoice does not tax it again. |
| **Cadence is a document fact** | `IMD+7081` rides on `InvoiceDocument::cadence`, not on `SettlementType`: the same NNE settlement is the same arithmetic at any billing rhythm. The two exceptions are decided by the settlement — MMM and the Abschlag, whose PID *is* its cadence. |
| **Pure functions** | All settlement functions are sync with no side effects. |
| **`recomputed_total` guard** | `debug_assert_eq!(result.total_eur, result.recomputed_total())` inside `settle_nne` and `settle_gas_awh` — catches rounding bugs in debug builds. `settle_mmm` is asserted in the test suite; `settle_abschlag`, `settle_msb` and `settle_dezentrale_einspeisung` are not yet covered either way. |
## Related crates
| [`grid-billing`](https://docs.rs/grid-billing) ← **this crate** | Grid-side settlement arithmetic — NNE, KA, MMM, MSB, Sperrprozesse Gas |
| [`mako-markt`](https://docs.rs/mako-markt) | Marktstammdaten — Marktlokation, Messlokation, Marktpartner, Rollenzuordnung — the model the settlement inputs are keyed on |
| [`invoic-checker`](https://docs.rs/invoic-checker) | Validates the resulting `Rechnung` in the service layer |
| [`energy-billing`](https://docs.rs/energy-billing) | The retail (LF) counterpart — the customer's invoice |
| [`mako-invoic`](https://docs.rs/mako-invoic) | The INVOIC settle/dispute workflow these invoices travel through |
| [`netzbilanzd`](https://hupe1980.github.io/mako/docs/services/netzbilanzd/) · [`invoicd`](https://hupe1980.github.io/mako/docs/services/invoicd/) | Production daemons — NB billing, and LF selbstausstellen |
Part of **mako**, an open-source Rust platform for German energy market
communication (Marktkommunikation). Full documentation: <https://hupe1980.github.io/mako/>