netzbilanzd Operator Guide

Operator guide for netzbilanzd, the NB billing daemon: Netznutzungsentgelt, Konzessionsabgabe and Mehr-/Mindermengen settlement into INVOIC drafts.

On this page 21 sections

netzbilanzd settles, checks and dispatches every invoice a network operator owes its counterparties, and carries the Redispatch 2.0 cost sheets.

Port: :8680 · Role: NB / GNB (and MSB for PID 31009) · Storage: PostgreSQL — invoice_drafts, invoice_number_seq, abschlag_verrechnungen, kostenblatt_records, fremdkosten_records

  1. TOC {:toc}

Architecture

The billing lifecycle

sequenceDiagram
    participant ERP as ERP / Operator
    participant nd as netzbilanzd :8680
    participant marktd as marktd :8180
    participant chk as invoic-checker
    participant makod as makod :8080
    participant LF as LF / MSB / LFG

    ERP->>nd: POST /api/v1/billing/run
    nd->>marktd: MMM prices · Lastprofil (only what the request left open)
    nd->>nd: grid_billing::settle_{nne,mmm,msb,gas_awh}
    nd->>nd: allocate rechnungsnummer (row-locked, in-transaction)
    nd->>chk: check the rendered Rechnung (periods · arithmetic · totals · Umsatzsteuer)
    chk-->>nd: CheckReport { outcome, findings }
    nd->>nd: INSERT invoice_drafts + outbox event (one transaction)
    nd-->>ERP: 201 { drafts: [{ rechnungsnummer, check_outcome, findings, warnings }] }

    ERP->>nd: PUT /api/v1/billing/drafts/{id}/dispatch
    nd->>nd: merge Fremdkosten into Rechnung.fremdkosten
    nd->>chk: re-check the document as amended
    alt outcome ≠ Dispute
        nd->>makod: ForwardCommand (marktrolle NB, or MSB for 31009)
        makod->>LF: INVOIC 31002 / 31005 / 31009 / 31011 (EDIFACT over AS4)
        makod-->>nd: { process_id }
        nd->>nd: status = dispatched
    else outcome = Dispute
        nd-->>ERP: 422 with the disputing findings
    end

    LF->>makod: REMADV
    makod-)nd: POST /api/v1/webhooks/remadv (HMAC-verified)
    alt 33001 — Zahlungsbestätigung
        nd->>nd: status = paid
    else 33002 / 33003 / 33004 — Abweisung
        nd->>nd: status = disputed, ERC code recorded
    end

Integration topology

graph LR
    ERP([ERP / Operator])
    nd[netzbilanzd :8680]
    marktd[(marktd :8180)]
    edmd[(edmd :8380)]
    makod[makod :8080]
    agent[netzbilanz-agent<br/>agentd :9580]

    ERP -->|settle · dispatch · correct| nd
    nd -->|MMM prices · Lastprofil| marktd
    nd -->|imbalance · Lastgang| edmd
    nd -->|INVOIC| makod
    nd -.->|CloudEvents via outbox| ERP
    nd -.->|CloudEvents| agent

What it issues

PIDDocumentDirectionbilling_typeRegulatory basis
31001Abschlagsrechnung Netznutzung (payment on account)NB → LFabschlagINVOIC AHB 1.0b · §14 Abs. 5 UStG
31002NN-Rechnung (Netznutzungsentgelt + Konzessionsabgabe)NB → LFnneStromNEV §§17/21 · GasNEV §15 · KAV §2
31005Mehr-/MindermengensaldoNB → LFmmmGPKE (BK6-24-174) Teil 1 Kap. 8.4 · GaBi Gas 2.1 (BK7-24-01-008)
31009MSB-Rechnung (Messstellenbetrieb)MSB → NB / LF / ESAmsb§30 MsbG
31011Rechnung sonstige Leistung (AWH Sperrprozesse)GNB → LFGgas_awhGeLi Gas 3.0 (BK7-24-01-009) §5.4

Every document is an INVOIC; the PID names which kind. Two properties cost money when missed:

  • The Sparte is not in the PID. Strom and Gas share 31002 and 31005, so every settlement states its sparte. It decides whether the Arbeit position cites StromNEV §21 or GasNEV §15, whether the EnFG levies are billed, and what Rechnung.sparte says on the wire.
  • PID 31009 runs MSB → NB / LF / ESA in all seven Anwendungsfälle (Anwendungsübersicht der Prüfidentifikatoren 4.0). The draft stores the MSB as sender_mp_id; dispatch declares marktrolle: MSB.

Regulatory baseline (2026)

StromNZV and GasNZV ceased to apply with the end of 31.12.2025 (Art. 15 Abs. 4 resp. Abs. 6 of the Gesetz v. 22.12.2023, BGBl. 2023 I Nr. 405). The successor competence is §20 Abs. 3 EnWG, exercised through BNetzA Festlegungen — for MMM Strom that is GPKE (BK6-24-174) Teil 1 Kap. 8.4, for MMM Gas GaBi Gas 2.1 (BK7-24-01-008).

Consequently MMM prices come from the VNB (GPKE Kap. 8.4 Nr. 3: "Der Betreiber von Elektrizitätsverteilernetzen berechnet für Jahresmehr- und Jahresmindermengen auf Grundlage der monatlichen Marktpreise einen einheitlichen Preis."), and the Konzessionsabgabe is KAV §2, its rate bands keyed on the municipality's inhabitant count.

The network levies (EnFG)

Three levies ride on the Strom Netzentgelt; netzbilanzd adds them to every Strom NNE settlement and to no Gas settlement:

Levy2026 rate (A′)Basis
Aufschlag für besondere Netznutzung (§19 StromNEV-Umlage)1.559 ct/kWh§19 Abs. 2 StromNEV
Offshore-Netzumlage0.941 ct/kWh§17f EnWG
KWKG-Umlage0.446 ct/kWh§26 KWKG

The §19 StromNEV levy has an A′/B′/C′ schedule (B′ capped at 0.050, C′ at 0.025 ct/kWh); set letztverbrauchergruppe to bill a privileged band. Offshore- and KWKG-Umlage are published at the non-privileged rate only; a privilege under §§ 21 ff. EnFG is granted per Entnahmestelle, so supply it via offshore_umlage_ct_per_kwh / kwkg_umlage_ct_per_kwh.

B′ and C′ apply „für Strommengen über 1 000 000 kWh" at one Entnahmestelle, so the year's first GWh carries A′. Supply enfg_jahresvorverbrauch_kwh (kWh consumed there earlier in the year); a period straddling the boundary bills two §19 positions. Omitted, the period is billed as if it opened the year, with ENFG_VORVERBRAUCH_MISSING. A levy with no published rate for the delivery year is omitted with a warning, never billed at zero silently.

The §30 MsbG Preisobergrenze is derived, not asserted

§30 Abs. 1 MsbG states five Nummern, each a disjunction over facts about the metering point (Jahresstromverbrauch, installierte Leistung, a §14a EnWG Vereinbarung). messstellen_kategorie carries those facts, not a band:

{
  "messstellen_kategorie": {
    "Pflichteinbau": {
      "jahresverbrauch_kwh": "18000",
      "steuerbare_verbrauchseinrichtung": true
    }
  },
  "entgeltschuldner": "Letztverbraucher"
}

The engine walks the Nummern top down, so a point meeting several takes the highest and a request cannot pick its ceiling. {"OptionalerEinbau": null} is §30 Abs. 3 (30 EUR each side). With no fact supplied, Nr. 5 applies — a Pflichteinbaufall exists only above 6 000 kWh (§29 Abs. 1).

Umsatzsteuer

Every invoice states its tax (§14 Abs. 4 Nr. 8 UStG). The treatment follows what is supplied:

SettlementNatureTreatment
NNE, MSB, Gas AWHsonstige Leistung19 %. UStAE 13b.3a excludes "die Bereitstellung und Unterhaltung des Netzes" from §13b
MMM Strom / GasLieferung of the commodity19 %, or reverse-charged under §13b Abs. 2 Nr. 5 Buchst. b

§13b Abs. 5 is asymmetric: for Elektrizität supplier and recipient must hold §3g status, for Gas über das Erdgasnetz the recipient alone (evidenced by a USt 1 TH, UStAE 13b.3a). An MMM settlement therefore carries both facts:

"wiederverkaeufer": { "leistender": true, "empfaenger": true }

Tax shown on a reverse-charge invoice is owed under §14c Abs. 1 UStG and gives the recipient no Vorsteuerabzug. The pre-dispatch gate refuses an invoice with no tax block and a reverse-charge invoice that states tax.

Rate windows. The departures from 19 % the engine knows:

WindowRateApplies toBasis
01.07.2020 – 31.12.202016 %every supply§28 Abs. 1–3 UStG a. F.
01.10.2022 – 31.03.20247 %gas through the Erdgasnetz§28 Abs. 5 UStG

The gas reduction reached the Lieferung, not the network: a Gas MMM in that window is 7 %, a Netznutzung Gas invoice 19 %. A delivery period straddling a rate change is refused.

Mehr-/Mindermengen sign convention

Named from the network operator's side, which inverts the intuitive reading:

Measurement vs profileQuantityMoney
measured < profiledungewollte MehrmengeNB vergütet → credit
measured > profiledungewollte MindermengeNB stellt in Rechnung → charge

Settling an invoice

A billing run carries an issue date, a due date, an optional Rechnungskreis and up to 1 000 positions (one MaLo, one period, one settlement each), all in one transaction. The settlement is a tagged union on billing_type; a field belonging to another kind is a 422.

curl -X POST http://localhost:8680/api/v1/billing/run \
  -H "Content-Type: application/json" \
  -d '{
    "invoice_date": "2026-02-01",
    "due_date": "2026-03-03",
    "rechnungskreis": "NNE",
    "positions": [{
      "malo_id": "51238696012",
      "period_from": "2026-01-01",
      "period_to": "2026-01-31",
      "settlement": {
        "billing_type": "nne",
        "nb_mp_id": "9900357000004",
        "lf_mp_id": "9900012345678",
        "sparte": "Strom",
        "arbeitspreis": { "Einheitlich": { "menge_kwh": "1500", "preis_ct_per_kwh": "3.5" } },
        "leistungspreis": {
          "spitzenleistung_kw": "40",
          "preis_eur_per_kw": "12.50",
          "system": { "MONAT": { "monate": "1" } }
        },
        "konzessionsabgabe": { "satz_ct_per_kwh": "0.11", "klasse": "Sondervertragskunde" },
        "netzebene": "Niederspannung",
        "jahresarbeit_kwh": "18000",
        "tariff_sheet_id": "Preisblatt-NNE-2026-Q1"
      }
    }]
  }'
{
  "drafted": 1,
  "drafts": [{
    "draft_id": "550e8400-…",
    "malo_id": "51238696012",
    "rechnungsnummer": "NNE-2026-000001",
    "pid": 31002,
    "sparte": "STROM",
    "check_outcome": "Ok",
    "check_findings": [],
    "settlement_warnings": [],
    "netto_eur":     "598.34",
    "steuer_eur":    "113.68",
    "brutto_eur":    "712.02",
    // gross less the Abschläge this invoice settles — see "Abschläge" below
    "zu_zahlen_eur": "712.02"
  }]
}

leistungspreis.system — which Leistungspreissystem the Preisblatt states

§ 17 Abs. 2 StromNEV: „Das Jahresleistungsentgelt ist das Produkt aus dem jeweiligen Jahresleistungspreis und der Jahreshöchstleistung", with no pro-rating. Abs. 8 presupposes a Monatsleistungspreissystem, published in the Netzbetreiber's Preisblatt. system selects:

ValueBilled asUse on
"JAHR" (default)Jahresleistungspreis × Jahreshöchstleistung (§ 17 Abs. 2 Satz 2)a yearly settlement
{ "MONAT": { "monate": "1" } }Höchstleistung × Monate × Monatsleistungspreis (§ 17 Abs. 8)a monthly or quarterly settlement

A monthly invoice that omits system bills the whole year's demand charge and raises JAHRESLEISTUNGSPREIS_UNTERJAEHRIG; MONATSLEISTUNGSPREIS_MONATE_MISMATCH fires when monate and the period length disagree by more than a month.

Invoice numbers are allocated here

§14 Abs. 4 Nr. 4 UStG requires an einmalig vergebene, fortlaufende number, so the caller supplies only the series (rechnungskreis). The number comes from invoice_number_seq under a row lock inside the drafting transaction: a rolled-back run consumes none, a reused one is refused by a unique index, and the counter restarts per tenant, series and calendar year. Numbers read NNE-2026-000001, or 2026-000001 with no series.

Read the warnings

check_findings come from invoic-checker (the library the receiving LF runs); settlement_warnings record what the engine could not do. Both are stored on the draft. Watch KA_ABOVE_KAV_MAXIMUM — KAV §2 rates are Höchstbeträge per group (ct/kWh):

klasseStromGas
Sondervertragskunde (§2 Abs. 3)0.110.03
Schwachlast (§2 Abs. 2)0.61—
Tarifkunde, Gemeinde ≤ 25 0001.320.22 (0.51 nur Kochen/Warmwasser)
Tarifkunde, ≤ 100 0001.590.27 (0.61)
Tarifkunde, ≤ 500 0001.990.33 (0.77)
Tarifkunde, > 500 0002.390.40 (0.93)
Exempt (§2 Abs. 4 Strom / Abs. 5 Gas)——

Exempt means no Konzessionsabgabe is due, so no position is billed. The rate and the group travel in one value, so the ceiling check cannot be skipped.

The group is a consequence, not a label

KAV § 2 Abs. 7: a Niederspannungslieferung is a Tariflieferung „es sei denn, die gemessene Leistung des Kunden überschreitet in mindestens zwei Monaten des Abrechnungsjahres 30 Kilowatt und der Jahresverbrauch beträgt mehr als 30.000 Kilowattstunden". State the facts in konzessionsabgabe.niederspannung and a disagreeing group raises KA_GRUPPE_WIDERSPRICHT_KAV_ABS7:

"konzessionsabgabe": {
  "satz_ct_per_kwh": "0.11",
  "klasse": "Sondervertragskunde",
  "niederspannung": { "monate_ueber_leistungsgrenze": 5, "jahresverbrauch_kwh": "90000" }
}

leistungsgrenze_kw / verbrauchsgrenze_kwh carry lower limits agreed under Satz 4 (default 30 kW / 30 000 kWh).

Two rules forbid a Konzessionsabgabe outright

Neither is a ceiling — a rate inside the Höchstbetrag is still unlawful:

WarningRuleFact it needs
KA_UNTER_GRENZPREIS§ 2 Abs. 4 (Strom) resp. Abs. 5 Nr. 2 (Gas) — a Sondervertragskunde whose Durchschnittspreis im Kalenderjahr lies under the Grenzpreiskonzessionsabgabe.grenzpreis, both figures in ct/kWh ohne USt
KA_GAS_UEBER_GRENZMENGE§ 2 Abs. 5 Nr. 1 — Gas above 5 Millionen kWh je Jahr und Abnahmefallnone — read off jahresarbeit_kwh

The Grenzpreis (Strom: the Durchschnittserlös of the vorletzte Kalenderjahr) and the customer's price are supplied; omitted, the comparison is not made.

§14a EnWG

BK8-22/010-A numbers three modules; ArbeitspreisModell holds one at a time:

ModuleMechanismShape
Modul 1pauschale Reduzierung — a flat annual amount credited pro rata, not a rate changeModul1Pauschal { basis, pauschale_eur_pro_jahr, jahresanteil }
Modul 2prozentuale Reduzierung of the controllable device's own Arbeitspreis; scales with consumption, and needs that device separately meteredModul2ProzentualeReduzierung { basis, reduktion }
Modul 3zeitvariable Netzentgelte in three Tarifstufen, opt-in since 01.04.2025Modul3ZeitVariabel { ht, st, nt }
"arbeitspreis": { "Modul3ZeitVariabel": {
  "ht": { "menge_kwh": "600", "preis_ct_per_kwh": "4.20" },
  "st": { "menge_kwh": "100", "preis_ct_per_kwh": "3.00" },
  "nt": { "menge_kwh": "400", "preis_ct_per_kwh": "1.50" }
}}

All three bands are required; a band with no energy carries menge_kwh: "0". The Modul-2 reduktion is pinned to 0.40: BK8-22/010-A Tenor 2. b) fixes the reduced Arbeitspreis at 40 % of the Niederspannungs-Arbeitspreis ohne Leistungsmessung, so every other value is refused. Modul 1 + Modul 3, the one permitted pair, is not representable in one settlement.

Data sources. Band quantities come from edmd GET /api/v1/billing-period/{malo_id} (HT/NT OBIS registers); band prices from the PreisblattNetznutzung (zeitvariable_preispositionen) in marktd.

A fifth Arbeitspreis shape that is not a §14a module

ArbeitspreisModell has one more variant, which is not a §14a module:

"arbeitspreis": { "SpotpreisNetzentgelt": { "intervalle": [
  { "period_from": "2026-01-15T11:00:00Z", "period_to": "2026-01-15T11:15:00Z",
    "menge_kwh": "1.25", "nne_rate_ct_per_kwh": "2.00",
    "epex_spot_ct_per_kwh": "1.10" }
] } }

SpotpreisNetzentgelt bills one rate per dispatch interval under the Netzbetreiber's own PreisblattNetznutzung formula; the rates arrive already derived.

§19 Abs. 2 StromNEV — individuelle Netzentgelte

Distinct from the §19 Umlage. An agreed individual charge (sect19) replaces Arbeits- and Leistungspreis and touches neither the Konzessionsabgabe nor the levies:

FormQualificationFloor, as a share of the published charge
AtypischeNetznutzung (Satz 1)annual peak predictably in the low-load window20 %
IntensiveNetznutzung (Satz 2)Benutzungsstundenzahl ≥ 7 000 h and consumption > 10 GWh20 % from 7 000 h · 15 % from 7 500 h · 10 % from 8 000 h

Hours are inclusive („erreicht mindestens"), energy is not („übersteigt"): exactly 10 GWh does not qualify. The agreed percentage is an input and may not fall below the floor.

Positions by settlement type

SettlementPositions
NNEArbeit (flat, or one per §14a module) · Leistung (RLM) · Gas Grundpreis (§15 Abs. 7 GasNEV) · Gas Kapazitätsentgelt (§15 GasNEV, pro-rated by calendar days over the actual year length) · Konzessionsabgabe · the three EnFG levies (Strom only) · Blindmehrarbeit
MMMMehrmengen (Gutschrift, negated) · Mindermengen
MSBGrundgebühr Messstellenbetrieb · Messdienstleistung, both measured together against the §30 Abs. 1 MsbG Preisobergrenze when messstellen_kategorie is supplied — and only for a period ending from 01.01.2025, which is what that schedule dates itself to · Steuerungseinrichtung am Netzanschlusspunkt, which §30 Abs. 2 charges „zusätzlich“ and caps separately at 50 EUR brutto/Jahr
NNE (privileged)the §19 Aufschlag splits into two positions where the period straddles the EnFG 1-GWh boundary
Gas AWHone per chargeable action: anzahl × preis_eur

Abschläge and the invoice that settles them

An Abschlagsrechnung (PID 31001) is a payment on account. It prices no energy and carries exactly one Positionszeile (INVOIC AHB 1.0b, Änd-ID 26817: "Eine Abschlagsrechnung kann und muss genau eine Positionszeile enthalten"). How the amount was arrived at is recorded:

grundlageMeaning
Vorjahresverbraucha share of the previous settled period's invoice
Prognosea forecast of the period being paid for
Vereinbarunga figure fixed in the Lieferantenrahmenvertrag

The deduction

The invoice that closes the period lists the Abschläge it settles, by draft ID:

{
  "malo_id": "51238696012",
  "period_from": "2026-01-01", "period_to": "2026-12-31",
  "cadence": "Abschlussrechnung",
  "abschlaege": ["550e8400-…", "6ba7b810-…"],
  "settlement": { "billing_type": "nne", … }
}
RuleWhy
Only zuZahlen moves; gesamtnetto / gesamtsteuer stand§14 Abs. 5 UStG taxed the Anzahlung on receipt; the AHB puts the deduction in SG50 MOA+113 (Vorausbezahlter Betrag inkl. USt.)
The amount is the stored Abschlag's gross MOA+77, never the request'sAHB rule [526]
A storniertes or never-dispatched Abschlag is refusedAHB rule [519]; nothing was paid on it
Each deduction names its invoice (SG51 RFF+AFL, SG51 DTM+3)the counterparty can break the total down
One Abschlag per MaLo, period and Rechnungsdatum (409 on a same-day duplicate)a replayed run would otherwise be deducted twice; the double-billing guard itself excludes 31001

zu_zahlen_eur_units is stored beside the three amounts, so summary, overdue alert and audit export answer what are we owed. A CHECK keeps the deduction directional but lets it pass zero: settling for less than the Anzahlungen leaves a Guthaben owed back.

Billing cadence

cadence is IMD+7081 on the wire — a document fact, omitted when unset:

cadenceIMD+7081
AbschlagsrechnungABS
AbschlussrechnungABR
TurnusrechnungJVR
MonatsrechnungMVR
ZwischenrechnungZVR

Dispatching

# Optional: attach typed external costs first.
curl -X PUT http://localhost:8680/api/v1/billing/fremdkosten/{draft_id} -d @fremdkosten.json

curl -X PUT http://localhost:8680/api/v1/billing/drafts/{draft_id}/dispatch

Dispatch runs four steps in one transaction:

  1. Merge the Fremdkosten into Rechnung.fremdkosten (BO4E's first-class field). They are informational and change no total; costs the counterparty owes belong in the settlement. PUT /fremdkosten/{draft_id} answers 409 once the invoice has left draft.

  2. Run the outbound BO4E gate over the merged document (ensure_conformant) — this is the one document netzbilanzd assembles at runtime from two stored parts.

  3. Re-check the amended document with invoic-checker; Dispute blocks the send and returns the findings.

  4. Hand it to makod under the idempotency key netzbilanzd-invoic-{draft_id}:

    FieldValue
    commandinvoic.nne-abschlag.stellen (31001) · invoic.nne.stellen (31002) · invoic.mmm.stellen (31005) · wim.msb-rechnung.stellen (31009) · invoic.sonstige-leistung.stellen (31011)
    marktrolleMSB for PID 31009, GNB for 31011 and any gas invoice, NB otherwise — checked by makod against the licensed roles
    invoice_refthe invoice number — what the inbound REMADV correlates on
    sender_mp_id / recipient_mp_idas the settlement resolved them
    pid, sparte, rechnungthe document and what identifies it

The commands are Sparte-neutral. Every PID has an issuer-side process in makod, 31011 included.

POST /api/v1/billing/drafts/dispatch-batch runs the same sequence per draft, each in its own transaction, and reports 207 Multi-Status when some refused: one rejection never rolls back the invoices that already went out.


Correcting

curl -X POST .../drafts/{id}/storno    -d '{"grund": "Messwertkorrektur"}'
curl -X POST .../drafts/{id}/korrektur -d '{"grund": "Messwertkorrektur", "settlement": { … }}'
RuleBehaviour
A Storno is recomputedthe stored settlement input is replayed and negated by grid_billing::reverse; the document sets ist_storno + original_rechnungsnummer, the fields invoic-checker stage 0 reads (BK6-24-174 §5)
Only a dispatched invoice can be reversed or correcteddraft and rejected answer 409; reject a draft and bill again
A Korrektur needs the Storno firstit carries the whole corrected amount; 409 until the original is reversed
A Korrektur corrects the same invoicechanging settlement_type, sparte, sender_mp_id or recipient_mp_id is a 422 naming the field
The recomputation must match the originalnet, Umsatzsteuer and gross; a mismatch (a tabled rate corrected since issue) is a 409 naming both figures
One Storno per invoiceunique index; Korrekturrechnungen are not limited

A Korrekturrechnung is a new settlement from corrected inputs through the same engine and checks. Both carry a grund (grid_billing::KorrekturGrund: Rechenfehler, Stammdatenkorrektur, RegulatorischeAenderung, …) and inherit the original's Rechnungskreis. The original is never mutated; a CHECK requires original_draft_id and korrektur_grund on corrections only.


Draft lifecycle

stateDiagram-v2
    direction LR
    [*] --> draft : POST /billing/run

    draft --> dispatched : PUT /dispatch<br/>(re-check ≠ Dispute)
    draft --> rejected   : PUT /reject
    draft --> draft      : dispatch blocked<br/>(Dispute)

    dispatched --> paid     : REMADV 33001
    dispatched --> disputed : REMADV 33002 / 33003 / 33004
    disputed   --> paid     : objection resolved, paid without correction

    rejected --> [*] : period reopened for a new run
    paid     --> [*]

REMADV 33001 is the only Zahlungsbestätigung; 33002–33004 are Abweisungen (33003/33004 the itemised Strom rejections). A dispute has its own columns (dispute_erc_code, dispute_reason) and leaves check_outcome — the NB's pre-dispatch evidence — untouched. Rejecting a draft reopens the period for a new run. REMADV ingest is idempotent: a replay matching the current state answers 204. The closing transitions are also reachable directly (Cedar record-payment):

MethodPathBodyEffect
PUT/api/v1/billing/drafts/{id}/mark-paidremadv_refdispatched or disputed → paid
PUT/api/v1/billing/drafts/{id}/mark-disputederc_code, reasondispatched → disputed

Mehr-/Mindermengen

curl -X POST http://localhost:8680/api/v1/billing/mmm-run/51238696012 \
  -d '{"nb_mp_id":"9900357000004","lf_mp_id":"9900012345678","sparte":"Gas",
       "period_year":2026,"period_month":1,"bilanziert_kwh":"1000.000"}'

bilanziert_kwh is required: it is what the Bilanzkreis was charged from the load profile, which edmd does not hold. sparte selects the price series (Trading Hub Europe for Gas, the BDEW series for Strom) and is passed to edmd as the aggregation basis — Gas balances on the 06:00 Gastag.

The cited basis follows the delivery period: to 31.12.2025 StromNZV §13 Abs. 3 / GasNZV §25, from 01.01.2026 §20 Abs. 3 EnWG with BK6-24-174 (Strom) or GaBi Gas 2.1 (BK7-24-01-008) (Gas), resolved by grid_billing::regulatory::RegulatoryRegime; a straddling period warns. Prices are fetched only when the request leaves them open, memoised per run per (Sparte, year, month), and stored on the draft so an audit replays the same numbers.


§42b EnWG Gemeinschaftliche Gebäudeversorgung

curl -X POST http://localhost:8680/api/v1/billing/ggv-nne/{ggv_malo_id} \
  -d '{"nb_mp_id":"…","lf_mp_id":"…",
       "period_from":"2026-01-01","period_to":"2026-01-31",
       "arbeitspreis_ct_per_kwh":"5.50",
       "tenant_consumption":{"51238696781":"450.000","51238696129":"550.000"}}'

tenant_consumption is required: §42b attributes the Netzentgelt to each tenant Marktlokation, and an equal split is not an attribution. The building settles in one transaction; the response reports each tenant's share (summing to 100 %).


Redispatch 2.0

BK6-20-061 §4.2 — the VNB submits a monthly Kostenblatt to the ÜNB by the 15th of the following month.

MethodPathDescription
PUT/api/v1/redispatch/kostenblatt/{activation_id}Create or update a record
GET/api/v1/redispatch/kostenblatt/{activation_id}Every TechnischeRessource under the activation
GET/api/v1/redispatch/kostenblatt?year=&month=&status=List a month
POST/api/v1/redispatch/kostenblatt/{activation_id}/computeQuantify from edmd's projected feed-in series
GET/api/v1/redispatch/kostenblatt/gaps/{year}/{month}Activations registered but never quantified
POST/api/v1/redispatch/kostenblatt/submit/{year}/{month}Submit the month's pending records
POST/api/v1/redispatch/verguetung/{activation_id}/compute§13a Abs. 2 EnWG compensation

The stored kosten_json is a BO4E Kosten built as a struct and passed through the outbound gate; an operator-supplied kosten_json or fremdkosten_json crosses the inbound gate as Bo4e<Kosten> and is stored as its canonical round-trip.

compute resolves the dispatched energy in one order and records it in dispatch_source:

  1. manual_override — a verified operator figure;
  2. lastgang_sum — edmd's projected feed-in series (GET /api/v1/energy/{malo_id}?direction=EINSPEISUNG) over the exact window [start, end);
  3. einspeisung_tag — the same series over the whole Europe/Berlin day, only when the window has none. For a 15-minute activation this is wrong by up to two orders of magnitude: check dispatch_source and supply dispatch_kwh_override. No Einspeisung that day → 404.

The BO4E /lastgang export is never folded into a figure: it mixes registers, directions and non-billable qualities. coverage_pct from the projection is logged; a partial window still yields a figure. Einsatzkosten = dispatch_kwh × arbeitspreis_eur_per_kwh is a generated column.

The redispatch case selects the §13a counterfactual

§13a Abs. 2 measures the curtailed energy differently in the two cases, and the two produce different figures for the same activation:

abwicklungCounterfactualAusfallarbeit source
DULDUNGSFALLThe NB steered the resource, so what the plant would have produced was never transmittedthe measured edmd feed-in series over the window
AUFFORDERUNGSFALLThe EIV steered to a transmitted schedule, and that schedule is the counterfactualausfallarbeit_kwh_override from that schedule — required; the request is refused 422 without it

The chosen basis travels into the result and its calculation trace.

BilAReM Kap. 3

The stateless Ausfallarbeit engine (BK6-23-241) sits at /api/v1/redispatch/ausfallarbeit/*: compute (per-TR W_A series for every Abrechnungsvariante), ueberbauung (the Kap.-3.4 cap), kf-bin (the Kap.-3.2.3.2 offshore Wind-Bin factor), malo-split (§ 24 Abs. 3 S. 2 EEG 2023) and the two Vergleichszeitraum selectors — vergleichszeitraum (Kap. 3.2.2.1, wind: four contiguous quarter-hours, measured to the beginning of the Maßnahme on one side and to its end on the other) and vergleichstag (Kap. 3.2.4.1, solar: a whole calendar day without a Maßnahme against the SR). Callers supply the quarter-hour series; sourcing them from SCADA, edmd or DWD stays with the operator.

Every JSON request body here rejects unknown fields; query strings are not strict.


Calculation audit trail

Every position carries a CalculationTrace — inputs, arithmetic, paragraphs and rate source — and the draft stores the replayable settlement input beside the document:

GET /api/v1/billing/drafts/{id}
→ rechnung.rechnungspositionen[0]
    positionstext = "Netznutzung Arbeit HT (§14a Modul 3)"
    zusatzAttribute["mako:calculation_trace"]
      explanation   = "600.000 kWh × 0.042000 EUR/kWh = 25.20000 EUR"
      legal_refs    = ["StromNEV §21", "§14a EnWG Modul 3", "BNetzA BK8-22/010-A"]
      tariff_source = { sheet_id: "Preisblatt-NNE-2026-Q1" }
→ rechnung.zusatzAttribute
    "mako:legal_references"   — every paragraph the settlement rests on, deduplicated
    "mako:settlement_warnings" — what the engine could not do
→ settlement_input            — the request the figure was computed from, replayable
flowchart LR
    req["SettlementRequest<br/>(sparte, arbeitspreis,<br/>konzessionsabgabe, …)"]
    calc["grid_billing::<br/>settle_*()"]
    res["SettlementResult<br/>positions[n].trace<br/>warnings"]
    doc["InvoiceDocument<br/>+ rechnungsnummer, dates, PID"]
    bo4e["rubo4e::Rechnung"]
    db[("invoice_drafts<br/>settlement_input + rechnung")]

    req --> calc --> res --> doc --> bo4e --> db
    req --> db

BDEW Artikelnummern

grid-billing decides which code applies to which position; the BO4E bridge looks it up. Source: BDEW Codeliste Artikelnummern und Artikel-ID v5.6 (valid 01.09.2025).

PositionBdewArtikelnummerCode
NNE Arbeit (Gas only, all variants)Wirkarbeit9990001 00026 9
NNE Leistung (Gas only) · Gas KapazitätsentgeltLeistung9990001 00005 3
Gas GrundpreisGrundpreis9990001 00008 7
KonzessionsabgabeKonzessionsabgabe9990001 00041 7
MehrmengenMehrmenge9990001 00074 8
MindermengenMindermenge9990001 00075 6
MSB GrundgebührEntgeltEinbauBetriebWartungMesstechnik9990001 00061 5
MessdienstleistungEntgeltMessungAblesung9990001 00062 3
BlindmehrarbeitBlindmehrarbeit9990001 00047 5
§19 StromNEV-UmlageParagraf19StromNevUmlage—
Offshore-NetzumlageOffshoreHaftungsumlage (the BO4E BdewArtikelnummer variant for this levy; its wire value is OFFSHORE_HAFTUNGSUMLAGE)—
KWKG-UmlageAbgabeKwkg—

No Artikelnummer: an Abschlag, Gas AWH positions (2-01-7-xxx Artikel-ID), a §19 Abs. 2 individuelles Entgelt and a dezentrale Einspeisung. NNE Strom carries the artikel_id from the Netznutzungspreisblatt (BK6-20-160), supplied through the price sheet.

Gas AWH (PID 31011) codes come from section 3.2 of the codelist, set per AwhPositionInput.artikel_id:

Actionartikel_id
Unterbrechung (reguläre AZ)2-01-7-001
Wiederherstellung (reguläre AZ)2-01-7-002
Erfolglose Unterbrechung2-01-7-003
Stornierung (bis Vortag)2-01-7-004
Stornierung (am Sperrtag)2-01-7-005
Wiederherstellung (außerhalb AZ)2-01-7-006

Reporting and audit

MethodPathDescription
GET/api/v1/billing/draftsFilter by MaLo, party, PID, Sparte, status, verdict, Rechnungsart
GET/api/v1/billing/drafts/{id}One invoice in full
GET/api/v1/billing/malo/{malo_id}Billing history for one MaLo
GET/api/v1/billing/summary?year=&month=Monthly net, Umsatzsteuer, gross and zu_zahlen by PID, Sparte, status, Rechnungsart
GET/api/v1/billing/audit?from=&to=&pid=&status=§ 147 AO / § 14b UStG export, up to 50 000 rows per page

Amounts are integers in 10⁻⁵ EUR. sparte is case-insensitive; anything but Strom/Gas is a 400. /drafts and /audit page by keyset: next_cursor is the (created_at, id) of the last row, omitted on the last page.

curl '.../api/v1/billing/drafts?sparte=Gas&status=dispatched&limit=100'
# → { "count": 100, "next_cursor": "2026-02-01T08:30:00Z_550e8400-…", "drafts": [ … ] }
curl '.../api/v1/billing/drafts?limit=100&after=2026-02-01T08:30:00Z_550e8400-…'

Reconcile month-end against zu_zahlen, not the gross.


Background workers

All three run only when erp_webhook_url is set, and stop promptly on shutdown:

WorkerIntervalEmits
Transactional outbox draincontinuousevery de.netzbilanz.* event, signed, with retry and dead-lettering
Undispatched-draft alert1 h (dispatch_alert_interval_secs)de.netzbilanz.invoic.dispatch-overdue — two clocks: a draft older than dispatch_stale_hours, or one whose own due_date falls inside that window
Kostenblatt deadline alert1 d (kostenblatt_alert_interval_secs)de.netzbilanz.kostenblatt.deadline-approaching on days 10–14 with pending records

Both alerts enqueue on the outbox. The overdue alert reports zu_zahlen_eur and due_date, and skips drafts the checker disputed (blocked, not overdue).

CloudEvents

Every event goes through event_outbox; the five business events are enqueued in the transaction of the change they describe.

EventTriggerKey data fields
de.netzbilanz.invoic.drafteda draft is settleddraft_id, rechnungsnummer, pid, check_outcome, brutto_eur, zu_zahlen_eur
de.netzbilanz.invoic.dispatcheddispatch succeedsdraft_id, dispatch_ref, rechnungsnummer
de.netzbilanz.invoic.paidREMADV 33001draft_id, remadv_ref
de.netzbilanz.invoic.disputedREMADV Abweisungdraft_id, erc_code, reason
de.netzbilanz.invoic.dispatch-overduehourly workerstale_hours, undispatched_count, drafts[] (each with due_date and zu_zahlen_eur)
de.netzbilanz.kostenblatt.computedan activation is quantifiedrecord_id, einsatzkosten_eur, dispatch_source
de.netzbilanz.kostenblatt.deadline-approachingdaily workerperiod_year, period_month, pending_count, days_until_deadline

Inbound REMADV events arrive on POST /api/v1/webhooks/remadv, HMAC-verified against inbound_secret, and are idempotent.


MCP server

At /mcp (Streamable HTTP), behind OIDC (use-mcp) or [mcp] api_key. Read-only, eight tools:

ToolPurpose
list_draftsfilter by MaLo, party, PID, Sparte, status, Rechnungsart, checker verdict
get_draftone invoice: BO4E document, settlement input, findings, warnings
list_disputedREMADV Abweisungen with their ERC codes
list_undispatcheddrafts past their dispatch window
list_correctionsthe Storno / Korrektur chain, as one limited window
get_billing_summarymonthly totals by PID, Sparte, status, Rechnungsart
list_pending_kostenblattcost sheets awaiting the 15th
list_kostenblatt_gapsactivations registered but never quantified

Settling, dispatching, rejecting and correcting live on REST, where the action is attributable to an operator.

Six prompts walk the common workflows: nb-invoic-overview, run-nne-billing, mmm-monthly-run, investigate-dispute, ggv-nne-billing, redispatch-monthly-submit.


Authentication and authorization

netzbilanzd refuses to boot with any of three doors open, unless allow_insecure_no_auth = true:

DoorGuardWhat it protects
REST[oidc] — every route extracts Claimsdispatch, Storno, mark-paid, the § 147 AO export, Kostenblatt submission
MCP[oidc] or [mcp] api_keythe tenant's whole invoice register and its Redispatch cost sheets
POST /api/v1/webhooks/remadvinbound_secret HMAC over the raw bodya forged REMADV marking an invoice paid, or disputing one that was not

services/netzbilanzd/policies/netzbilanzd.cedar states 13 actions in four grants:

GrantActionsRequires
Reads and the stateless calculatorsread-settlement, export-audit, read-kostenblatt, compute-ausfallarbeita matching tenant only — so an auditor's role-less token reads the export and can change nothing
Invoicingrun-settlement, amend-settlement, dispatch-settlement, correct-settlement, record-paymentNB or MSB — the NB bills Netznutzung, the MMM-Saldo and the GeLi-Gas Handlungen; the MSB bills the Messstellenbetrieb (PID 31009)
Redispatch 2.0compute-kostenblatt, submit-kostenblatt, compute-verguetungNB or UENB — the money moves between the NB that steered the resource and the ÜNB that asked for it
MCPuse-mcpa matching tenant; the tools are read-only by construction

tests/it/authorization_guard.rs pins every handler to a Claims extractor and an authorization call, and the action sets of code and policy to each other; the REMADV webhook is the documented HMAC exception. There is no four-eyes split: mako_roles carries market roles only, so "may settle" and "may dispatch" are one grant. Denials are logged with sub and action.


Configuration

# netzbilanzd.toml
port   = 8680
tenant = "9900357000004"

marktd_url     = "http://marktd:8180"
marktd_api_key = "env:NETZBILANZD_MARKTD_API_KEY"
makod_url      = "http://makod:8080"
makod_api_key  = "env:NETZBILANZD_MAKOD_API_KEY"
edmd_url       = "http://edmd:8380"
edmd_api_key   = "env:NETZBILANZD_EDMD_API_KEY"

erp_webhook_url    = "http://erp:9000/webhooks/mako"
erp_webhook_secret = "env:NETZBILANZD_WEBHOOK_SECRET"
inbound_secret     = "env:NETZBILANZD_INBOUND_SECRET"

dispatch_alert_interval_secs    = 3600
dispatch_stale_hours            = 48
kostenblatt_alert_interval_secs = 86400

[database]
url = "postgres://nb:secret@db:5432/netzbilanzd"

[oidc]
issuer    = "https://idp.example.com/realms/mako"
audience  = "netzbilanzd"

[mcp]
api_key = "env:NETZBILANZD_MCP_API_KEY"

All keys support a _FILE suffix for Kubernetes secrets (NETZBILANZD_MAKOD_API_KEY_FILE=/run/secrets/makod-key); nested keys use a double underscore (NETZBILANZD_DATABASE__URL).


PostgreSQL schema

Migrations run at startup; every table is tenant-scoped. invoice_drafts enforces in SQL:

ConstraintRule
pid CHECK31001, 31002, 31005, 31009, 31011
id_totals_add_upnetto + steuer = brutto (units of 10⁻⁵ EUR)
id_zu_zahlen_within_bruttothe Abschlag deduction never flips the sign or exceeds the gross
id_reverse_charge_states_no_taxAE ⇒ zero tax and rate; S ⇒ rate > 0
id_correction_is_linkedRECHNUNG has no original_draft_id / korrektur_grund; corrections have both
id_rechnungsnummer_uniqueone invoice number, one invoice
id_no_double_billingone live RECHNUNG per MaLo × period × PID (rejected rows and 31001 excluded)
id_one_abschlag_per_invoice_dateone 31001 per MaLo × period × invoice_date
id_one_storno_per_originalone Stornorechnung per original
id_tenant_createdthe (tenant, created_at, id) index the keyset cursor walks

kostenblatt_records is unique per tenant, activation_id, tr_id with a generated einsatzkosten_eur; fremdkosten_records holds one row per draft.


Regulatory basis

RegulationRequirement handled
StromNEV §§17, 21 · GasNEV §15NNE Arbeits-, Leistungs-, Grund- and Kapazitätspreis; the §17 Abs. 6 Arbeitspreis-only check
KAV §2Konzessionsabgabe as its own position, with the Höchstbetrag checked per customer group
EnFG §§ 21 ff. · §19 Abs. 2 StromNEV · §17f EnWG · §26 KWKGthe three network levies, per Letztverbrauchergruppe, Strom only
§14a EnWG (BK8-22/010-A)Modul 1 pauschal, Modul 2 prozentual, Modul 3 zeitvariabel
GPKE (BK6-24-174) Teil 1 Kap. 8.4Mehr-/Mindermengensaldo Strom and its sign convention
GaBi Gas 2.1 (BK7-24-01-008)Mehr-/Mindermengensaldo Gas, on the 06:00 Gastag
§30 MsbGMSB-Rechnung and its Preisobergrenze
GeLi Gas 3.0 (BK7-24-01-009) §5.4AWH Sperrprozesse, billed per action
§42b EnWGGGV: each tenant Marktlokation billed for its own metered Netzentgelt
§§13, 13a EnWG · BK6-20-061 §4.2 · BK6-23-241Redispatch Kostenblatt, angemessene Vergütung, BilAReM Ausfallarbeit
§14 Abs. 4 Nr. 4 UStGconsecutive, single-use invoice numbering
§ 147 Abs. 3 AO · § 14b UStGinvoices are Buchungsbelege — 8 years, reduced from 10 with effect from 01.01.2025

Informatorisches Unbundling

netzbilanzd is an NB-side service: Cedar gates every write on NB/MSB (invoicing) or NB/UENB (Redispatch), it is not in the LF agentd MCP server list, and billingd / invoicd do not receive de.netzbilanz.* events.

See § 6a EnWG Informatorisches Unbundling.

Edit this page ↗