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, or marktd
  • Testable: energy-billing is 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 asyncBillingEngine::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
MwSt

SOLAR — 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 EEG category in billingd is 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_epex to 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: true in the product definition in tarifbd. 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)
MwSt

WAERMEPUMPE / 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

AdditionLaw
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_id in PostgreSQL
  • rechnung_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:

  1. Product from tarifbd GET /api/v1/customer/51238696780/product
  2. Meter data from edmd GET /api/v1/billing-period/51238696780?from=...&to=...
  3. NNE tariff from marktd GET /api/v1/preisblaetter/{nb_mp_id}
  4. 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:

  1. Fetches 15-min Lastgang from edmd (GET /api/v1/lastgang/{malo_id}?from=…&to=…)
  2. 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)
  3. Calculates Σ(kWh_MTU × (EPEX_MTU_ct + Aufschlag_ct)) / 100 as the energy cost — each 15-min consumption interval is floored to its quarter-hour and joined to that MTU's price
  4. 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 credit
  • 0 — 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 tarifbd via PUT /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

MethodPathDescription
POST/api/v1/billing/{malo_id}/calculateCalculate, persist, emit CloudEvent
POST/api/v1/billing/{malo_id}/previewDry-run calculation (no persist, no CloudEvent)
GET/api/v1/billingList records (?malo_id=&lf_mp_id=&outcome=)
GET/api/v1/billing/{id}Fetch single record with full Rechnung JSONB
GET/api/v1/billing/{id}/xrechnungXRechnung 3.0 CII XML (via en16931-formats)
GET/api/v1/billing/{id}/ublPEPPOL BIS Billing 3.0 UBL 2.1 (EN16931)
POST/api/v1/billing/{id}/correctionKorrekturrechnung / Stornorechnung (§ 147 AO / GoBD)
POST/api/v1/billing/{malo_id}/tarifwechselCombined invoice for mid-period price change (§41 EnWG)
POST/api/v1/billing/{id}/submit-b2gXRechnung B2G submission (§27 EGovG)
GET/healthLiveness
GET/health/readyReadiness
POST|GET/mcpMCP 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
PromptDescription
order-to-cashFull O2C: GPKE Lieferbeginn → Jahresabschluss
preview-invoiceStep-by-step: preview before committing a billing run
check-dynamic-tariffVerify §41a EPEX tariff configuration
14a-steuerungsrabattConfigure §14a Modul 1/3 for Wärmepumpe / Wallbox
eeg-billingSet up EEG / EINSPEISUNG billing with double-booking guard
gas-billingConfigure 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: true in 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 ConflictKORR-{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-formats are pinned exactly; cross-check generated XML against the KoSIT/Mustang validators before relying on B2G in production. ZUGFeRD PDF/A-3 (the en16931-formats zugferd feature) 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 the Amount fixed-point core applies internally. energy_billing::round_money/.round_kfm(dp) delegate to it; bare Decimal::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 use KORR-{original} and a second correction of the same original is refused (409).
  • Schlussrechnung (§40c EnWG): POST …/calculate with "schlussrechnung": true renders the Schlussrechnung (typed rechnungstyp; the exact label rides as the rechnungsart ZusatzAttribut) 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_json carries 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

ColumnNotes
idUUID primary key
malo_id, lf_mp_idMaLo + LF identity
product_code, categoryProduct reference (VPP for dispatch settlements)
period_from, period_toBilling period
rechnung_jsonFull BO4E Rechnung JSONB (§ 147 AO / GoBD) — the accounting representation
en16931_jsonEN 16931 semantic invoice model (serde JSONB) — the source every XRechnung/CII/UBL render reads
total_netto_eur, total_brutto_eurCached totals for fast reporting
outcomegenerateddispatchedpaid/disputed
ce_idCloudEvent 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.

ColumnNotes
tx_idTransaction ID from the WimSteuerungsauftrag (primary key)
tenantTenant data-isolation key
record_idFK 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 check

Setup

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 Rechnungsposition with positionstyp = "vpp_dispatch" and a zeitraum covering 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_id cross-references the originating WimSteuerungsauftrag process in makod

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.confirmed produced 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 zusatzAttribute fields 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:

RateCase
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:

TermCII elementValue
BT-112GrandTotalAmountgross for the period
BT-113TotalPrepaidAmountsum of the advances, gross
BT-115DuePayableAmountBT-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)

Edit this page ↗