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_typeSignTrigger
RECHNUNG+debitde.billing.rechnung.erstellt (is_correction=false)
STORNO±signedde.billing.rechnung.erstellt (is_correction=true) — billing reversal / Gutschrift (a Gutschrift is a negated Rechnung, not a separate event)
ZAHLUNG-creditCAMT.054 import or de.invoic.receipt.settled
EEG_GUTSCHRIFT-creditde.eeg.verguetung.berechnet — §21 EEG Einspeisevergütung
EEG_MARKTPRAEMIE-creditde.eeg.marktpraemie.berechnet — §20 EEG Direktvermarktung
BANKRUECKLAST+debitReturned SEPA direct debit
MAHNGEBUEHR+debitDunning fee per Mahnstufe (configurable)
ABSCHLAG−creditMonthly advance payment — reduces the balance (Abschlagslauf scheduler)
JAHRESABSCHLUSS±signedAnnual Mehr-/Mindermengenabrechnung (§40 EnWG)
KORREKTUR±signedManual 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

MethodPathDescription
POST/webhookIngest 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}/balanceCurrent balance in ct; status: overdue/credit/settled
GET/api/v1/accounts/{malo_id}/ledgerPaged ledger entries
GET/api/v1/accounts/{malo_id}/kontoauszugAccount statement (portald-consumable)
GET/api/v1/accounts/{malo_id}/open-itemsOffene Posten — authoritative unpaid/partial invoices (after recorded clearings)
POST/api/v1/accounts/{malo_id}/clearRecord a FIFO Zahlungszuordnung (open credits → oldest open debits)
POST/api/v1/clearings/{clearing_id}/resetRelease a mis-assigned Zahlungszuordnung
GET/api/v1/trial-balanceSummen- und Saldenliste (§ 238 HGB) — Soll/Haben/Saldo per account, Σ debits = Σ credits
PUT/api/v1/accounts/{malo_id}/abschlagUpdate monthly advance payment
GET/PUT/api/v1/accounts/{malo_id}/vorauszahlungTyped rubo4e::current::Vorauszahlung (§40 EnWG)
GET/PUT/api/v1/accounts/{malo_id}/zahlungsinformationTyped rubo4e::current::Zahlungsinformation
POST/api/v1/accounts/{malo_id}/buchenManual booking (operator-authorised ledger entry)
POST/api/v1/accounts/{malo_id}/reconcileBalance reconciliation — detect/repair balance_ct cache drift
POST/api/v1/accounts/{malo_id}/anonymizeGDPR Art. 17 pseudonymization (preserves ledger) — OIDC required
GET/POST/api/v1/accounts/{malo_id}/interest-chargesVerzugszinsen §288 BGB — list/book default interest
GET/POST/api/v1/accounts/{malo_id}/payment-plansZahlungsvereinbarung — list/create payment plans
GET/api/v1/agingAging analysis — receivables by 0–30d / 31–60d / 61–90d / >90d buckets
POST/api/v1/periods/{period_id}/sealFestschreibung (GoBD / § 146 AO) — close + seal a period; body { "start", "end" }
GET/api/v1/periods/sealsSeal history + chain verification (chain_valid)
GET/api/v1/entries/{entry_id}/proofMerkle inclusion proof an entry is committed (content hash + tree head)
POST/api/v1/payments/importIngest CAMT.054 bank statement (JSON array, deduplicated by bank_transaction_id)
GET/api/v1/offene-postenOverdue accounts
GET/api/v1/dunningOpen dunning cases
POST/api/v1/dunning/{account_id}/escalateManual Mahnstufe escalation
POST/api/v1/dunning/{id}/resolveMark dunning case resolved
POST/api/v1/dunning/{id}/abwendungRecord 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}/unverhaeltnismaessigFlag 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/mandatesRegister 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/runGenerate one pain.008 message (one PmtInf group per SequenceType, mandatory Gläubiger-ID)
POST/api/v1/payments/import/camt054Ingest a camt.054 XML notification (batch-booked entries expanded per TxDtls; returns → BANKRUECKLAST)
GET/api/v1/eeg/payoutsList 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/runBatch-generate pain.001 for all unbatched EEG_GUTSCHRIFT entries
PUT/api/v1/eeg/payouts/{id}/statusProcess 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-partnerLink account to a kunden_nr
GET/api/v1/business-partners/{kunden_nr}/accountsAll accounts of a business partner
GET/api/v1/business-partners/{kunden_nr}/balanceConsolidated balance
GET/metricsPrometheus financial + operational gauges
GET/health · /health/readyLiveness / 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:

PhaseTriggerFristActionRechtsgrundlage
1. SperrandrohungMahnstufe 3, both §41f Abs. 3 thresholds cleared (see below), not halted≥ 4 Wochen nach Mahnungde.accounting.sperrandrohung via outbox; sets sperrandrohung_at§41f Abs. 1
2. SperrankündigungAndrohung + sperrandrohung_frist_days (default 28) elapsedannounces disconnection 8 Werktage im Vorausde.accounting.sperrankuendigung via outbox; sets sperrankuendigung_at + geplantes_sperrdatum = heute + 8 Werktage (BDEW-Kalender)§41f Abs. 5
3. Sperrauftraggeplantes_sperrdatum reachedPOST 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}/abwendungabwendung_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}/unverhaeltnismaessigunverhaeltnismaessig_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 ≥ 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}/clear re-runs the match (idempotent — assigns nothing when everything is already cleared).
  • POST /api/v1/clearings/{clearing_id}/reset releases 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.ibanANONYMIZED, mandatsref/zahlungsinformation/vorauszahlungNULL; sepa_mandates.ibanANONYMIZED, kontoinhaberANONYMIZED, bicNULL.

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 typeFormulaLegal basis
B2CECB Basiszinssatz + 5 pp§288 Abs. 1 BGB
B2BECB 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_typeCustomer leg (Kontokorrent)GL contra
RECHNUNGDebitErlöse (SKR 4000)
ABSCHLAG, ZAHLUNGCreditBank (SKR 1200)
BANKRUECKLASTDebitBank (SKR 1200)
GUTSCHRIFTCreditErlöse (SKR 4000)
MAHNGEBUEHRDebitMahnerlöse (SKR 4003)
EEG_GUTSCHRIFT, EEG_MARKTPRAEMIECreditEEG-Aufwand (Expense)
JAHRESABSCHLUSS (Erstattung)DebitErstattungen (Liability)
STORNO, KORREKTURby signErlö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 ErrPmtInf[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 --> pain001

pain.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_id from config is validated via sepa::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 CHECK
  • with_description: Each entry carries "Abschlag YYYY-MM" as RemittanceInfo (Ustrd) — visible on debtor's bank statement
  • Hard error: missing or invalid creditor_iban returns 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 in sepa_collection_runs for 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

ModeTOMLXML schemaSettlementLegal basis
SCT Instantsepa_instant = truepain.001.001.09<10 secondsEU Reg 2024/886
SCT COREsepa_instant = falsepain.001.003.03D+1SEPA 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
    end

Creditor 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 retry

Endpoints

# 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

ColumnTypeDescription
payout_idUUID PKGenerated automatically
malo_idTEXTPlant MaLo
tr_idTEXT?Plant Anlage-ID
billing_year, billing_monthSMALLINTSettlement period
amount_ctBIGINTPayout amount (positive, EUR-cent)
creditor_ibanTEXTPlant operator IBAN
payment_typeTEXTSCT_INST or SCT_CORE
end_to_end_refTEXT UNIQUEISO 20022 EndToEndId (EEG-{malo}-{year}-{month}-{ce_short})
pain001_xmlTEXTFull pain.001 XML (audit + replay)
pain002_statusTEXT?PDNG | ACCP | RJCT | CANC
pain002_reasonTEXT?EPC reason code (e.g. AC01 = invalid IBAN)
submitted_atTIMESTAMPTZ?When XML was POSTed to bank adapter
settled_atTIMESTAMPTZ?When ACCP received (funds credited)
source_ce_idTEXT UNIQUESource 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)

ParserUse case
sepa::parse_pain002Bank rejection report → auto-create BANKRUECKLAST entries
sepa::parse_camt053End-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

ColumnNotes
account_idUUID primary key
malo_id, lf_mp_idCustomer + LF identity
balance_ctLedger-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_ctMonthly advance payment in ct
billing_dayDay of month for advance payment (1–28)
ibanSEPA mandate IBAN; when iban_encrypted = true stores ciphertext
iban_hashApp-computed keyed BLAKE3 hash of the normalised IBAN — used for CAMT.054 matching even when the IBAN is encrypted (no pgcrypto)
iban_encryptedfalse (default) or true when column stores encrypted ciphertext
mandatsrefActive SEPA mandate link (fast lookup)
vorauszahlungrubo4e::current::Vorauszahlung JSONB
zahlungsinformationrubo4e::current::Zahlungsinformation JSONB
anonymized_atGDPR 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

ColumnNotes
mandatsrefUNIQUE per (tenant, mandatsref) — no cross-tenant namespace collisions
sequence_typeFRST / RCUR / FNAL / OOFF
signed_atDatum der Unterzeichnung
revoked_atSet by DELETE /api/v1/sepa/mandates/{id}
created_atMandate creation timestamp (audit trail)
first_collected_atSet 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: PENDINGDISPATCHEDFAILED. 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_iban is required. Missing or invalid creditor_iban causes POST /sepa/run to 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):

ToolDescription
get_balanceCurrent open-items balance in ct
list_ledgerLedger entries for a MaLo
list_dunningActive dunning cases
list_overdueAccounts with overdue invoices
update_abschlagUpdate monthly advance payment
import_paymentsImport CAMT.054 bank entries (deduplicated)
run_sepa_collectionGenerate pain.008 batches for all active mandates
trigger_jahresabschlussRun annual settlement (dry-run or commit)
run_abschlag_cycleProcess Abschlagslauf for a specific billing day
compute_bilanzielle_abgrenzungpRAP/aRAP calculation for HGB §250 period close
suggest_payment_matchAI payment reconciliation — match CAMT.054 to open Rechnungen
post_manual_bookingCreate 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_name regression 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

Edit this page ↗