Skip to main content

DemozPay — Roadmap Reality Check

Snapshot: 2026-05-31. Method: compare claims in README.md, CLAUDE.md, docs/STATUS_LEGEND.md, docs/SYSTEM_GAP.md, docs/SYSTEM_GAP_ACTION_PLAN.md, docs/architecture/restructure-2026-05.md, and the prior docs/API_INVENTORY.md against the code in apps/, packages/, services/, apps/api/prisma/. The companion audit docs/audits/CURRENT_STATE_AUDIT.md is the long-form evidence.

Tone: I'm not here to defend the docs or the code. Where they disagree, the code wins and the gap gets named. Where the docs are right and unflattering, I keep them.


1. What founders + product think exists

Pulled from the documents at the top of the source tree (README.md:1-40, CLAUDE.md:9-19, docs/architecture/restructure-2026-05.md:82-92):

LayerStated claim (paraphrased)
ArchitectureModular monolith + 3 Go services + 6 frontends; bank-to-bank orchestrator (ADR-014)
Infrastructure"Infrastructure is LIVE end-to-end (Phases A-D)" — auth, RBAC, RLS, ledger, recon-runner, metrics, alerts, SLOs
PayrollPlanned per the table at README.md:14 — but CLAUDE.md:35 says "backend LIVE end-to-end E3 2026-05-29"
EWAPartial — domain Live, automated repayment Planned
LendingPartial — same shape as EWA
KYC + sanctionsPartial per README; CLAUDE.md says "backend LIVE end-to-end including disburse-gate + sanctions-at-approve"
EqubPlanned per README:19 — but CLAUDE.md:35 says "backend LIVE end-to-end E3" — and a Phase 1 Alpha shipping is the most recent commit history
BNPLPlanned
SavingsPlanned
Wallet"By design (derived from ledger)" — no separate aggregate
FrontendsPartial — UI shells live, all mock data
NotificationsStub per CLAUDE.md:23
What's blocking pilot"A real pilot to one employer with five employees needs ~7-8 weeks of focused work on top of what's shipped" (README:23)

2. What actually exists in code

SubsystemReality (verified by code, schema, tests)
apps/api NestJS monolithLive. Boots clean. 41 controllers in apps/api/src + 9 controllers in packages/*/backend/presentation. Three APP_GUARDs chained.
Prisma + PostgresLive. 71 models, 85 migrations applied, 20+ tables FORCE RLS with verify guards.
services/ledger/ Go serviceLive. All 8 RPCs implemented (PostTransaction, GetBalance, GetEntries, Reverse, ReconcileAccount, ReconcileWithBank, ConfirmSettlement, MarkSettlementFailed). 7 store integration tests pass. DB invariants (deferred sum-to-zero, append-only, RLS) all enforced.
services/integration-gateway/Live. All 4 RPCs implemented (Initiate, Lookup, Status, AdapterStatus). Mock + Dashen adapters. Recon runner CLI exists. Webhook receiver live.
services/notifications/Stub. /health only. Notification dispatch happens in apps/api/src/_infra/notification-consumers/ instead.
services/bank-sandbox/Live dev harness.
packages/payroll/Live. 41 aggregates across 8 subdomains, 38 use cases, 22+ events, 34 unit tests + 3 integration tests behind RUN_INTEGRATION=1. E3 closed 2026-05-29.
packages/ewa/Live. Aggregate + 4 use cases + 7 events + 5 tests (100% use-case). KYC + lookup gates wired.
packages/lending/Live. 4 aggregates + 5 use cases + 1 query + 8 events + 6 tests. FI remit + per-installment lifecycle.
packages/kyc/Live (backend). 6 use cases, 6 events, maker-checker. Test gap: only 2 of 6 use cases have specs.
packages/sanctions/Live (backend). 2 use cases, OFAC/UN/Ethiopia/custom ingest CLI. Test gap: ingest lacks spec.
packages/equb/Live (Phase 1 Alpha). 2 aggregates + 8 use cases + 8 events. Commit/reveal lottery, PRIVATE invitations. Test gap: 4 of 8 use cases lack specs. Partner-bank statement reader is a null-returning stub.
packages/bnpl/Does not exist. Not found in codebase.
packages/wallet/Does not exist by design (ADR-006). Legacy Wallet/WalletTransaction/WithdrawalRequest Prisma models exist with @deprecated and zero callers.
packages/savings/Does not exist. Not found in codebase.
Better-authLive for email+password, email verification, organization, phone OTP, magic link. Plugin twoFactor is not yet wired (table exists, plugin row pending).
Service-to-service auth (R1)Live. TS signer + Go verifier + tests. Default log-only, flip to strict is a follow-up after observation window.
Outbox publisherLive (code), disabled by default. OUTBOX_PUBLISHER_ENABLED=false. Outbox rows are written; in-process notification consumer reads them directly.
Recon runnerLive (CLI), not yet scheduled. No cron / CronJob wired.
FrontendsPartial. 5 Next.js apps boot and render; all data is hardcoded mocks. Zero API calls from any frontend to apps/api today.
First real bank partnerNone. Dashen adapter exists in code but returns ErrNotConfigured without GATEWAY_DASHEN_BASE_URL+GATEWAY_DASHEN_SIGNING_KEY. No contract in repo.

3. What is partially built (the honest middle column)

ItemWhat's thereWhat's missing
Better-auth 2FATwoFactor Prisma model + AdminMfaGuard enforces TOTP on platform-admin routesThe better-auth twoFactor plugin itself isn't wired into the factory
Idempotency-Key on every money-moving POSTUse-case-level enforcement in EWA + lending + ledger; idempotency_record tableNot a route-level guard; PRs can land new money-moving POST without it
Bank-statement reconciliationDashen CSV ingester + matcher + runner CLI + 4 testsNo cron / CronJob; no admin UI to view flagged drift
Equb partner-bank escrow reconPartnerBankEscrowBinding + EqubReconciliationRun + EqubEscrowController admin endpointsStubPartnerBankStatementReader always returns null; recon runs but skips
Outbox publisherWorker code presentDisabled by default; Kafka topic-name conventions not documented
Auth on payroll / KYC / sanctions controllersSession middleware pins tenant; routes workExplicit @RequireOrgRole / @RequirePlatformAdmin decorators missing on many routes
TS proto stubs for gRPCTS clients use runtime proto loading via @grpc/proto-loaderNo buf generate for TS; field-name typos surface at runtime
gRPC service-to-service strict modeVerifier wired; defaults to log-onlyStrict mode not flipped; observation window not closed
Per-domain Prisma adapter test coverageDomain logic covered by in-memory adaptersPrisma adapters tested at apps/api level only; contract tests between in-memory and Prisma adapters absent

4. What is missing (zero code)

CapabilityWhy it mattersWhere it would live
First real bank partner contractWithout a real Dashen / CBE / Telebirr contract, no real money moves. The technical adapter is ready; the legal contract isn'tinfra + integration-gateway adapter config
packages/bnpl/One of the 6 product pillars in the thesisnew package
packages/savings/One of the 6 product pillarsnew package
Frontend ↔ API integrationWithout it, the platform has no end-user surfaceapps/employer-web first, then employee-web, fi-web
packages/dispute/ (ADR-016)Required by NBE for any product moving real money; today the system has no first-class dispute lifecyclenew package
PostAdjustment RPC + adjustment journal (ADR-015)Without it, ops cannot post corrective entries cleanly; today they would have to call Reverse + Post by handextend ledger proto + Go server
TLS / mTLS between servicesHMAC over plain TCP is the year-0 stopgap (ADR-style note in R1 work); a service mesh + cert rotation is year-1 workinfra
Idempotency-Key route-level guardDefence in depth — today the use case enforces, but a new endpoint can ship without itapps/api global interceptor
DSAR (right-to-be-forgotten) toolingRegulator requirementnew admin surface
Encryption at rest of PII columnsToday PII (national-id, phone, email) stored verbatim, relying on disk encryptionPrisma middleware + KMS
Per-domain DBsLedger + gateway already have own clusters; everything else shares apps/api's clusterinfra
9 of 10 runbooksFirst-response playbooks for every alertdocs/runbooks
Reconciliation cron scheduleDrift detection runs every day on every (tenant, partner)infra K8s CronJob
Outbox publisher in production modeCross-process consumers blind today; in-process consumer = single point of failure with apps/apiflip the flag once services/notifications is built out
Audit log read surfaceAudit rows are written but there is no endpoint or UI for querying themnew admin route
Sanctions HTTP ingestToday CSV ingest is CLI onlynew admin route

5. Doc vs code drift — the explicit mismatches

DocSaysCode saysVerdict
README.md:14 table Payroll: PlannedE3 closed 2026-05-29 — backend Live end-to-end with 38 use cases, 22+ events, 34 testsFIXED 2026-05-31 — README now shows Live (backend)
README.md:14 table Equb / savings: PlannedEqub backend Live (Phase 1 Alpha), 8 use cases, 8 events, escrow + reconciliation Prisma modelsFIXED 2026-05-31 — README now shows Equb Live — Phase 1 Alpha; Savings stays Planned
docs/architecture/restructure-2026-05.md:86Ledger Go binary "not yet built on this host"All 8 RPCs implemented; 7 store tests passRestructure log stale
docs/STATUS_LEGEND.md:212FA PlannedTwoFactor Prisma table + AdminMfaGuard exists; only the plugin wiring is missingSTATUS_LEGEND mostly right; specify the missing piece (plugin wiring)
docs/API_INVENTORY.md (older pass)/api/payroll/:id/audit PlannedLive in apps/api/src/payroll/payroll-audit.controller.ts:83Inventory drifted; superseded by docs/audits/API_INVENTORY_FRESH.md
docs/architecture/restructure-2026-05.md:89Phone OTP PartialLive — phoneNumber plugin wired; SMS dispatch via pluggable moduleRestructure log stale
docs/SYSTEM_GAP.md (claims all 12 GAPs closed)All GAP-01..GAP-12 [x]Verified — they are closedDoc matches code
CLAUDE.md:9"Of these, only EWA and lending have any code today; payroll, BNPL and savings are Planned"Payroll + Equb are also Live (backend); BNPL + savings remain PlannedCLAUDE.md needs a line edit

Ranked by revenue impact × money-movement risk × regulatory exposure × operational fragility — NOT by architectural aesthetics.

Priority 1 — first real bank partner (unblocks the entire revenue line)

Without a real partner contract, every revenue lane in §5 of DOMAIN_KNOWLEDGE_BASE.md is theoretical. The technical adapter (services/integration-gateway/internal/adapters/dashen/) is ready; the contract isn't.

  • Work: Legal contract with at least one partner (Dashen is the named candidate). Production credentials wired via secret manager. End-to-end test: send 1 ETB through the live rail and reconcile. Bake for 72h.
  • Acceptance: real money in a real partner account; recon drift = 0 for 7 consecutive days.
  • Owner: business + compliance, not engineering.

Priority 2 — flip the outbox publisher + finish R1 strict rollout

Today's cross-domain communication relies on the in-process outbox poller (apps/api/src/_infra/notification-consumers/). Acceptable for development; risky for production because the moment apps/api is down, no event ships.

  • Work: Enable OUTBOX_PUBLISHER_ENABLED=true in staging. Document Kafka topic names per event. Smoke-test event delivery to a separate consumer process. Then in production.
  • R1: observe grpc_auth_outcome_total{outcome="ok"} for a week. Once it dominates, flip GRPC_AUTH_MODE=strict on the ledger and gateway. (Servers already deployed in log-only.)
  • Owner: platform engineering.

Priority 3 — close the runbook gap

9 of 10 runbooks are placeholders. The first incident is going to find one. Without a runbook, MTTR balloons.

  • Work: Write the first three real ones — bank-statement-parse-failed.md, gateway-down.md, webhook-failure.md. They should each have: paging contacts, first-five-minutes checklist, escalation path, common root causes with one-liner mitigations.
  • Acceptance: a new on-call can resolve a synthetic failure in each domain without escalation.
  • Owner: SRE / platform.

Priority 4 — wire one frontend to the API

Today no end-user can interact with DemozPay. Tests + curl substitute for product. Pilot requires at least one self-serve surface.

  • Recommended first wire: apps/employer-web → payroll run lifecycle (POST /api/payroll, :id/calculate, :id/approve, :id/disburse). The audience is one employer; the user count is small; the surface area is bounded.
  • Acceptance: one employer can run a payroll cycle end-to-end in the browser.
  • Owner: frontend.

Priority 5 — packages/dispute/ (ADR-016) + adjustment journal (ADR-015)

Without dispute + adjustment journal:

  • A bad disbursement has no first-class path to be undone or contested (today's only option is Reverse + Post by hand, with no operator-visible workflow).
  • NBE will ask about it in the compliance review.

ADR-015 is Proposed; ADR-016 builds on ADR-015. Start with ADR-015 — extend the ledger proto with PostAdjustment, implement Go-side, write the operator flow. Then ADR-016 layers a Dispute aggregate over it.

  • Owner: ledger team + compliance.

Priority 6 — close the explicit-decorator + idempotency-route-guard gap

Defensive hygiene. The risk today is "someone ships a new money-moving endpoint without @RequireOrgRole or without enforcing Idempotency-Key." Both are addressable with a one-shot sweep + a NestJS interceptor.

  • Work: Add @RequireOrgRole('admin','owner') on payroll-tenant-settings, payroll-auto-lock-policy POST/DELETE; on KYC approve/reject/claim; on sanctions screen + checks (PlatformAdmin). Add a global interceptor that fails any POST returning 2xx from a MoneyMoving-decorated handler without a present Idempotency-Key header.
  • Owner: API team.

Priority 7 — drop the dead Prisma models

13 deprecated models with zero callers (Wallet*, WithdrawalRequest, BNPL*, Merchant, BillPayment, Expense, SavingGoal, SettlementBatch/Record, FinancialInstitution) live in schema.prisma and 12 of them violate ADR-005 (Decimal(15,2) money). They have no callers; ESLint blocks new imports.

  • Work: A single 2026XXXX_drop_legacy_money_models_phase2 migration that DROPs them all. Run on staging, observe, run on prod.
  • Owner: schema custodian.

What does NOT belong on this list

  • BNPL + savings domains. They are real product gaps but no revenue exists today; no regulator will ask. They wait until P1-P4 above unblock the platform.
  • mTLS proper. HMAC is fine until partner traffic crosses the public network. Year-1 work.
  • Per-domain DBs. Pre-scale optimisation. Wait for actual numbers.

7. The bottom-line picture

  • Infrastructure layer is genuinely Live. Ledger Go server, integration-gateway Go server, RLS, outbox + audit, KYC, sanctions, payroll engine, EWA, lending, Equb Phase 1 Alpha. The code does what the docs claim it does — usually more.
  • Product layer is hollow. No frontend is wired. No real bank partner. No customer has moved a santim through this in production.
  • The bottleneck is non-engineering. Top of the list is bank partnership (legal/business work), then runbooks (process work), then frontend integration (engineering work). The platform is ready before the rest of the company is.
  • The doc layer has drift. Two of the three "what is built" tables (README.md, CLAUDE.md) understate the current state for payroll + Equb. The companion docs in this audit set (SYSTEM_OVERVIEW.md, CURRENT_STATE_AUDIT.md, API_INVENTORY_FRESH.md) supersede them for the overlapping claims.