invoicd Operator Guide

invoicd operator guide: INVOIC plausibility-check daemon (LF role). Checks the ten inbound billing PIDs against marktd price sheets, persists every receipt for § 147 AO / GoBD, answers the counterparty through makod, and notifies the ERP.

invoicd Operator Guide

invoicd is the INVOIC plausibility-check daemon for the LF (Lieferant) role. It subscribes to marktd's fan-out, receives inbound INVOIC events, and:

  1. Routes the PID through src/routing.rs, which decides the check, the price sheet and the answer commands.
  2. Fetches the reference data that PID calls for from marktd, and runs the deterministic invoic-checker pipeline.
  3. Persists the receipt for the § 147 AO / § 14b UStG audit trail (a received INVOIC is a Buchungsbeleg: 8-year retention). A failed write aborts the dispatch.
  4. Answers the counterparty through makod — accept or dispute.
  5. Notifies the ERP with de.invoic.receipt.*, durable at-least-once.

Every PID takes this path; what varies is data in the routing table, not a copy of the pipeline. A PID with no route is ignored rather than answered with a default command, and an event that cannot become a receipt goes to invoic_dlq with the reason instead of vanishing.

graph TB
    marktd["marktd :8180<br/>fan-out"]
    invoicd["invoicd :8280<br/>(this service)"]
    makod["makod :8080"]
    pg["PostgreSQL<br/>invoic_receipts<br/>(§ 147 AO / GoBD, 8y)"]

    marktd -->|"de.mako.process.initiated<br/>(10 INVOIC PIDs)<br/>HMAC POST /webhook"| invoicd
    invoicd -->|"price sheets + MMM reference prices"| marktd
    invoicd -->|"persist receipt BEFORE dispatch<br/>(§ 147 AO / GoBD)"| pg
    invoicd -->|"accept / reject, per the answering PID"| makod
    invoicd -->|"de.invoic.receipt.settled/disputed<br/>durable at-least-once<br/>(inline + outbox worker retry)"| erp["ERP webhook"]

Port layout

┌─────────────────────────────────────────────────────────────────┐
│  invoicd  :8280                                                  │
│                                                                 │
│  POST /webhook                      ← marktd CloudEvents        │
│  GET  /api/v1/receipts              ← INVOIC receipt ledger     │
│  GET  /api/v1/receipts/{id}         ← single receipt by UUID    │
│  GET  /api/v1/receipts/{id}/rechnung← full BO4E Rechnung JSON   │
│  POST /api/v1/receipts/{id}/confirm-payment  ← ERP payment ack  │
│  POST /api/v1/receipts/{id}/dispatch-answer  ← re-send answer   │
│  POST /api/v1/receipts/{id}/resolve-dispute  ← close dispute    │
│  GET  /api/v1/disputes              ← open disputes             │
│  GET  /api/v1/overdue-remadv        ← receipts near pay_by      │
│  GET  /api/v1/zahlungsstatus/{malo_id}  ← payment status per MaLo│
│  POST /api/v1/selbstausstellen      ← self-issued MMM (31006)   │
│  GET  /invoicd/metrics              ← invoicd Prometheus gauges │
│  GET  /metrics  /health/live  /health/ready  ← runner infra     │
│  POST|GET /mcp      ← MCP Streamable HTTP (LLM tooling)         │
└─────────────────────────────────────────────────────────────────┘

Authorization

/webhook is HMAC-authenticated (marktd is the caller, with replay protection on the timestamp). Every /api/v1/* route requires a JWT and a Cedar action:

RoutesActionGranted to
/receipts, /receipts/{id}, /receipts/{id}/rechnung, /zahlungsstatus/{malo_id}read-receiptany caller in the tenant
/disputesread-disputesany caller in the tenant
/overdue-remadvread-overdue-remadvany caller in the tenant
/receipts/{id}/confirm-payment, /dispatch-answer, /resolve-disputewrite-receiptLF role
/selbstausstellendispatch-selbstausstellenLF role

Cedar is deny-by-default, so an action the code checks and the policy does not permit is a permanent 403 no configuration can lift. tests/cedar_actions.rs pins the two lists together in both directions.

POST /api/v1/receipts/{id}/dispatch-answer

Re-sends the market answer for a receipt whose automatic dispatch failed (dispatched_at IS NULL). Both the routing key and the command come from the receipt: the stored INVOIC message reference, and the answering PID's own command from the routing table. Answers 409 when the receipt was already dispatched and 422 when it carries no message reference.


Reading the inbound Rechnung

The BO4E payload crosses the gate on its received-document setting. The distinction is load-bearing:

  • A document that will not type — a wrong _typ, an out-of-schema rechnungstyp that would otherwise decode to Unknown — is dead-lettered. There is nothing for the checker to adjudicate, and saying so is the honest outcome.
  • A document that types but breaks a BO4E-stated rule — a gesamtbrutto that is not net plus tax — is not refused. That invoice is disputable, and the market's answer to it is a REMADV naming the defect, which check stage 3 below already produces. Refusing to parse would replace that answer with silence and a dead letter for an operator to find.

Handled PIDs

PIDDescriptionDirectionSparteStatus
31001Abschlagsrechnung Netznutzung (NB → LF)InboundStrom
31002Netznutzungsabrechnung (NB → LF)InboundStrom
31003WiM-Rechnung — Abrechnung von Dienstleistungen im Messwesen (MSBA → NB · MSBA → MSBN)Inbound⚡🔥
31004Stornorechnung — universal Storno (GPKE/MMM/WiM/Kapazität/AWH/GeLi)InboundStrom + Gas✅ arithmetic-only (check_storno)
31005MMM-Rechnung Mehr-/MindermengensaldoInboundStrom
31006MMM Mehrmenge, selbst ausgestellt (LF → NB)Inbound + OutboundStrom
31007GaBi Gas Aggreg. MMM-Rechnung (NB → MGV)InboundGas✅ + MMM check 6
31008GaBi Gas selbst ausgest. Aggreg. MMM-RechnungInboundGas✅ + MMM check 6
31009MSB-Rechnung (MSB → NB / LF / ESA, WiM)InboundStromPreisblattMessung — or the accepted Angebot toward an ESA
31011GeLi Gas Rechnung sonstige Leistung (AWH)InboundGas

PID 31009 (WiM MSB-Rechnung) prices metering service, so it is checked against PreisblattMessung, not the NNE tariff. When the Rechnung is not embedded in the process payload, invoicd asks makod for it.

Toward an ESA the price basis is the accepted Angebot. 31009 is „MSB-Rechnung" toward the NB, the LF or the ESA (WiM Teil 1 Kap. 6.2 / Teil 2 Kap. 4.5), and the three do not share one. PreisblattMessung is what an MSB publishes toward the NB and the LF; there is none for a Kapitel-4.6 Messprodukt, because §35 MsbG leaves the Entgelt for a Zusatzleistung to be agreed per request.

An ESA's basis is the QUOTES 15003 it ordered against. makod files the accepted offer at marktd on the ORDRSP 19011 (esa_messprodukt_preise) and invoicd looks it up. The join is exact rather than a plausibility band, because the offer prices Artikel-IDs (SG27 PIA+Z02) and the invoice names the same ones back (SG26 LIN DE 7143 Z09).

The wire says which Use-Case this is. IMD+7081 = KON („Abrechnung von Konfigurationen (Universalbestellprozess)") is the ESA billing of WiM Teil 2 Kap. 4.5 — the Kapitel-4.6 Messprodukte are the Konfigurationen. MSB is the Messstellenbetrieb billed toward NB or LF, TEC the Änderung der Technik. An accepted offer on record is the fallback for a sender that omits the qualifier, but not the primary signal: the 4-Werktage answer window is owed whether or not mako filed the offer.

SG1 RFF+ACE names the order. Muss on the 31009 (segment 00020), carrying the ORDERS Dokumentennummer on IMD++KON (hint [501]) or the QUOTES on IMD++MSB ([508]) — the reference E_0264 Prüfschritt 40 („Basiert die Rechnung auf einer Bestellung?") checks. BO4E has no field for it or for the Rechnungstyp, so both ride the process rather than the BO4E document.

FindingMeaning
AngebotDeviationa position billed away from the agreed price — dispute
AngebotPositionUnknownan Artikel-ID the offer never priced — a charge nobody agreed to; dispute
TariffNotFoundno accepted offer on record — warn and skip, never dispute: a gap in mako's records is not a defect in the invoice

The answer names the tree the Use-Case publishes

A REMADV Abweisung carries SG7 AJT: DE 4465 the code, DE 1082 the Entscheidungsbaum that publishes it. The tree is not a constant — every invoice Use-Case has its own quartet, and PID 31009 alone has three of them:

PIDEmpfängerRechnungNicht-ZahlungsaviserneutStorno
31001/31002/31005/31006LFE_0406E_0452E_0407E_0459
31009ESA (IMD+7081 = KON)E_0264E_0265E_0266E_0267
31009NB, Messstellenbetrieb (MSB)E_0566E_0567E_0568E_0569
31009NB, Preisblatt B (TEC)E_0273E_0274E_0277E_0275
31009LF, Messstellenbetrieb (MSB)E_0210E_0211E_0243
31009LF, Preisblatt B (TEC)E_0270E_0271E_0276E_0272
31003NB / MSBNE_0259E_0260E_0261

mako_pruefung::codes::rechnungspruefung(pid, empfaenger, gegenstand) is that table. PID 31009 carries five Use-Cases, and the recipient's Marktrolle narrows it only to two: the message body names no Use-Case (Allgemeine Festlegungen §2.13 gives every Marktrolle its own MP-ID), so the second fact is IMD+7081TEC „Abrechnung von Technik" is a Leistung of the MSB's Preisblatt B, ordered through the AWH „Änderung der Technik an Lokationen" round and priced from the PRICAT 27002 („Preisblatt Technik"); MSB and KON are the Messstellenbetrieb and the ESA's.

An unlabelled invoice is read as the Messstellenbetrieb, deliberately: E_0270/E_0273 open with „Basiert die Rechnung auf einer Bestellung des Rechnungsempfängers?" and refuse with A04 where there is none, so the other default would refuse every ordinary MSB invoice.

The answer's shape then picks the REMADV Prüfidentifikator. REMADV AHB 1.0a publishes two: §3.1.1 „Bestätigung und Abweisung" (33001/33002) and §3.1.2 „Abweisung auf Kopf/Summen- und Positionsebene" (33003/33004), and DE 1082 admits a different list of trees on each. A tree that answers with one code rides 33002; one that answers with a set rides 33003 (Kopf/Summe) or 33004 (Position).

invoicd walks the ESA and both Preisblatt-B families in full and emits their codes with the Ebene and Positionsnummer. Which family is decided by [identity] marktrolle plus IMD+7081. All three run the one walk in mako_pruefung::rechnung; what differs is the second round's Prüfschritt-1 code (A25 against AC1) and the two Preisblatt-only Kopf-Prüfschritte 80/90.

E_0566 and E_0210 — the Messstellenbetriebs-Rechnung toward an NB resp. an LF — are named correctly and carry no Codeliste here, so invoicd answers under the right tree with no code and logs the dispute for an operator. A code borrowed from another tree would be worse than none: A70 is E_0406 Prüfschritt 900 and means nothing in E_0210.

The Marktrolle is configuration, not inference. E_0270 and E_0273 publish the same spellings under different tree numbers, so reading an NB deployment as an LF one answers with codes the counterparty's Codeliste does not contain. A walked ESA answer outranks the setting: it exists only where the wire said KON or an accepted Angebot corroborated it.

E_0264 — what the ESA checks. Three levels with disjoint code ranges: Kopf 10–90 (A01A07, A90), Position 300–430 (A09A15, A20, A99), Summe 500–550 (A21A24, A96). A Kopf-level refusal ends the walk; position defects are all reported, each under its Positionsnummer. Settlement is „ganz oder gar nicht" (WiM Teil 2 UC 4.5.1), so one Befund refuses the whole invoice.

Five Prüfschritte need facts no INVOIC carries — whether the Rechnungsnummer is a repeat (50), whether the service was performed (310), whether the Artikel-ID was billed before (370), whether the § 14 Abs. 4 UStG content is complete (10), and whether the MSB's COMDIS rebutted the objections (E_0266 1). Each is optional on EsaRechnungsFakten, and an unknown answer never refuses. A marktd outage likewise passes Prüfschritt 40 rather than refusing on evidence invoicd does not have.

PID 31004 (Stornorechnung) is a single universal, Sparte-neutral Storno (INVOIC AHB §3.1.2) cancelling an original invoice from any process — GPKE, MMM Strom+Gas, WiM Strom+Gas, Kapazitätsabrechnung, AWH, GeLi Gas. The Sparte is read from Rechnung.sparte, never assumed. It runs the arithmetic-only check and answers with the Sparte-neutral invoic.stornorechnung.{annehmen,ablehnen}.

A Rechnung flagged ist_storno takes that same arithmetic-only check whatever its PID: it carries the original's amounts negated, so a tariff comparison would dispute every line.

MMM reference prices. The Strom Mehr-/Mindermengenpreise are one nationwide monthly BDEW series (§ 13 Abs. 3 StromNZV; GPKE Teil 1 Kap. 8.4 from 01.01.2026), so the application month is the whole key — the sending NB is not part of it. Gas prices are per Marktgebiet, and Trading Hub Europe is the single German MGV.


invoic-checker — checks

#CheckPIDsOutcome on failure
0Storno referenceist_storno=true must have original_rechnungsnummerallDispute
1Billing period validity (boundaries consistent, in scope)allDispute
1.5Zahlungszielfaelligkeitsdatum must not precede rechnungsdatum (invalid: Dispute) or exceed max_zahlungsziel_days (exceeded: Warn)allDispute or Warn
2Position arithmetic (unit price × quantity = line net; tolerance 1%)allDispute
3Document total (sum of positions = Gesamtnetto; tolerance 1%)allWarn
4Tariff unit price within tolerance — ToU-aware: each INVOIC position’s text is matched against the zaehlzeitregister band code of zeitvariablePreispositionen entries. Flat positions fall back to Preisstaffel prices. PID 31009: uses PreisblattMessung. Stornorechnungen: skipped (ist_storno=true carries negated original amounts, not tariff positions)all (not Storno)Warn or Dispute
5Tariff entry found in price sheetall (not Storno)Warn or Dispute
6MMM settlement price — for Strom MMM PIDs (31005/31006): MMMA Strom reference; for Gas MMM PIDs (31007/31008): MMMA Gas reference (THE)31005/31006/31007/31008Warn or Dispute
6AufAbschlag discount validation — for PID 31009: every negative position must match a contracted AufAbschlag name from PreisblattMessung.auf_abschlaege (WiM PRICAT 27001–27003). AufAbschlag names are fetched from marktd and passed to check_msb_rechnung_with_aufabschlaege31009Dispute

Warn outcomes auto-approve unless the total net invoice exceeds auto_dispute_threshold_eur. Set this to 0 to always approve warnings (default).

Gas tariff (31009 / Gas PIDs): Energy (kWh) = Volume (m³) × brennwert_kwh_per_m3 × zustandszahl. Both values are populated in edmd MeterBillingPeriod via PID 13007 (Gas Datenabruf / geli.datenabruf.anfragen).


Payment CloudEvents

invoicd emits outbound payment CloudEvents to your ERP after each validated INVOIC when [erp] webhook_url is configured.

CloudEvents typeTrigger
de.invoic.receipt.settledOutcome Ok, AcceptedPartial, or Warn
de.invoic.receipt.disputedOutcome Dispute
de.invoic.receipt.dispatchedSelf-issued 31006 sent
de.invoic.payment.overdueZahlungsziel passed without confirm-payment

Accepted or disputed, the ERP hears about every checked invoice. dispatched in the payload says whether the market answer actually went out — a settled invoice whose REMADV never left is not one the ERP may pay against.

{
  "specversion": "1.0",
  "type": "de.invoic.receipt.settled",
  "source": "urn:mako:invoicd:tenant:9900357000004",
  "subject": "<process_id>",
  "data": {
    "process_id": "...",
    "pid": 31001,
    "direction": "inbound",
    "sender_mp_id": "9904234560001",
    "outcome": "Ok",
    "pay_by": "2026-10-15",
    "findings_count": 0,
    "dispatched": true
  }
}

de.invoic.payment.overdue — emitted by the payment_overdue worker (every 6 h) for each accepted, dispatched receipt whose pay_by has passed without payment_confirmed_at. A disputed invoice is not overdue. Each receipt is announced once: overdue_notified_at is stamped after delivery, so the notice does not repeat every six hours until someone acts on it.

{
  "specversion": "1.0",
  "type": "de.invoic.payment.overdue",
  "source": "urn:mako:invoicd:tenant:9900357000004",
  "subject": "<receipt_id>",
  "data": {
    "receipt_id": "550e8400-...",
    "process_id": "...",
    "pid": 31001,
    "sender_mp_id": "9904234560001",
    "pay_by": "2026-10-15T00:00:00Z",
    "tenant": "9900357000004"
  }
}

Delivery guarantee — durable at-least-once

The first attempt runs inline, immediately after the market answer is dispatched. On any failure the erp_outbox worker retries with backoff. It claims each batch with a lease (UPDATE … RETURNING, not a pooled SELECT … FOR UPDATE, whose locks do not survive the pooled statement), so replicas do not double-deliver:

AttemptDelay before retry
130 s
25 min
330 min
42 h
5dead-lettered

HTTP status semantics:

  • 2xx — success; erp_notified_at set in invoic_receipts
  • 4xx — permanent failure (bad config / auth); dead-lettered immediately
  • 5xx / transport error — transient; retried per schedule above

Request signing ([erp] hmac_secret): when configured, every POST includes Standard Webhooks (webhook-signature) so the ERP can verify authenticity.

The market answer is dispatched before ERP notification — a failed ERP webhook never blocks the regulatory obligation. Dead-lettered events are counted by invoicd_erp_dead_lettered_total.

Without [erp] webhook_url the events are recorded and nothing delivers them; the service warns at startup.


Idempotency and § 147 AO / GoBD

invoicd writes each receipt to PostgreSQL before dispatching any command to makod. The invoic_receipts table has a UNIQUE (process_id) constraint, so re-delivery of the same de.mako.process.initiated event is a no-op.

Receipts must be retained for 8 years — a received INVOIC is a Buchungsbeleg (§ 147 Abs. 3 AO / § 14b UStG). The received_at column drives the retention query:

-- Receipts past the § 147 Abs. 3 AO retention period:
SELECT * FROM invoic_receipts
WHERE received_at < now() - INTERVAL '8 years';

Payment Lifecycle & Zahlungsstatus

After invoicd dispatches a REMADV, the payment is settled via bank transfer outside the EDIFACT process. invoicd provides an ERP callback endpoint to close the § 147 AO / GoBD / §41 EnWG payment audit trail and a status query endpoint for accounts-payable reconciliation.

POST /api/v1/receipts/{id}/confirm-payment

The ERP calls this endpoint when it confirms that the bank transfer for an invoice has been received. Sets payment_confirmed_at = now() on the receipt.

curl -X POST http://invoicd:8280/api/v1/receipts/550e8400-e29b-41d4-a716-446655440000/confirm-payment \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{}'
# → 204 No Content
ResponseMeaning
204 No ContentPayment confirmed; payment_confirmed_at set
404 Not FoundReceipt not found or already confirmed
403 ForbiddenCaller lacks the write-receipt Cedar action (LF role)

GET /api/v1/zahlungsstatus/{malo_id}

Returns the payment status for all INVOIC receipts linked to a MaLo, with a summary of overdue / pending / settled counts.

curl -s http://invoicd:8280/api/v1/zahlungsstatus/10001234558 \
  -H "Authorization: Bearer <token>" | jq .
{
  "malo_id": "10001234558",
  "overdue_count": 1,
  "pending_count": 2,
  "settled_count": 14,
  "items": [
    {
      "id": "550e8400-...",
      "pid": 31001,
      "zahlungsstatus": "overdue",
      "pay_by": "2026-10-15T00:00:00Z",
      "dispatched_at": "2026-10-01T09:12:00Z",
      "payment_confirmed_at": null,
      "received_at": "2026-10-01T08:00:00Z"
    }
  ]
}

zahlungsstatus values:

ValueCondition
settledpayment_confirmed_at IS NOT NULL
overduedispatched_at IS NOT NULL AND pay_by < now() AND payment_confirmed_at IS NULL
pendingdispatched_at IS NOT NULL AND pay_by >= now() AND payment_confirmed_at IS NULL
undispatcheddispatched_at IS NULL

Alert rule: overdue_count > 0 should trigger an accounts-payable escalation. The payment_overdue background worker (runs every 6 hours) automatically emits de.invoic.payment.overdue CloudEvents to your ERP webhook for each overdue receipt, so dunning workflows can be triggered without polling this endpoint.

Payment lifecycle state machine

stateDiagram-v2
    [*] --> Received : invoicd persists receipt (atomic)
    Received --> Dispatched : REMADV 33001 Zahlungsavis sent
    Received --> Disputed : REMADV 33002 / 33003 / 33004 sent (Dispute outcome)
    Dispatched --> Settled : POST /confirm-payment (ERP ack)
    Dispatched --> Overdue : pay_by passes without confirmation
    Overdue --> Settled : POST /confirm-payment (late ERP ack)
    Disputed --> Resolved : POST /resolve-dispute (operator closes after NB agreement)
    Resolved --> [*] : § 147 AO / GoBD audit trail complete
    Settled --> [*] : § 147 AO / GoBD audit trail complete

Configuration reference

invoicd reads its configuration from a TOML file (default: invoicd.toml), with secrets deferred to environment variables via "env:VAR_NAME" values.

Startup inputs

The lifecycle is owned by mako_service::run; the binary takes no config CLI flags. It discovers everything from the environment:

SettingSourceDefaultDescription
Config file pathINVOICD_CONFIG envinvoicd.tomlPath to invoicd.toml
Tracing filterLOG_LEVEL / RUST_LOG envinfoLog level
--checkcontainer HEALTHCHECKProbe the running instance's /health/ready and exit 0/non-zero. Used by the Dockerfile HEALTHCHECK.

Any TOML key may be overridden by an INVOICD_-prefixed environment variable (__ separates nested sections, e.g. INVOICD_DATABASE__URL).

INVOICD_CONFIG=/etc/invoicd/invoicd.toml invoicd

Full invoicd.toml reference

[http]
addr = "0.0.0.0:8280"          # default

[database]
# Required for § 147 AO / § 14b UStG receipt retention (Buchungsbelege, 8 years).
url             = "env:DATABASE_URL"   # required; use env: for secrets
max_connections = 5                    # default

[identity]
tenant     = "9900357000004"           # required — MP-ID of the operator
marktrolle = "lieferant"               # lieferant (default) | netzbetreiber | esa

[makod]
url     = "http://makod:8080"          # required
api_key = "env:INVOICD_MAKOD_API_KEY" # required

[marktd]
url     = "http://marktd:8180"            # required
api_key = "env:INVOICD_MARKTD_API_KEY"   # required

[webhook]
inbound_secret = "env:INVOICD_INBOUND_SECRET"  # optional; omit for dev

[subscription]
# Self-registers with marktd on startup — no manual curl required.
# The event type and PID filter are not configurable: invoicd acts on
# de.mako.process.initiated for the PIDs in its routing table and nothing else.
webhook_url   = "http://invoicd:8280/webhook"  # public URL marktd POSTs to
subscriber_id = "invoicd"                        # default

[check]
# Relative tolerances for invoic-checker plausibility pipeline.
arithmetic_tolerance       = 0.01   # 1 % — qty × price = line net
total_tolerance            = 0.01   # 1 % — Σ line nets = Gesamtnetto
tariff_tolerance           = 0.03   # 3 % — PRICAT unit price vs INVOIC
require_tariff             = false  # true → missing tariff escalates to Dispute
auto_dispute_threshold_eur = 0.0    # 0.0 → Warn always auto-approved
max_zahlungsziel_days      = 30     # 0 = disable; default 30 (§7 Allg. Festlegungen)

[erp]
# Required for ERP accounts-payable automation.
webhook_url = "https://erp.example.com/webhooks/invoicd"
# Optional: sign outbound requests with HMAC-SHA256.
# The ERP verifies via webhook-signature: v1,<base64>.
hmac_secret = "env:INVOICD_ERP_HMAC_SECRET"

[edmd]
# Required only for POST /api/v1/selbstausstellen (PID 31006), which reads the
# measured quantity for the Bilanzierungsmonat. Omitted, that endpoint answers
# 503; nothing else in the service needs it.
url = "http://edmd:8380"
# api_key = "env:INVOICD_EDMD_API_KEY"

# [oidc]          # omit to disable auth (dev only — never omit in production)
# issuer   = "https://login.microsoftonline.com/{tenant-id}/v2.0"
# audience = "api://mako-invoicd"
# jwks_refresh_secs = 300

# [otel]          # omit to disable tracing
# endpoint = "http://otel-collector:4317"

marktd subscription

invoicd auto-registers its fan-out subscription with marktd on startup when subscription.webhook_url is set in the config — no manual curl required.

To force re-registration or verify the subscription:

curl -s http://marktd:8180/api/v1/subscriptions/invoicd \
  -H "Authorization: Bearer <token>" | jq .

Self-issued Mehrmengen-Rechnung (PID 31006)

PID 31006 is the Mehrmenge leg of a Mehr-/Mindermengen settlement, written by the receiving party itself — the Gutschriftverfahren of § 14 Abs. 2 Satz 2 UStG. It is not a Netznutzungsrechnung; that is PID 31002.

The endpoint settles one Bilanzierungsmonat. Mehr-/Mindermengen settle per month, and the price series is published per application month, so a period straddling two months has no single price to settle against.

curl -X POST http://invoicd:8280/api/v1/selbstausstellen \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "malo_id":        "51238696012",
    "nb_mp_id":       "9900000000002",
    "year":           2026,
    "month":          6,
    "bilanziert_kwh": "12500.000"
  }'
InputSource
gemessen_kwhedmd GET /api/v1/imbalance/{malo}/{y}/{m} — edmd measures
bilanziert_kwhthe caller — edmd does not balance; the allocated quantity is a commercial figure from the LF's own Bilanzkreis
Mehr-/Mindermengenpreisemarktd GET /api/v1/mmm-preise/strom/{y}/{m}

A month with no imported prices is refused (422) rather than settled against a neighbouring month's: an invoice wrong by that margin is one nobody notices.

The document is built by grid_billing::settle_mmm with selbstausgestellt, so the rendered BO4E states netznutzungrechnungsart = Selbstausgestellt and netznutzungrechnungstyp = Mehrmindermengenrechnung. An NNE settlement stamped with PID 31006 renders as a Handelsrechnung instead — individually well-formed fields, and a document the AHB rejects.

The document crosses the BO4E outbound gate before it is dispatched. grid-billing checks its own emissions in tests, but that covers the shapes settle_mmm produces, not the values this request supplies — the quantities, the period, the price series. The counterparty runs the same rules on receipt (invoic-checker stage 3) and answers a document that does not reconcile with a REMADV rejection, so a non-conformant document is a 422 here rather than a dispute there.

The receipt carries makod's process id, so the answering REMADV, a later Storno and the payment confirmation all find the same row.

Electricity needs both parties to hold §3g Wiederverkäufer status for § 13b Abs. 2 Nr. 5 Buchst. b UStG to shift the tax, which a self-issued invoice cannot assert from the issuer's side alone — so the endpoint settles at the ordinary rate.


MCP tools

ToolDescription
get_receiptGet a single INVOIC receipt by process UUID
list_disputesList all receipts with outcome Dispute
get_check_resultGet the full invoic-checker findings for a process
list_overdue_remadvReceipts approaching Zahlungsziel without dispatched REMADV
get_zahlungsstatusPayment status per MaLo-ID (settled / pending / overdue counts)
summarize_billing_monthMonthly billing volume + dispute rate per NB counterparty
list_exceptionsThe two operator queues: undispatched answers, and INVOICs that could not be processed at all

MCP prompts

PromptDescription
resolve-disputeGuided dispute investigation (check classification + resolution steps)
check-overdue-remadvMonitor and action overdue REMADV dispatches
monthly-billing-review§ 147 AO / GoBD monthly reconciliation checklist
detect-systematic-errorsFind NB counterparties with systematic billing errors

The invoice-reconciliation-agent in agentd subscribes to de.invoic.payment.overdue and de.invoic.receipt.disputed, runs the systematic-error detection workflow automatically, and escalates when a single NB exceeds 10% dispute rate over 2+ consecutive months.


Monitoring

Query / metricTarget
outcome IN ('Ok','AcceptedPartial','Warn') rate> 95 %
outcome = 'Dispute' count< 1 % of volume
pay_by < now() + INTERVAL '3 days' AND dispatched_at IS NULL0
pay_by < now() AND payment_confirmed_at IS NULL AND dispatched_at IS NOT NULL0 (trigger dunning)
invoic_dlq WHERE resolved_at IS NULL0 — an unprocessed Buchungsbeleg

Alert when receipts approach pay_by without a dispatched_at — the counterparty may not have received the answer and will begin a dispute window. POST /api/v1/receipts/{id}/dispatch-answer re-sends it.

Prometheus metrics (/invoicd/metrics)

These invoicd-specific gauges live at /invoicd/metrics; the runner mounts the generic request-counter /metrics separately.

All gauges are tenant-scoped.

MetricAlert when
invoicd_receipts_total
invoicd_disputes_totalrising against a single counterparty
invoicd_overdue_remadv_total> 0 — an unanswered invoice past its Zahlungsziel
invoicd_erp_dead_lettered_total> 0 — the ERP is not hearing about settled invoices
invoicd_dlq_open_total> 0 — an unprocessed Buchungsbeleg
invoicd_receipts_by_pid_outcome{pid, outcome}
-- invoic_receipts (§ 147 AO / § 14b UStG, 8-year retention)
SELECT
  process_id,    -- UUID, unique business key
  invoice_ref,   -- EDIFACT BGM 1004 — what makod routes the answer by
  pid,           -- 31001..31009, 31011
  direction,     -- 'inbound' | 'outbound'
  sender_mp_id,  -- NB/MSB MP-ID
  outcome,       -- 'Ok' | 'AcceptedPartial' | 'Warn' | 'Dispute'
                 -- | 'Resolved' | 'Dispatched' | 'Paid'
  pay_by,        -- Zahlungsziel from INVOIC SG8 DTM+265
  received_at,   -- first ingest timestamp
  dispatched_at, -- when the answer went out
  payment_confirmed_at  -- set by POST /confirm-payment
FROM invoic_receipts
WHERE tenant = 'your-tenant-gln';

An inbound receipt without invoice_ref is refused by the schema: it could be checked but never answered, and discovering that at the Zahlungsziel is a day too late.

Edit this page ↗