invoicd Operator Guide
invoicd operator guide: the LF-side INVOIC plausibility check over ten inbound billing PIDs, with § 147 AO receipts and the answer back through makod.
On this page 14 sections
invoicd is the INVOIC plausibility-check daemon for the LF (Lieferant) role.
It subscribes to marktd's fan-out, receives inbound INVOIC events, and:
- Routes the PID through
src/routing.rs, which decides the check, the price sheet and the answer commands. - Fetches the reference data that PID calls for from
marktd, and runs the deterministicinvoic-checkerpipeline. - 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.
- Answers the counterparty through
makod— accept or dispute. - Notifies the ERP with
de.invoic.receipt.*, durable at-least-once.
What the words mean. INVOIC is the EDIFACT invoice message and
REMADV the EDIFACT answer to one — a remittance advice, either „I will pay
this" or „I dispute it". Each is stamped with a Prüfidentifikator (PID), the
five-digit BDEW code naming the business case it carries; the platform-wide list
is the PID reference. LF (Lieferant,
the retail supplier), NB (Netzbetreiber, the grid operator), MSB
(Messstellenbetreiber, the metering operator) and ESA
(Energieserviceanbieter) are market roles — see
Party Roles.
A Marktlokation (MaLo) is the supply point an invoice is about
(MaLo vs MeLo),
an Abschlag is a monthly advance payment, and Mehr-/Mindermengen (MMM)
is the settlement of the gap between metered and balanced energy. invoicd is
the receiving side: it reads invoices other market partners send this operator.
Issuing this operator's own invoices is billingd
and netzbilanzd.
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:
| Routes | Action | Granted to |
|---|---|---|
/receipts, /receipts/{id}, /receipts/{id}/rechnung, /zahlungsstatus/{malo_id} | read-receipt | any caller in the tenant |
/disputes | read-disputes | any caller in the tenant |
/overdue-remadv | read-overdue-remadv | any caller in the tenant |
/receipts/{id}/confirm-payment, /dispatch-answer, /resolve-dispute | write-receipt | LF role |
/selbstausstellen | dispatch-selbstausstellen | LF role |
POST|GET /mcp | use-mcp | any caller in the tenant (the MCP surface is authenticated separately by [mcp]) |
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-schemarechnungstypthat would otherwise decode toUnknown— 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
gesamtbruttothat 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 7 below already produces. Refusing to parse would replace that answer with silence and a dead letter for an operator to find.
Handled PIDs
| PID | Description | Direction | Sparte | Status |
|---|---|---|---|---|
| 31001 | Abschlagsrechnung Netznutzung (NB → LF) | Inbound | ⚡🔥 | ✅ |
| 31002 | NN-Rechnung — Netznutzungsabrechnung (NB → LF) | Inbound | ⚡🔥 | ✅ |
| 31003 | WiM-Rechnung — Abrechnung von Dienstleistungen im Messwesen (MSBA → NB · MSBA → MSBN) | Inbound | ⚡🔥 | ✅ |
| 31004 | Stornorechnung — universal Storno (GPKE/MMM/WiM/Kapazität/AWH/GeLi) | Inbound | Strom + Gas | ✅ arithmetic-only (check_storno) |
| 31005 | MMM-Rechnung Mehr-/Mindermengensaldo | Inbound | Strom | ✅ |
| 31006 | MMM Mehrmenge, selbst ausgestellt (LF → NB) | Inbound + Outbound | Strom | ✅ |
| 31007 | GaBi Gas Aggreg. MMM-Rechnung (NB → MGV) | Inbound | Gas | ✅ + MMM-Preisprüfung |
| 31008 | GaBi Gas selbst ausgest. Aggreg. MMM-Rechnung | Inbound | Gas | ✅ + MMM-Preisprüfung |
| 31009 | MSB-Rechnung (MSB → NB / LF / ESA, WiM) | Inbound | Strom | ✅ PreisblattMessung — or the accepted Angebot toward an ESA |
| 31011 | Rechnung sonstige Leistung (NB → LF) — in practice the Sperr-/Entsperrkosten | Inbound | Strom + Gas | ✅ |
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.
| Finding | Meaning |
|---|---|
AngebotDeviation | a position billed away from the agreed price — dispute |
AngebotPositionUnknown | an Artikel-ID the offer never priced — a charge nobody agreed to; dispute |
TariffNotFound | no 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 (EBD — the decision tree BDEW publishes per Use-Case,
whose numbered Prüfschritte each carry the answer code a checker may return).
The tree is not a constant — every invoice Use-Case has its own quartet, and
PID 31009 alone has three of them:
| PID | Empfänger | Rechnung | Nicht-Zahlungsavis | erneut | Storno |
|---|---|---|---|---|---|
| 31001/31002/31005/31006 | LF | E_0406 | E_0452 | E_0407 | E_0459 |
| 31009 | ESA (IMD+7081 = KON) | E_0264 | E_0265 | E_0266 | E_0267 |
| 31009 | NB, Messstellenbetrieb (MSB) | E_0566 | E_0567 | E_0568 | E_0569 |
| 31009 | NB, Preisblatt B (TEC) | E_0273 | E_0274 | E_0277 | E_0275 |
| 31009 | LF, Messstellenbetrieb (MSB) | E_0210 | E_0211 | — | E_0243 |
| 31009 | LF, Preisblatt B (TEC) | E_0270 | E_0271 | E_0276 | E_0272 |
| 31003 | NB / MSBN | E_0259 | E_0260 | — | E_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+7081 — TEC „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 — resolve to the right tree and their Codelisten ship, so an inbound
REMADV under one of them is read with its published Bedeutung and Cluster rather
than as an opaque string. What is not there is the walk: these trees are not
the ESA/Preisblatt-B one renamed (E_0210 has 37 Prüfschritte to E_0264's 26
and agrees on two of the twenty step numbers they share), so invoicd still
answers them 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 (A01–A07, A90), Position 300–430 (A09–A15, A20, A99),
Summe 500–550 (A21–A24, 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 mako_pruefung::RechnungsFakten, 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
| # | Stage | Finding | Outcome |
|---|---|---|---|
| 1 | Storno reference — ist_storno=true must name original_rechnungsnummer | StorniertWithoutReference | Dispute |
| 2 | Billing period validity — boundaries present and consistent | PeriodInvalid | Dispute |
| 3 | Zahlungsziel — faelligkeitsdatum not before rechnungsdatum, and within max_zahlungsziel_days | ZahlungszielInvalid · ZahlungszielExceeded | Dispute · Warn |
| 3a | WiM 31003 send window — a Dienstleistungsrechnung dated more than 20 Werktage after the period it bills (WiM Teil 1 Kap. 3.7.2 Nr. 1) | RechnungZuSpaet | Warn |
| 4 | Currency agreement — every monetary field on one currency | WaehrungMismatch | Dispute |
| 5 | Position arithmetic — quantity × unit price = line net | ArithmeticError | Dispute |
| 6 | Document total — Σ line nets = gesamtnetto | TotalMismatch | Warn |
| 7 | Umsatzsteuer — the § 14 Abs. 4 Nr. 8 UStG block: a rate and an amount, gesamtbrutto = gesamtnetto + gesamtsteuer, and no tax stated on a reverse-charge invoice | SteuerMissing · SteuerMismatch · ReverseChargeStatesTax | Dispute |
| 8 | Tariff / Angebot — the unit price against the published Preisblatt, or against the accepted ESA offer | TariffDeviation · TariffNotFound · AngebotDeviation · AngebotPositionUnknown | Warn or Dispute |
Stage 3 is skipped when max_zahlungsziel_days = 0, and stage 3a runs only for
PID 31003 — no other invoice family publishes that window. Stage 8 is skipped
for a Stornorechnung, which carries the original's negated amounts rather than
tariff positions. Stage 4 runs before the arithmetic on purpose: a Betrag
marked CHF would otherwise be read as EUR and every later comparison would come
out consistent.
RechnungZuSpaet is the one warning the auto_dispute_threshold_eur
escalation does not pick up. The threshold exists because the money at stake
justifies a human reading the arithmetic; a late invoice is no more late for
being a large one, and escalating it would refuse a correct document with an
Antwortcode no REMADV tree publishes for lateness.
Which price basis stage 8 uses is the routing table's decision, not the stage's:
| Route | Basis | PIDs |
|---|---|---|
Netznutzung | PreisblattNetznutzung of the sending NB | 31001, 31002, 31011 |
NetznutzungMitMmmStrom / …Gas | the same sheet, plus a Mehr-/Mindermengen settlement-price check against the BDEW Strom series resp. the Gas MGV's | 31005, 31006 / 31007, 31008 |
Messung | PreisblattMessung, plus every negative position matched to a contracted AufAbschlag name (WiM PRICAT 27001–27003) | 31003, 31009 |
ArithmetikNur | none — stages 1–6 only | 31004, and any ist_storno document |
Stage 8 is time-of-use aware. A Preisblatt may publish a flat
Preisposition.preisstaffeln price and time-variable prices as
zeitvariablePreispositionen, each carrying a zaehlzeitregister band code; an
INVOIC position's text is matched against those band codes first, and falls back
to the flat Preisstaffel when it names none. Without that a load-profile invoice
with a night rate is disputed against the day price on every line.
Toward an ESA the basis is the accepted Angebot instead of a Preisblatt, as
described above. Both the Messung and the ESA entry point run the document
stages 2–7 and replace stage 8 with their own price basis; neither runs stage 1,
because a Storno is routed to ArithmetikNur before either is reached.
Stages 3 and 7 run on the Messung path too. No rule about MSB invoices
exempts them: the INVOIC AHB makes the Fälligkeitsdatum (SG8 DTM+265, MIG
Nr. 00033) and the tax block (TAX Nr. 00058 with MOA Nr. 00061/00062)
Muss on 31003 and 31009 exactly as on 31001/31002, and § 14 Abs. 4 Nr. 8
UStG reaches every invoice. Stage 7 tells an absent tax block apart from a
reverse-charged zero: a § 13b invoice states 0,00 EUR with an RCV Steuerbetrag
naming the ground, and that is not a SteuerMissing.
The MMM settlement check is skipped, not disputed, when the month's reference
prices are not yet published — the BDEW series lands after the
Bilanzierungsmonat. An unreachable marktd is not that state: it aborts the
check with a ReferenceDataUnavailable rather than reporting a complete check
that silently omitted a stage.
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 type | Trigger |
|---|---|
de.invoic.receipt.settled | Outcome Ok, AcceptedPartial, or Warn |
de.invoic.receipt.disputed | Outcome Dispute |
de.invoic.receipt.dispatched | Self-issued 31006 sent |
de.invoic.payment.overdue | Zahlungsziel 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. It is not claimed and does not spend the budget: a receipt whose
inline POST failed still has all five attempts. On any failure the erp_outbox
worker retries with backoff, claiming 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:
| Attempt | Delay before retry |
|---|---|
| 1 | 30 s |
| 2 | 5 min |
| 3 | 30 min |
| 4 | 2 h |
| 5 | dead-lettered |
HTTP status semantics:
- 2xx — success;
erp_notified_atset ininvoic_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 attempt is counted by the claim, not by the outcome. The same
UPDATE … RETURNING that leases a batch raises erp_attempts, so the budget is
spent by the attempt being made. Nothing after it depends on the outcome write
landing: a receipt whose failure or dead-lettering was never recorded still runs
out of attempts and leaves the outbox, rather than retrying forever because the
statement that would have counted it was the one that was lost.
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| Response | Meaning |
|---|---|
204 No Content | Payment confirmed; payment_confirmed_at set |
404 Not Found | Receipt not found or already confirmed |
403 Forbidden | Caller 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:
| Value | Condition |
|---|---|
settled | payment_confirmed_at IS NOT NULL |
overdue | dispatched_at IS NOT NULL AND pay_by < now() AND payment_confirmed_at IS NULL |
pending | dispatched_at IS NOT NULL AND pay_by >= now() AND payment_confirmed_at IS NULL |
undispatched | dispatched_at IS NULL |
Alert rule:
overdue_count > 0should trigger an accounts-payable escalation. Thepayment_overduebackground worker (runs every 6 hours) automatically emitsde.invoic.payment.overdueCloudEvents 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:
| Setting | Source | Default | Description |
|---|---|---|---|
| Config file path | INVOICD_CONFIG env | invoicd.toml | Path to invoicd.toml |
| Tracing filter | LOG_LEVEL / RUST_LOG env | info | Log level |
--check | container HEALTHCHECK | — | Probe 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).
The config struct is deny_unknown_fields: a misspelled key is a startup
failure, not a silently ignored line.
INVOICD_CONFIG=/etc/invoicd/invoicd.toml invoicdFull 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
pool_size = 10 # 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"
# Required. Omitting it is a startup refusal unless `allow_insecure_no_auth`
# is set, which is how a dev stack opts out deliberately rather than by leaving
# a section out.
[oidc]
# issuer = "https://login.microsoftonline.com/{tenant-id}/v2.0"
# audience = "api://mako-invoicd"
# jwks_refresh_secs = 300
# [mcp] # MCP authentication: an API key beside the OIDC verifier
# # above, for agent clients that mint no token
# api_key = "env:INVOICD_MCP_API_KEY"
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"
}'| Input | Source |
|---|---|
gemessen_kwh | edmd GET /api/v1/imbalance/{malo}/{y}/{m} — edmd measures |
bilanziert_kwh | the caller — edmd does not balance; the allocated quantity is a commercial figure from the LF's own Bilanzkreis |
| Mehr-/Mindermengenpreise | marktd 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
| Tool | Description |
|---|---|
get_receipt | Get a single INVOIC receipt by process UUID |
list_disputes | List all receipts with outcome Dispute |
get_check_result | Get the full invoic-checker findings for a process |
list_overdue_remadv | Receipts approaching Zahlungsziel without dispatched REMADV |
get_zahlungsstatus | Payment status per MaLo-ID (settled / pending / overdue counts) |
summarize_billing_month | Monthly billing volume + dispute rate per NB counterparty |
list_exceptions | The two operator queues: undispatched answers, and INVOICs that could not be processed at all |
MCP prompts
| Prompt | Description |
|---|---|
resolve-dispute | Guided dispute investigation (check classification + resolution steps) |
check-overdue-remadv | Monitor and action overdue REMADV dispatches |
monthly-billing-review | § 147 AO / GoBD monthly reconciliation checklist |
detect-systematic-errors | Find 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 / metric | Target |
|---|---|
outcome IN ('Ok','AcceptedPartial','Warn') rate | > 95 % |
outcome = 'Dispute' count | < 1 % of volume |
pay_by < now() + INTERVAL '3 days' AND dispatched_at IS NULL | 0 |
pay_by < now() AND payment_confirmed_at IS NULL AND dispatched_at IS NOT NULL | 0 (trigger dunning) |
invoic_dlq WHERE resolved_at IS NULL | 0 — 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.
| Metric | Alert when |
|---|---|
invoicd_receipts_total | — |
invoicd_disputes_total | rising 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' (the verdict, never payment)
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.