accountingd Operator Guide
accountingd operator guide — Massenkontokorrent / Customer Account Ledger (LF role). Tamper-evident double-entry ledger (the doubleentry crate — Merkle proofs, period seals), per-Marktlokation Kontokorrent + GL contra chart (SKR 03/04-aligned), FIFO open-item management, camt.054 XML + JSON dedup import, SEPA pain.008 (multi-group single message, mandatory Gläubiger-ID) + pain.001 XML, Verzugszinsen §288 BGB, payment plans (Zahlungsvereinbarung), aging analysis, Mahnwesen automatic rule engine (Mahnstufe 1–3), OIDC/JWT auth, inbound HMAC verification, GDPR Art. 17 pseudonymization, balance reconciliation, EEG Gutschrift + Marktprämie ingest, Jahresabschluss §40 EnWG.
accountingd — Massenkontokorrent / Customer Account Ledger
accountingd provides the FI-CA equivalent for the mako retail billing stack.
Without it, billingd invoices are fire-and-forget — no Offene-Posten tracking,
no automated dunning, no SEPA collection.
Port: :9380
Why a dedicated ledger?
SAP IS-U calls this module FI-CA (Financial Contract Accounting). powercloud and
Wilken ENER:GY both include it natively. accountingd provides the same capabilities
as a standalone microservice with CloudEvents integration.
The ledger is event-driven and idempotent. CloudEvents from billingd, einsd,
and invoicd drive entries atomically — re-delivering the same CloudEvent produces
no duplicate entry, because every post carries the CloudEvent id as the doubleentry
ledger's idempotency key (an identical replay is a store-level no-op).
Event flow
graph TB
billingd["billingd :9280"]
einsd["einsd :9180"]
invoicd["invoicd :8280"]
accountingd["accountingd :9380"]
erp["ERP webhook"]
sperrd["sperrd :8780"]
portald["portald :9480"]
bank["Bank adapter<br/>(pain.001 SCT/Inst)"]
billingd -->|"de.billing.rechnung.erstellt → RECHNUNG debit<br/>(is_correction=true → STORNO credit; a Gutschrift is a negated Rechnung)"| accountingd
einsd -->|"de.eeg.verguetung.berechnet (carries the §14 UStG Gutschrift: number, net, USt, brutto)<br/>→ EEG_GUTSCHRIFT credit + pain.001 SCT Inst auto-payout (§25 EEG 2023)"| accountingd
einsd -->|"de.eeg.marktpraemie.berechnet → EEG_MARKTPRAEMIE credit"| accountingd
invoicd -->|"de.invoic.receipt.settled → ZAHLUNG credit"| accountingd
accountingd -->|"de.accounting.mahnung.issued (Mahnstufe 1–3)"| erp
accountingd -->|"de.accounting.sperrandrohung / .sperrankuendigung (§41f)"| erp
accountingd -->|"de.accounting.sperrauftrag (§41f Sperrauftrag)"| sperrd
accountingd -->|"de.accounting.eeg.payout.rejected (pain.002 RJCT)"| erp
accountingd -->|"pain.001 XML (SCT Inst <10s / CORE D+1)"| bank
bank -->|"pain.002 ACCP/RJCT → PUT /eeg/payouts/{id}/status"| accountingd
accountingd -->|"GET /kontoauszug"| portald
Ledger entry types
entry_type | Sign | Trigger |
|---|---|---|
RECHNUNG | +debit | de.billing.rechnung.erstellt (is_correction=false) |
STORNO | ±signed | de.billing.rechnung.erstellt (is_correction=true) — billing reversal / Gutschrift (a Gutschrift is a negated Rechnung, not a separate event) |
ZAHLUNG | -credit | CAMT.054 import or de.invoic.receipt.settled |
EEG_GUTSCHRIFT | -credit | de.eeg.verguetung.berechnet — §21 EEG Einspeisevergütung |
EEG_MARKTPRAEMIE | -credit | de.eeg.marktpraemie.berechnet — §20 EEG Direktvermarktung |
BANKRUECKLAST | +debit | Returned SEPA direct debit |
MAHNGEBUEHR | +debit | Dunning fee per Mahnstufe (configurable) |
ABSCHLAG | −credit | Monthly advance payment — reduces the balance (Abschlagslauf scheduler) |
JAHRESABSCHLUSS | ±signed | Annual Mehr-/Mindermengenabrechnung (§40 EnWG) |
KORREKTUR | ±signed | Manual operator correction via POST /buchen |
Balance = the signed net of the customer's Kontokorrent leg in the ledger — negative = credit balance (customer overpaid); positive = outstanding debt. (accounts.balance_ct mirrors this net as a derived read cache.)
No f64 money. All amounts use i64 cents (1 ct = 0.01 EUR). The pain.008 XML
generator uses integer arithmetic — no floating-point rounding errors.
Mahnwesen (dunning) lifecycle
The dunning engine operates in two modes: automatic (background worker) and manual (operator-triggered).
graph LR
subgraph auto ["Auto-dunning worker (daily, dunning_auto_enabled=true)"]
trigger["balance_ct > 0<br/>+ oldest RECHNUNG > grace_days<br/>+ no active dunning case"]
a1["Auto: Mahnstufe 1<br/>created + fee1 (\u20ac0)"]
a2["Auto: Mahnstufe 2<br/>+ fee2 (\u20ac5.00)"]
a3["Auto: Mahnstufe 3<br/>+ fee3 (\u20ac10.00)<br/>\u2192 opens \u00a741f Sperr-Sequenz"]
trigger -->|"grade_days elapsed"| a1
a1 -->|"due_date passed"| a2
a2 -->|"due_date passed"| a3
end
subgraph manual ["Manual operator path"]
m1["POST /dunning/{id}/escalate<br/>stufe=1|2|3"]
end
resolved["POST /dunning/{id}/resolve"]
a1 -->|"payment received"| resolved
a2 -->|"payment received"| resolved
a3 -->|"payment received"| resolved
m1 -->|"payment received"| resolved
Automatic escalation (P1-5 fix): set dunning_auto_enabled = true in config.
The worker runs daily and is idempotent (auto_dunning_runs UNIQUE guard). After
escalation it runs the §§41f/41g Sperr-Sequenz —
Sperrandrohung → Sperrankündigung → Sperrauftrag — for every qualifying
Mahnstufe-3 case.
Manual escalation: POST /api/v1/dunning/{account_id}/escalate remains available
for operator override (e.g. grace extensions, special B2B arrangements).
Endpoints
| Method | Path | Description |
|---|---|---|
POST | /webhook | Ingest CloudEvents (billingd, einsd, invoicd) — HMAC-verified |
GET/PUT | /api/v1/accounts/{malo_id} | Account CRUD (IBAN, Abschlag, billing_day) — OIDC required for PUT |
GET | /api/v1/accounts/{malo_id}/balance | Current balance in ct; status: overdue/credit/settled |
GET | /api/v1/accounts/{malo_id}/ledger | Paged ledger entries |
GET | /api/v1/accounts/{malo_id}/kontoauszug | Account statement (portald-consumable) |
GET | /api/v1/accounts/{malo_id}/open-items | Offene Posten — authoritative unpaid/partial invoices (after recorded clearings) |
POST | /api/v1/accounts/{malo_id}/clear | Record a FIFO Zahlungszuordnung (open credits → oldest open debits) |
POST | /api/v1/clearings/{clearing_id}/reset | Release a mis-assigned Zahlungszuordnung |
GET | /api/v1/trial-balance | Summen- und Saldenliste (§ 238 HGB) — Soll/Haben/Saldo per account, Σ debits = Σ credits |
PUT | /api/v1/accounts/{malo_id}/abschlag | Update monthly advance payment |
GET/PUT | /api/v1/accounts/{malo_id}/vorauszahlung | Typed rubo4e::current::Vorauszahlung (§40 EnWG) |
GET/PUT | /api/v1/accounts/{malo_id}/zahlungsinformation | Typed rubo4e::current::Zahlungsinformation |
POST | /api/v1/accounts/{malo_id}/buchen | Manual booking (operator-authorised ledger entry) |
POST | /api/v1/accounts/{malo_id}/reconcile | Balance reconciliation — detect/repair balance_ct cache drift |
POST | /api/v1/accounts/{malo_id}/anonymize | GDPR Art. 17 pseudonymization (preserves ledger) — OIDC required |
GET/POST | /api/v1/accounts/{malo_id}/interest-charges | Verzugszinsen §288 BGB — list/book default interest |
GET/POST | /api/v1/accounts/{malo_id}/payment-plans | Zahlungsvereinbarung — list/create payment plans |
GET | /api/v1/aging | Aging analysis — receivables by 0–30d / 31–60d / 61–90d / >90d buckets |
POST | /api/v1/periods/{period_id}/seal | Festschreibung (GoBD / § 146 AO) — close + seal a period; body { "start", "end" } |
GET | /api/v1/periods/seals | Seal history + chain verification (chain_valid) |
GET | /api/v1/entries/{entry_id}/proof | Merkle inclusion proof an entry is committed (content hash + tree head) |
POST | /api/v1/payments/import | Ingest CAMT.054 bank statement (JSON array, deduplicated by bank_transaction_id) |
GET | /api/v1/offene-posten | Overdue accounts |
GET | /api/v1/dunning | Open dunning cases |
POST | /api/v1/dunning/{account_id}/escalate | Manual Mahnstufe escalation |
POST | /api/v1/dunning/{id}/resolve | Mark dunning case resolved |
POST | /api/v1/dunning/{id}/abwendung | Record an accepted Abwendungsvereinbarung (§41g Abs. 1 S. 10 EnWG) — bars disconnection of the supply point (halts every open case of the account) |
POST | /api/v1/dunning/{id}/unverhaeltnismaessig | Flag Unverhältnismäßigkeit/Schutzbedürftigkeit (§41f Abs. 1 S. 2 / Abs. 2 EnWG) — halts every open case of the account |
GET | /api/v1/payment-plans/{id} | Get payment plan with full installment schedule |
DELETE | /api/v1/payment-plans/{id} | Cancel payment plan (CANCELLED status) |
POST | /api/v1/sepa/mandates | Register SEPA mandate (IBAN validated via mod-97) — OIDC required |
GET | /api/v1/sepa/mandates/{id} | Fetch mandate |
DELETE | /api/v1/sepa/mandates/{id} | Revoke mandate (§58 ZAG) |
POST | /api/v1/sepa/run | Generate one pain.008 message (one PmtInf group per SequenceType, mandatory Gläubiger-ID) |
POST | /api/v1/payments/import/camt054 | Ingest a camt.054 XML notification (batch-booked entries expanded per TxDtls; returns → BANKRUECKLAST) |
GET | /api/v1/eeg/payouts | List EEG payout orders (?status=PDNG|ACCP|RJCT|CANC) |
GET | /api/v1/eeg/payouts/{id} | Single EEG payout with pain001_xml for audit |
POST | /api/v1/eeg/payouts/run | Batch-generate pain.001 for all unbatched EEG_GUTSCHRIFT entries |
PUT | /api/v1/eeg/payouts/{id}/status | Process pain.002 ACCP/RJCT/CANC |
POST | /api/v1/jahresabschluss/{malo_id} | Annual settlement (§40 EnWG, idempotent per year; refund on Erstattung) |
PUT | /api/v1/accounts/{malo_id}/business-partner | Link account to a kunden_nr |
GET | /api/v1/business-partners/{kunden_nr}/accounts | All accounts of a business partner |
GET | /api/v1/business-partners/{kunden_nr}/balance | Consolidated balance |
GET | /metrics | Prometheus financial + operational gauges |
GET | /health · /health/ready | Liveness / readiness |
Manual booking (POST /api/v1/accounts/{malo_id}/buchen)
For operator-authorised bookings not driven by CloudEvents:
curl -X POST "http://accountingd:9380/api/v1/accounts/51238696780/buchen" \
-H "Content-Type: application/json" \
-d '{
"entry_type": "ZAHLUNG",
"amount_ct": -5000,
"reference_id": "BANK-TXN-2026-07-10",
"description": "Überweisung Kunde (ausserhalb SEPA)"
}'
Allowed entry_type values: RECHNUNG, ZAHLUNG, GUTSCHRIFT, EEG_GUTSCHRIFT,
EEG_MARKTPRAEMIE, BANKRUECKLAST, MAHNGEBUEHR, ABSCHLAG, JAHRESABSCHLUSS, KORREKTUR, STORNO.
amount_ct: positive = debit (increases outstanding debt); negative = credit (reduces debt).
Jahresabschluss (§40 Abs. 1 EnWG)
The annual settlement compares actual billed amounts against advance payments collected:
# Preview (dry_run=true)
curl "http://accountingd:9380/api/v1/jahresabschluss/51238696780?year=2025&dry_run=true"
# Commit
curl -X POST "http://accountingd:9380/api/v1/jahresabschluss/51238696780?year=2025"
Response:
{
"malo_id": "51238696780",
"year": 2025,
"rechnung_sum_ct": 120000,
"abschlag_paid_ct": -108000,
"settlement_ct": 12000,
"settlement_eur": "120.00",
"new_monthly_abschlag_ct": 10000,
"action": "NACHZAHLUNG",
"committed": true,
"ce_id": "3fa85f64-..."
}Model
ABSCHLAG entries are advance-payment credits (negative) and the annual
Jahresrechnung is booked as a full-cost debit (gesamtbrutto), so the
running balance already equals the settlement:
settlement_ct = rechnung_sum + abschlag_sum (abschlag_sum is negative)
= 1300.00 − 1200.00 = 100.00 → Nachzahlung
- Nachzahlung (settlement > 0): no settlement entry is written — the balance is the open receivable, collected by the SEPA/dunning path.
- Erstattung (settlement < 0): a clearing debit zeroes the credit balance
and a pain.001 refund is generated to the customer's IBAN (returned in
the response and dispatched as
de.accounting.erstattung.faellig). Without a stored IBAN the credit is carried forward and offset against the next Rechnung.
The run is idempotent per (tenant, malo_id, year) via jahresabschluss_runs
and recalibrates the monthly abschlag_ct to actual_annual ÷ 12.
The annual sum includes RECHNUNG + STORNO + MAHNGEBUEHR.
Business partner aggregation (FI-CA contract account)
One customer (vertragd.kunden.kunden_nr) may hold several market-location
accounts. Linking them enables cross-MaLo balance and dunning:
# Link an account to its business partner
curl -X PUT ".../api/v1/accounts/51238696780/business-partner" \
-H 'Content-Type: application/json' -d '{"kunden_nr":"K-100234"}'
# Consolidated view
curl ".../api/v1/business-partners/K-100234/accounts"
curl ".../api/v1/business-partners/K-100234/balance"Sperr-Sequenz (§§41f/41g EnWG)
Since 23.12.2025 (BGBl. 2025 I Nr. 347, umsetzend EU-RL 2024/1711) the
payment-default disconnection of a Haushaltskunde is governed by §§41f/41g
EnWG — not the repealed §19 StromGVV/GasGVV (which now covers only the
illegal-use case). accountingd drives the sequence itself; the daily dunning
worker calls sperr::run_sperr_sequence every cycle (not only when new
Mahnungen were created), advancing each qualifying Mahnstufe-3 case one phase:
| Phase | Trigger | Frist | Action | Rechtsgrundlage |
|---|---|---|---|---|
| 1. Sperrandrohung | Mahnstufe 3, both §41f Abs. 3 thresholds cleared (see below), not halted | ≥ 4 Wochen nach Mahnung | de.accounting.sperrandrohung via outbox; sets sperrandrohung_at | §41f Abs. 1 |
| 2. Sperrankündigung | Androhung + sperrandrohung_frist_days (default 28) elapsed | announces disconnection 8 Werktage im Voraus | de.accounting.sperrankuendigung via outbox; sets sperrankuendigung_at + geplantes_sperrdatum = heute + 8 Werktage (BDEW-Kalender) | §41f Abs. 5 |
| 3. Sperrauftrag | geplantes_sperrdatum reached | — | POST sperrd /api/v1/sperr-orders (order_type: "sperrung"); sets sperrauftrag_ce_id | §41f |
Each phase is idempotent (its candidate query excludes already-advanced cases); the first two commit the state flag and the outbound CloudEvent in one transaction (persist-before-dispatch), because the Androhung and Ankündigung are legal acts (letters the ERP must send). The sequence halts on:
- an accepted Abwendungsvereinbarung (§41g Abs. 1 S. 10 — acceptance in
Textform bars disconnection):
POST /api/v1/dunning/{id}/abwendung→abwendung_vereinbart_at; - an Unverhältnismäßigkeit / Schutzbedürftigkeit (§41f Abs. 1 S. 2 / Abs. 2
— payment prospect, or konkrete Gefahr für Leib oder Leben):
POST /api/v1/dunning/{id}/unverhaeltnismaessig→unverhaeltnismaessig_seit.
Both halts are account-scoped: disconnection is per supply point and
auto-dunning creates a fresh case per Mahnstufe, so each flag is set on every
open dunning case of the account owning {id}. Both are filtered out by every
phase query, so a halted account never progresses. Fristen are configurable (sperrandrohung_frist_days,
sperrankuendigung_frist_werktage). The governing text is §§ 41f–41g EnWG in the
consolidated version of 23.12.2025 (BGBl. 2025 I Nr. 347).
Threshold — both §41f Abs. 3 gates
A Mahnstufe-3 case enters Phase 1 only when it clears both gates:
- Satz 2 (absolute floor): arrears ≥
sperrung_threshold_ct(default 100 EUR). - Satz 1 (consumption-relative): arrears ≥ 2× the agreed monthly Abschlag
(
accounts.abschlag_ct); wenn keine Abschläge vereinbart sind (abschlag_ct = 0), arrears ≥ ⅙ of the most recent expected annual bill (jahresabschluss_runs.annual_bill_ct).
When neither an Abschlag nor a prior Jahresrechnung is on record the Satz-1
gate cannot be established and the case is conservatively excluded — mako
never disconnects without a provable consumption basis. Populate abschlag_ct
(set at contract start / recalibrated by the Jahresabschluss) to arm the sequence.
No ERP webhook → notice phases paused
The Androhung and Ankündigung are legal acts (letters the ERP renders and sends
off the emitted CloudEvent). If erp_webhook_url is not configured there is
no dispatch path, so Phases 1–2 are paused — no case is marked, so none can
progress to a Sperrauftrag without its notices having been sent. (Phase 3 needs
no ERP, but has no candidates until Phase 2 has run, so the sequence stays inert
until a webhook is set.)
Follow-up (documented): the §41g Sozialhilfeträger consent flow (Abs. 3–6) and the Abwendungsvereinbarung Ratenzahlung content (6–18 / 12–24 months) are ERP concerns triggered off the emitted CloudEvents.
Metrics
GET /metrics exposes Prometheus gauges queried live on scrape:
accountingd_open_receivables_ct, accountingd_credit_balances_ct,
accountingd_dunning_open{stufe}, accountingd_sepa_runs_pending,
accountingd_sperrung_pending, accountingd_accounts_total.
Vorauszahlung (§40 Abs. 1 EnWG)
curl -X PUT "http://accountingd:9380/api/v1/accounts/51238696780/vorauszahlung" \
-H "Content-Type: application/json" \
-d '{
"_typ": "VORAUSZAHLUNG",
"betrag": { "_typ": "BETRAG", "wert": "75.00", "waehrung": "EUR" },
"gueltigkeit": { "_typ": "ZEITRAUM", "startdatum": "2026-08-01" }
}'
Syncs abschlag_ct = 7500 atomically. GET returns the stored BO4E object or synthesises
from abschlag_ct when no typed value has been stored.
IBAN validation
Every SEPA mandate PUT validates the IBAN using ISO 13616 mod-97 via the
sepa crate (sepa::validate_iban).
Covered by dedicated IBAN unit tests (DE, GB, NL, AT, CH, checksum failures, length, lowercase).
Offene-Posten-Verwaltung (authoritative clearing)
Open items are authoritative, not a computed view: every post records a FIFO
Zahlungszuordnung in the doubleentry clearing register — open credits (payments,
Abschläge, Gutschriften) are matched against the oldest open debits (invoices, fees).
GET /api/v1/accounts/{malo_id}/open-items then returns the debits' real residuals
after everything that has actually been paid (§ 252 HGB Abs. 1 Nr. 4 —
Einzelbewertung of receivables, SAP-FI-CA "oldest-first"):
{
"malo_id": "51238696780",
"open_items": [
{ "entry_id": "…", "entry_type": "RECHNUNG", "amount_ct": 8000,
"outstanding_ct": 0, "booking_date": "2026-05-15" },
{ "entry_id": "…", "entry_type": "RECHNUNG", "amount_ct": 12000,
"outstanding_ct": 15000, "booking_date": "2026-06-15" }
]
}
POST /api/v1/accounts/{malo_id}/clearre-runs the match (idempotent — assigns nothing when everything is already cleared).POST /api/v1/clearings/{clearing_id}/resetreleases a mis-assigned clearing; the applied amounts return to the postings' residuals and the original record stays (an assignment made and withdrawn is part of the trail).
Unlike a running balance, this tracks which payment settled which invoice — recorded in the ledger, provable, and reversible.
Summen- und Saldenliste (GET /api/v1/trial-balance)
The GL trial balance (§ 238 HGB): gross Soll/Haben turnover and the Saldo per
account, with the per-Marktlokation Kontokorrent leaves aggregated into one
Debitoren line. Σ debits = Σ credits by construction (balanced: true), so it
doubles as an integrity check and a DATEV/SAP-FI export basis.
The authoritative balance is the doubleentry Kontokorrent net; balance_ct is the
read cache of it, and open-items add invoice-level transparency.
Balance integrity (POST /reconcile)
The doubleentry Kontokorrent net is authoritative; accounts.balance_ct is a cache
refreshed from it after every post. Reconcile compares the two and re-derives the
cache from the ledger:
# Check only
curl -X POST "http://accountingd:9380/api/v1/accounts/51238696780/reconcile"
# Detect + repair
curl -X POST "http://accountingd:9380/api/v1/accounts/51238696780/reconcile?repair=true"
Response:
{
"is_consistent": true,
"cached_balance_ct": 5000,
"recomputed_balance_ct": 5000,
"drift_ct": 0
}
When drift_ct != 0, the repair=true flag resets balance_ct to the authoritative
ledger net. Because the cache is set absolutely (not incremented) on every post, drift
is not expected — this is a defence-in-depth health check for the weekly pipeline.
Festschreibung + audit proofs (GoBD / § 146 AO / § 239 HGB)
Closing a period seals it: the doubleentry ledger commits to which entries the period contains and what they add up to, as chained BLAKE3 Merkle roots. A sealed period is terminal — a backdated booking into it is refused, and a correction books into a later open period carrying its original date (§ 146 Abs. 4 AO).
# Seal January 2026 (Festschreibung)
curl -X POST "http://accountingd:9380/api/v1/periods/2026-01/seal" \
-H 'content-type: application/json' \
-d '{"start":"2026-01-01","end":"2026-01-31"}'
# → { "period":"2026-01", "seal_hash":"…", "tree_root":"…",
# "trial_balance_root":"…", "entry_count": 41234, "prev_seal":"…" }
# The seal history, with chain verification
curl "http://accountingd:9380/api/v1/periods/seals" # → { count, chain_valid, seals:[…] }
Seals chain, so removing or reordering a sealed period breaks every seal after
it — chain_valid catches that. Any single entry is independently provable:
curl "http://accountingd:9380/api/v1/entries/{entry_id}/proof"
# → { content_hash, tree_size, tree_root, verified: true, proof: {…} }
The O(log n) inclusion proof lets an auditor confirm the entry is committed to by
the current head without access to this service — the tamper-evidence the old
single-row ledger could not give.
GDPR Art. 17 — Pseudonymization
curl -X POST "http://accountingd:9380/api/v1/accounts/51238696780/anonymize" \
-H "Content-Type: application/json" \
-d '{ "requested_by": "operator-1", "legal_basis": "GDPR Art. 17 - customer request #42" }'
What is anonymized: accounts.iban → ANONYMIZED, mandatsref/zahlungsinformation/vorauszahlung → NULL; sepa_mandates.iban → ANONYMIZED, kontoinhaber → ANONYMIZED, bic → NULL.
What is preserved: The entire double-entry ledger (amounts, dates, kinds, references) is untouched — it is immutable and append-only, and exempt from GDPR Art. 17 under Art. 17(3)(b) and §238 HGB / §147 AO retention requirements (10 years). Only the personal-data columns on the account and mandate rows are pseudonymized; no posting is ever altered or removed.
Audit trail: An immutable record is written to anonymization_log (GDPR Art. 5(2)).
The operation is idempotent — returns 409 Conflict if already anonymized.
CAMT.054 payment import
curl -X POST "http://accountingd:9380/api/v1/payments/import" \
-H "Content-Type: application/json" \
-d '[{ "iban": "DE89 3704 0044 0532 0130 00", "amount_eur": "155.42",
"reference": "Rechnung R2026-06-001", "date": "2026-07-10",
"bank_transaction_id": "NTRY-REF-20260710-001" }]'
Response: { "accepted": 1, "deduplicated": 0, "skipped": 0, "total": 1 }
CAMT.054 deduplication
Every import entry is checked against bank_import_log before a ledger entry is created.
The deduplication key is bank_transaction_id (from CAMT.054 <NtryRef> or <EndToEndId>).
When that field is absent, a deterministic hash of (iban|amount|date|reference) is used.
Re-importing the same bank file (operator error, ERP retry) is safe — duplicates are
counted as deduplicated, not accepted. Cross-tenant isolation: bank_import_log is
scoped by tenant.
IBAN lookup (encrypted-IBAN compatible)
CAMT.054 matching uses iban_hash — a keyed BLAKE3 hash of the normalised IBAN, computed
in the application and keyed by the iban_hash_secret. Keying matters: the IBAN keyspace is
small enough to enumerate offline, so an unkeyed digest would leak the plaintext from a stolen
hash column; the secret makes that attack infeasible. The hash is written alongside the row, so
lookup works even when iban_encrypted = true (the plaintext is encrypted, the keyed hash is the
index). Absent secret → an unkeyed hash with a startup warning (dev only).
Amount parsing uses sepa::ct_from_eur_str — integer arithmetic only, no f64.
Aging analysis
curl "http://accountingd:9380/api/v1/aging"
Response:
{
"tenant": "9910000000002",
"total_overdue_ct": 120000,
"total_overdue_eur": "1200.00",
"total_overdue_accounts": 12,
"buckets": [
{ "bucket": "0-30d", "account_count": 5, "total_ct": 40000, "total_eur": "400.00" },
{ "bucket": "31-60d", "account_count": 4, "total_ct": 50000, "total_eur": "500.00" },
{ "bucket": "61-90d", "account_count": 2, "total_ct": 20000, "total_eur": "200.00" },
{ "bucket": ">90d", "account_count": 1, "total_ct": 10000, "total_eur": "100.00" }
]
}
The age is computed from the oldest unresolved dunning_cases.issued_at, falling back
to accounts.updated_at. Use this report for receivables management, provisioning,
and §252 HGB Abs. 1 Nr. 4 Vorsichtsprinzip assessments.
Verzugszinsen §288 BGB (default interest)
When a customer invoice remains unpaid past its due date, the creditor is entitled to
statutory default interest per §288 BGB. accountingd calculates and books interest
as a MAHNGEBUEHR ledger entry:
curl -X POST "http://accountingd:9380/api/v1/accounts/51238696780/interest-charges" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"invoice_reference": "R2026-05-001",
"principal_ct": 50000,
"is_b2b": false,
"period_from": "2026-06-15",
"period_to": "2026-07-15"
}'| Rate type | Formula | Legal basis |
|---|---|---|
| B2C | ECB Basiszinssatz + 5 pp | §288 Abs. 1 BGB |
| B2B | ECB Basiszinssatz + 9 pp | §288 Abs. 2 BGB |
The current ECB Basiszinssatz is read from the ecb_base_rates table, which is
pre-seeded and updated twice per year (1 January + 1 July) per §247 BGB.
Formula: interest_ct = principal_ct × rate × days / 36500 (no float arithmetic).
# List interest charges for an account
curl "http://accountingd:9380/api/v1/accounts/51238696780/interest-charges"
Payment plans (Zahlungsvereinbarung)
A structured payment plan (Zahlungsvereinbarung) allows a customer in financial
difficulty to pay an overdue balance in instalments, suppressing automatic Sperrung
escalation at Mahnstufe 3 while the plan is ACTIVE.
# Create a 3-month plan: 300 EUR split into 3 × 100 EUR
curl -X POST "http://accountingd:9380/api/v1/accounts/51238696780/payment-plans" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"total_ct": 30000,
"installment_ct": 10000,
"billing_day": 1,
"first_due_date": "2026-08-01",
"dunning_case_id": "a1b2-...",
"note": "Customer agreed to payment plan #42"
}'
The response includes a plan_id and auto-generated installment schedule:
{
"plan": { "plan_id": "...", "status": "ACTIVE", "installment_count": 3 },
"installments": [
{ "installment_no": 1, "due_date": "2026-08-01", "amount_ct": 10000, "status": "PENDING" },
{ "installment_no": 2, "due_date": "2026-09-01", "amount_ct": 10000, "status": "PENDING" },
{ "installment_no": 3, "due_date": "2026-10-01", "amount_ct": 10000, "status": "PENDING" }
]
}
Plan lifecycle:
graph LR
ACTIVE -->|"all paid"| COMPLETED
ACTIVE -->|"DELETE /payment-plans/{id}"| CANCELLED
ACTIVE -->|"installment missed"| DEFAULTED
DEFAULTED -->|"re-escalate dunning"| escalate["Mahnstufe +1"]
Double-entry accounting — the doubleentry ledger
The ledger is the doubleentry crate: an
immutable, tamper-evident double-entry engine (balanced by construction, an
append-only BLAKE3 Merkle log with O(log n) inclusion/consistency proofs, period
seals for GoBD/§ 146 AO Unveränderbarkeit, open-item clearing, and store-level
idempotency). It runs in the doubleentry PostgreSQL schema of accountingd's own
database. accountingd owns the chart of accounts and the mapping; doubleentry owns
the invariants — the §15/§20 boundary of the crate's design.
Each Buchungsart maps to one balanced entry with two legs: the per-Marktlokation
Kontokorrent (Kontokorrent:<lf_mp>:<malo>, an Asset leaf — the SKR 1400
Debitoren subledger, whose signed net is the customer balance) against a GL contra
leaf. The customer leg's direction follows the sign of the amount, so the Kontokorrent
net reproduces the old balance_ct exactly, and the GL leaves roll up to the SKR trial
balance (ledger::Chart):
entry_type | Customer leg (Kontokorrent) | GL contra |
|---|---|---|
RECHNUNG | Debit | Erlöse (SKR 4000) |
ABSCHLAG, ZAHLUNG | Credit | Bank (SKR 1200) |
BANKRUECKLAST | Debit | Bank (SKR 1200) |
GUTSCHRIFT | Credit | Erlöse (SKR 4000) |
MAHNGEBUEHR | Debit | Mahnerlöse (SKR 4003) |
EEG_GUTSCHRIFT, EEG_MARKTPRAEMIE | Credit | EEG-Aufwand (Expense) |
JAHRESABSCHLUSS (Erstattung) | Debit | Erstattungen (Liability) |
STORNO, KORREKTUR | by sign | Erlöse (SKR 4000) |
Soll = Haben is enforced in-engine and by a deferred DB constraint trigger in the
doubleentry schema (§238 HGB). The entry_type rides along as the entry's
doubleentry kind label (persisted, hashed, and surfaced on every statement line), and
provenance records the source system, the CloudEvent id, and the operator. Every entry
is provable to an auditor via a Merkle inclusion proof — a guarantee a plain mutable
ledger table cannot give.
SEPA payments
accountingd uses the sepa crate (0.5) —
schema defaults are the current SEPA releases (pain.008.001.08,
pain.001.001.09) and can be pinned per bank via the pain008_schema /
pain001_schema config keys (e.g. pain.008.001.02 for banks still on the
pre-2023 EPC version); dates flow through the crate's typed IsoDate, names are
transliterated into the SEPA character set, and every message is validated
before serialisation (build() returns a located Err — PmtInf[1]/Tx[…]: …
— instead of emitting a bank-rejectable file):
graph LR
subgraph out ["Outgoing payments"]
pain008["pain.008 SDD<br/>Direct Debit<br/>(N-5 scheduler + /sepa/run)"]
pain001["pain.001 SCT / SCT Inst<br/>EEG Verg\u00fctung + Erstattungen<br/>(/eeg/payouts/run, auto_payout)"]
end
subgraph in ["Bank responses"]
pain002["pain.002 parser<br/>Payment Status Report<br/>(PUT /eeg/payouts/{id}/status)"]
camt053["camt.053 parser<br/>End-of-day statement<br/>(reconciliation)"]
camt054["camt.054 parser<br/>Debit/Credit notification<br/>(/payments/import/camt054)"]
end
creditor["Creditor Identifier<br/>(EPC AT-02)"]
creditor --> pain008
creditor --> pain001pain.008 Direct Debit
curl -X POST "http://accountingd:9380/api/v1/sepa/run" > batches.json
Returns one pain.008 message containing one PmtInf group per
SequenceType present (FRST, RCUR, FNAL, OOFF — in that order, with
PmtInfId = <MsgId>-<SEQ>). The EPC SDD Core Rulebook §3.8 requires FRST and
RCUR in separate payment-information blocks; they live in separate groups of
the same file, so a collection run is a single bank submission and a single
sepa_collection_runs audit row.
Response shape:
{
"collection_date": "2026-07-25",
"entry_count": 43,
"total_ct": 320000,
"groups": [
{ "sequence_type": "FRST", "entry_count": 1, "total_ct": 5000 },
{ "sequence_type": "RCUR", "entry_count": 42, "total_ct": 315000 }
],
"xml": "<?xml version=\"1.0\"?>..."
}
Key features of the pain.008 generator:
- Typed
SequenceType: FRST/RCUR/FNAL/OOFF dispatch per mandate - Gläubiger-ID (EPC AT-02):
creditor_idfrom config is validated viasepa::validate_creditor_id(correct EPC262-08 check digits) and included as<CdtrSchmeId>— required; a missing or invalid CI blocks the run (the EPC rulebook mandates it, banks reject without it) Mandatsreferenz=EndToEndId: capped at 35 characters (Max35Text) — enforced at mandate registration and by a DB CHECKwith_description: Each entry carries"Abschlag YYYY-MM"as RemittanceInfo (Ustrd) — visible on debtor's bank statement- Hard error: missing or invalid
creditor_ibanreturns HTTP 503 (no silent placeholder IBAN) - N-5 scheduler: Background worker auto-generates and dispatches the pain.008 message 5 days before each
billing_day; persisted once per collection date insepa_collection_runsfor audit and ERP replay
To revoke a mandate (§58 ZAG — customer right to revoke before cut-off):
curl -X DELETE "http://accountingd:9380/api/v1/sepa/mandates/{mandate_id}"
After the first successful direct debit collection, the mandate automatically transitions
from FRST to RCUR (tracked via first_collected_at). Operators do not need to manually
update the sequence type.
pain.001 Credit Transfer — EEG SCT Inst payout pipeline
accountingd implements a full §25 EEG 2023 payment pipeline: when
de.eeg.verguetung.berechnet is received from einsd, it credits the ledger
(EEG_GUTSCHRIFT) and — when auto_payout = true — immediately generates
a SEPA Credit Transfer pain.001 and schedules payout to the plant operator.
SCT Inst vs SCT CORE
| Mode | TOML | XML schema | Settlement | Legal basis |
|---|---|---|---|---|
| SCT Instant | sepa_instant = true | pain.001.001.09 | <10 seconds | EU Reg 2024/886 |
| SCT CORE | sepa_instant = false | pain.001.003.03 | D+1 | SEPA SCT Rulebook |
§25 Abs. 1 EEG 2023 mandates "unverzüglich nach Ende des Monats". SCT Inst satisfies this more strongly than CORE, which becomes D+2 across weekends. EU Regulation 2024/886 mandates SCT Inst support for all PSPs from October 2025.
Payout flow
sequenceDiagram
participant einsd
participant accountingd
participant DB as PostgreSQL
participant Bank as Bank adapter
einsd->>accountingd: de.eeg.verguetung.berechnet<br/>{malo_id, settlement_eur, bank_iban, bank_bic, zahlungsempfaenger}
accountingd->>DB: ledger.post EEG_GUTSCHRIFT (doubleentry)
accountingd->>accountingd: build_pain_001(instant=cfg.eeg.sepa_instant)
accountingd->>DB: INSERT eeg_payout_orders<br/>(SCT_INST, end_to_end_ref, pain001_xml)
alt bank_submit_url configured
accountingd->>Bank: POST pain.001 XML
Bank-->>accountingd: 200 OK
accountingd->>DB: SET submitted_at, pain002_status=PDNG
Bank-->>accountingd: pain.002 ACCP/RJCT
accountingd->>DB: PUT /eeg/payouts/{id}/status → settled_at
endCreditor IBAN resolution
einsd forwards bank_iban + bank_bic + zahlungsempfaenger in every
de.eeg.verguetung.berechnet CE (from the eeg_anlagen.bank_iban column).
accountingd uses the CE-supplied IBAN as the fast path, falling back to
accounts.zahlungsinformation.bankverbindung.iban when the CE lacks bank fields.
EEG payout order lifecycle
[created]
│ build_pain_001() → pain001_xml stored
▼
[pain002_status = NULL]
│ POST to bank_submit_url (if configured)
▼
[pain002_status = PDNG] ← awaiting pain.002 confirmation
│
├── PUT /eeg/payouts/{id}/status { status: "ACCP" }
│ → settled_at = now()
│ → [pain002_status = ACCP] ✅ funds credited to plant operator
│
└── PUT /eeg/payouts/{id}/status { status: "RJCT", reason_code: "AC01" }
→ de.accounting.eeg.payout.rejected CloudEvent
→ [pain002_status = RJCT] ❌ operator must correct IBAN and retryEndpoints
# List payout orders for a specific plant/month
curl "http://accountingd:9380/api/v1/eeg/payouts?malo_id=51238696780&year=2026&month=7"
# Get single order with full pain.001 XML
curl "http://accountingd:9380/api/v1/eeg/payouts/a1b2c3d4-..."
# Manually batch-generate for all unbatched EEG_GUTSCHRIFT entries
curl -X POST "http://accountingd:9380/api/v1/eeg/payouts/run" \
-H "Content-Type: application/json" \
-d '{ "billing_year": 2026, "billing_month": 7, "instant_override": true }'
# Process pain.002 bank confirmation (called by bank adapter)
curl -X PUT "http://accountingd:9380/api/v1/eeg/payouts/a1b2c3d4-.../status" \
-H "Content-Type: application/json" \
-d '{ "status": "ACCP" }'
# Pain.002 rejection with EPC reason code
curl -X PUT "http://accountingd:9380/api/v1/eeg/payouts/a1b2c3d4-.../status" \
-H "Content-Type: application/json" \
-d '{ "status": "RJCT", "reason_code": "AC01" }'eeg_payout_orders table
| Column | Type | Description |
|---|---|---|
payout_id | UUID PK | Generated automatically |
malo_id | TEXT | Plant MaLo |
tr_id | TEXT? | Plant Anlage-ID |
billing_year, billing_month | SMALLINT | Settlement period |
amount_ct | BIGINT | Payout amount (positive, EUR-cent) |
creditor_iban | TEXT | Plant operator IBAN |
payment_type | TEXT | SCT_INST or SCT_CORE |
end_to_end_ref | TEXT UNIQUE | ISO 20022 EndToEndId (EEG-{malo}-{year}-{month}-{ce_short}) |
pain001_xml | TEXT | Full pain.001 XML (audit + replay) |
pain002_status | TEXT? | PDNG | ACCP | RJCT | CANC |
pain002_reason | TEXT? | EPC reason code (e.g. AC01 = invalid IBAN) |
submitted_at | TIMESTAMPTZ? | When XML was POSTed to bank adapter |
settled_at | TIMESTAMPTZ? | When ACCP received (funds credited) |
source_ce_id | TEXT UNIQUE | Source de.eeg.verguetung.berechnet CE id — idempotency guard |
[eeg] configuration
[eeg]
sepa_instant = true # SCT Inst (<10s) vs SCT CORE (D+1)
auto_payout = true # generate pain.001 on every settlement CE
debtor_iban = "env:LF_BANK_IBAN" # LF's own account (debit side)
bank_submit_url = "https://banking.internal/pain001" # optional: auto-submit to bank
bank_api_key = "env:BANK_API_KEY"
When auto_payout = false (default), operators trigger payouts manually via
POST /api/v1/eeg/payouts/run. The table always provides a full audit trail.
pain.002 + camt.053 parsers (sepa 0.5.0)
| Parser | Use case |
|---|---|
sepa::parse_pain002 | Bank rejection report → auto-create BANKRUECKLAST entries |
sepa::parse_camt053 | End-of-day bank statement → full automated reconciliation |
Idempotency
Every money movement carries an idempotency key into the doubleentry ledger — a
CloudEvent id, a bank transaction id, or a deterministic string
(ABSCHLAG-{malo}-{YYYY}-{MM}, mahngebuehr:{malo}:{stufe}:{date}, bank:{txn}).
An identical replay is a store-level no-op returning the original entry; the same
key with different content is refused. The /buchen endpoint is idempotent when a
reference_id is supplied (a fresh random key otherwise).
Database schema
accounts
| Column | Notes |
|---|---|
account_id | UUID primary key |
malo_id, lf_mp_id | Customer + LF identity |
balance_ct | Ledger-derived balance cache (i64 ct) — set absolutely from the doubleentry Kontokorrent net after each post (never incremented → cannot drift); backs the portfolio SUM queries. NOT the system of record. |
abschlag_ct | Monthly advance payment in ct |
billing_day | Day of month for advance payment (1–28) |
iban | SEPA mandate IBAN; when iban_encrypted = true stores ciphertext |
iban_hash | App-computed keyed BLAKE3 hash of the normalised IBAN — used for CAMT.054 matching even when the IBAN is encrypted (no pgcrypto) |
iban_encrypted | false (default) or true when column stores encrypted ciphertext |
mandatsref | Active SEPA mandate link (fast lookup) |
vorauszahlung | rubo4e::current::Vorauszahlung JSONB |
zahlungsinformation | rubo4e::current::Zahlungsinformation JSONB |
anonymized_at | GDPR Art. 17 timestamp — set when account is pseudonymized |
Tenant isolation: (malo_id, lf_mp_id, tenant) UNIQUE constraint.
The ledger — doubleentry schema
The journal, per-account balances, the append-only Merkle log, period seals, and
open-item clearing live in the doubleentry schema (the crate's own tables:
entries, postings, accounts, log_subtrees, seals, clearings, …), applied
by PgLedger::connect at startup. There is no ledger_entries/journal_lines
table in accountingd's public schema any more — booking_date/value_date
(§238 HGB Buchungsdatum vs. Wertstellung), immutability, and the balance invariant
are all properties of the doubleentry engine.
sepa_mandates
| Column | Notes |
|---|---|
mandatsref | UNIQUE per (tenant, mandatsref) — no cross-tenant namespace collisions |
sequence_type | FRST / RCUR / FNAL / OOFF |
signed_at | Datum der Unterzeichnung |
revoked_at | Set by DELETE /api/v1/sepa/mandates/{id} |
created_at | Mandate creation timestamp (audit trail) |
first_collected_at | Set on first successful collection → triggers FRST→RCUR auto-transition |
sepa_collection_runs
One row per pain.008 batch run. Stores the full XML for audit and ERP webhook replay.
dispatch_status: PENDING → DISPATCHED → FAILED.
UNIQUE (tenant, collection_date) prevents duplicate batches.
interest_charges
Verzugszinsen per §288 BGB. Links to a MAHNGEBUEHR ledger entry.
Stores principal_ct, interest_ct, rate_pct, ecb_base_rate_pct, customer_type (B2C/B2B), period_from, period_to, legal_basis.
ecb_base_rates
ECB Basiszinssatz history (§247 BGB). Updated twice per year (1 Jan + 1 Jul). Pre-seeded with rates through 2026-07-01. New rates must be inserted by the operator via SQL.
payment_plans + payment_plan_installments
Zahlungsvereinbarung lifecycle (ACTIVE/COMPLETED/CANCELLED/DEFAULTED).
payment_plan_installments: one row per scheduled payment, UNIQUE (plan_id, installment_no).
bank_import_log
CAMT.054 deduplication log. UNIQUE (tenant, bank_transaction_id). Prevents duplicate
ZAHLUNG/BANKRUECKLAST entries on re-import of the same bank file.
dunning_cases, anonymization_log, auto_dunning_runs
Standard schema — see migrations/0001_schema.sql.
jahresabschluss_runs
Idempotency guard for POST /jahresabschluss: one row per (tenant, malo_id, billing_year)
prevents double annual settlement. (Ledger-level idempotency — duplicate ABSCHLAG or event
replays — is handled by the doubleentry idempotency key, so no separate run table is needed.)
account_audit_log (INSERT-only)
§238 HGB traceability: records every change to account master data (IBAN, billing_day, abschlag_ct)
with operator_sub (JWT sub), action (endpoint), old_values and new_values (JSONB).
Security
OIDC/JWT authentication
All financial write endpoints (PUT /accounts, POST /mandates, POST /interest-charges,
POST /payment-plans, DELETE /payment-plans, POST /anonymize) require a valid JWT via
Authorization: Bearer <token>.
When [oidc] is not configured, the service accepts all requests but emits a startup warning:
[WARN] OIDC disabled — financial write endpoints accept all requests (dev mode)Inbound webhook HMAC verification
POST /webhook verifies the X-Mako-Signature: sha256=<hex> header when erp_hmac_secret
is configured. Requests with a missing or invalid signature are rejected with HTTP 403.
Dev mode (no erp_hmac_secret): all webhooks accepted, WARN emitted on each request.
erp_hmac_secret = "env:ACCOUNTINGD_INBOUND_HMAC_SECRET"Secrets
erp_hmac_secret is stored as SecretString internally — it never appears in debug output,
log lines, or config dumps.
Configuration
port = 9380
tenant = "9910000000002"
erp_webhook_url = "http://erp:8000/webhooks/accounting"
erp_hmac_secret = "env:ACCOUNTINGD_INBOUND_HMAC_SECRET"
# OIDC authentication (optional — dev mode when absent, all writes accepted)
[oidc]
issuer = "https://keycloak:8080/realms/mako"
audience = "accountingd"
# Dunning fees per Mahnstufe
dunning_fee_stufe1_ct = 0 # no fee for first reminder
dunning_fee_stufe2_ct = 500 # 5.00 EUR
dunning_fee_stufe3_ct = 1000 # 10.00 EUR
dunning_grace_days = 30
# Auto-dunning rule engine (opt-in, default false)
dunning_auto_enabled = true
# §§41f/41g EnWG disconnection sequence (runs after escalation to Mahnstufe 3)
sperrd_url = "http://sperrd:8780"
sperrung_threshold_ct = 10000 # §41f Abs. 3 S. 2: arrears ≥ 100 EUR
sperrandrohung_frist_days = 28 # §41f Abs. 1: Androhung → Ankündigung, 4 Wochen
sperrankuendigung_frist_werktage = 8 # §41f Abs. 5: Ankündigung → Sperrung, 8 Werktage im Voraus
# SEPA creditor IBAN (required for pain.008 generation; hard error if missing/invalid)
creditor_iban = "DE89370400440532013000"
# SEPA Creditor Identifier (Gläubiger-ID, EPC AT-02)
# Obtain from your bank or the Bundesbank creditor registry.
# Format example: DE74ZZZ09999999999
# Required for POST /sepa/run: a missing creditor_id returns HTTP 503
# (the EPC rulebook mandates CdtrSchmeId; the run does not fall back).
creditor_id = "DE74ZZZ09999999999"
# Display name on pain.008 <Cdtr><Nm> (defaults to tenant if absent)
creditor_name = "Muster Energie GmbH"
# SEPA schema versions (optional; default to the current EPC releases).
# Set only if your bank requires the pre-2023 EPC version. Unknown values are a
# hard error at startup — the service refuses to run rather than emit a
# bank-rejectable file.
# pain008_schema = "pain.008.001.02" # default: pain.008.001.08
# pain001_schema = "pain.001.001.03" # default: pain.001.001.09
# SEPA N-5 pre-notification window (default: 5 calendar days)
sepa_pre_notification_days = 5
# §25 EEG 2023 — SEPA Credit Transfer payout pipeline
[eeg]
sepa_instant = true # SCT Inst (<10s) vs SCT CORE (D+1)
auto_payout = true # generate pain.001 on every settlement CE
debtor_iban = "env:LF_BANK_IBAN"
bank_submit_url = "https://banking-adapter.internal/api/v1/pain001"
bank_api_key = "env:BANK_API_KEY"
# PostgreSQL connection + pool tuning (application_name is set to "accountingd")
[database]
url = "postgresql://accountingd:secret@db:5432/accountingd"
# pool_size = 10 # optional (min_connections, acquire/idle/max_lifetime also available)
creditor_ibanis required. Missing or invalidcreditor_ibancausesPOST /sepa/runto return HTTP 503. The N-5 background worker also blocks (no silent placeholder IBAN fallback).
MCP server
accountingd exposes 12 tools at /mcp (Streamable HTTP 2025-11-25):
| Tool | Description |
|---|---|
get_balance | Current open-items balance in ct |
list_ledger | Ledger entries for a MaLo |
list_dunning | Active dunning cases |
list_overdue | Accounts with overdue invoices |
update_abschlag | Update monthly advance payment |
import_payments | Import CAMT.054 bank entries (deduplicated) |
run_sepa_collection | Generate pain.008 batches for all active mandates |
trigger_jahresabschluss | Run annual settlement (dry-run or commit) |
run_abschlag_cycle | Process Abschlagslauf for a specific billing day |
compute_bilanzielle_abgrenzung | pRAP/aRAP calculation for HGB §250 period close |
suggest_payment_match | AI payment reconciliation — match CAMT.054 to open Rechnungen |
post_manual_booking | Create an operator-authorised ledger entry |
The payment-reconciliation-agent in agentd uses these tools for automated payment
matching (powercloud-equivalent >98% match rate).
Testing
cargo test -p accountingd --all-features # unit + pure-logic integration tests
just test-accountingd-db # DB scenarios against a throwaway Postgres
Unit and pure-logic tests (unit_tests.rs, integration_tests.rs, inline #[cfg(test)])
run without a database and cover:
- IBAN validation (DE/GB/NL/AT/CH — checksum, length, lowercase, mod-97)
- Entry-type sign conventions and STORNO vs KORREKTUR semantics
- Jahresabschluss §40 EnWG: Nachzahlung / Erstattung / Ausgeglichen, STORNO inclusion
- FIFO open-item clearing (oldest-first, partial payment, reset)
- §288 BGB Verzugszinsen: B2C (+5pp) and B2B (+9pp) rates
- pain.008 / pain.001 formatting: integer-only arithmetic,
CtrlSum, FRST/RCUR separation, Gläubiger-ID inclusion,creditor_nameregression guard - GDPR anonymization field-list completeness
DB scenario tests (db_scenarios.rs, #[ignore] — require a live DATABASE_URL) exercise
the doubleentry-backed ledger end-to-end against real PostgreSQL:
- CloudEvent replay books exactly once (idempotency key)
- ABSCHLAG credit nets against RECHNUNG in the account balance
- A conflicting idempotency key is refused
- A payment clears its invoice and the trial balance still balances to zero
- Sealing a period freezes it (Festschreibung / §146 AO)
- Every entry is provable via a Merkle inclusion proof