portald Operator Guide

Operator guide for portald, the customer-portal read-model gateway: one REST API over Lastgang, invoices, ledger and supply status, plus §41 EnWG self-service.

On this page 10 sections

portald is a stateless read-model gateway that aggregates the LF back-end services into one customer-facing REST API, and proxies the § 41 EnWG self-service writes to the services that own them.

It serves the LF (Lieferant) role; every route is keyed on a Marktlokation (MaLo).

graph LR
    customer["Customer app<br/>(mobile / web)"]
    portald["portald :9480<br/>(this service)"]
    vertragd["vertragd :9780<br/>customer ↔ MaLo · contracts"]

    edmd["edmd :8380<br/>Lastgang"]
    billingd["billingd :9280<br/>invoices · XRechnung"]
    accountingd["accountingd :9380<br/>ledger · SEPA"]
    marktd["marktd :8180<br/>VersorgungsStatus"]
    einsd["einsd :9180<br/>EEG settlement"]
    outputd["outputd :9880<br/>issued documents"]

    customer -->|"GET /portal/{malo_id}/…<br/>Bearer JWT"| portald
    portald -->|"1. authenticate?malo_id=…<br/>(customer token forwarded)"| vertragd
    portald -->|"2. read"| edmd
    portald --> billingd
    portald --> accountingd
    portald --> marktd
    portald --> einsd
    portald --> outputd

Port: :9480


Design principles

  • Stateless. No database, no cache, no session store. Every response is assembled from the authoritative services on the request path.
  • No domain policy. Notice periods, tariff rules, IBAN validation and invoice rendering belong to the services that own them. portald validates request shape and relays their verdicts unchanged.
  • Degrade a tile, not the screen. On the dashboard, a field is null when its upstream is unconfigured or has no data.

Authorization

portald verifies no tokens and holds no customer↔MaLo map. It forwards the customer's Authorization: Bearer header to vertragd GET /api/v1/kunden/authenticate?malo_id=… — the owner of the OIDC verifier, the customer record and the mapping — and relays the verdict. Its own service credential rides as X-Api-Key, never as a second Authorization header.

vertragd answersportald answers
2xx with a kunden_idthe request proceeds
401 or 404, or no bearer token on the request401
403403
unreachable, 5xx, or 2xx without a kunden_id503

Every customer-scoped handler takes the PortalAuthCtx only auth::authorize returns; tests/it/authorization_guard.rs fails if any handler answers a refusing vertragd with upstream data. …/invoices/{record_id}/download also compares the record's malo_id with the authorised one. Starting without vertragd_url is refused unless allow_insecure_no_auth = true.


Endpoints

All paths are prefixed /api/v1/portal/{malo_id}.

MethodPathUpstream
GET/dashboardmarktd · accountingd · billingd · edmd — five reads, concurrently
GET/lastgang?from=&to=edmd — BO4E Lastgang array
GET/invoices?limit=&outcome=billingd — page size defaults to 24, clamped to 1–100
GET/invoices/{record_id}/downloadbillingd — the EN 16931 CII XML of the stored model (GET /billing/{id}/xrechnung); BT-24 declares the XRechnung CIUS only on the B2G path
GET/dokumente?kind=&limit=outputd — the document inbox: what was issued and sent. kind is an allowlist (INVOICE, MAHNUNG, PREISANPASSUNG); page size defaults to 50, clamped to 1–200
GET/dokumente/{document_id}outputd — the bytes as issued; opening it records the portal read receipt
GET/balanceaccountingd — open-items balance
GET/kontoauszug?from=&to=accountingd — account statement (§ 666 BGB); both bounds together scope it to a period and open it at that period's balance
GET/vorauszahlungaccountingd — Abschlag schedule (§ 40 Abs. 1 EnWG)
GET/eegeinsd — plants + settlements
GET/versorgungmarktd — supply state
GET/vertragvertragd — active supply contract
GET/kuendigungsfristvertragd — reachable end dates per reason
POST/tarifwechselvertragd
POST/kuendigenvertragd
GET/kontaktvertragd — DSGVO Art. 15; read it before a PUT, which replaces the Geschaeftspartner whole. Projected: operator notes and the ERP key are left out
PUT/kontaktvertragd — DSGVO Art. 16. geschaeftspartner crosses the BO4E gate here, so a mistyped enum is a 422 naming the field
PUT/sepaaccountingd

/health/live, /health/ready and /metrics come from the service runner.

Failures

Every failure is the service runtime's problem body. An upstream that fails — unreachable (503), too slow (504), a 5xx or an unreadable body (502), or not configured in this deployment (503) — is answered with that upstream's name and an error_id the log line carries; its own error text stays in the log. A 4xx refusal from vertragd or accountingd on a write is relayed with its status and body, because it names the rule the customer ran into (a notice period, a contract state, an IBAN check). A record, document or contract that does not exist, or belongs to another Marktlokation, is 404.

Invoices and documents are two different lists

/invoices is what billingd calculated, drafts held by the risk gate included. /dokumente is what the customer was sent, byte for byte, with delivery evidence; opening one records the read receipt a § 41f EnWG dispute asks about. outputd refuses a document query that names neither a MaLo nor a Kundennummer.


Dashboard

GET /api/v1/portal/{malo_id}/dashboard fetches from every configured upstream concurrently and returns one object:

{
  "malo_id": "51238696012",
  "tenant": "9900357000004",
  "kundentyp": "B2C",
  "versorgung":    { "lieferstatus": "Beliefert", "lf_mp_id": "9900357000004" },
  "balance":       { "balance_ct": -4500, "currency": "EUR" },
  "last_invoice":  [ { "id": "…", "total_brutto_eur": "126.14" } ],
  "meter_summary": { "arbeitsmenge_kwh": "312.5", "sparte": "STROM" },
  "vorauszahlung": { "betrag_ct": 8900, "naechste_faelligkeit": "2026-07-01" }
}

Self-service writes (§ 41 EnWG)

Notice periods live in vertragd

portald validates the date format only. Whether a lieferende or wirksamkeit is reachable depends on Vertragsart, Haushaltskunde status (§ 3 Nr. 57 EnWG) and the reason — § 20 Abs. 1 StromGVV/GasGVV in the Grundversorgung, § 41b Abs. 5 EnWG on a move, § 41 Abs. 5 Satz 4 EnWG after a price change, § 309 Nr. 9 lit. c BGB on term length — and vertragd decides. Its 422 naming the rule is relayed unchanged. Call GET /kuendigungsfrist first to show the reachable dates.

SEPA mandates

PUT /sepa registers a mandate with accountingd, which validates the IBAN (ISO 13616 mod-97) and the debtor address.

The caller supplies neither sequence_type (FRST before RCUR is accountingd's to track) nor mandatsref (derived here with a random suffix, 35 characters, the SEPA MndtId limit, so a same-day IBAN correction gets a new mandate).

debtor_address is optional: EPC153-22 v2.2 (30 September 2026) permits a collection without a structured address and sets no end date for that, and accountingd refuses only a half-filled one.


Configuration

# portald.toml
tenant = "9900357000004"

# Required — the authorization authority for every route.
vertragd_url     = "http://vertragd:9780"
vertragd_api_key = "env:PORTALD_VERTRAGD_SERVICE_KEY"   # sent as X-Api-Key

edmd_url        = "http://edmd:8380"
billingd_url    = "http://billingd:9280"
accountingd_url = "http://accountingd:9380"
einsd_url       = "http://einsd:9180"
marktd_url      = "http://marktd:8180"
outputd_url     = "http://outputd:9880"

# Opaque service Bearer tokens; register each in the upstream's service keys.
# edmd_api_key        = "env:PORTALD_EDMD_SERVICE_KEY"
# billingd_api_key    = "env:PORTALD_BILLINGD_SERVICE_KEY"
# accountingd_api_key = "env:PORTALD_ACCOUNTINGD_SERVICE_KEY"
# einsd_api_key       = "env:PORTALD_EINSD_SERVICE_KEY"
# marktd_api_key      = "env:PORTALD_MARKTD_SERVICE_KEY"
# outputd_api_key     = "env:PORTALD_OUTPUTD_SERVICE_KEY"

# MP-ID a self-service SEPA mandate is registered under. Defaults to `tenant`;
# must match accountingd's `lf_mp_id`.
# lf_mp_id = "9900357000004"

# Local development only: serve portal routes without resolving ownership.
# allow_insecure_no_auth = true

[http]
addr = "0.0.0.0:9480"

There is no oidc_issuer / oidc_audience: portald verifies no tokens.


Deployment

Stateless — no schema, no migrations. Deploy as many replicas as needed behind a load balancer.

# docker-compose.yml (excerpt)
portald:
  image: ghcr.io/hupe1980/mako-portald:latest
  ports: ["9480:9480"]
  volumes:
    - ./portald.toml:/etc/mako/portald.toml:ro
  environment:
    PORTALD_CONFIG: /etc/mako/portald.toml
    PORTALD_VERTRAGD_SERVICE_KEY: "${PORTALD_VERTRAGD_SERVICE_KEY}"

No MCP server

portald serves no /mcp. An MCP tool takes a malo_id and carries no customer token, so it cannot pass the vertragd ownership check every portal route makes, and would read every customer in the tenant. Agents read the same data from the service that owns it, each behind that service's Cedar policy:

DatumMCP tool
Invoicesbillingd list_billing_records, get_billing_record
Balance, account statement, Abschlagaccountingd get_balance, list_ledger, get_vorauszahlung
Lastgangedmd get_timeseries
EEG/KWKG plantseinsd list_plants, get_plant
Supply statusmarktd get_versorgungsstatus

agentd's portald-agent holds read grants on these services, none mutating.


Informatorisches Unbundling (§ 6a EnWG)

An LF-role service. It reads marktd only for VersorgungsStatus — the LF's own supply records — never NB grid topology or NB billing data. The unbundled NB services (netzbilanzd, processd's Sperrauftrag queue) are not reachable through it.


ServiceRole
vertragdAuthorization authority; contracts, Tarifwechsel, Kündigung
edmdMeter data
billingdInvoices + XRechnung rendering
accountingdAccount ledger + SEPA
einsdEEG/KWKG settlement
marktdSupply status + MaLo master data
outputdIssued documents + delivery evidence (the inbox)

Edit this page ↗