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 German network operator owes its counterparties, and carries the Redispatch 2.0 cost sheets. It is the outbound half of the NB role: what the operator bills, under which paragraph, and what happened to the money.

Port: :8680 Storage: PostgreSQL — invoice_drafts, invoice_number_seq, abschlag_verrechnungen, kostenblatt_records, fremdkosten_records Role: NB / GNB only

  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 §§14/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

A Prüfidentifikator (PID) is the four- or five-digit code the BDEW rulebooks use to name one business message in one direction — it is what tells a receiver which Anwendungsfall of an EDIFACT message type it is holding. Every invoice here is an INVOIC, and the PID says which kind.

Two properties of this table are easy to get wrong, and both cost money.

The Sparte is not in the Prüfidentifikator. NN-Rechnung Strom and Gas share PID 31002; the two MMM variants share 31005. Every settlement therefore states its sparte, and that one field decides three things: whether the Arbeit position cites StromNEV §21 or GasNEV §15, whether the three EnFG network levies are billed at all, and what Rechnung.sparte says on the wire — the only place a receiver can read which Sparte a 31002 settles.

PID 31009 runs the other way. The Messstellenbetreiber issues it in all seven of its Anwendungsfälle (Anwendungsübersicht der Prüfidentifikatoren 4.0); it is never addressed to one. The draft stores the MSB as sender_mp_id, and the 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).

Two consequences for this service:

  • MMM prices come from the VNB, not the ÜNB. 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."
  • Konzessionsabgabe is KAV §2, never StromNZV. The rate bands key on the municipality's inhabitant count, not on annual consumption.

The network levies (EnFG)

Three levies ride on the electricity Netzentgelt and are billed to the network user through the NN-Rechnung. 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 is published as an explicit A′/B′/C′ schedule — B′ is capped at 0.050 ct/kWh and C′ at 0.025 ct/kWh. Set letztverbrauchergruppe on the settlement to bill a privileged band. The Offshore- and KWKG-Umlage are published as the non-privileged rate only; a privilege under §§ 21 ff. EnFG is granted per Entnahmestelle, so supply the granted rate through offshore_umlage_ct_per_kwh / kwkg_umlage_ct_per_kwh where one applies.

The privilege is a tranche, not a rate. B′ and C′ are published „für Strommengen über 1 000 000 kWh" at one Entnahmestelle, so the year's first Gigawattstunde carries A′ whatever the group. The threshold is annual and a settlement covers one period, so supply enfg_jahresvorverbrauch_kwh — the kWh already consumed there earlier in the same year. A period straddling the boundary then bills two §19 positions. Omit the field and the period is billed as though it opened the year — the over-billing direction — with ENFG_VORVERBRAUCH_MISSING.

A levy with no published rate for the delivery year is omitted with a warning, never billed at zero silently — an understated invoice is one the ÜNB reclaims later.

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, and whether a §14a EnWG Vereinbarung covers a steuerbare Verbrauchseinrichtung there. messstellen_kategorie therefore 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 own ceiling. {"OptionalerEinbau": null} is the §30 Abs. 3 case — 30 EUR each side, regardless of consumption. With no fact supplied the tightest ceiling applies: a Pflichteinbaufall exists only above 6 000 kWh (§29 Abs. 1), so Nr. 5 is the catalogue's floor rather than a guess.

Umsatzsteuer

Every invoice states its tax. §14 Abs. 4 Nr. 8 UStG requires "den anzuwendenden Steuersatz sowie den auf das Entgelt entfallenden Steuerbetrag" — or a note saying why neither is stated — and an invoice carrying only a net figure is worth no Vorsteuerabzug to the counterparty.

What is taxed how turns on what is being supplied, which is a different axis from the Sparte:

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

The §13b condition is asymmetric, and §13b Abs. 5 states it twice on purpose:

SupplyWho must hold §3g status
Elektrizitätthe supplier and the recipient
Gas über das Erdgasnetzthe recipient alone

An MMM settlement therefore carries both facts rather than a reverse_charge: bool the caller has to reason out — status is evidenced by a valid USt 1 TH (UStAE 13b.3a):

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

Getting it backwards is not a rounding error. Tax shown on a reverse-charge invoice is owed under §14c Abs. 1 UStG and gives the recipient no Vorsteuerabzug, because the recipient still owes it under §13b — the worst of both. The pre-dispatch gate refuses both shapes: an invoice with no tax block at all, and a reverse-charge invoice that states tax anyway.

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 inside that window is 7 % while a Netznutzung Gas invoice for the same period is 19 %. A delivery period that straddles a rate change is refused rather than billed at one of the two — no single rate describes it, and picking one misbills part of it invisibly, because the invoice still adds up.

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

Consuming below the profile leaves surplus energy the network absorbed, and that surplus is reimbursed.


Settling an invoice

A billing run carries an issue date, a due date, an optional Rechnungskreis and a list of positions. Each position names one MaLo, one delivery period and one settlement.

The settlement is a tagged union: each billing_type carries exactly its own fields. A field belonging to another settlement kind is a 422, not a silently ignored key — which is how a grundpreis on a GGV position once went unbilled, and how the documented §14a time-of-use fields were accepted and never read.

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",
    // What is left to collect once the Abschläge this invoice settles are
    // deducted. Equal to the gross when there are none, and the figure the
    // payment run collects — see "Abschläge" below.
    "zu_zahlen_eur": "712.02"
  }]
}

leistungspreis.system — which Leistungspreissystem the Preisblatt states

§ 17 Abs. 2 Satz 1 StromNEV builds the Netzentgelt „aus einem Jahresleistungspreis in Euro pro Kilowatt und einem Arbeitspreis in Cent pro Kilowattstunde", and Satz 2 states the Entgelt: „Das Jahresleistungsentgelt ist das Produkt aus dem jeweiligen Jahresleistungspreis und der Jahreshöchstleistung in Kilowatt der jeweiligen Entnahme im Abrechnungsjahr." Two figures multiplied — the ordinance sets no day-count convention, so there is nothing to pro-rate by. Abs. 8 nevertheless lets a Netzbetreiber offer Tagesleistungspreise for Landstrom „neben einem Jahres- und Monatsleistungspreissystem", which presupposes the monthly one; StromNEV does not define it, and it is published in the Netzbetreiber's own Preisblatt as a EUR/kW·Monat price against the month's Höchstleistung.

system is where the settlement says which of the two it is:

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

The default is a real answer, not a placeholder. A monthly invoice that omits system bills the whole Abrechnungsjahr's demand charge in January — the engine bills what § 17 Abs. 2 Satz 2 says rather than inventing a share no Preisblatt publishes, and raises JAHRESLEISTUNGSPREIS_UNTERJAEHRIG in settlement_warnings to say the period is not the Abrechnungsjahr. A monthly settlement over a monthly period, as above, warns about nothing.

The mirror-image check exists too: MONATSLEISTUNGSPREIS_MONATE_MISMATCH when monate and the period's own length disagree by more than a month.

A run is one transaction and carries at most 1 000 positions. Either every position is billed or none is; a portfolio job belongs in several runs rather than one that holds the Rechnungskreis row lock — and every concurrent billing job behind it — for minutes.

Invoice numbers are allocated here

§14 Abs. 4 Nr. 4 UStG requires an einmalig vergebene, fortlaufende invoice number, so the caller does not supply one. rechnungskreis only names the series; the running number comes from invoice_number_seq under a row lock, inside the drafting transaction:

  • a rolled-back run consumes no number, so the sequence has no gaps;
  • a retried run cannot reuse one, and a reused number is refused by a unique index;
  • the counter restarts per tenant, per series and per calendar year.

Numbers read NNE-2026-000001, or 2026-000001 when no series is named.

Read the warnings

check_findings comes from invoic-checker — the same library the receiving LF runs on arrival. settlement_warnings comes from the engine and records what it could not do. Both are returned on the run, stored on the draft, and readable through get_draft.

The one most worth watching is KA_ABOVE_KAV_MAXIMUM. KAV §2 rates are Höchstbeträge, and the ceiling depends on the customer group:

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 states that no Konzessionsabgabe is due, which is a different fact from a rate of zero: the position is not billed at all and the ceiling check has nothing to compare.

The rate and the group travel together in one value, so the ceiling check can never be skipped. Sending 1.32 ct/kWh on a Sondervertragskunde — twelve times its lawful maximum — raises the warning rather than passing silently.

The group is a consequence, not a label

KAV § 2 Abs. 7 decides which group a Niederspannungslieferung belongs to, and it decides it from facts: a supply counts as 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". Both limbs, measured on the einzelne Betriebsstätte oder Abnahmestelle — one of them alone leaves the point a Tarifkunde, and the two ceilings are 1,32 ct and 0,11 ct apart.

State the facts in konzessionsabgabe.niederspannung and the settlement holds the group against them, raising KA_GRUPPE_WIDERSPRICHT_KAV_ABS7 when the two disagree:

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

leistungsgrenze_kw and verbrauchsgrenze_kwh carry the lower figures Netzbetreiber and Gemeinde may agree under Satz 4; omitted, the statutory 30 kW and 30 000 kWh apply. The month count is the caller's, because only it holds the per-month Leistung.

Two rules forbid a Konzessionsabgabe outright

Neither is a ceiling, so neither is caught by the table above — a rate well 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 is not derivable here: for Strom it is the Durchschnittserlös the amtliche Statistik published for the vorletzte Kalenderjahr, and the customer's own price is measured „unter Einschluß des Netznutzungsentgelts" over the whole supply. Both are supplied; omitted, the comparison is simply not made.

§14a EnWG

BNetzA BK6-22-300 / BK8-22/010-A define exactly three modules, and ArbeitspreisModell makes them mutually exclusive by construction:

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". That is the partial state the variant exists to prevent: independent per-band fields admit combinations that are not a Modul-3 tariff, and a half-filled set falls through to flat billing with no error at all.

The Modul-2 reduktion is range-checked at the request boundary: a factor outside (0, 1] is refused, so a request body carrying 5 cannot multiply the Arbeitspreis by five.

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 beside Einheitlich and the three modules:

"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 a Netzentgelt whose rate follows the spot price under the Netzbetreiber's own PreisblattNetznutzung formula, one rate per dispatch interval. BK6-22-300 defines exactly three modules and none of them is spot-linked, so this is deliberately outside the §14a table — and grid-billing never queries a spot market: the rates arrive already derived.

§19 Abs. 2 StromNEV — individuelle Netzentgelte

Distinct from the §19 StromNEV-Umlage above, which compensates the network for exactly this. An agreed individual charge replaces the Netzentgelt — Arbeits- and Leistungspreis — and touches neither the Konzessionsabgabe nor the levies. Supply it as sect19:

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

The hours are inclusive („erreicht mindestens") and the energy is not („übersteigt"), so an Abnahmestelle at exactly 10 GWh does not qualify. The agreed percentage arrives as an input — BK4-22-089's physikalischer Pfad is not derived here — and the engine refuses to let it fall below the statutory 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) asks the Lieferant for a payment on account against a period the Netzbetreiber has not settled yet. It prices no energy, so it carries no quantity and no Arbeitspreis — and exactly one Positionszeile, which the INVOIC AHB 1.0b requires by name (Änd-ID 26817: "Eine Abschlagsrechnung kann und muss genau eine Positionszeile enthalten", with LIN DE1082 fixed at 1).

How the amount was arrived at is recorded, not computed. The engine cannot check a forecast; an audit can ask which basis was used, and an invoice that answers "a share of the prior Turnusrechnung" is defensible where a bare figure is not:

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", }
}

Three properties are enforced rather than trusted:

It reduces what is owed, never what was supplied. §14 Abs. 5 UStG taxes an Anzahlung when it is received, so the invoice that settles the period must not tax the same money a second time: gesamtnetto and gesamtsteuer stand unchanged and only zuZahlen moves. The INVOIC AHB puts the deduction in the Summenteil (SG50 MOA+113, Vorausbezahlter Betrag inkl. USt.) rather than among the positions for exactly that reason.

The amount comes from the stored Abschlag, not from the request. AHB rule [526]: the deducted amount must be identical to the referenced Abschlagsrechnung's own MOA+77 Rechnungsbetrag — which the MIG defines as "Rechnungsbetrag (inkl. USt.)", so the deduction is gross. A caller-supplied figure is precisely the one that can disagree with the document the counterparty holds.

A reversed Abschlag is refused. AHB rule [519]: a stornierte Abschlagsrechnung is not listed. Nothing was paid on it, so deducting it would credit money that never moved. An Abschlag that was never dispatched is refused on the same footing.

Each deduction names the invoice it reconciles against — SG51 RFF+AFL for the number and SG51 DTM+3 for its date — because a total the counterparty cannot break down is a total it will dispute.

A period carries many Abschläge and one final invoice. A monthly Abschlag against a yearly period is the ordinary case, so the double-billing guard excludes PID 31001; the invoice number keeps them distinct and the Abschlussrechnung reconciles them by it.

But "many" is not "unbounded". Abschläge carry their own, looser guard: one per MaLo, period and Rechnungsdatum. Instalments are billed on a cadence and differ by that date; a replayed POST /billing/run does not, and a second Abschlag under a fresh invoice number would be deducted twice by the Abschlussrechnung. A same-day duplicate answers 409.

The collectible amount is stored. zu_zahlen_eur_units sits beside the three amounts the invoice states, so the summary, the overdue alert and the audit export answer what are we owed rather than what did we invoice. A CHECK keeps the deduction directional — it only reduces — but lets it pass zero: an Abschlussrechnung settling for less than the Anzahlungen leaves a Guthaben the Netzbetreiber owes back, which is ordinary.

Billing cadence

cadence is IMD+7081 on the wire, and a document fact rather than a calculation one: the same settlement is the same arithmetic whether billed monthly, per Turnus, or as the Abschlussrechnung that closes a year. Left unset, the field is omitted rather than guessed.

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 does four things in order, inside one transaction:

  1. Merges the Fremdkosten into Rechnung.fremdkosten. BO4E models external cost pass-through as a first-class field, so it does not travel as a free-text ZusatzAttribut and the LF's own parser reads it.

    Fremdkosten are informational — a BO4E cost breakdown beside the invoice, not positions that add to it, so gesamtnetto, gesamtsteuer and zuZahlen are untouched. Third-party costs the counterparty actually owes belong in the settlement, which prices, traces and taxes them.

    PUT /fremdkosten/{draft_id} therefore accepts them only while the invoice is a draft, and answers 409 afterwards: the merge happens at dispatch, so a later attachment would store costs nobody was sent.

  2. Runs the outbound BO4E gate over the merged document (ensure_conformant). This is the one place netzbilanzd assembles a document at runtime rather than emitting it whole from the engine — a stored Rechnung plus a separately stored Fremdkosten, each valid when written, combined into a shape no test has seen. Step 3 covers the arithmetic; this covers the rest, notably an out-of-schema enum anywhere in the merged tree. mako does not send a document it would refuse to receive.

  3. Re-checks the amended document. The verdict stored at drafting time describes the document as drafted; the counterparty checks what actually arrives. A Dispute verdict blocks the send and returns the disputing findings.

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

    FieldValue
    commandone per PID: invoic.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 a gas invoice, NB otherwise
    invoice_refthe invoice number — the business key the inbound REMADV correlates on
    sender_mp_id / recipient_mp_idas the settlement resolved them
    pid, sparte, rechnungthe document and what identifies it

    The command is Sparte-neutral; the asserted role is not. The Prüfidentifikatoren are the same in Strom and Gas, and so are the commands — makod_command reads the PID alone. What the Sparte decides is the marktrolle the dispatch asserts, which makod checks against the deployment's licensed roles: MSB for PID 31009 whatever the Sparte (the Messstellenbetreiber issues it), GNB for PID 31011 and for any gas invoice, NB otherwise. A --marktrollen GNB deployment is the only kind that issues the gas ones, and asserting NB there fails its licence check.

Every PID this service issues has an issuer-side process in makod. PID 31011 included: the GeLi Gas INVOIC workflow models both ends of the conversation — SendInvoic for the GNB and ReceiveInvoic for the LFG — so an AWH invoice dispatches and its REMADV correlates back like any other.

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": { … }}'

A Stornorechnung is recomputed, not edited. The stored settlement input is replayed through the engine and the result negated by grid_billing::reverse, so every position flips sign, the total flips sign with them, and the rendered document declares itself a reversal on ist_storno + original_rechnungsnummer — the two fields invoic-checker stage 0 reads. A Storno that sets neither is not a Storno to any receiver; one that sets ist_storno without the reference is disputed on arrival (BK6-24-174 §5).

A Korrekturrechnung requires the Storno first. It carries the whole corrected amount, not the difference, so issuing one against a live invoice bills the period twice — and both documents are well-formed, so nothing downstream notices. /korrektur answers 409 until the original is reversed.

Only a dispatched invoice can be reversed or corrected. A draft was never sent and a rejected one was discarded before it could be, so both answer 409. Reversing an invoice the counterparty never received issues a credit note against nothing — a negative amount an ERP will pay out. A draft that was never dispatched needs no correction: reject it and bill again.

A Korrekturrechnung must correct the same invoice. The corrected settlement is a caller-supplied SettlementRequest, and corrections are exempt from the double-billing guard, so an unrelated second invoice would otherwise be stored linked to an original it has nothing to do with. Changing settlement_type, sparte, sender_mp_id or recipient_mp_id is a 422 naming the field.

The recomputation is checked against the original — net, Umsatzsteuer and gross. It normally reproduces all three exactly, but the engine reads tabled figures (EnFG levy rates, KAV §2 ceilings, the UStG rate window, the regime for the period), and a table corrected since issue produces a near-miss: a Storno that cancels most of an invoice and leaves a residue nothing reconciles. A mismatch is a 409 naming both figures.

All three are compared because the tax is derived — from the §13b Wiederverkäufer status and the rate window — so a corrected table can move the Umsatzsteuer while the net matches, and a reversal that cancels the net but not the tax leaves a §14c Abs. 1 liability standing.

A Korrekturrechnung is a new settlement from corrected inputs, run through the same engine and the same checks. It is not an operator-supplied document blob.

Both carry a grund (grid_billing::KorrekturGrund), and the choice is part of the audit trail rather than decoration: Rechenfehler and Stammdatenkorrektur mark a defect in the original worth counting, while RegulatorischeAenderung marks a lawful recalculation. Both inherit the original's Rechnungskreis, so a correction stays in its series.

The original is never mutated. original_draft_id and korrektur_grund are enforced by a CHECK constraint: an original carries neither, a correction carries both. A second Storno of the same invoice is refused by a unique index — it would credit the counterparty twice, and nothing downstream would notice, because both reversals are well-formed documents referencing the same original. Korrekturrechnungen are not limited that way: re-issuing corrected amounts is the point of having reversed.


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, 33003 and 33004 are all Abweisungen; 33003 and 33004 are the itemised Strom rejections, not partial payments.

A dispute is its own status with its own columns (dispute_erc_code, dispute_reason). It does not overwrite check_outcome — the NB's own pre-dispatch verdict is the evidence that says whether the invoice left the house defensible, and losing it is losing the argument.

Rejecting a draft reopens the period: the partial unique index excludes rejected rows, so a corrected run can bill the same MaLo, period and PID again. Once dispatched, the way back is a Storno — and only then: draft and rejected are both "never left the house", and both refuse a Storno or a Korrektur.

Inbound REMADV is idempotent. Delivery is at-least-once, so the same event arrives more than once. A replay that matches the state the invoice is already in answers 204, not 404: a sender told the event failed never stops retrying it.

The two closing transitions are also reachable directly, for a REMADV that reached the operator by another route:

MethodPathBodyEffect
PUT/api/v1/billing/drafts/{id}/mark-paidremadv_refdispatched or disputedpaid
PUT/api/v1/billing/drafts/{id}/mark-disputederc_code, reasondispatcheddisputed

Both sit behind the Cedar action record-payment, so falsifying a receivable is not something a read token can do.


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 and cannot be fetched. It is what the Bilanzkreis was charged from the load profile, which lives on the balancing side; edmd holds only the measured half. Supplying the measured total for both halves makes every saldo structurally zero.

sparte does two jobs here. It selects the price series — Trading Hub Europe per Marktgebiet for Gas, the nationwide BDEW series for Strom, so the application month is the whole key and there is nothing per-operator to configure — and it is passed through to edmd as the aggregation basis.

Which paragraph the settlement cites follows the delivery period, because StromNZV and GasNZV both lapsed with the end of 31.12.2025: to that date StromNZV §13 Abs. 3 and GasNZV §25, from 01.01.2026 §20 Abs. 3 EnWG with BK6-24-174 for Strom and GaBi Gas 2.1 (BK7-24-01-008) for Gas. grid_billing::regulatory::RegulatoryRegime resolves it once per settlement, and a period straddling the turnover warns. Gas balances on the 06:00 Gastag, so a Gas saldo aggregated over calendar days misplaces six hours of every day's energy.

Prices are auto-fetched only when the request leaves them open, and the fetched values are stored on the draft as part of the settlement input — an audit replays the same numbers rather than re-querying a service whose published series has since been revised.

A monthly sweep settles every MaLo of one Sparte against the same published series, so the fetch is memoised per run: one marktd round-trip per (Sparte, year, month) instead of one per position. The memo is dropped with the run, so a later run reads the current series.


§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 — it bills one tenant for another's consumption. Meter the tenants, or do not bill them individually.

The building settles in one transaction: either every tenant is billed or none is. A run that bills six of nine and reports success leaves the other three invisible, and a retry trips the double-billing guard on the six that landed.

The response reports each tenant's share of the metered total; the shares add 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 Kostenblatt is built typed

The stored kosten_json is a BO4E Kosten with a Kostenblock, a Kostenposition and its Menge, Preis and Betrag — seven objects the ÜNB settles against. It is constructed as a struct and serialised, never assembled as JSON: a struct literal fails to compile on a field rename, whereas a JSON literal round-tripped through from_value::<Kosten>() proves nothing, because rubo4e absorbs an unknown key into _additional and the decode still returns Ok. _typ is stamped by rubo4e on all four nested components, and the value crosses the outbound gate before it is persisted.

A kosten_json an operator supplies on PUT /api/v1/redispatch/kostenblatt/{activation_id} crosses the inbound gate as a Bo4e<Kosten>, and what is stored is the gate's canonical round-trip. A hand-rolled serde_json::from_value::<Kosten> beside a stored request body would let a wrong _typ, an out-of-schema enum and unbounded nesting into the column the ÜNB settles against. fremdkosten_json is gated the same way.

The dispatched energy comes from the projected series

The quantity the ÜNB is invoiced for is read from edmd's GET /api/v1/energy/{malo_id}?direction=EINSPEISUNG — the canonical projected series, one entry per interval in one direction. Both callers settle lost generation: the Kostenblatt prices the curtailed energy, and §13a Abs. 2 Ausfallarbeit is by definition what the resource would have produced.

GET /api/v1/lastgang/{malo_id} is the BO4E export and is the wrong input to a figure: one object per register, both directions, every quality, non-kWh registers included. Folding it back into one number is the register projection, and doing it here would sum the grid draw into a figure that means feed-in, count a total register (1-0:1.8.0) on top of the tariff registers that already cover the same energy, and keep qualities that are not billable — a Schätzwert or an unvalidated reading is an estimate, and § 40a Abs. 2 EnWG lets one carry a settlement only where Satz 3's conspicuous disclosure is made.

coverage_pct arrives with the projection, so a window edmd covers only in part is a fact the caller can act on rather than a smaller number indistinguishable from a small dispatch. It is logged; the figure is still produced, because the resource was curtailed for the part that is there.

Where the energy comes from

compute resolves the dispatched energy in one order, and records which path it took in dispatch_source:

  1. manual_override — a verified operator figure, when supplied;
  2. lastgang_sumedmd's projected feed-in series (/api/v1/energy/{malo_id}?direction=EINSPEISUNG) summed over the exact activation window, half-open [start, end);
  3. billing_period — the monthly aggregate, only when no series exists.

Check dispatch_source on the result. For a 15-minute activation the monthly aggregate is wrong by roughly three orders of magnitude — 2 500 kWh/month is not 2.5 kWh/quarter-hour. The fallback is logged loudly and should be replaced with a dispatch_kwh_override.

Einsatzkosten = dispatch_kwh × arbeitspreis_eur_per_kwh is a generated column, so it cannot drift from its factors. The typed BO4E Kosten payload built for CIM export is validated against rubo4e before it is stored: a field rename upstream fails here rather than shipping a document the ÜNB's parser silently drops.

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

Using the measured series for an Aufforderungsfall settles against what happened rather than against what was instructed — a money error in whichever direction the plant deviated, and one nothing downstream can detect. The chosen basis travels into the result and its calculation trace, so an audit can see which counterfactual a figure rests on.

BilAReM Kap. 3

Every JSON request body in this service rejects unknown fields. A misspelt konzessionsabgabe on a GGV run would drop that charge, a misspelt dispatch_kwh_override would fall back to edmd, and a misspelt p_bean would remove the beanspruchbare-Leistung cap — each a money error a 400 prevents. Query strings are deliberately not strict: an unknown query parameter is a proxy artefact, not a missing charge.

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. All six return the same JSON problem body as every other endpoint here.


Calculation audit trail

Every position carries a CalculationTrace — the inputs it used, the arithmetic, the paragraphs it applied and where the rate came from. It answers "why is this amount on the invoice?" without re-running anything, which is what a §20 EnWG audit or an LF dispute needs.

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 BK6-22-300"]
      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

Storing the input as well as the rendered document is what makes the trail complete: the document says what was billed, the input says what it was billed from, and a Storno recomputes rather than guesses.

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

Four position kinds carry no Artikelnummer at all: an Abschlag (it prices nothing), the Gas AWH positions (they carry a 2-01-7-xxx Artikel-ID instead), a §19 Abs. 2 individuelles Entgelt, and a dezentrale Einspeisung, which is a bilateral payment outside the INVOIC market processes.

NNE Strom. BK6-20-160 replaced the classic artikelnummer with an artikel_id from the Netznutzungspreisblatt. Supply it through the price sheet; the settlement states what was charged, not how it is coded.

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 units of 10⁻⁵ EUR, so a total never rounds through a float. The audit export omits the JSONB columns to keep a full-portfolio export manageable; fetch the document per draft.

Filter by Sparte

curl '.../api/v1/billing/drafts?sparte=Gas&status=dispatched'

PID 31002 (NN-Rechnung) and 31005 (Mehr-/Mindermengen) are each shared between Strom and Gas, so the Prüfidentifikator cannot answer show me the gas invoices. sparte is case-insensitive; a value that is neither Strom nor Gas is a 400, since ignoring a typo would answer the wider question.

Paging

curl '.../api/v1/billing/drafts?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-…'

/drafts and /audit both return next_cursor, omitted on the last page. It is the (created_at, id) of the last row, and the listing is ordered by that pair, so resuming is a range scan on id_tenant_created.

It is a keyset, not an offset: OFFSET n re-reads the whole prefix, and it is unstable — a draft inserted between two page requests shifts the window and the caller skips a row. The audit export is ordered by (created_at, id) for the same reason; the delivery period is a filter, not the sort key.

What is owed, not what was invoiced

summary totals zu_zahlen alongside the gross. On a portfolio with Abschläge the two differ — the gross is what the invoices state, zu_zahlen what is left to collect — so a month-end reconciliation runs against zu_zahlen.


Background workers

All three run only when erp_webhook_url is configured, and all three stop promptly on shutdown rather than holding the process open until their next tick.

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 rather than posting for themselves, so every de.netzbilanz.* event takes one delivery path with one retry policy and one dead-letter queue.

The overdue alert names each draft's zu_zahlen_eur and due_date, not the gross: an exposure report over the gross overstates every period that carries Abschläge.

The overdue alert watches the Zahlungsziel as well as the age, because age alone cannot answer the question: a 90-day payment term makes 48 hours meaningless, and a 7-day one makes it far too slow. It excludes drafts the checker disputed — those are blocked, not overdue, and alerting on them hourly trains an operator to ignore the alert.

CloudEvents

Every event is written to event_outbox and drained by the worker — persist-before-dispatch, so a crash never drops one. The five business events are enqueued in the same transaction as the change they describe; the two timer alerts describe no write, so they are enqueued on their own.

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 and are HMAC-verified against inbound_secret. Set it: without it, a forged REMADV can mark an invoice paid or contest one that was not.

Ingest is idempotent. Delivery is at-least-once, so the same REMADV arrives more than once; a replay whose target state the invoice already holds answers 204 rather than 404, since a sender told the event failed retries indefinitely.


MCP server

At /mcp (Streamable HTTP). The tools read the whole invoice register, so the surface is gated by the same verifier and the same policy as REST: a JWT is verified and checked for use-mcp, and a configured [mcp] api_key stays accepted for agent clients that mint no OIDC token. Without either, /mcp refuses to start — see Authentication and authorization.

The surface is 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

Dispatching an invoice sends EDIFACT to a counterparty and starts a payment obligation whose only reversal is a Stornorechnung. Model output is untrusted input, so settling, dispatching, rejecting and correcting live on the REST API, where the action is attributable to an operator. Read on MCP, act on REST.

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

A route here dispatches an INVOIC to a counterparty over AS4, reverses one it already holds, marks a receivable paid, exports the § 147 AO / § 14b UStG record of a whole period, or files the month's Redispatch cost sheet. None of that is served to whoever can reach the port.

Three doors, checked together at startup — netzbilanzd refuses to boot with any of them open, unless the deployment asks for that by name with 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

Authentication says who is calling; Cedar says what they may do, and the two are separate decisions — an auditor reads the § 147 AO export with the same token shape an operator dispatches invoices with. 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

The BilAReM calculators sit with the reads deliberately: they touch no stored data, take their whole input from the request body and persist nothing, so they disclose nothing about the tenant.

tests/authorization_guard.rs pins the surface against three failure classes the compiler cannot see — a handler with no Claims extractor is unauthenticated; a handler that takes Claims and never authorizes is reachable by any accepted token; and a Cedar action checked in code but named in no policy is a permanent 403, while one named in the policy and checked nowhere is a dead grant. The REMADV webhook is the single documented exception, authenticated by HMAC rather than by a bearer token.

Four-eyes is not implemented. Separating "may settle a period" from "may dispatch the invoice it produced" needs a job-function axis, and mako_roles carries market roles only — a policy naming a BUCHHALTUNG role would deny every caller. Denials are logged with the caller's sub and the action, so who tried what stays visible.


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 via sqlx::migrate!. Every table is tenant-scoped, and every read path filters on tenant.

-- Consecutive invoice numbering (§14 Abs. 4 Nr. 4 UStG), allocated under a row
-- lock inside the drafting transaction.
CREATE TABLE invoice_number_seq (
    tenant         TEXT     NOT NULL,
    rechnungskreis TEXT     NOT NULL,      -- '' when the caller names no series
    year           SMALLINT NOT NULL,
    last_number    BIGINT   NOT NULL DEFAULT 0,
    PRIMARY KEY (tenant, rechnungskreis, year)
);

CREATE TABLE invoice_drafts (
    id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant              TEXT    NOT NULL,
    malo_id             TEXT    NOT NULL,
    sender_mp_id        TEXT    NOT NULL,   -- NB/GNB, or the MSB for 31009
    recipient_mp_id     TEXT    NOT NULL,   -- LF, or NB/LF/ESA for 31009
    pid                 INTEGER NOT NULL
                        CHECK (pid IN (31001, 31002, 31005, 31009, 31011)),
    sparte              TEXT    NOT NULL CHECK (sparte IN ('STROM', 'GAS')),
    settlement_type     TEXT    NOT NULL,
    period_from         DATE    NOT NULL,
    period_to           DATE    NOT NULL,
    CONSTRAINT id_period_ordered CHECK (period_from <= period_to),
    rechnungsnummer     TEXT    NOT NULL,
    -- The document's own dates: both are §14 UStG mandatory content, and both
    -- are asked for outside the document — `invoice_date` is what an Abschlag is
    -- referenced by (`SG51 DTM+3`), `due_date` is what an overdue report
    -- measures against. Kept only inside the JSONB, neither could be queried.
    invoice_date        DATE    NOT NULL,
    due_date            DATE    NOT NULL,
    CONSTRAINT id_due_after_invoice CHECK (due_date >= invoice_date),
    settlement_input    JSONB   NOT NULL,   -- replayable: what the figure was computed from
    rechnung            JSONB   NOT NULL,   -- rubo4e::current::Rechnung
    bo4e_version        TEXT    NOT NULL DEFAULT '202607.1.0',

    -- The three amounts an invoice states, each × 10⁻⁵ EUR, enforced to add up:
    -- an invoice whose parts do not sum to its whole is the one error nobody
    -- catches by reading it.
    netto_eur_units     BIGINT  NOT NULL,
    steuer_eur_units    BIGINT  NOT NULL,
    brutto_eur_units    BIGINT  NOT NULL,
    CONSTRAINT id_totals_add_up CHECK (netto_eur_units + steuer_eur_units = brutto_eur_units),
    -- What the recipient actually pays: the gross less every Abschlagsrechnung
    -- this invoice settles. Stored rather than derived — it is what the payment
    -- run collects and what the overdue report measures, and deriving it would
    -- mean re-reading the deducted drafts on every query.
    zu_zahlen_eur_units BIGINT  NOT NULL,
    -- A deduction reduces what is owed; it never flips the sign or exceeds the
    -- invoice it is deducted from.
    CONSTRAINT id_zu_zahlen_within_brutto CHECK (
        (brutto_eur_units >= 0 AND zu_zahlen_eur_units BETWEEN 0 AND brutto_eur_units)
     OR (brutto_eur_units <  0 AND zu_zahlen_eur_units BETWEEN brutto_eur_units AND 0)
    ),
    steuer_kategorie    TEXT    NOT NULL CHECK (steuer_kategorie IN ('S', 'AE')),
    steuer_satz_prozent NUMERIC(5, 2) NOT NULL,
    -- A reverse charge states no tax; a taxed supply states a rate.
    CONSTRAINT id_reverse_charge_states_no_tax CHECK (
        (steuer_kategorie = 'AE' AND steuer_eur_units = 0 AND steuer_satz_prozent = 0)
     OR (steuer_kategorie = 'S'  AND steuer_satz_prozent > 0)
    ),
    rechnungsart        TEXT    NOT NULL DEFAULT 'RECHNUNG'
                        CHECK (rechnungsart IN ('RECHNUNG','STORNORECHNUNG','KORREKTURRECHNUNG')),
    original_draft_id   UUID REFERENCES invoice_drafts(id) ON DELETE RESTRICT,
    korrektur_grund     TEXT,
    CONSTRAINT id_correction_is_linked CHECK (
        (rechnungsart =  'RECHNUNG' AND original_draft_id IS NULL     AND korrektur_grund IS NULL)
     OR (rechnungsart <> 'RECHNUNG' AND original_draft_id IS NOT NULL AND korrektur_grund IS NOT NULL)
    ),
    check_outcome       TEXT  NOT NULL CHECK (check_outcome IN ('Ok','Warn','Dispute')),
    check_findings      JSONB NOT NULL DEFAULT '[]',
    settlement_warnings JSONB NOT NULL DEFAULT '[]',
    status              TEXT  NOT NULL DEFAULT 'draft'
                        CHECK (status IN ('draft','dispatched','paid','disputed','rejected')),
    dispatch_ref        TEXT,
    dispatched_at       TIMESTAMPTZ,
    remadv_ref          TEXT,
    dispute_erc_code    TEXT,
    dispute_reason      TEXT,
    reject_reason       TEXT,
    created_at          TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at          TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- One invoice number identifies exactly one invoice.
CREATE UNIQUE INDEX id_rechnungsnummer_unique ON invoice_drafts (tenant, rechnungsnummer);

-- One live RECHNUNG per MaLo × period × PID. Corrections and rejected drafts
-- are excluded — rejecting is how an operator reopens a period.
-- Abschlagsrechnungen are excluded: a period legitimately carries several.
CREATE UNIQUE INDEX id_no_double_billing
    ON invoice_drafts (tenant, malo_id, period_from, period_to, pid)
    WHERE rechnungsart = 'RECHNUNG' AND status <> 'rejected' AND pid <> 31001;

-- Abschlagsrechnungen get their own, looser guard rather than none at all:
-- instalments differ by their Rechnungsdatum, a replayed billing run does not.
CREATE UNIQUE INDEX id_one_abschlag_per_invoice_date
    ON invoice_drafts (tenant, malo_id, period_from, period_to, invoice_date)
    WHERE rechnungsart = 'RECHNUNG' AND status <> 'rejected' AND pid = 31001;

-- One reversal per invoice. A second Storno credits the counterparty twice.
CREATE UNIQUE INDEX id_one_storno_per_original
    ON invoice_drafts (tenant, original_draft_id)
    WHERE rechnungsart = 'STORNORECHNUNG';

-- The listing orders by (created_at, id) and pages by that same pair, so the
-- cursor walks this index instead of counting rows it will discard.
CREATE INDEX id_tenant_created ON invoice_drafts (tenant, created_at DESC, id DESC);

kostenblatt_records (unique per tenant, activation_id, tr_id, with a generated einsatzkosten_eur and a window-ordering CHECK) and fremdkosten_records (one per draft) complete the schema.


Regulatory basis

RegulationRequirement handled
StromNEV §§17, 21 · GasNEV §§14, 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 (BK6-22-300, 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-only service. The LF billing services (billingd, accountingd, invoicd) run independently:

  • Cedar ABAC gates every write on a market role — NB or MSB for invoicing, NB or UENB for Redispatch (see Authentication and authorization).
  • netzbilanzd does not appear in the LF agentd MCP server list.
  • billingd and invoicd do not receive de.netzbilanz.* CloudEvents.

See § 6a EnWG Informatorisches Unbundling.

Edit this page ↗