# energy-billing
**Pure multi-product retail energy billing library for German markets.**
`energy-billing` is the calculation core of [`billingd`](https://hupe1980.github.io/mako/docs/services/billingd/) — the
Energy Billing Engine daemon for the Lieferant (LF) role. The library is **zero I/O,
zero async, zero hardcoded regulatory rates**. It answers one question:
> Given a product definition, meter readings, and statutory rates —
> what does the customer's invoice look like?
---
## §42 Stromkennzeichnung — structured, and on the invoice
`BillingContext.energiequellen` carries the typed `EnergieQuellen` (fuel-mix
percentages, the CO₂ g/kWh figure §42 Abs. 2 Nr. 2 EnWG makes mandatory, HKN
certification) and `to_rechnung_json` emits it as the `stromkennzeichnung`
ZusatzAttribut with the structure intact. billingd copies it from the productd
product via `Product::energiequellen()`.
The invoice emits the structured type — there is no free-text Energiemix
field.
## §14a — all three modules
BK8-22/010-A defines exactly three, and their numbering matters — it is printed on
the invoice and shared with the NB-side `grid-billing` engine:
| **1** | *pauschale Reduzierung des Netzentgelts* — a flat reduction needing no extra metering, hence the default where no choice is made | `sect14a_modul1_pauschale_eur_per_year` |
| **2** | *prozentuale Reduzierung des Arbeitspreises* — attaches to the device's **separately metered** energy | `sect14a_modul2_nne_reduktion_ct_per_kwh` |
| **3** | *zeitvariable Netzentgelte* (from 01.04.2025) — **three** Tarifstufen HT/ST/NT, requires an iMSys | `sect14a_modul3_nne_*` + `Sect14aModul3Verbrauch` |
**`Modul 1 + Modul 3` is the only pair BK8-22/010-A offers.** Modul 1 and Modul 2 are
the two forms the base module takes and the Anschlussnutzer picks one; configuring
both raises the Error-severity `MODUL1_AND_MODUL2` and the run is refused. Modul 2 and
Modul 3 both re-price the Arbeitspreis, so holding both reduces the same network usage
twice; configuring both raises `MODUL2_AND_MODUL3`.
The Modul 3 bands *replace* the flat NNE Arbeitspreis; setting both raises
`MODUL3_AND_FLAT_NNE` for the same reason.
A **Steuerungsentschädigung** (`sect14a_steuerungsentschaedigung_*`) is compensation
for a dispatch that actually happened. It is deliberately not numbered: all three
BK8-22/010-A modules are rate reductions, none of them a payment for a Steuerungseingriff. The bands come from the
Netzbetreiber's time windows, which is why they are not derived from the
supplier's own HT/NT split.
## Warnings that actually fire
`invoice.warnings` carries machine-readable codes beyond the §41a guard:
`ESTIMATED_READING` (§ 40a Abs. 2 EnWG), `PREISGARANTIE_ENDET` (ends within 30
days of the period), `VERBRAUCH_ABWEICHUNG_50PCT` (deviation beyond half the
prior year's consumption). They are `Warning` severity: they inform dispatch,
they do not block it.
Every position built through the shared helpers (Arbeitspreis, Grundpreis, and
all levy positions — Stromsteuer, Energiesteuer, BEHG, KA, NNE) carries a
populated `PositionTrace`.
## Explainability reaches the stored invoice
Every `BillingPosition` carries a `PositionTrace` (formula, inputs, §-citations,
tariff source). `to_rechnung_json()` emits it per position as the
`mako:calculation_trace` ZusatzAttribut — BO4E has no field for a calculation
trace, and the attribute is the sanctioned place for what the schema does not
model. This is the only surviving record of *why* an amount is what it is once
the `Invoice` value is dropped after storage; billingd's
`explain_invoice_position` MCP tool reads it from there.
## Two boundary representations: BO4E and EN 16931
`to_rechnung()` (feature `bo4e`) produces the BO4E `Rechnung` for accounting.
`to_en16931(spec_id, seller, buyer)` (feature `en16931`) produces the
[`en16931::Invoice`](https://docs.rs/en16931) semantic model that
`en16931-formats` renders to XRechnung/CII and PEPPOL UBL (its `zugferd` PDF/A-3
feature exists but is not enabled here). The map runs
here — where every position still carries its own VAT category and rate — so each
BG-25 line keeps a correct BT-151/152, and the BG-23 breakdown plus BG-22 totals
are derived from the rounded line amounts so BR-CO-10/13 and BR-S-08 reconcile.
E-invoicing does not round-trip through BO4E.
**Statutory disclosures become BG-1 notes.** An `Info` position charges nothing —
the CO2KostAufG § 3 figures, the § 53a Entlastungshinweis, the § 40 Abs. 2 Nr. 6
Energieträgermix, the § 14 WPG share, the § 41a Abs. 6 comparison — so it is no
BG-25 line. The law requires each *auf der Rechnung* and the XRechnung **is** the
invoice a B2G/B2B recipient receives, so each becomes its own BT-22 note. The legal
basis is appended only when it reads as a citation, because the Gasqualität position
stores its value in `legal_basis` for `to_rechnung_json`.
**Both mappings emit net supply lines only.** `Tax` and `Abschlag` positions
live in this crate's flat position vector so one pass can compute everything,
but neither is an invoice *line* at either boundary: EN 16931 carries them as
BG-23 and BT-113, and BO4E as `steuerbetraege`/`gesamtsteuer` and
`vorauszahlungen`/`zuZahlen`. Emitting them as lines states each amount twice
and leaves the document's own totals irreconcilable —
`BillingPosition::is_rechnungsposition` is the single predicate both mappings
use, so they cannot drift apart again. `Info` positions do belong: they carry
`net_eur == 0`, so they change no sum, and § 40 EnWG wants the Zählerstand and
Brennwert lines on the document.
Every shape `to_rechnung()` can emit is asserted against mako's outbound BO4E
gate in `tests/golden_scenarios.rs` — out-of-schema enums *and* the rules BO4E
states about an invoice's totals. mako refuses a received document that breaks
those, so it must not emit one.
`to_en16931` is **fallible**, for one reason: EN 16931 category `O` (*not
subject to VAT*) is exclusive to its document under **BR-O-11 … BR-O-14**. A
hoheitliche Abwassergebühr is category `O`, so a combined
Trinkwasser-plus-Gebühr invoice — the shape over 90 % of German municipalities
bill in — has no valid rendering. The engine still produces the combined paper
document (with a `GEBUEHR_UND_ENTGELT_AUF_EINEM_BELEG` warning, because that
statement is lawful); it is the *e-invoice* that is refused, with the reason,
instead of handing the recipient a file their schematron rejects days later.
## Period-correct rates
The year tables (`stromsteuer_for_year`, `energiesteuer_gas_for_year`,
`behg_ct_per_kwh_for_year`) are joined by `mwst_rate_for_period`: 19 % since
2007 except the COVID window 01.07.2020–31.12.2020 at 16 %. A period straddling
the window yields `None` — no single rate is correct for it, so the caller
splits rather than misbilling half of it. billingd derives its default
`RegulatoryRates` from these tables per billing period; explicit configuration
still wins.
## Pro-rating conventions (stated, deliberately)
Everything is clipped to the **active contract window** — `vertragsbeginn` /
`vertragsende` intersected with the billing period — and then expressed in the
unit its price is quoted in:
| EUR/day, ct/day | active contract days | `ctx.prorate_days().0` |
| EUR/month | calendar-exact months | `ctx.billed_months()` |
| EUR/year, EUR/kW·a | calendar-exact years | `ctx.billed_years()` |
**Calendar-exact** means each calendar month contributes `billed days ÷ that
month's own length` and each year `billed days ÷ that year's length`. January
1–31 is exactly `1` month, a full year exactly `12`, a leap year exactly `1`
year, and 16–31 January exactly `16/31` — none of which an average-month
divisor produces.
## Typed errors
`BillingEngine::bill` returns `EngineError`, not a stringly error:
| `ValidationBlocked { warnings }` | `Error`-severity regulatory warnings blocked the run — carries **all** collected warnings | `VALIDATION_BLOCKED` |
| `PriceOutOfRange { field, value }` | A tariff price exceeds the monetary range (corrupt tariff) | `PRICE_OUT_OF_RANGE` |
| `InvalidPeriod { from, to }` | What `BillingPeriod::new` returns for `from > to` | `INVALID_PERIOD` |
| `AllocationMismatch { fractions, contexts }` | `allocate_proportionally` shape mismatch | `ALLOCATION_MISMATCH` |
| `AllocationWeightsInvalid { sum }` | a negative allocation weight, or weights summing to zero | `ALLOCATION_WEIGHTS_INVALID` |
| `NutzungsplanSharesInvalid { sum }` | §42b GGV Nutzungsplan shares that do not sum to 1 | `NUTZUNGSPLAN_SHARES_INVALID` |
| `Unrepresentable { field, value }` | a date or amount the EN 16931 semantic model cannot carry | `UNREPRESENTABLE` |
| `ReconciliationFailed { reason }` | `to_en16931` could not derive BG-22/BG-23 — refused rather than substituted | `RECONCILIATION_FAILED` |
| `Arithmetic(billing::BillingError)` | Passthrough from the arithmetic core | `ARITHMETIC` |
`code()` is stable and machine-readable; `blocking_warnings()` exposes the
warnings behind a blocked validation so services can answer with structured
error bodies instead of parsed prose.
### Refusals, not silent zeros
A pairing the engine cannot price refuses the run rather than issuing an invoice
that charges the levies and nothing for the energy:
| `KEIN_ARBEITSPREIS` | a product with every price field `None` |
| `INDEXWERT_FEHLT` | an index-linked tariff whose index value has not arrived |
| `ZWEITARIF_UNVOLLSTAENDIG` | one HT/NT band priced and not the other |
| `ZWEITARIF_OHNE_HT_NT_AUFTEILUNG` | an HT/NT-only product against a meter reporting one total |
| `HT_NT_SUMME_WEICHT_AB` | an HT/NT split that does not reconcile with the stated total |
| `SECT41A_MISSING_EPEX_PRICES` | a §41a interval carrying consumption at no market price |
| `SECT41A_KEINE_INTERVALLE` | a §41a tariff with no interval series at all, against a meter reporting consumption |
| `SECT41A_INTERVALLSUMME_WEICHT_AB` | a §41a interval series that does not reconcile with the meter total (0,5 % tolerance, 1 kWh floor) |
| `KEIN_TRINKWASSERPREIS` | water delivered under a tariff pricing only the Abwasser side |
| `KEIN_LADEPREIS` | charging energy measured at the charge point under a tariff with no per-kWh price |
The two §41a series findings are the dynamic twin of the HT/NT pair above: on
that path the quarter-hour series *is* the billed quantity — Arbeitspreis,
Netzentgelt, Konzessionsabgabe and Stromsteuer all ride the sum of the priced
intervals — so an absent or short series bills every levy on whatever arrived.
The meter total is the independent witness.
Two neighbouring shapes are *billed* instead, because the data is complete and
only its reading is at issue: HT/NT registers with no stated total bill on their
sum (`MeterInput::billable_kwh`), and a two-register meter on an Eintarif product
bills on its total.
One finding does not refuse. `KEIN_EREIGNISPREIS` reports counted
Energiedienstleistung events that nothing prices, at `Warning` severity: an
event count is also a legitimate informational figure — how many Einsätze a
maintenance flat rate covered — so it alone does not settle which was meant.
The general statement is a pair of property tests in `proptest_invoice`:
whenever `bill()` succeeds and a quantity was delivered, an Arbeitspreis position
exists and is non-zero. `any_billable_consumption_produces_a_work_price` covers
the electricity pricing shapes (Eintarif, HT/NT, indexed, tiered, split and
unsplit meters); `any_delivered_commodity_produces_a_work_price` covers gas,
Fernwärme and charging energy. The second exists because the first was
electricity-only, which is precisely why the defect kept recurring outside it.
### Every line multiplies out
**PEPPOL-EN16931-R120** allows ±0.02 between a line's `price × quantity` and its
amount. The trap is a *rounded* price: rounding what the page prints is right,
but the same figure is BT-146, and the further it is rounded and the more units
it multiplies, the further the product drifts from the amount beside it.
§41a states a weighted average — rarely representable — so the machine field
carries it at full precision and only the description rounds
(`∅ 7,8430 ct/kWh`). `every_position_price_multiplies_out_to_its_amount` holds
the rule for every position of every product.
## Validated period, stated regime
`BillingContext.period` is a `BillingPeriod` — the constructor (and the serde
path) refuse `from > to`, so an inverted period is unrepresentable in every
provider and helper downstream.
`BillingContext.vertragsart` states the contractual regime and is emitted as
the `vertragsart` ZusatzAttribut on every invoice:
- **`Sondervertrag`** (default) — freely negotiated, §41 EnWG.
- **`Grundversorgung`** — the published Allgemeine Preise apply (§36 EnWG,
StromGVV/GasGVV).
- **`Ersatzversorgung`** — §38 EnWG fallback supply. It ends by law three
months after it began (§ 38 Abs. 4 EnWG), so the engine **refuses** a
longer Ersatzversorgung period with `ERSATZVERSORGUNG_UEBER_3_MONATE`:
such a supply cannot exist, and billing it would invent one.
## Architecture
```
billingd (HTTP service)
│ productd/edmd/marktd clients · HTTP endpoints
│ XRechnung 3.0 CII / PEPPOL UBL · PostgreSQL · CloudEvents
│
└── energy-billing (pure crate)
│
├── Product — typed enum with 13 per-category variants
│ ├── Strom(ElectricityProduct)
│ ├── Waermepumpe/Wallbox(ControllableLoadProduct) §14a
│ ├── Gas(GasProduct)
│ ├── Waerme(HeatProduct)
│ ├── Wasser(WaterProduct) Trinkwasser + Abwasser
│ ├── Solar(SolarProduct)
│ ├── Eeg(EegProduct)
│ ├── Einspeisung(EinspeisungProduct)
│ ├── Hems/Emobility/Energiedienstleistung(…)
│ └── Sharing(SharingProduct) §42c
│
├── Quantities — all meter inputs for one billing period
├── BillingContext — period, IDs, invoice type, regulatory rates
│ └── period: BillingPeriod — validated; from > to unrepresentable
├── BillingEngine — composes BillingProvider instances
│ ├── validate() — pre-flight regulatory check (no positions)
│ ├── bill(&self, …) — pure function → Result<Invoice, EngineError>
│ └── bill_batch(…) — portfolio billing
├── BillingProvider — one implementation per product/tax type
└── Invoice — result with positions + totals + warnings + BO4E JSON
├── warnings: Vec<BillingWarning> — regulatory compliance notices
├── has_errors() — any Error-severity warning?
└── to_rechnung_json() — BO4E JSONB for accountingd (feature `bo4e`)
```
The engine runs in passes:
```
Pass 0 validate_warnings() §38/§41a guards · regulatory pre-checks
Pass 1 commodity / levy providers (ElectricityProvider, GasProvider, …)
Pass 2 tax provider (MwStProvider — sees all net positions)
Pass 3 Abschlag deductions (Final invoice reconciliation)
Pass 4 Minimum invoice top-up (B2B Mindestabnahmeverpflichtung)
Pass 5 Cancellation sign reversal (Stornorechnung — all signs negated)
```
---
## Quick start
```rust
use energy_billing::{BillingContext, BillingPeriod, GridInput, InvoiceType, MeterInput,
Product, Quantities, RegulatoryRates};
use rust_decimal::dec;
use time::macros::date;
// Deserialize directly from productd JSONB using the "category" discriminator
let product: Product = serde_json::from_str(r#"{
"category": "STROM",
"arbeitspreis_ct_per_kwh": "32.0",
"grundpreis_ct_per_day": 12.0
}"#)?;
let ctx = BillingContext {
malo_id: "51238696012".to_owned(),
lf_mp_id: "9910000000002".to_owned(),
rechnungsnummer: "R2026-06-001".to_owned(),
period: BillingPeriod::new(date!(2026-06-01), date!(2026-06-30))?,
invoice_type: InvoiceType::Initial,
regulatory_rates: RegulatoryRates::default(),
..Default::default()
};
let quantities = Quantities {
electricity: Some(MeterInput {
arbeitsmenge_kwh: dec!(312.5),
..Default::default()
}),
..Default::default()
};
// Build and run — Product::build_engine() handles all category routing
let invoice = product
.build_engine(&GridInput::default(), &ctx.regulatory_rates)
.bill(ctx, &quantities)?;
invoice.assert_valid();
println!("Brutto: {} EUR", invoice.brutto_eur);
let rechnung_json: serde_json::Value = invoice.to_rechnung_json(); // feature `bo4e`
```
---
## Product enum
`Product` is the typed dispatch enum the engine bills from. Each category has its own
struct carrying only the fields that category prices, so a Gas field cannot be read on an
electricity tariff.
```rust
// Deserializes via #[serde(tag = "category")] from flat productd JSONB:
// {"category":"STROM","arbeitspreis_ct_per_kwh":"28.5"} → Product::Strom(ElectricityProduct{...})
// {"category":"WAERMEPUMPE","sect14a_modul2_nne_reduktion_ct_per_kwh":"1.5",...} → Product::Waermepumpe(...)
// {"category":"GAS","gas_arbeitspreis_ct_per_kwh_hs":"7.5",...} → Product::Gas(GasProduct{...})
```
| `Strom(ElectricityProduct)` | `STROM` | `ElectricityProvider` or `DynamicElectricityProvider` | SLP/RLM; HT/NT; block tariffs; §41a EPEX |
| `Waermepumpe(ControllableLoadProduct)` | `WAERMEPUMPE` | `ControllableLoadProvider` | §14a Modul 1/2/3 |
| `Wallbox(ControllableLoadProduct)` | `WALLBOX` | `ControllableLoadProvider` | §14a Modul 1/2/3 |
| `Gas(GasProduct)` | `GAS` | `GasProvider` | Brennwertkorrektur; Energiesteuer; BEHG CO₂ |
| `Waerme(HeatProduct)` | `WAERME` | `HeatProvider` | Fernwärme; standard-rated (19 %); AVBFernwärmeV §24 Preisgleitklausel; the CO2KostAufG § 3 Abs. 1 statement + § 14 WPG Anteil |
| `Wasser(WaterProduct)` | `WASSER` | `WaterProvider` | Trinkwasser 7 % USt; gesplittete Abwassergebühr (Schmutzwasser − Absetzungen, Niederschlagswasser m²); public-law fee is EN 16931 `O`, not `Z` |
| `Solar(SolarProduct)` | `SOLAR` | `SolarProvider` | §42b EnWG GGV; Mieterstrom mit § 42a Abs. 4 EnWG 90 %-Deckel; Stromsteuer per § 9 Abs. 1 Nr. 3 StromStG, **stated** not omitted; 0 % USt if Kleinunternehmer (§19 UStG) |
| `Eeg(EegProduct)` | `EEG` | `EegProvider` | LF-side Gutschrift; `eeg` feature for §51/§52 |
| `Einspeisung(EinspeisungProduct)` | `EINSPEISUNG` | `EinspeisungProvider` | Direktvermarktung Marktwert − Gebühr |
| `Hems(HemsProduct)` | `HEMS` | `HemsProvider` | Platform subscription + events |
| `Emobility(EmobilityProduct)` | `EMOBILITY` | `EmobilityProvider` | CPO/EMSP: service + kWh + session/roaming |
| `Energiedienstleistung(ServiceProduct)` | `ENERGIEDIENSTLEISTUNG` | `ServiceProvider` | Flat fee + per-event |
| `Sharing(SharingProduct)` | `SHARING` | `ElectricityProvider` + `EnergyShareProvider` | §42c Energiegemeinschaft credit |
`ControllableLoadProduct` composes `ElectricityProduct` (via `#[serde(flatten)]`) plus §14a fields — the standard electricity billing is delegated to `ElectricityProvider` then §14a credits are appended.
---
## Pricing capabilities
| HT/NT Zweitarif | `billing::TimeOfUsePricing` (validated, penny-correct) |
| Block / graduated tariffs | `billing::RateSchedule::graduated()` |
| Indexed prices (TTF, Phelix, NCG) | `IndexedPriceConfig { base_ct, spread_ct, index_value, factor }` |
| Gas indexed price | `gas_indexed_price: Option<IndexedPriceConfig>` in `GasProduct` |
| Fernwärme Preisgleitklausel | `waerme_indexed_price: Option<IndexedPriceConfig>` (AVBFernwärmeV §24 Abs. 4) |
| Seasonal prices | `SeasonalPriceOverride` by month range (wraps year boundary) |
| §41a EPEX dynamic | `billing::DynamicPricing`, per 15-min MTU: kWh × (spot + Aufschlag); spot clamped into `[dynamic_epex_floor_ct_kwh, dynamic_epex_cap_ct_kwh]` |
| §41a iMSys guard | Hard error when `dynamic_epex=true` and `MeteringMode != Imsys` |
| Pro-rata Grundpreis | `ctx.prorate_days()` clips to `vertragsbeginn`/`vertragsende` |
| Minimum invoice (B2B) | Pass 4 auto-top-up to `minimum_invoice_eur_brutto` |
| Discounts | `auf_abschlag_ct_per_kwh`, `auf_abschlag_eur_per_month` (`Discount` category) |
| Boni (§17 UStG Entgeltminderung) | `sofortbonus_eur` (one-time), `treuebonus_eur_per_year` (pro-rated) → `Bonus` category |
| MSB pass-through | `msb_gebuehr_ct_per_day` (MsbG) |
| Multi-rate MwSt | Per-position `applicable_tax_rate` → grouped `MwStProvider` |
| 0% USt feed-in Gutschrift | `kleinunternehmer_19_ustg` (§19 UStG Kleinunternehmer) |
| Verbrauchsteuer-Begünstigungen | `stromsteuer_tarif` / `energiesteuer_tarif` (Befreiung, Ermäßigung) and `steuerentlastungen` (notes only) — see below |
| Gas RLM Leistungspreis | `gas_leistungspreis_ct_per_kw_month` in `GasProduct` |
| §42 Energiemix | `EnergieQuellen` struct with `co2_g_per_kwh` (mandatory §42 Abs. 2 Nr. 2 EnWG) |
---
## Regulatory compliance
### §41a Abs. 1 EnWG — iMSys guard for dynamic tariffs
Dynamic tariffs (`Product::Strom(p)` where `p.dynamic_epex = true`) require an intelligent
metering system. `BillingEngine::bill()` rejects with
`EngineError::ValidationBlocked` — carrying every collected warning — when
`quantities.electricity.metering_mode != MeteringMode::Imsys`:
```rust
// Pre-flight check: validate without generating positions
let warnings = engine.validate(&ctx, &quantities);
for w in &warnings {
if w.severity == WarningSeverity::Error {
eprintln!("[{}] {}", w.code, w.message);
}
}
// §41a violations produce BillingWarning { code: "SECT41A_IMSYS_REQUIRED", severity: Error }
```
### Verbrauchsteuern — Befreiung, Ermäßigung, Entlastung (`steuer` module)
German excise law knows three instruments and only two of them change what a
supplier may invoice:
| **Steuerbefreiung** — § 9 Abs. 1 StromStG, §§ 25–28 EnergieStG | supplier, against the customer's Erlaubnis | the levy is **not** invoiced |
| **Steuerermäßigung** — § 9 Abs. 2/3 StromStG | supplier | invoiced at the **reduced** statutory rate |
| **Steuerentlastung** — § 9a/§ 9b/§ 9c StromStG, §§ 53a, 54 EnergieStG | the *customer*, afterwards, at the Hauptzollamt | **none** — invoiced in full |
```rust
pub enum StromsteuerTarif {
Regel, // § 3 StromStG — 2,05 ct/kWh
Befreiung { grund: StromsteuerBefreiung }, // § 9 Abs. 1 Nr. 1–8
Ermaessigung{ grund: StromsteuerErmaessigung },// § 9 Abs. 2 (11,42 €/MWh) / Abs. 3 (0,50 €/MWh)
}
pub enum EnergiesteuerTarif {
Regel, // § 2 Abs. 3 S. 1 Nr. 4 — 0,55 ct/kWh_Hs
Befreiung { grund: EnergiesteuerBefreiung }, // §§ 25–28 gegen Erlaubnis (§ 24 Abs. 2)
}
/// Notes only. Never an amount.
pub enum Steuerentlastung {
Stromsteuer9a, Stromsteuer9b, Stromsteuer9c,
Energiesteuer53a, Energiesteuer54,
}
```
**Why the split is load-bearing.** Zero-rating an Entlastung at supply
under-declares the supplier's own Stromsteueranmeldung, and the customer's later
Entlastungsantrag duplicates it rather than repairing it — a Unternehmen des
Produzierenden Gewerbes is invoiced the full 2,05 ct/kWh and reclaims 2,00 from
the Hauptzollamt (§ 9b, permanent at the EU minimum rate since 01.01.2026, from
12 500 kWh a year). Treating an Ermäßigung as an exemption fails the other way:
§ 9 Abs. 2 Fahrstrom is a *rate* of 11,42 EUR/MWh, and dropping the line loses
1,142 ct/kWh on every rail-traction invoice.
A `Steuerentlastung` renders one 0-EUR informational position stating the levy
it may be claimed against — the customer cannot file without that figure.
---
## Invoice types
```rust
pub enum InvoiceType {
Initial, // RECHNUNG — normal periodic billing
AdvancePayment, // ABSCHLAGSRECHNUNG — estimated advance request
Final, // SCHLUSSRECHNUNG — Jahresabrechnung, deducts ctx.abschlage
CreditNote, // GUTSCHRIFT — LF pays generator (EEG, EINSPEISUNG)
PartialInvoice, // TEILRECHNUNG — §41 EnWG move-in/move-out / Tarifwechsel
Correction { original_invoice_id, reason }, // KORREKTURRECHNUNG (§ 147 AO / GoBD)
Cancellation { original_invoice_id }, // STORNORECHNUNG — all signs negated
}
```
---
## Advance payments (Abschläge)
A Jahresabrechnung (`InvoiceType::Final`) reconciles the advances the customer
already paid. Each one is an `AbschlagDeduction`:
```rust
AbschlagDeduction {
datum: date!(2026 - 01 - 15),
betrag_eur: dec!(120.00), // gross, as paid
ust_satz: dec!(0.19), // rate this advance was invoiced at
beschreibung: Some("Abschlag Januar 2026".to_owned()),
}
```
`ust_satz` is mandatory because **§14 Abs. 5 Satz 2 UStG** requires an Endrechnung
to deduct the advances *and the tax attributable to them* — "die vereinnahmten
Teilentgelte und die auf sie entfallenden Steuerbeträge". A gross total alone
cannot express that: EUR 120 collected at 19 % and EUR 120 collected at 7 %
deduct different amounts of tax. The rate is per advance rather than per invoice,
so a rate change mid-year leaves earlier advances at the rate they were billed at.
| `betrag_eur` | gross paid |
| `netto_eur()` | `betrag_eur / (1 + ust_satz)`, to cents |
| `ust_eur()` | `betrag_eur - netto_eur()` — derived, so net + tax always re-sums to the gross paid |
On the resulting invoice:
```text
brutto_eur gross for the period
- abschlag_total_eur gross already paid
= zahlbetrag_eur balance due (negative → refund)
abschlag_ust_eur tax contained in abschlag_total_eur (§14 Abs. 5 Satz 2 UStG)
```
Abschlag positions never affect `netto_eur` / `mwst_eur` / `brutto_eur` — they
reconcile what was paid, they are not turnover.
### Two lawful settlement forms
`BillingContext::settlement_form` picks how a settling invoice presents them.
Both are lawful; they differ in what the document shows, not in what the customer
pays.
| `Endrechnung` (default) | the whole supply, then deducts the advances **and their tax** | §14 Abs. 5 Satz 2 UStG |
| `Restrechnung` | only the remainder; advances are not listed | BMF 15.10.2024, Rn. 48 |
The Endrechnung form has one failure mode worth naming: deducting the advances
but not the tax contained in them. Under UStAE 14.8 Abs. 10 the issuer then owes
the tax shown **plus** the advance-related portion again under §14c Abs. 1 — the
same tax billed twice. `abschlag_ust_eur` exists so that figure is always
available to state.
The Restrechnung form is what the BMF recommends for e-invoices, because
EN 16931's core profiles have nowhere to carry per-advance tax. Compute the
residual directly with:
```rust
let residual = invoice.residual_breakdown(default_rate)?; // supply − advances, per rate
```
Over-deduction is refused rather than silently accepted: advances exceeding the
supply in any `(category, rate)` group would understate the output tax owed.
**`to_en16931` implements the difference.** An Endrechnung states the full
supply and the advances as BT-113 *paid*. A Restrechnung deducts each advance as
a **BG-20 document-level allowance** carrying its own BT-95/96 VAT category and
rate — one per `(category, rate)` group, not one per advance, so a monthly
Abschlagsplan does not put eleven identical rows on the page. The reconciler then
derives BG-23 as `lines − allowances` per rate, which *is* the residual, and
nothing is stated as paid. Both documents ask the customer for the same BT-115.
That is what the flat BT-113 cannot do: an advance invoiced at 19 % stays a 19 %
deduction on a settlement billed at another rate. The field selected nothing at
all until it was wired — declared, documented here, and read by no code path, so
every settling invoice went out as an Endrechnung regardless.
### Crossing into `billing`
```rust
invoice.advance_payments()? // Vec<billing::AdvancePayment> — each with its own tax
invoice.prepayment()? // billing::Prepayment::Itemised, or ::None
```
Advances are always itemised, never collapsed to a flat total: the per-advance tax
is what makes the deduction lawful. `AdvancePayment` mirrors the ZUGFeRD /
Factur-X EXTENDED group `SpecifiedAdvancePayment` (BG-X-45), the standardised
place where per-advance tax data has a home.
---
## VAT breakdown (EN 16931 BG-23 / BO4E `steuerbetraege`)
`Invoice::tax_subtotals(default_rate)` groups the positions into one entry per
distinct rate, each with its own taxable base (BT-116) and tax amount (BT-117).
A single aggregate `mwst_eur` cannot describe an invoice that mixes 19 %
commodity with 7 % Fernwärme or 0 % PV feed-in.
Zero-rated bases are included. A supply taxed at 0 % is still a taxable supply,
and omitting it would make the sum of the bases differ from the invoice net —
exactly what the EN 16931 total-reconciliation rules check.
The breakdown is emitted as BO4E `steuerbetraege`, whose entries must sum to
`gesamtsteuer`, and is carried into XRechnung as BG-23.
---
## Meter inputs
```rust
pub struct MeterInput {
pub arbeitsmenge_kwh: Decimal,
pub arbeitsmenge_ht_kwh: Option<Decimal>, // HT register
pub arbeitsmenge_nt_kwh: Option<Decimal>, // NT register
pub spitzenleistung_kw: Option<Decimal>, // peak demand (RLM)
pub steuerung_stunden: Option<Decimal>, // §14a load-shedding hours
pub zaehlernummer: Option<String>, // §41 EnWG — shown on invoice
pub zaehlerstand_von: Option<Decimal>, // start reading
pub zaehlerstand_bis: Option<Decimal>, // end reading
pub metering_mode: MeteringMode, // Slp | Rlm | Imsys
pub ablesungsart: Ablesungsart, // § 40 Abs. 2 Nr. 6 EnWG — its own duty
pub is_estimated: bool, // § 40a Abs. 2 EnWG notice on invoice
pub zaehler_replaced: bool, // Zählerwechsel notice on invoice
pub coverage_pct: Option<Decimal>, // 0–100; below 100 the invoice rests in
// part on a § 40a Abs. 2 estimate
}
```
---
## Key regulatory fields per product
### `ElectricityProduct` / `ControllableLoadProduct`
| `kleinunternehmer_19_ustg` | §19 UStG | 0 % USt on the feed-in Gutschrift (operator has elected Kleinunternehmer) |
| `stromsteuer_tarif` | § 9 StromStG | `REGEL` \| `BEFREIUNG{grund}` \| `ERMAESSIGUNG{grund}` |
| `steuerentlastungen` | § 9a/9b/9c StromStG | Notes only — the levy is billed in full |
| `leistungspreis_strom_ct_per_kw_month` | §41 EnWG | RLM demand charge (ct/kW/month) |
| `preisgarantie_bis` | §41 Abs. 1 Nr. 4 EnWG | Price guarantee expiry on invoice |
| `mwst_rate_override` | §12 UStG | Override 19% per product |
| `dynamic_epex` | §41a EnWG | 15-min EPEX MTU spot billing (requires `MeteringMode::Imsys`) |
| `dynamic_epex_floor_ct_kwh` | §41a EnWG | Price floor for the spot component (Aufschlag added on top) |
| `energiequellen` | §42 Abs. 2 Nr. 2 EnWG | Typed fuel mix with CO₂ label |
### `ControllableLoadProduct` (§14a extras)
| `sect14a_modul2_nne_reduktion_ct_per_kwh` | §14a EnWG Modul 2 | Per-kWh Arbeitspreis reduction |
| `sect14a_modul1_pauschale_eur_per_year` | §14a EnWG Modul 1 | Pauschale Reduzierung (EUR/year, flat) |
| `sect14a_steuerungsentschaedigung_ct_per_kwh` | §14a EnWG | Per-kWh Steuerungsentschädigung (not a module) |
| `sect14a_steuerungsentschaedigung_eur_per_kw_year` | §14a EnWG | Capacity Steuerungsentschädigung (not a module) |
### `GasProduct`
| `energiesteuer_tarif` | §§ 24 Abs. 2, 25–28 EnergieStG | `REGEL` \| `BEFREIUNG{grund}` (Erlaubnisschein) |
| `gas_leistungspreis_ct_per_kw_month` | §41 EnWG | RLM demand charge for large gas customers |
| `gas_indexed_price` | §41 EnWG (Sonderkundenvertrag) | B2B TTF/NCG indexed price |
---
## Advanced operations
### Tarifwechsel — mid-period price change
```rust
// Old tariff: Jan 1–14
let inv_old = old_product.build_engine(&grid, &rates).bill(ctx_jan1_14, &meter_old)?;
// New tariff: Jan 15–31
let inv_new = new_product.build_engine(&grid, &rates).bill(ctx_jan15_31, &meter_new)?;
// Combined January invoice
let merged = inv_old.merge(inv_new);
```
### Portfolio billing
```rust
let engine = product.build_engine(&grid, &rates);
let results: Vec<Result<Invoice, EngineError>> = engine.bill_batch(
customers.into_iter().map(|(ctx, quantities)| (ctx, quantities)).collect()
);
```
### Regulatory pre-flight
```rust
let engine = product.build_engine(&grid, &rates);
let warnings = engine.validate(&ctx, &quantities);
if invoice.has_errors() {
// Block dispatch — Error-severity regulatory violation
}
```
### Proportional cost allocation (B2B shared buildings)
```rust
// Weights, not shares — square metres here; they are normalised internally.
let parts = building_invoice.allocate_proportionally(
&[dec!(84), dec!(73), dec!(52)],
vec![ctx_tenant_a, ctx_tenant_b, ctx_tenant_c],
)?;
// Guaranteed: the parts' brutto sum back to the original brutto, and each
// position's split quantities sum back to the metered volume. Every part
// inherits the source invoice's warnings, so a blocking finding still blocks.
```
### §41a Abs. 6 annual savings comparison
```rust
let comparison = Sect41aAnnualComparison::compute(
dec!(2400), // actual kWh under dynamic tariff
dec!(650.00), // actual EUR brutto
dec!(40.0), // reference fixed tariff ct/kWh
);
// Rendered as Info position on the annual invoice
```
---
## Optional features
```toml
energy-billing = { path = "…", features = ["eeg"] } # full eeg-billing accuracy
# energy-billing = { path = "…", features = ["full"] } # …or every optional feature
```
| `eeg` | `eeg-billing` | `EegProvider` delegates to `eeg_billing::calculate_settlement()` for §51/§52/§36h |
| `bo4e` | `rubo4e` | `Invoice::to_rechnung()` and `to_rechnung_json()`, `TaxSubtotal::to_bo4e()`, `InvoiceType::rechnungstyp()` |
| `en16931` | `en16931` | `Invoice::to_en16931(spec_id, seller, buyer)` → the semantic model `en16931-formats` renders |
| `full` | both of the above | everything |
All three are **off by default**, so the core engine computes and validates a
complete invoice without either boundary representation compiled in. `billingd`
runs with `full`.
---
## Audit trail and explainability
Every `BillingPosition` carries a `PositionTrace` with the full calculation audit:
```rust
pub struct PositionTrace {
pub formula: String, // "500.000 kWh × 0.30000 EUR/kWh = 150.00000 EUR"
pub input_quantity: Decimal,
pub input_unit_price_eur: Decimal,
pub gross_eur: Decimal,
pub regulatory_basis: Vec<String>, // ["§3 StromStG", "§41 EnWG"]
pub tariff_source: Option<String>, // product sheet ID from productd
pub pro_rata_fraction: Option<Decimal>,
pub rounding_note: Option<String>,
}
```
The `BillingWarning` field on `Invoice` carries regulatory compliance notices:
```rust
// Check for dispatch-blocking violations
if invoice.has_errors() {
for w in invoice.warnings.iter().filter(|w| w.severity == WarningSeverity::Error) {
// e.g. { code: "SECT41A_IMSYS_REQUIRED", message: "§41a Abs. 1 EnWG: …" }
}
}
```
---
## Regulatory basis
| §3 StromStG | Stromsteuer 2.05 ct/kWh; `stromsteuer_for_year(year)` for retroactive corrections |
| § 9 StromStG | All eight Abs. 1 Befreiungen plus the Abs. 2/3 ermäßigte Sätze, typed; § 9a/9b/9c Entlastungen kept out of the amounts |
| §2 EnergieStG | Erdgassteuer 0.55 ct/kWh; `energiesteuer_gas_for_year(year)` (incl. 2022 0-rate) |
| §§ 53a, 54 EnergieStG | Steuer**entlastungen** the customer claims at the Hauptzollamt — the supply is invoiced in full, and a `Steuerentlastung` renders the 0-EUR note the application needs |
| BEHG §10 | CO₂-Preis H-Gas (65 EUR/t 2026) + L-Gas factor; `behg_ct_per_kwh_for_year(year)` |
| §25 Nr. 4 MessEV | Brennwertkorrektur m³ → kWh_Hs |
| §12 Abs. 2 Nr. 1 UStG | Reduced 7% MwSt for Anlage-2 goods (Trinkwasser) — NOT district heating |
| §19 UStG | 0% USt on the feed-in Gutschrift (Kleinunternehmer election) |
| §14a EnWG | Controllable loads, Modul 1/2/3 (BNetzA BK8-22/010-A) via `ControllableLoadProvider` |
| § 40a Abs. 2 EnWG | Estimated reading notice on invoice |
| §40 / §40b EnWG | Mandatory ct/kWh; structured price-comparison data in JSON |
| §40 EnWG | Invoice content (Netzbetreiber, Energiemix §42) |
| § 40 Abs. 2 Nr. 6 EnWG | Anfangs-/Endzählerstand, the consumption **and** the `Ablesungsart` — the third is its own duty and was on the page only for estimates |
| §40 Abs. 2 Nr. 7/8 EnWG | Verbrauchshistorie (prior-year + national average) |
| §41a / §41a Abs. 1 EnWG | §41a EPEX per-interval; iMSys guard and missing-price guard both hard errors; the § 40 Abs. 2 display duties ride the dynamic path too |
| §42 Abs. 2 Nr. 2 EnWG | CO₂ emissions label via typed `EnergieQuellen.co2_g_per_kwh` |
| § 42a Abs. 4 EnWG | Mieterstrom price capped at 90 % of the Grundversorgung — refused above it, and the ceiling stated on the page |
| § 9 Abs. 1 Nr. 3 StromStG | A rooftop Mieterstrom/GGV supply is exempt (≤ 2 MW, räumlicher Zusammenhang) — the **default**, and the ground reaches the invoice |
| §42b EnWG | Gemeinschaftliche Gebäudeversorgung (PV/grid hybrid split) |
| CO2KostAufG § 3 Abs. 1 / § 14 WPG | The five-item CO₂ statement on every Gas and Fernwärme invoice, plus the Wärme renewable share |
| §42c EnWG | Energiegemeinschaft sharing credit via `SharingProduct` |
| §51 EEG 2023 | Negativpreisregel (contractual LF feature via `eeg` feature) |
---
## Testing
```bash
cargo test -p energy-billing --all-features
```
Coverage spans these suites:
| Unit tests (lib) | `RegulatoryRates`, levy lookups, `prorate_days`, `InvoiceType`, `Product` enum roundtrip, `StromsteuerTarif`/`Steuerentlastung`, `billed_months`/`billed_years`, tariff deserialization |
| `calculator_tests` | All 13 categories (incl. WASSER), §14a/§41a/§41a Abs. 1, GGV, seasonal, indexed, prosumer, block tariffs, RLM demand charge, multi-rate MwSt, cancellation, BO4E JSON, pro-rata, Tarifwechsel, `bill_batch`, `validate` |
| `golden_scenarios` | Golden master: SLP electricity; gas + levies; EEG Gutschrift; RLM demand charge; §54 EnergieStG billed in full as an Entlastung; historic rates 2022 (heating gas constant 0.55, 7 % gas-USt window); §41a Abs. 1 rejection; §40 ct/kWh; §40 mandatory fields; §42c sharing; §9 exemption |
| `proptest_invoice` | Property-based: `brutto == netto + mwst`, cancellation sign, 0% MwSt, gas arithmetic, demand charge non-negative, StromStG year table |
| `en16931_conformance` | `Invoice::to_en16931` passes the real EN 16931 rule engine (per-line VAT + BG-23 reconcile), and the statutory `Info` disclosures reach BT-22 |
| `priceless_product_is_refused` | A product with every price field `None` refuses the run rather than billing the levies alone |
| Doc tests | Inline usage examples |
## Related crates
| [`energy-billing`](https://docs.rs/energy-billing) ← **this crate** | Retail invoice calculation — Strom, Gas, Wärme, Wasser, §14a, §41a, EEG |
| [`eeg-billing`](https://docs.rs/eeg-billing) | The feed-in leg — EEG/KWKG Vergütung and the §14 UStG Gutschrift |
| [`mako-markt`](https://docs.rs/mako-markt) | Marktstammdaten — Marktlokation, Messlokation, Marktpartner, Rollenzuordnung — the model the customer and Marktlokation inputs are keyed on |
| [`grid-billing`](https://docs.rs/grid-billing) | The grid-side (NB) counterpart — NNE, KA, MMM, MSB |
| [`billingd`](https://hupe1980.github.io/mako/docs/services/billingd/) | Production daemon — the Energy Billing Engine for the LF role |
Part of **mako**, an open-source Rust platform for German energy market
communication (Marktkommunikation). Full documentation: <https://hupe1980.github.io/mako/>