portald Operator Guide

portald operator guide: customer portal read-model gateway (LF role). Aggregates Lastgang, invoices, the document inbox, account ledger, supply status and EEG settlement into one customer-facing REST API, and proxies the §41 EnWG self-service writes. Every route resolves customer ownership through vertragd. Port :9480.

portald — Customer Portal Gateway

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.

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, so a portal reply can never be staler than they are and replicas need no coordination.
  • 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=… and relays the verdict. vertragd owns the OIDC verifier, the customer record and the mapping, so it is the only place that can answer "may this identity read this delivery point" — and a second verifier here could only ever disagree with it.

The service credential rides as X-Api-Key, never as a second Authorization header: which identity vertragd sees must not depend on header ordering.

vertragd answersportald answers
2xx with a kunden_idthe request proceeds
401 / no bearer / no customer profile401
403403
unreachable, 5xx, or 2xx without a kunden_id503

An authorization service that cannot answer is not an answer of yes.

One gate, applied everywhere. auth::authorize is the only way to obtain a PortalAuthCtx, and every customer-scoped handler takes one by value — a route that skips the check has no context to work with. tests/authorization_guard.rs drives all 15 routes against a refusing vertragd and fails if any of them answers with upstream data.

Object ownership is checked too. GET …/invoices/{record_id}/download re-reads the billing record and compares its malo_id to the authorised one before rendering. Authorising the path parameter alone would let any customer stream any invoice in the tenant by id.

Starting without vertragd_url is refused unless allow_insecure_no_auth = true is set explicitly — an omitted URL is a mistake, not a request to serve every customer's data to every caller.


Endpoints

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

MethodPathUpstream
GET/dashboardall five, concurrently
GET/lastgang?from=&to=edmd — BO4E Lastgang array
GET/invoices?limit=&outcome=billingd — page size clamped to 100
GET/invoices/{record_id}/downloadbillingd — XRechnung 3.0 CII XML (EN 16931)
GET/dokumente?kind=&limit=outputd — the document inbox: what was issued and sent
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
PUT/kontaktvertragd — GDPR Art. 16
PUT/sepaaccountingd

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

Invoices and documents are two different lists

/invoices is what billingd calculated — drafts the risk gate is holding included. /dokumente is what the customer was sent, byte for byte, with the delivery evidence beside it. An inbox shows the second; opening a document there records the read receipt a § 41f EnWG dispute asks about, which is more than § 126b BGB requires and exactly what is asked for afterwards.

Both are scoped twice: portald forwards the authorised MaLo, and 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 and nothing else. Whether a lieferende or wirksamkeit is reachable depends on the Vertragsart, on whether the customer is a Haushaltskunde (§ 3 Nr. 57 EnWG) and on 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. vertragd holds all of them.

A second, simpler rule here could only disagree with the one that decides, and would reject terminations the contract allows. vertragd answers 422 with the rule it applied, and that answer is relayed unchanged.

Call GET /kuendigungsfrist first to show the customer the reachable dates.

SEPA mandates

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

Two fields the caller does not supply. sequence_type — the scheme requires a FRST collection before any RCUR, and the sequence is accountingd's to track across the mandate's life. mandatsref — derived here with a random suffix (35 characters, the SEPA MndtId limit), so a customer correcting a mistyped IBAN the same day gets a new mandate rather than reusing the reference of the one being replaced.

debtor_address is optional until 15 November 2026, when version 1.1 of the 2025 SEPA rulebooks ends the unstructured address and the schemes begin requiring town + country on every collection.


Configuration

# portald.toml
port   = 9480
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"

# 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"

# 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

[mcp]
api_key = "env:PORTALD_MCP_API_KEY"

There is deliberately no oidc_issuer / oidc_audience: portald verifies no tokens, and a key suggesting otherwise would misstate where the trust boundary is.


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/portald:latest
  ports: ["9480:9480"]
  volumes:
    - ./portald.toml:/etc/mako/portald.toml:ro
  environment:
    PORTALD_VERTRAGD_SERVICE_KEY: "${PORTALD_VERTRAGD_SERVICE_KEY}"

MCP server

/mcp (Streamable HTTP), 8 read-only tools:

ToolDescription
get_dashboardAggregated snapshot: supply status, latest invoice, balance
get_lastgangConsumption time-series, optional ISO-8601 range
get_invoicesBilling history, newest first
get_balanceOpen-items net balance (positive = owed)
get_kontoauszugFull account statement, for dispute investigation
get_vorauszahlungAbschlag amount, cycle, next due date
get_eeg_statusEEG/KWKG plants + settlements
get_versorgungSupply status and effective date

Prompts: customer-overview, billing-dispute, eeg-foerderung-check.

This surface is operator-facing, not customer-facing. Its tools take a malo_id and carry no customer token, so they do not run through the authorization gate the REST routes do — whoever can call /mcp can read every customer in the tenant. That is the right shape for a customer-service agent and the wrong one for a portal: gate it with [mcp], keep it off the public ingress, and never hand its credential to an end user.


Informatorisches Unbundling (§9 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, sperrd) 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

Edit this page ↗