billingd Operator Guide
billingd operator guide: Multi-Product Billing Engine (LF role). Energy billing engine — user-defined product prices from tarifbd; 13 categories (STROM/GAS/WAERME/WASSER/SOLAR/EEG/EINSPEISUNG/WAERMEPUMPE/WALLBOX/HEMS/EMOBILITY/ENERGIEDIENSTLEISTUNG/SHARING); §41a EPEX dynamic; §25 Nr. 4 MessEV Brennwertkorrektur; §14a Modul 1/3; EN 16931 e-invoicing (XRechnung 3.0 CII + PEPPOL UBL, B2G mandate 01.01.2027).
billingd — Multi-Product Billing Engine
billingd is a pure calculation service. It has no grid topology knowledge and no
business policy — all decisions come from the product definition in tarifbd and the
measurement data in edmd.
Port: :9280
Why pure calculation?
Every billing run is deterministic and reproducible: given the same inputs (product, meter,
tariff), the output is always the same Rechnung. This means:
- BNetzA § 147 AO / GoBD compliance: auditors can re-run the calculation from stored inputs
- No hidden state: all inputs are either stored in
tarifbd,edmd, ormarktd - Testable:
energy-billingis exhaustively covered (property-based, golden master, integration) — zero I/O, zero async, all pure Rust
Architecture: energy-billing crate
The pure billing logic lives in the energy-billing crate (extracted from billingd).
This follows the same pattern as eeg-billing for einsd:
billingd (HTTP service)
│ config · persistence · CloudEvents · EN 16931 e-invoicing
│ HTTP endpoints · tarifbd/edmd/marktd clients
│
└── energy-billing (pure crate, crates.io)
│ Product (typed enum, 13 variants)
│ Quantities · BillingContext · RegulatoryRates
│ BillingEngine (provider pipeline, validate + bill + bill_batch)
│ Invoice { positions, warnings, netto_eur, mwst_eur, brutto_eur }
│
├── ElectricityProvider §41a EPEX; HT/NT; block tariffs; RLM demand
├── ControllableLoadProvider §14a Modul 1/3 (WAERMEPUMPE, WALLBOX)
├── GasProvider §25 Nr. 4 MessEV Brennwertkorrektur; BEHG CO₂
├── HeatProvider Fernwärme (standard-rated; 7% only in the §28 window)
├── WaterProvider Trinkwasser 7% USt; Abwasser gesplittet; Absetzungen
├── SolarProvider §21 Abs. 3 EEG Mieterstrom; §42b EnWG GGV
├── EegProvider LF-side Gutschrift; contractual §51
├── EinspeisungProvider Direktvermarktung Marktwert
├── HemsProvider Platform subscription + events
├── EmobilityProvider CPO/EMSP
├── ServiceProvider Energiedienstleistung
├── DynamicElectricityProvider §41a per-interval EPEX (§41a iMSys guard)
├── EnergyShareProvider §42c Energiegemeinschaft credit
└── MwStProvider Multi-rate MwSt (7% / 19% / 0% per position)
energy-billing is zero I/O, zero async — BillingEngine::bill() is a pure function
returning Result<Invoice, EngineError>; each error variant carries a stable
machine-readable code() that billingd surfaces in structured error bodies.
Invoice carries positions: Vec<BillingPosition>, warnings: Vec<BillingWarning>, and
exposes to_rechnung() — a fully-typed rubo4e::Rechnung (Decimal-exact money;
canonical BO4E fields: rechnungstyp, istStorno, originalRechnungsnummer,
faelligkeitsdatum, zuZahlen, typed marktlokation/zaehler/vertrag;
mako-specific §40b/§40-Abs.-2 facts ride as zusatzAttribute).
to_rechnung_json() is its thin serialization wrapper (JSONB for accountingd
ingestion and the stored billing_records.rechnung_json).
Helper methods: .assert_valid(), .total_by_tag(), .positions_by_tag(),
.kilowattstundenpreis_brutto_ct(), .has_errors().
Statutory rates (Stromsteuer, Energiesteuer Gas, BEHG CO₂) are injected via RegulatoryRates
from billingd.toml — zero hardcoded values in the crate.
Calculation pipeline
graph TB
ERP["ERP<br/>POST /api/v1/billing/{malo_id}/calculate"]
tarifbd["tarifbd :9080<br/>ProductDefinition<br/>(Tarifpreisblatt JSONB)"]
edmd["edmd :8380<br/>MeterBillingPeriod<br/>(arbeitsmenge, spitzenleistung)"]
marktd["marktd :8180<br/>PreisblattNetznutzung<br/>PreisblattKonzessionsabgabe"]
calculator["energy-billing crate<br/>Product → BillingEngine → Invoice"]
pg[("PostgreSQL<br/>billing_records<br/>§ 147 AO / GoBD")]
erp_hook["ERP webhook<br/>de.billing.rechnung.erstellt"]
accountingd["accountingd :9380<br/>→ debit entry"]
ERP --> tarifbd
ERP --> edmd
ERP --> marktd
tarifbd --> calculator
edmd --> calculator
marktd --> calculator
calculator --> pg
pg -->|"best-effort"| erp_hook
erp_hook --> accountingd
Product categories
billingd routes each billing request to a category-specific pure calculator.
All commercial prices are user-defined in tarifbd — the engine contains no hardcoded
rates. Statutory rates (Stromsteuer, Energiesteuer Gas, BEHG CO₂) are configured in
billingd.toml under [rates] and can be overridden per-product.
graph LR
subgraph energy [Electricity]
STROM["STROM<br/>SLP/RLM Eintarif/Zweitarif<br/>§14a Modul 1/3<br/>EEG Gutschrift<br/>§41a EPEX dynamic"]
WP["WAERMEPUMPE<br/>§14a mandatory<br/>(like STROM)"]
WB["WALLBOX<br/>§14a mandatory<br/>(like STROM)"]
end
subgraph heat_gas ["Gas & Heat"]
GAS["GAS<br/>§25 Nr. 4 MessEV Brennwertkorrektur<br/>Energiesteuer §2 EnergieStG<br/>BEHG CO₂"]
WAERME["WAERME<br/>Grundpreis + Leistungspreis<br/>+ Arbeitspreis (kWh_th)"]
WASSER["WASSER<br/>Trinkwasser 7 % USt<br/>gesplittete Abwassergebühr<br/>Absetzungen (Schleppwasser)"]
end
subgraph solar_eeg ["Solar & Feed-in"]
SOLAR["SOLAR<br/>§21 Abs. 3 EEG Mieterstrom-Aufschlag<br/>§42b EnWG GGV-Rabatt<br/>Eigenverbrauch supply"]
EEG["EEG<br/>Vergütung / Marktprämie<br/>Managementprämie / KWKG<br/>(Gutschrift / credit note)"]
EINSP["EINSPEISUNG<br/>Direktvermarktung<br/>Marktwert − Vermarktungsgebühr"]
end
subgraph services [Services]
HEMS["HEMS<br/>Platform fee + events<br/>Smart meter readouts"]
EMOB["EMOBILITY<br/>CPO/EMSP: Betriebsgebühr<br/>Ladeenergie + Session/Roaming"]
EDL["ENERGIEDIENSTLEISTUNG<br/>MSB / EMS packages<br/>Flat fee + per-event"]
end
BUNDLE["BUNDLE<br/>Component references<br/>→ per-position recursion"]
SHARING["SHARING<br/>§42c Energy Sharing<br/>community credit"]STROM — Electricity
Grundpreis [from tarifbd] ct/day
Arbeitspreis [from tarifbd] ct/kWh
Leistungspreis [from tarifbd] ct/kW/month RLM demand charge on spitzenleistung_kw
NNE Grundpreis [from marktd] pass-through
NNE Arbeitspreis [from marktd] pass-through
NNE Leistungspreis [from marktd] RLM only (EUR/kW/month)
Konzessionsabgabe [from marktd] pass-through
§14a Modul 1 Rabatt [if product set] negative EUR/kW/year
§14a Modul 3 Gutschrift [if product set] negative, pro-rated to load-shedding hours
EEG Gutschrift [from einsd] negative, if PV self-consumption
Stromsteuer [from billingd.toml, overridable per-product] ct/kWh
──────────────────────────────────────────────────
Netto
MwSt [from billingd.toml or product override]
Brutto
Variants: Eintarif, Zweitarif (HT/NT), Mehrtarif (multiple registers).
§41a EPEX dynamic: when dynamic_epex = true in the product, billingd fetches
15-min Lastgang and 15-min EPEX MTU prices from tarifbd. arbeitspreis_ct_per_kwh is
ignored; the per-MTU price is spot + auf_abschlag_ct_per_kwh.
RLM demand charge: For large commercial customers with measured peak demand (§ 12 StromNZV,
≥100 MWh/year), set leistungspreis_strom_ct_per_kw_month in the product definition.
billingd bills spitzenleistung_kw × rate as a Leistungspreis position.
Supply spitzenleistung_kw from edmd MeterBillingPeriod. Applies to metering_mode: RLM
or Imsys metering points.
GAS — Natural Gas
Brennwertkorrektur [informational] m³ × Hs × Z → kWh_Hs (§25 Nr. 4 MessEV)
Grundpreis Gas [from tarifbd] ct/day
Arbeitspreis Gas [from tarifbd] ct/kWh_Hs
Gasnetzentgelt GP [from marktd] pass-through
Gasnetzentgelt AP [from marktd] pass-through
Konzessionsabgabe Gas [from marktd] pass-through
Bilanzierungsumlage Gas [from marktd] pass-through
Energiesteuer Erdgas [from billingd.toml] §2 EnergieStG 0.55 ct/kWh_Hs
OR Exemption notice when gas_energiesteuer_befreiung=true
(§54 EnergieStG KWK/industrial) — requires customer certificate
CO₂-Abgabe BEHG [from billingd.toml] ~1.31 ct/kWh_Hs (65 EUR/t CO₂, 2026)
MwSt [from billingd.toml] 19%
Since 2026 the nEHS certificate price is auction-formed (§10 Abs. 1 BEHG:
weekly EEX auctions from 01.07.2026 within the 55–65 EUR/t corridor,
Verkaufsphase at 68 EUR/t), so on the live bill/preview paths billingd
overlays the dated market price from tarifbd's nehs_prices series onto
the year-table default. Resolution order: explicit [rates] override →
GET /api/v1/nehs-prices/latest?date={period_from} (start-of-period basis,
consistent with regulatory_rates_for_period; converted via
energy_billing::behg_ct_per_kwh_from_price) → year-table fallback. The
EUR/t→ct/kWh conversion uses the H-Gas CO₂ factor (0.20160 kg/kWh) unless
[rates] behg_co2_factor_kg_per_kwh overrides it (L-Gas: 0.20140).
Historical XRechnung re-renders keep the stored record's rates
(CO2KostAufG §3: the pass-through follows the supplier's actual CO₂ costs at
billing time).
Historic statutory rates: For retroactive correction invoices, the year tables in
energy_billing::rates apply the correct historical defaults: effective_stromsteuer_for_year(),
effective_energiesteuer_gas_for_year() (heating gas has been a constant 0.55 ct/kWh_Hs —
the 2022 Energiesteuersenkungsgesetz reduced motor-fuel rates only) and
effective_behg_gas_for_year(). VAT history is commodity-aware:
mwst_rate_for_period() covers the 2020 COVID 16 % window, and
mwst_rate_for_gas_waerme_period() additionally covers the 7 % gas/Fernwärme window
01.10.2022–31.03.2024 (§28 Abs. 5/6 UStG). Periods straddling a VAT boundary return
None — split at the Stichtag and merge the invoices.
Supply gas_meter.messung_qm3 + brennwert_kwh_per_qm3 + zustandszahl in the request.
billingd computes kWh_Hs = m³ × Hs × Z and uses it for all price positions.
H2-blend / gasqualitaet: Supply the optional gasqualitaet field from
marktd.malo.gasqualitaet (e.g. "H_GAS", "L_GAS", "H2_BLEND"). The field does not
alter the billing amount — per DVGW G 260, edmd already reports the measured Brennwert
reflecting the actual gas blend. billingd records gasqualitaet as a ZusatzAttribut on
the Rechnung for regulatory audit transparency, enabling operators to trace billing periods
during H2-blend transitions.
WAERME — District Heat (Fernwärme)
Grundpreis Fernwärme [from tarifbd] EUR/month
Leistungspreis [from tarifbd] EUR/kW/month × peak kW
Arbeitspreis [from tarifbd] ct/kWh_th
MwStSOLAR — Mieterstrom / §42b EnWG GGV
Arbeitspreis Solar [from tarifbd] ct/kWh (Eigenverbrauch supply price)
Mieterstrom-Aufschlag [from tarifbd] ct/kWh §42b EnWG (BNetzA-capped annually)
§42b EnWG GGV-Rabatt [from tarifbd] ct/kWh negative discount
Stromsteuer skipped by default §9a StromStG exemption for on-site Eigenverbrauch
MwSt
Set solar_include_stromsteuer: true in the product definition for non-exempt cases.
EEG — Feed-in Settlement (Gutschrift)
Credit note for feed-in plant operators (§21 EEG Vergütung, §38 EEG Marktprämie):
EEG Einspeisevergütung [from tarifbd] ct/kWh (credit)
EEG Marktprämie [from tarifbd] ct/kWh (credit, per settlement period)
Managementprämie [from tarifbd] ct/kWh §53 EEG (fixed by technology)
KWKG Zuschlag [from tarifbd] ct/kWh (credit, if applicable)
MwSt
Net result is typically negative brutto (the LF pays the producer).
LF vs NB for §51 EEG Negativpreisregel
The mandatory §51 EEG implementation (suspension of Vergütung during negative-EPEX hours) lives in
eeg-billing/einsd— this governs the NB paying the plant operator under the statutory EEG.The
EEGcategory inbillingdis for the LF (private contractual billing): Mieterstrom §38a contracts and Direktvermarktung arrangements where the LF is the contracting party. These are private law contracts not subject to statutory §51.For contracts that voluntarily mirror §51 (e.g. "no credit during negative hours"): supply
eeg_meter.kwh_during_negative_epexto suspend Vergütung/Marktprämie for those kWh. KWKG Zuschlag is always exempt (different law).Kleinunternehmer (§19 UStG): a small feed-in operator who has elected the Kleinunternehmerregelung issues the Gutschrift at 0 % USt — set
kleinunternehmer_19_ustg: truein the product definition intarifbd. This is the operator's tax election, not a function of plant size (§12 Abs. 3 UStG zero-rates the PV system supply, which this engine does not bill).
EINSPEISUNG — Direktvermarktung Settlement
Marktwert Strom [from tarifbd] ct/kWh (EPEX Spot Monatsmarktwert)
Vermarktungsgebühr [from tarifbd] ct/kWh negative (aggregator fee)
MwStWAERMEPUMPE / WALLBOX — §14a Controlled Loads
Identical to STROM but §14a positions are always included when the product carries
steuerungsrabatt_modul1_eur_per_kw_year and/or steuerungsrabatt_modul3_eur_per_kw_year.
No separate provider — Product::Waermepumpe and Product::Wallbox variants use
ControllableLoadProvider, which delegates standard electricity billing to ElectricityProvider
and appends §14a Modul 1/3 credit positions.
Set steuerungsrabatt_modul1_eur_per_kw_year (annual capacity-based NNE reduction) and/or
sect14a_modul1_nne_reduktion_ct_per_kwh (per-kWh NNE reduction) in the product definition.
HEMS / EMOBILITY / ENERGIEDIENSTLEISTUNG / BUNDLE
HEMS: Platform fee (EUR/month) + Optimization events + Smart meter readouts
EMOBILITY: Betriebsgebühr (EUR/month) + Ladeenergie (ct/kWh) + Session/Roaming fees
ENERGIEDIENSTLEISTUNG: Flat fee (EUR/period) + per-event charge
BUNDLE: per-component recursion — ERP must submit individual calculate requests per position
Product and tariff model
Product — type-safe dispatch
Product is a typed enum deserialized directly from tarifbd JSONB using the "category"
discriminator. Call product.build_engine(&grid, &rates) to obtain a configured BillingEngine:
// Deserializes from {"category":"STROM","arbeitspreis_ct_per_kwh":32.0,...}
let product: Product = serde_json::from_str(&product_json)?;
let engine = product.build_engine(&grid, &rates);
// No more Option<BillingEngine> or PricingModel::try_from() needed
let invoice = engine.bill(ctx, &quantities)?;
Product has 13 exhaustive variants, each wrapping a typed per-category struct:
Strom(ElectricityProduct), Waermepumpe/Wallbox(ControllableLoadProduct),
Gas(GasProduct), Waerme(HeatProduct), Wasser(WaterProduct), Solar(SolarProduct),
Eeg(EegProduct), Einspeisung(EinspeisungProduct),
Hems(HemsProduct), Emobility(EmobilityProduct), Energiedienstleistung(ServiceProduct),
Sharing(SharingProduct).
Regulatory additions
| Addition | Law |
|---|---|
kleinunternehmer_19_ustg → 0% USt on feed-in Gutschrift | §19 UStG |
industrie_stromsteuer_befreiung → exemption notice | §9 Abs. 1 Nr. 4 StromStG |
preisgarantie_bis → disclosure on invoice | §41 Abs. 1 Nr. 4 EnWG |
MeteringMode (SLP/RLM/iMSys) on MeterInput | §3/§ 12 StromNZV, §31 MsbG |
is_estimated flag → § 60 Abs. 2 MsbG notice | § 60 Abs. 2 MsbG |
zaehler_replaced flag → Zählerwechsel notice | §41 EnWG |
Sect41aAnnualComparison in Quantities | §41a Abs. 6 EnWG |
InvoiceType::PartialInvoice | §41 EnWG, StromGVV §17 |
Tarifwechsel endpoint
Mid-period price changes (§41 EnWG transparency requirement) are supported natively:
POST /api/v1/billing/{malo_id}/tarifwechsel
Content-Type: application/json
{
"lf_mp_id": "9910000000002",
"period_from": "2026-01-01",
"period_to": "2026-01-31",
"switch_date": "2026-01-15",
"old_tariff": { "category": "STROM", "arbeitspreis_ct_per_kwh": 28.0 },
"new_tariff": { "category": "STROM", "arbeitspreis_ct_per_kwh": 32.0 },
"old_meter": { "arbeitsmenge_kwh": 140 },
"new_meter": { "arbeitsmenge_kwh": 170 }
}
Two sub-period invoices are calculated and merged via Invoice::merge(). Positions from
both sub-periods appear on one combined invoice. Tax is applied independently per sub-period
(correct per §41 EnWG for mid-month rate changes).
Pro-rata Grundpreis (move-in / move-out)
billingd pro-rates Grundpreis when vertragsbeginn or vertragsende falls
within the billing period. Pass these in the BillingContext:
{
"vertragsbeginn": "2026-01-16"
}
A customer joining on Jan 16 is billed 16 × rate instead of 31 × rate. A customer who moves in mid-period is charged the standing rate for the days supplied, not the full period.
Audit trail
Every billing run generates a unique billing_run_id (UUID v4). It is stored on
Invoice.billing_run_id and propagated to:
billing_records.billing_run_idin PostgreSQLrechnung_json.zusatzAttribute["billingRunId"]
This links each database record to the exact calculation output for § 147 AO / GoBD compliance.
Triggering a billing run
POST /api/v1/billing/51238696780/calculate
Content-Type: application/json
{
"lf_mp_id": "9910000000002",
"nb_mp_id": "9900000000001",
"period_from": "2026-06-01",
"period_to": "2026-06-30",
"rechnungsnummer": "R2026-06-001"
}
billingd automatically fetches:
- Product from
tarifbd GET /api/v1/customer/51238696780/product - Meter data from
edmd GET /api/v1/billing-period/51238696780?from=...&to=... - NNE tariff from
marktd GET /api/v1/preisblaetter/{nb_mp_id} - KA tariff from
marktd GET /api/v1/preisblaetter-ka/{nb_mp_id}
Override any input by passing it directly in the request body — useful for testing or when the upstream service is temporarily unavailable:
POST /api/v1/billing/51238696780/calculate
Content-Type: application/json
{
"lf_mp_id": "9910000000002",
"nb_mp_id": "9900000000001",
"period_from": "2026-06-01",
"period_to": "2026-06-30",
"meter": {
"arbeitsmenge_kwh": "312.5",
"sparte": "STROM"
},
"tariff": {
"category": "STROM",
"grundpreis_ct_per_day": "20.0",
"arbeitspreis_ct_per_kwh": "32.0"
}
}§41a Dynamic Tariff (iMSys)
When the product in tarifbd has dynamic_epex: true, billingd automatically:
- Fetches 15-min Lastgang from
edmd(GET /api/v1/lastgang/{malo_id}?from=…&to=…) - Fetches 15-min EPEX prices from
tarifbd(GET /api/v1/epex-prices/{date}/quarter-hourly), keyed on each Market Time Unit's UTC start instant (SDAC 15-min go-live 2025-10-01) - Calculates
Σ(kWh_MTU × (EPEX_MTU_ct + Aufschlag_ct)) / 100as the energy cost — each 15-min consumption interval is floored to its quarter-hour and joined to that MTU's price - Adds NNE / Konzessionsabgabe / Stromsteuer as usual
The tariff.arbeitspreis_ct_per_kwh field is ignored when dynamic_epex: true — the EPEX
spot price from tarifbd is the actual price applied per 15-min MTU, plus the supplier's
fixed auf_abschlag_ct_per_kwh Arbeitspreis-Aufschlag (§41a: market price + margin).
Price floor (dynamic_epex_floor_ct_kwh): Set this field in the tarifbd product to cap
how low the EPEX price can go. Common configurations:
null(default) — full pass-through; negative EPEX → customer receives a credit0— zero floor; negative EPEX bills at 0 ct/kWh (no credit, no charge)5— minimum 5 ct/kWh regardless of spot price
{
"category": "STROM",
"dynamic_epex": true,
"dynamic_epex_floor_ct_kwh": "0"
}
Fallback: when Lastgang data is unavailable, billingd falls back to arbeitsmenge_kwh
from edmd's billing-period endpoint with the static arbeitspreis_ct_per_kwh.
POST /api/v1/billing/51238696780/calculate
Content-Type: application/json
{
"lf_mp_id": "9910000000002",
"nb_mp_id": "9900000000001",
"period_from": "2026-06-01",
"period_to": "2026-06-30",
"tariff": {
"category": "STROM",
"grundpreis_ct_per_day": "5.0",
"dynamic_epex": true
}
}
EPEX prices must be imported daily into
tarifbdviaPUT /api/v1/epex-prices/{date}.
Idempotency
billing_records has a partial unique index on (malo_id, lf_mp_id, period_from, period_to, product_code, tenant) for non-correction, non-Sammel rows. Re-running
the same billing request updates the existing record only while it is a draft
(outcome = 'generated') — a dispatched record refuses the overwrite and points
at the correction path.
Endpoints
| Method | Path | Description |
|---|---|---|
POST | /api/v1/billing/{malo_id}/calculate | Calculate, persist, emit CloudEvent |
POST | /api/v1/billing/{malo_id}/preview | Dry-run calculation (no persist, no CloudEvent) |
GET | /api/v1/billing | List records (?malo_id=&lf_mp_id=&outcome=) |
GET | /api/v1/billing/{id} | Fetch single record with full Rechnung JSONB |
GET | /api/v1/billing/{id}/xrechnung | XRechnung 3.0 CII XML (via en16931-formats) |
GET | /api/v1/billing/{id}/ubl | PEPPOL BIS Billing 3.0 UBL 2.1 (EN16931) |
POST | /api/v1/billing/{id}/correction | Korrekturrechnung / Stornorechnung (§ 147 AO / GoBD) |
POST | /api/v1/billing/{malo_id}/tarifwechsel | Combined invoice for mid-period price change (§41 EnWG) |
POST | /api/v1/billing/{id}/submit-b2g | XRechnung B2G submission (§27 EGovG) |
GET | /health | Liveness |
GET | /health/ready | Readiness |
POST|GET | /mcp | MCP Streamable HTTP (LLM tooling) |
MCP server
billingd ships a built-in MCP server at /mcp (Streamable HTTP 2025-11-25). Twelve tools
and six prompts are available to LLM agents:
| Tool | Description |
| --- |
|---|
list_billing_records |
get_billing_record |
preview_billing |
calculate_billing |
get_xrechnung |
check_billing_anomaly |
list_vpp_settlements |
list_corrections |
list_product_categories |
get_billing_summary |
validate_tariff_config |
explain_invoice_position |
| Prompt | Description |
|---|---|
order-to-cash | Full O2C: GPKE Lieferbeginn → Jahresabschluss |
preview-invoice | Step-by-step: preview before committing a billing run |
check-dynamic-tariff | Verify §41a EPEX tariff configuration |
14a-steuerungsrabatt | Configure §14a Modul 1/3 for Wärmepumpe / Wallbox |
eeg-billing | Set up EEG / EINSPEISUNG billing with double-booking guard |
gas-billing | Configure Brennwertkorrektur, BEHG CO₂, H2-blend, L-Gas |
The tariff-optimization-agent in agentd calls list_billing_records and
get_billing_summary to detect customers on sub-optimal tariffs and automatically suggests
§41a dynamic tariff switches for iMSys customers.
Korrekturrechnung (§ 147 AO / GoBD)
POST /api/v1/billing/{id}/correction creates a Korrekturrechnung or Stornorechnung:
{ "reason": "Falsche Zählerstandsaufnahme Q2 2026", "negate": true }
negate: true→ Stornorechnung (all positions negated,is_correction: truein DB)negate: false→ Korrekturrechnung (amended positions only)
Both variants include zusatzAttribute.originalRechnungsnummer for § 147 AO / GoBD audit trail.
A second correction of the same original is refused with 409 Conflict —
KORR-{original_nr} must stay einmalig (§14 Abs. 4 Nr. 4 UStG), and a double
negation would corrupt the accounting ledger.
ENERGIEDIENSTLEISTUNG products
When tariff.category == "ENERGIEDIENSTLEISTUNG", billingd deserializes the product JSON to
Product::Energiedienstleistung(ServiceProduct) and builds a ServiceProvider:
{
"lf_mp_id": "9910000000002",
"nb_mp_id": "9900000000001",
"period_from": "2026-06-01",
"period_to": "2026-06-30",
"tariff": {
"category": "ENERGIEDIENSTLEISTUNG",
"service_fee_eur": "14.99",
"service_event_price_eur": "0.05"
},
"service_meter": {
"months": "1",
"event_count": 30
}
}
Generates two positions: ServiceFee (monthly Grundgebühr) and EventFee (per-readout charge).
E-invoicing — EN 16931, not BO4E
XRechnung/CII and PEPPOL UBL are EN 16931, so the render source is the EN 16931
semantic model, never a re-parse of the BO4E Rechnung. At bill time
energy_billing::Invoice::to_en16931(spec, seller, buyer) maps the invoice — at the
layer that still has each position's own amount, VAT category and rate — into an
en16931::Invoice, and billingd stores it in billing_records.en16931_json. The
external en16931 crate derives the BG-23 VAT breakdown
and BG-22 totals from the lines via reconcile (so BR-CO/BR-S hold by construction),
and en16931-formats writes the syntaxes. The
hand-rolled CII/UBL builders that once walked the BO4E steuerbetraege are gone;
every render path reads the stored model and answers 422 if it is missing.
graph LR
calc["POST /calculate<br/>(+ correction · VPP · GGV · Sammelrechnung)"]
eng["energy-billing<br/>Invoice"]
map["Invoice::to_en16931<br/>(seller/buyer party,<br/>per-line BT-151/152)"]
rec["en16931::reconcile<br/>BG-23 + BG-22<br/>from the lines"]
db[("billing_records<br/>rechnung_json (BO4E)<br/>en16931_json (model)")]
fmt["en16931-formats"]
xr["GET /xrechnung<br/>CII (XRechnung 3.0)"]
ubl["GET /ubl<br/>PEPPOL BIS 3.0 UBL"]
b2g["POST /submit-b2g<br/>to_string_for(XRECHNUNG)<br/>validate → dispatch"]
calc --> eng --> map --> rec --> db
eng -->|to_rechnung| db
db --> fmt --> xr & ubl & b2g
Per-line VAT is correct. A mixed-rate invoice (gas 19 % + Fernwärme 7 % + PV 0 %) carries a distinct BT-151/BT-152 per line that reconciles with the BG-23 breakdown — the single-blended-rate defect of the old renderer is gone.
GET /api/v1/billing/{id}/xrechnung → XRechnung 3.0 CII (en16931-formats::cii).
Profile identifier urn:cen.eu:en16931:2017#compliant#urn:xoev-de:kosit:standard:xrechnung_3.0.
GET /api/v1/billing/{id}/ubl → PEPPOL BIS Billing 3.0 UBL 2.1 from the same model.
The MCP get_xrechnung tool renders CII the same way.
POST /api/v1/billing/{id}/submit-b2g — the B2G path is stricter: the caller
supplies the receiving authority in the request buyer (name, address, contact) plus
the reference Leitweg-ID (BT-10), because the recipient is known to the sender, not
the billing engine. billingd completes the buyer party, stamps BT-10, and renders via
cii::to_string_for(&model, &XRECHNUNG) — which validates against the full XRechnung
3.0 profile before writing, so a rejectable document is never emitted. On a violation
it answers 422 with violated_rules and the precise buyer_gaps (from
Party::missing_for). On success it emits de.billing.xrechnung.b2g.ready for the
ERP's PEPPOL AS4 gateway.
Every document is complete for the profile: BT-23 business process and the BG-16
SEPA payment instruction (means code 58 + the seller IBAN) are stamped from config; the
seller party is filled from [seller] with a split address and contact (BR-DE-2..7);
the due date (BT-9) is issue + 14 days (§40c EnWG).
Legal mandate: B2G invoices mandatory from 01.01.2027 (§27 EGovG; EU Directive 2014/55/EU); B2B e-invoices from 01.01.2028 (§14 UStG n.F.).
Configuration:
seller_vat_id = "DE123456789" # BT-31 Seller VAT registration number
seller_iban = "DE89370400440532013000" # BT-84 — XRechnung BG-16 SEPA credit transfer
seller_bic = "COBADEFFXXX" # BT-86 (optional)
Version note:
en16931/en16931-formatsare pinned exactly; cross-check generated XML against the KoSIT/Mustang validators before relying on B2G in production. ZUGFeRD PDF/A-3 (theen16931-formatszugferdfeature) is not enabled — CII/UBL XML only.
Invoice content & arithmetic guarantees
- Rounding has one authority: kaufmännisches Runden (DIN 1333, half away
from zero) via
billing::RoundingStrategy::MidpointAwayFromZero— the same strategy theAmountfixed-point core applies internally.energy_billing::round_money/.round_kfm(dp)delegate to it; bareDecimal::round_dp(banker's) is banned from money paths. - Rechnungsnummer scheme (§ 14 Abs. 4 Nr. 4 UStG): auto-generated
numbers embed the product code —
BILL-{malo}-{product}-{period_from}— so two products billed for the same MaLo and period never collide; corrections useKORR-{original}and a second correction of the same original is refused (409). - Schlussrechnung (§40c EnWG):
POST …/calculatewith"schlussrechnung": truerenders the Schlussrechnung (typedrechnungstyp; the exact label rides as therechnungsartZusatzAttribut) and settles the paid advances passed as"abschlaege": [{datum, betrag_eur, ust_satz}]— each at the VAT rate it was invoiced at (§ 14 Abs. 5 UStG). - Verbraucherinformationen (§40 Abs. 2 EnWG): every
rechnung_jsoncarries the supplier identity from config plus the statutory hints (Schlichtungsstelle Energie § 111b EnWG, BNetzA Verbraucherservice, Energieberatung, § 41c Wechselhinweis) — the engine defaults guarantee they are never silently absent.
Risk gate (deterministic release scoring)
Every calculated invoice is scored by billingd::risk ([risk], default
on): coded findings — Σ-Steuerbeträge-Abgleich, USt-Satz-Validität,
Null-/Negativverbrauch, Schätzwert-Ketten (§ 60 Abs. 2 MsbG),
Perioden-Überlappung/-Lücke zur Vorrechnung, rollende Abweichung — summieren
zu 0–100. Ab hold_at (Standard 80) wird der Versand angehalten:
GET /api/v1/billing/review-queue listet REVIEW/HELD,
POST /api/v1/billing/{id}/release gibt frei und versendet das CloudEvent.
risk_score/risk_band/risk_findings sind auf jedem Record persistiert
und in allen MCP-Record-Tools sichtbar. hold_dispatch = false = Shadow-Mode.
§40b scheduled billing runs
The [billing_runs] worker (default off) sweeps daily after run_hour_utc:
active contracts and their abrechnungszyklus come from vertragd
(GET /api/v1/vertraege/billing-candidates); each contract's most recently
completed period (previous month/quarter/half, or the rolling year before the
vertragsbeginn anniversary for JAEHRLICH) is billed through the same
pipeline as POST …/calculate, skipping periods that already have a
billing_records row. Monthly audit lives in billing_run_log (one
accumulated row per tenant/LF/month; any failed sweep pins the month
failed). iMSys MaLos additionally receive the free monthly
Abrechnungsinformation (§40b Abs. 2 EnWG) as
de.billing.abrechnungsinformation.monatlich, logged in
abrechnungsinfo_log — exactly once per MaLo and month.
Preview (dry-run)
POST /api/v1/billing/{malo_id}/preview runs the full calculation pipeline without
persisting a record or emitting a CloudEvent.
POST /api/v1/billing/51238696780/preview
Content-Type: application/json
{
"lf_mp_id": "9910000000002",
"nb_mp_id": "9900000000001",
"period_from": "2026-06-01",
"period_to": "2026-06-30"
}
Returns { "preview": true, "netto_eur": "…", "brutto_eur": "…", "rechnung": { … } }.
Useful for:
- ERP billing simulations before committing to a monthly run
- Customer portal "estimated invoice" features via
portald - Plausibility checks before importing a new tariff into
tarifbd
Database schema
billing_records
| Column | Notes |
|---|---|
id | UUID primary key |
malo_id, lf_mp_id | MaLo + LF identity |
product_code, category | Product reference (VPP for dispatch settlements) |
period_from, period_to | Billing period |
rechnung_json | Full BO4E Rechnung JSONB (§ 147 AO / GoBD) — the accounting representation |
en16931_json | EN 16931 semantic invoice model (serde JSONB) — the source every XRechnung/CII/UBL render reads |
total_netto_eur, total_brutto_eur | Cached totals for fast reporting |
outcome | generated → dispatched → paid/disputed |
ce_id | CloudEvent ID of emitted de.billing.rechnung.erstellt |
vpp_dispatch_ledger
Idempotency table for de.vpp.dispatch.confirmed webhook delivery. Each tx_id is
recorded exactly once per tenant; retried deliveries return 202 Accepted without
re-billing.
| Column | Notes |
|---|---|
tx_id | Transaction ID from the WimSteuerungsauftrag (primary key) |
tenant | Tenant data-isolation key |
record_id | FK to billing_records.id (NULL if vpp_auto_billing = false) |
VPP Aggregation Billing (§ 41e EnWG / Art. 17 RL (EU) 2019/944)
billingd supports fully automatic VPP (Virtual Power Plant) dispatch-to-billing,
closing the loop from ORDRSP confirmation to BO4E Rechnung without operator intervention.
Architecture
sequenceDiagram
participant NB as NB (grid operator)
participant makod
participant billingd
participant accountingd
participant agentd as agentd<br/>(vpp-billing-agent)
NB->>makod: ORDERS 55168 Steuerungsauftrag<br/>(Konfiguration, max_power_kw=11, SR-ID=C001...)
makod->>makod: MSB confirms → EndantwortPositiv
makod--)billingd: de.vpp.dispatch.confirmed CloudEvent<br/>{tx_id, location_id, max_power_kw,<br/>execution_time_from, execution_time_until}
billingd->>billingd: HMAC verify + tx_id idempotency check
billingd->>billingd: find_active_vpp_contract(sr_id)
billingd->>billingd: flexibility_kwh = max_power_kw × duration_h<br/>Rechnung = flexibility_kwh × capacity_price
billingd--)accountingd: de.vpp.settlement.berechnet
makod--)agentd: de.vpp.dispatch.confirmed (monitoring trigger)
agentd->>billingd: verify settlement record created + arithmetic
agentd->>agentd: Art. 17 RL (EU) 2019/944 audit field checkSetup
1. Register the §41e Aggregatorvertrag in vertragd (Contract context —
billingd reads it over HTTP and keeps no copy):
curl -s -X PUT "http://vertragd:9780/api/v1/aggregatorvertraege/C0001234567890" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"vpp_id": "VPP-PORTFOLIO-001",
"malo_id": "51238696780",
"aggregator_mp_id": "9910000000002",
"capacity_price_eur_per_kwh": "0.12",
"vertragsbeginn": "2026-01-01",
"vertragsende": null,
"mwst_rate_override": null
}'
An overlapping validity window for the same SR is refused with 409 Conflict
(agg_no_overlap): a SteuerbareRessource has at most one Aggregatorvertrag in
force at any instant.
2. Enable auto-billing in billingd.toml:
vpp_auto_billing = true
inbound_webhook_secret = "env:BILLINGD_INBOUND_HMAC_SECRET"
3. Register billingd as a subscriber in marktd so it receives
de.vpp.dispatch.confirmed events from makod's outbox via the marktd EventBus fan-out:
curl -s -X PUT "http://marktd:8180/api/v1/subscriptions/billingd-vpp" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"webhook_url": "http://billingd:9280/api/v1/webhooks/vpp-dispatch",
"event_types": ["de.vpp.dispatch.confirmed"],
"hmac_secret": "env:BILLINGD_INBOUND_HMAC_SECRET"
}'Flexibility calculation
flexibility_kwh = max_power_kw × (execution_time_until − execution_time_from) / 3600
When execution_time_until is absent, billingd falls back to 15 minutes
(the statutory BNetzA §14a minimum dispatch window).
Invoice shape
Each auto-billed dispatch generates a Rechnung with:
category = "VPP",product_code = "VPP_{vpp_id}"- One
Rechnungspositionwithpositionstyp = "vpp_dispatch"and azeitraumcovering the exact dispatch window zusatzAttribute:regulatory_basis = "§ 41e EnWG, Art. 17 RL (EU) 2019/944, VPP-Vertrag",tx_id,sr_id,flexibility_kwh- The
tx_idcross-references the originatingWimSteuerungsauftragprocess inmakod
Manual fallback
When vpp_auto_billing = false or no contract exists for the SR-ID, the webhook records
the dispatch in vpp_dispatch_ledger without generating a Rechnung. Operators can
still trigger billing manually via POST /api/v1/billing/vpp/{vpp_id} at any time.
Monitoring
The built-in vpp-billing-agent in agentd monitors the pipeline for completeness:
- Settlement completeness: verifies every
de.vpp.dispatch.confirmedproduced a matching settlement within the SLA window - Arithmetic validation:
flexibility_kwh = max_power_kw × duration_h; flags deviations - Art. 17 RL (EU) 2019/944 audit: confirms all required
zusatzAttributefields are present - Missing contract escalation: alerts operator if no Aggregatorvertrag is in force for the SR-ID
EN16931 VAT breakdown (BG-23)
EN16931 requires one VAT breakdown entry per category and rate, each with its
own taxable base (BT-116) and tax amount (BT-117). A single aggregate mwst_eur
cannot express that.
The breakdown is produced twice, from the same per-position rates, for the two
representations: energy_billing::invoice::tax_subtotals_of groups the positions by
effective rate (a position's own applicable_tax_rate when set, otherwise the engine
default) for the BO4E steuerbetraege; and en16931::reconcile derives the
EN 16931 BG-23 from the semantic-model's per-line BT-151/BT-152 when the e-invoice
is built. Both key on the same (category, rate) pairs, so the two agree.
This matters because multi-rate invoices are already reachable:
| Rate | Case |
|---|---|
| 19 % | standard supply |
| 7 % | Trinkwasser (§12 Abs. 2 Nr. 1 UStG); gas/Fernwärme only 01.10.2022–31.03.2024 (§28 Abs. 5/6 UStG) |
| 0 % | Kleinunternehmer feed-in Gutschrift, §19 UStG |
Zero-rated bases are included (category Z). Omitting them would leave the
sum of the taxable bases short of the invoice net, which is precisely what the
EN16931 total-reconciliation rules check.
Tax, Abschlag and Info positions are excluded from the base — they are not
supplies, and including them would levy VAT on VAT.
Each subtotal projects to BO4E via TaxSubtotal::to_bo4e() →
rubo4e::current::Steuerbetrag, carrying basiswert, steuerwert, steuersatz
(as a percentage, matching BT-119) and steuerart (Ust, or Rcv for §13b
reverse charge).
The BO4E breakdown is derived, never stored: a persisted copy could disagree
with the positions it summarises. It is emitted on the BO4E Rechnung as
steuerbetraege, whose entries must sum to gesamtsteuer. The e-invoice BG-23 is
reconcile-derived from the stored en16931_json model's lines at render time.
Advance payments on the invoice
A Jahresabrechnung settles the Abschläge the customer already paid. They appear on
the BO4E Rechnung as vorauszahlungen — one Vorauszahlung per payment with its
gross amount and the date it was received, so the reconciliation is verifiable per
payment as §41 EnWG requires, rather than as one lump sum.
In the CII rendering they drive the monetary summary:
| Term | CII element | Value |
|---|---|---|
| BT-112 | GrandTotalAmount | gross for the period |
| BT-113 | TotalPrepaidAmount | sum of the advances, gross |
| BT-115 | DuePayableAmount | BT-112 − BT-113 |
BT-115 is derived, per EN 16931 rule BR-CO-16. Emitting the gross there would bill the customer a second time for advances they have already settled.
The tax contained in the advances is available as Invoice::abschlag_ust_eur,
which §14 Abs. 5 Satz 2 UStG requires an Endrechnung to state. See
energy-billing for the two settlement
forms — Endrechnung by deduction, or Restrechnung by residual.
Configuration
# billingd.toml
port = 9280
tenant = "9910000000002"
tarifbd_url = "http://tarifbd:9080"
edmd_url = "http://edmd:8380"
marktd_url = "http://marktd:8180"
# §3 StromStG: Stromsteuer 2.05 ct/kWh (valid since 01.04.2003)
stromsteuer_ct_per_kwh = "2.05"
mwst_rate = "0.19"
# Seller identity for XRechnung (B2G mandate 01.01.2027)
seller_vat_id = "DE123456789" # BT-31
seller_iban = "DE89370400440532013000" # BT-84 — XRechnung BG-16 SEPA credit transfer
seller_bic = "COBADEFFXXX" # BT-86 (optional)
# Optional: ERP webhook
erp_webhook_url = "http://erp:8000/webhooks/billing"
# VPP dispatch-to-billing automation (§ 41e EnWG / Art. 17 RL (EU) 2019/944)
# Set vpp_auto_billing = true and register an Aggregatorvertrag in vertragd per SR-ID.
vpp_auto_billing = false # flip to true to enable auto-billing
inbound_webhook_secret = "env:BILLINGD_INBOUND_HMAC_SECRET" # HMAC for POST /webhooks/vpp-dispatch
[database]
url = "postgresql://billingd:secret@db:5432/billingd"
# pool_size = 10 # optional pool tuning (min_connections, acquire/idle/max_lifetime)