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 priordocs/API_INVENTORY.mdagainst the code inapps/,packages/,services/,apps/api/prisma/. The companion auditdocs/audits/CURRENT_STATE_AUDIT.mdis 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):
| Layer | Stated claim (paraphrased) |
|---|---|
| Architecture | Modular 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 |
| Payroll | Planned per the table at README.md:14 — but CLAUDE.md:35 says "backend LIVE end-to-end E3 2026-05-29" |
| EWA | Partial — domain Live, automated repayment Planned |
| Lending | Partial — same shape as EWA |
| KYC + sanctions | Partial per README; CLAUDE.md says "backend LIVE end-to-end including disburse-gate + sanctions-at-approve" |
| Equb | Planned 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 |
| BNPL | Planned |
| Savings | Planned |
| Wallet | "By design (derived from ledger)" — no separate aggregate |
| Frontends | Partial — UI shells live, all mock data |
| Notifications | Stub 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
| Subsystem | Reality (verified by code, schema, tests) |
|---|---|
apps/api NestJS monolith | Live. Boots clean. 41 controllers in apps/api/src + 9 controllers in packages/*/backend/presentation. Three APP_GUARDs chained. |
| Prisma + Postgres | Live. 71 models, 85 migrations applied, 20+ tables FORCE RLS with verify guards. |
services/ledger/ Go service | Live. 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-auth | Live 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 publisher | Live (code), disabled by default. OUTBOX_PUBLISHER_ENABLED=false. Outbox rows are written; in-process notification consumer reads them directly. |
| Recon runner | Live (CLI), not yet scheduled. No cron / CronJob wired. |
| Frontends | Partial. 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 partner | None. 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)
| Item | What's there | What's missing |
|---|---|---|
| Better-auth 2FA | TwoFactor Prisma model + AdminMfaGuard enforces TOTP on platform-admin routes | The better-auth twoFactor plugin itself isn't wired into the factory |
| Idempotency-Key on every money-moving POST | Use-case-level enforcement in EWA + lending + ledger; idempotency_record table | Not a route-level guard; PRs can land new money-moving POST without it |
| Bank-statement reconciliation | Dashen CSV ingester + matcher + runner CLI + 4 tests | No cron / CronJob; no admin UI to view flagged drift |
| Equb partner-bank escrow recon | PartnerBankEscrowBinding + EqubReconciliationRun + EqubEscrowController admin endpoints | StubPartnerBankStatementReader always returns null; recon runs but skips |
| Outbox publisher | Worker code present | Disabled by default; Kafka topic-name conventions not documented |
| Auth on payroll / KYC / sanctions controllers | Session middleware pins tenant; routes work | Explicit @RequireOrgRole / @RequirePlatformAdmin decorators missing on many routes |
| TS proto stubs for gRPC | TS clients use runtime proto loading via @grpc/proto-loader | No buf generate for TS; field-name typos surface at runtime |
| gRPC service-to-service strict mode | Verifier wired; defaults to log-only | Strict mode not flipped; observation window not closed |
| Per-domain Prisma adapter test coverage | Domain logic covered by in-memory adapters | Prisma adapters tested at apps/api level only; contract tests between in-memory and Prisma adapters absent |
4. What is missing (zero code)
| Capability | Why it matters | Where it would live |
|---|---|---|
| First real bank partner contract | Without a real Dashen / CBE / Telebirr contract, no real money moves. The technical adapter is ready; the legal contract isn't | infra + integration-gateway adapter config |
| packages/bnpl/ | One of the 6 product pillars in the thesis | new package |
| packages/savings/ | One of the 6 product pillars | new package |
| Frontend ↔ API integration | Without it, the platform has no end-user surface | apps/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 lifecycle | new package |
PostAdjustment RPC + adjustment journal (ADR-015) | Without it, ops cannot post corrective entries cleanly; today they would have to call Reverse + Post by hand | extend ledger proto + Go server |
| TLS / mTLS between services | HMAC over plain TCP is the year-0 stopgap (ADR-style note in R1 work); a service mesh + cert rotation is year-1 work | infra |
Idempotency-Key route-level guard | Defence in depth — today the use case enforces, but a new endpoint can ship without it | apps/api global interceptor |
| DSAR (right-to-be-forgotten) tooling | Regulator requirement | new admin surface |
| Encryption at rest of PII columns | Today PII (national-id, phone, email) stored verbatim, relying on disk encryption | Prisma middleware + KMS |
| Per-domain DBs | Ledger + gateway already have own clusters; everything else shares apps/api's cluster | infra |
| 9 of 10 runbooks | First-response playbooks for every alert | docs/runbooks |
| Reconciliation cron schedule | Drift detection runs every day on every (tenant, partner) | infra K8s CronJob |
| Outbox publisher in production mode | Cross-process consumers blind today; in-process consumer = single point of failure with apps/api | flip the flag once services/notifications is built out |
| Audit log read surface | Audit rows are written but there is no endpoint or UI for querying them | new admin route |
| Sanctions HTTP ingest | Today CSV ingest is CLI only | new admin route |
5. Doc vs code drift — the explicit mismatches
| Doc | Says | Code says | Verdict |
|---|---|---|---|
README.md:14 tablePlanned | E3 closed 2026-05-29 — backend Live end-to-end with 38 use cases, 22+ events, 34 tests | FIXED 2026-05-31 — README now shows Live (backend) | |
README.md:14 tablePlanned | Equb backend Live (Phase 1 Alpha), 8 use cases, 8 events, escrow + reconciliation Prisma models | FIXED 2026-05-31 — README now shows Equb Live — Phase 1 Alpha; Savings stays Planned | |
docs/architecture/restructure-2026-05.md:86 | Ledger Go binary "not yet built on this host" | All 8 RPCs implemented; 7 store tests pass | Restructure log stale |
docs/STATUS_LEGEND.md:21 | 2FA Planned | TwoFactor Prisma table + AdminMfaGuard exists; only the plugin wiring is missing | STATUS_LEGEND mostly right; specify the missing piece (plugin wiring) |
docs/API_INVENTORY.md (older pass) | /api/payroll/:id/audit Planned | Live in apps/api/src/payroll/payroll-audit.controller.ts:83 | Inventory drifted; superseded by docs/audits/API_INVENTORY_FRESH.md |
docs/architecture/restructure-2026-05.md:89 | Phone OTP Partial | Live — phoneNumber plugin wired; SMS dispatch via pluggable module | Restructure log stale |
docs/SYSTEM_GAP.md (claims all 12 GAPs closed) | All GAP-01..GAP-12 [x] | Verified — they are closed | Doc 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 Planned | CLAUDE.md needs a line edit |
6. Recommended next priorities
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=truein 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, flipGRPC_AUTH_MODE=stricton 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 aMoneyMoving-decorated handler without a presentIdempotency-Keyheader. - 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_phase2migration 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.