DemozPay — Current State Audit
Snapshot: 2026-05-31. Branch:
restructure/modular-monolith-scaffold. Method: parallel code exploration ofapps/,packages/,services/,apps/api/prisma/,docs/. Every claim cites a file:line, migration, or test.Reading guide. Use
docs/STATUS_LEGEND.mdfor the six tags. This document classifies every named subsystem into LIVE / PARTIAL / STUBBED / DEAD CODE, then surfaces TECHNICAL DEBT, RISKS, REGULATORY RISKS, SCALING RISKS.
A. LIVE — implemented, tested, runtime-verified
A.1 Backend infrastructure
| Capability | Evidence |
|---|---|
| NestJS modular monolith boots clean | apps/api/src/main.ts:1-145; globalPrefix api at line 87; APP_GUARD chain wired in apps/api/src/identity/auth/auth.module.ts:71-78 |
| 41 Prisma migrations applied | apps/api/prisma/migrations/ — every migration directory present; migration_lock.toml is the standard Prisma marker |
| 73 Prisma models | schema.prisma |
| Tenant isolation via Postgres RLS, FORCEd | 20260526030000_apply_tenant_rls/migration.sql:28-47 + per-table verify guards in subsequent migrations |
| Outbox + audit single-txn | OutboxEvent + AuditEntry models; pattern enforced across all domain use cases |
/healthz + /readyz + /api/metrics | apps/api/src/_infra/health/, apps/api/src/_infra/observability/metrics/metrics.controller.ts:13 |
| Three APP_GUARDs (Auth → OrgRole → AdminMfa) | apps/api/src/identity/auth/auth.module.ts:71-78 |
| Better-auth (email/pw, email verify, org plugin, phone OTP, magic link) | apps/api/src/identity/auth/better-auth.factory.ts; plugins wired |
| Auth event hash-chain | apps/api/src/identity/auth/prisma-auth-event-sink.ts; AuthEventEntry Prisma model with sequenceNo autoincrement |
| Webhook nonce replay defence | apps/api/src/money/integration/webhook-nonce.repository.ts; WebhookNonceUse Prisma model |
| Pluggable SMS module | apps/api/src/_infra/sms/sms.module.ts:87-182; providers LoggingSmsSender / EthioTelecomSmsSender / HttpSmsSender |
| Pluggable email module | apps/api/src/_infra/email/email.module.ts:28-78; providers LoggingEmailSender / SmtpEmailSender / HttpEmailSender |
| SMS prod boot guard (refuses logger in prod) | apps/api/src/_infra/sms/sms.module.ts:144-157 |
| In-process notification dispatcher | apps/api/src/_infra/notification-consumers/notification-dispatcher.service.ts + 8 specs |
| gRPC HMAC service-to-service auth (R1) | TS signer apps/api/src/_infra/grpc-auth/grpc-hmac.ts + Go verifier packages/grpcauth-go/hmac.go; defaults to log-only, strict mode behind GRPC_AUTH_MODE=strict |
A.2 Domain packages (backend)
| Package | Aggregates | Use cases | Events | Tests | Notes |
|---|---|---|---|---|---|
packages/ewa/ | EwaRequest | 4 | 7 | 5 (100% use-case coverage) | Disburse gated by KYC + lookup |
packages/lending/ | Loan, LoanRepayment, RepaymentSchedule, UnderwritingPolicy | 5 + 1 query | 8 | 6 | FI-remit + per-installment lifecycle |
packages/kyc/ | KycSubmission, KycDocument, NationalId | 6 | 6 | 4 | Maker-checker (claim-for-review) |
packages/sanctions/ | ScreeningCheck, SanctionsListEntry, NameNormaliser | 2 | 2 | 2 | OFAC/UN/Ethiopia/custom list ingestion |
packages/payroll/ | 41+ across 8 subdomains | 38 | 22+ | 34 (53% use-case coverage) | E3 closed 2026-05-29; deductions_taken.v1 wired to EWA + lending |
packages/equb/ | EqubCycle, Draw | 8 | 8 | 5 | Phase 1 Alpha; commit/reveal lottery; PRIVATE invitations |
A.3 Go services
| Service | RPCs / endpoints | Tests | Status |
|---|---|---|---|
services/ledger/ | 8 RPCs (PostTransaction, GetBalance, GetEntries, Reverse, ReconcileAccount, ReconcileWithBank, ConfirmSettlement, MarkSettlementFailed) | 7 store integration tests | Live — all RPCs implemented, DB invariants proven |
services/integration-gateway/ | 4 RPCs (Initiate, Lookup, Status, AdapterStatus) + webhook receiver + recon-runner CLI | matcher / runner / ingester / lookup tests | Live — mock + dashen adapters; dashen ErrNotConfigured without partner contract |
services/bank-sandbox/ | 6 HTTP routes; failure injection by account prefix | lookup test | Live dev harness |
A.4 Shared packages
All packages/shared/* are Live except as noted:
| Package | Evidence | Test count |
|---|---|---|
money | Money bigint class + allocate-without-loss | 1 |
idempotency | IdempotencyStore interface + in-memory impl | 1 |
audit | AuditEmitter interface + in-memory impl | 1 |
events | OutboxRepository + EventPublisher interfaces + in-memory impls | 1 |
tenant-context | runWithTenant AsyncLocalStorage wrapper + getTenantId() | 1 |
auth | RBAC decorators (@RequireOrgRole, @RequirePlatformAdmin) + identity ports — actively imported by apps/api (5+ sites) | 0 (covered by apps/api integration tests) |
database | TransactionRunner interface | 1 |
logging | Logger + PII mask functions | 2 |
config | envSchema + AppConfig | 1 |
ui | shadcn/ui components (40+) for frontends | 0 |
grpcauth-go (Go) | HMAC verifier + interceptor + config parser | 11 tests across 2 files |
A.5 Contracts
packages/contracts/grpc/ledger.proto— 8 RPCs, 3 enums, 6+ messages.packages/contracts/grpc/integration_gateway.proto— 4 RPCs, 4 enums, 6+ messages.packages/contracts/gen/go/— generated stubs for ledger + integration; TypeScript stubs not generated (the TS clients use@grpc/proto-loaderat runtime instead — seeapps/api/src/products/ewa/ledger.grpc-client.ts:98).
A.6 HTTP surface
41 controllers in apps/api/src/ (+ 9 in packages/*/backend/presentation). See docs/audits/API_INVENTORY_FRESH.md for the route enumeration with auth decorators (note: that inventory is itself stale and under-counts — it predates banking, multi-org, payroll rule/settings, and the /api/me/* surface).
B. PARTIAL — implemented at one layer but not end-to-end
| Capability | What's Live | What's missing | Evidence |
|---|---|---|---|
| Better-auth 2FA (TOTP) | TwoFactor Prisma model + AdminMfaGuard enforces TOTP on @RequirePlatformAdmin routes via the identity provider port | The better-auth twoFactor plugin is not yet wired into the auth factory | STATUS_LEGEND.md:21; apps/api/src/identity/auth/admin-mfa-enforcer.service.ts |
| Idempotency-Key on every money-moving POST | CLOSED 2026-05-31 | Enforced at the ledger Go layer + inside EWA/lending use cases + now route-level via @MoneyMoving() decorator + IdempotencyHeaderGuard (apps/api/src/_infra/idempotency/). Applied to 11 routes: EWA requests/disburse/repayment, lending request/disburse/remit/repayment, payroll approve/disburse, equb contribute/payout | |
| Bank-statement reconciliation | recon-runner CLI exists with matcher + ingester; tests pass; integration-gateway exposes the data | No cron schedule wired; no admin UI; first real partner statement still pending | services/integration-gateway/cmd/recon-runner/main.go:1-75 |
| Frontend apps | Next.js shells boot, route, render | All data is hardcoded mocks; no API integration to apps/api | CLAUDE.md:24 |
| Equb partner-bank escrow | PartnerBankEscrowBinding + EqubReconciliationRun Prisma models live; admin controller EqubEscrowController live | Recon worker present but EQUB_ESCROW_RECON_ENABLED=false by default; partner bank statement reader is a stub (StubPartnerBankStatementReader always returns null) | apps/api/src/products/equb/partner-bank-statement-reader.stub.ts; apps/api/src/products/equb/equb-api.module.ts:75-78 |
| Outbox publisher | OutboxEvent writes are Live; outbox poller code present | Disabled by default (OUTBOX_PUBLISHER_ENABLED=false); Kafka topic-name conventions undocumented | restructure-2026-05.md:92 |
| Dashen bank adapter | HTTP HMAC adapter wired; registry includes it on dashen partner | Returns ErrNotConfigured without GATEWAY_DASHEN_BASE_URL + GATEWAY_DASHEN_SIGNING_KEY; no real Dashen contract present | services/integration-gateway/internal/adapters/dashen/dashen.go |
| gRPC service-to-service strict mode | TS signs every outbound call; Go verifies | Default mode is log-only; flip to strict is a 2-step rollout that has not been done | services/ledger/README.md §Service-to-service auth |
| EWA bank-rejection reversal | On synchronous bank rejection, DisburseEwaUseCase calls ledger.markSettlementFailed (excludes the PENDING entry from balances) → EWA BANK_REJECTED. The ledger's Reverse RPC is itself Live. | No compensating Reverse() is posted on this path, and the EWA LedgerPort exposes no reverse() method — yet three comments claim a reversal is recorded (ewa-status.ts:22-25, disbursement.port.ts:25-27, events.ts:84-85). Decision pending: either (a) correct the comments — markSettlementFailed-only is sufficient because the entry never reached POSTED, or (b) wire reverse() onto the EWA port for an explicit append-only audit trail (ADR-009 alignment). Until decided, the comments are aspirational, not descriptive. | packages/ewa/backend/application/disburse-ewa.usecase.ts:280-290; packages/ewa/backend/application/ports/ledger.port.ts:64-75; services/ledger/internal/server/reverse.go |
C. STUBBED — placeholders that look alive but do nothing useful
| Item | Evidence | What's stubbed |
|---|---|---|
services/notifications/ Go service | services/notifications/cmd/notifications/main.go:9-24 | Only exposes GET /health. No Kafka consumer, no gRPC, no DB. Real notification dispatch happens in-process at apps/api/src/_infra/notification-consumers/. |
apps/docs-web/ | Docusaurus template | No content; never deployed |
StubPartnerBankStatementReader | apps/api/src/products/equb/partner-bank-statement-reader.stub.ts | Always returns null; the worker logs "skipped" and retries next day |
| 9 of 10 runbooks | docs/runbooks/{bank-statement-parse-failed, cron-migration, drift-detected, gateway-down, payroll-ledger-enablement, permission-matrix, sms-smpp-to-http-migration, transport-swap-pattern, webhook-failure}.md | Templates only; first-line dispatcher / fix guidance absent. Only payroll-migration-deploy.md is real. |
D. DEAD CODE — present, no callers, schema-only debt
D.1 Deprecated Prisma models with zero callers in apps/api/
Verified by grep -r "prisma\.<modelName>\." apps/api/:
| Model | Evidence | Money column problem | ADR violation |
|---|---|---|---|
Wallet | schema.prisma:797 @deprecated; 0 callers | Decimal(20,2) | ADR-005, ADR-006 |
WalletTransaction | schema.prisma:828 @deprecated; 0 callers; ESLint no-restricted-imports blocks new imports | Decimal(20,2) | ADR-005, ADR-006 |
WithdrawalRequest | schema.prisma:865 @deprecated; 0 callers | — | pre-bank-orchestrator |
BNPLPurchase | 0 callers | Decimal(15,2), no tenantId, no RLS | ADR-005, ADR-013 |
BNPLPayment | 0 callers | Decimal(15,2) | ADR-005 |
Merchant | 0 callers; now holds the merged BNPLPartner fields (legalName, website, settlementAccount, settings, settlements) | — | — |
FinancialInstitution | 0 callers (despite being referenced by Loan.fiPartnerId indirectly) | — | — |
SettlementBatch | 0 callers | Decimal(20,2) | ADR-005 |
SettlementRecord | 0 callers; line 954 note "legacy BNPL/lending payouts" | Decimal(15,2) | ADR-005 |
BillPayment | 0 callers | Decimal(15,2) | ADR-005 |
Expense | 0 callers | Decimal(15,2) | ADR-005 |
SavingGoal | 0 callers | Decimal(15,2) | ADR-005 |
D.2 Deprecated columns
| Column | Evidence | Status |
|---|---|---|
User.password | schema.prisma:38-42 | Kept for migration window; better-auth now owns Account.password |
LedgerAccount.balance | schema.prisma:486-490 @deprecated | ADR-006 forbids stored balances; column never written by new code; reads return stale 0 |
D.3 Likely-dead packages
| Package | Evidence |
|---|---|
@demoz-pay/shared-validation | 0 imports anywhere in workspace (grep). Exports a Zod schema helper that nothing consumes. Either remove or document an intended consumer. |
D.4 Removed legacy migrations
Two cleanup migrations have already run:
20260526020000_remove_legacy_money_models/— droppedEmployeeLoan,BusinessLoan,CapitalFlowLog.20260530100000_drop_legacy_payroll/(69 lines) — dropped legacyPayroll+PayrollEntry+ status enums.
E. TECHNICAL DEBT
E.1 Duplicated gRPC client code
apps/api/src/products/ewa/ledger.grpc-client.ts and apps/api/src/products/lending/ledger.grpc-client.ts are structurally near-identical (~200 lines each). The lending file's header literally says:
"Duplicated from EWA's client (structurally identical port — domain has its own copy per ADR-011). Unify into a shared apps/api/src/grpc/ client later."
Same shape for apps/api/src/products/ewa/integration-gateway.grpc-client.ts (used by lending via DI today; the equb adapter just added signing). Recommended: consolidate under apps/api/src/grpc/ once a third domain needs it (lending already does — refactor justified).
E.2 Auth decorator gaps — CLOSED 2026-05-31
Two payroll controllers used to rely on implicit session gating. Both now have class-level @RequireOrgRole('admin','owner') decorators:
apps/api/src/payroll/tenant-settings.controller.ts:73— fixed.apps/api/src/payroll/auto-lock-policy.controller.ts:45— fixed.
Remaining decorator-gap candidates (in domain-package controllers, intentionally session-only today): KYC + sanctions controllers per docs/audits/API_INVENTORY_FRESH.md §13.3. Lower priority because the tenant-pinning middleware does cover them; tightening is hygiene, not a vulnerability.
E.3 Use-case test coverage gaps — PARTIALLY CLOSED 2026-05-31
| Package | Use cases without specs |
|---|---|
| KYC | approve-kycclaim-for-reviewreject-kycrequest-more-infoget-kyc-status |
| Sanctions | ingest-sanctions-list |
| Payroll | ~18 of 38 (mostly state-transition + serializer code) — unchanged |
| Equb | run-equb-drawaccept-equb-invitationrecord-equb-contributiondecline-invitation, add-member, create-cycle, activate-cycle, close-payout (covered by lifecycle integration spec) |
Net new tests: 35 cases across 8 spec files. All green: KYC 39, sanctions 22, equb 65 (was 21).
E.4 Service-to-service auth still in HMAC intermediate
ADR-style write-up (docs/security/AUTH_MIGRATION_STRATEGY.md) treats HMAC as the year-0 stopgap before mTLS. Replace once a service mesh / cert-rotation runbook exists. Year-1 work.
E.5 No TypeScript proto stubs
The TS gRPC clients use runtime proto loading (@grpc/proto-loader). Compile-time type safety on RPC payloads is therefore missing from apps/api. Cost: typos in field names show up at runtime, not at build. Fix: add buf generate --template buf.gen.ts.yaml and import generated types. Bigger refactor.
E.6 Sanctions event publishing in composition root, not the package
packages/sanctions/.../events.ts:1-4 notes events are "emitted by composition root, NOT by the package." This is asymmetric with EWA / lending / equb (which publish in the use-case layer). Risk: a future caller misses the composition-root step and silently drops a sanctions.hit.v1. Recommendation: move publishing into the use case, accept OutboxPort as a port like EWA/lending do.
E.7 KYC ↔ sanctions ordering
KYC calls sanctions-screen.port inline during approve. If sanctions screening becomes async (e.g., partner API latency spike), the current shape is a synchronous block. Recommendation: pre-screen on submit, cache the result, re-check on approve only when stale > N hours.
E.8 Equb reconciliation is opt-in
EQUB_ESCROW_RECON_ENABLED=false by default; the worker exists but doesn't run. Acceptable for Phase 1 Alpha; flip and observe in Phase 1b.
E.9 Outbox publisher disabled by default
OUTBOX_PUBLISHER_ENABLED=false. Outbox rows are written but never shipped to Kafka. Today's in-process notification-consumers poll the outbox directly. Acceptable while cross-domain consumers are all in-process; becomes a sharp edge the moment a separate worker process needs the events.
F. RISKS (operational)
| # | Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|---|
| F-1 | No real bank partner contract — disbursements only via mock or ErrNotConfigured | Certain (planned state) | Cannot move real money | First-bank contract is the unblocking task; Dashen adapter is ready |
| F-2 | Outbox publisher disabled — events written, never shipped to Kafka | Certain | Cross-process consumers blind to events | Flip OUTBOX_PUBLISHER_ENABLED=true once a real consumer exists |
| F-3 | services/notifications/ is a stub. SMS/email dispatch lives in-process — single point of failure with apps/api | High | If apps/api goes down, no SMS/email delivery | Move into the Go service before scaling |
| F-4 | gRPC strict mode not yet enabled — log-only accepts unsigned calls | High | Anyone reaching the gRPC port without HMAC is accepted | Flip GRPC_AUTH_MODE=strict after observing grpc_auth_outcome_total{outcome="ok"} |
| F-5 | 9 of 10 runbooks are templates — first responders have nothing to follow | High | Long MTTR on first incident | Write the first 3 (bank-statement-parse-failed, gateway-down, webhook-failure) before pilot |
| F-6 | Frontends are mocked. No customer can self-serve through the web | Certain | Blocked from pilot | Wire one frontend (employer-web) to apps/api before pilot |
@RequireOrgRole | — | — | Both controllers now carry @RequireOrgRole('admin','owner') at the class level | |
| F-8 | Stub partner-bank statement reader for Equb | Certain | Equb reconciliation produces "skipped" rows forever | Implement reader before Equb Phase 1b |
| F-9 | No backups documented for ledger + gateway Postgres clusters | Unknown | Total data loss possible | Operator runbook |
| F-10 | gRPC reflection is enabled on production builds | Low | Attacker enumerates RPC surface; not a vuln on its own | Disable in prod main.go path |
G. REGULATORY RISKS
| # | Concern | Status | Evidence |
|---|---|---|---|
| G-1 | NBE (National Bank of Ethiopia) registration — DemozPay is an orchestrator (ADR-014), not a custodian, which materially changes the licence required. | Decision documented but pending legal counter-sign | ADR-014; SYSTEM_GAP.md mentions ADR-014 as Tier 1 pilot-blocker |
| G-2 | Sanctions screening (OFAC, UN, NBE Ethiopia list) | Backend Live | packages/sanctions/ ingester + screen; integrated into KYC approve + Equb payout |
| G-3 | KYC (identity, address, nationality, age >= 18) | Live | packages/kyc/; gates every disbursement |
| G-4 | Data residency — Ethiopian customer PII must stay in Ethiopia per NBE directive | Not enforced in code | No infra/ docs describing region; recommend a runbook + secret-manager controls |
| G-5 | Audit trail completeness — every state change in the canonical financial tier is audited | Live | ADR-008; AuditEntry written in same txn as state change |
| G-6 | No DELETE on financial rows — preserves audit history | Live | ADR-009; DB triggers in services/ledger/migrations/0001_init.up.sql:88-101 |
| G-7 | PII masking in logs | Live | packages/shared/logging/ mask helpers; Go services redact in redactPII slog ReplaceAttr (services/ledger/cmd/ledger/main.go:50-72) |
| G-8 | Hash-chained auth events | Live | AuthEventEntry with sequenceNo + sha256 chain; apps/api/src/identity/auth/prisma-auth-event-sink.ts |
| G-9 | Reconciliation drift detection — required to prove our book matches the bank's book | Live (engine), Partial (cron) | ReconcileWithBank RPC + recon-runner CLI; no scheduled execution yet |
| G-10 | Dispute workflow — required by NBE | Not implemented | ADR-016 is Proposed; no packages/dispute/ |
| G-11 | Adjustment journal process — codified rules for ops to post corrective entries | Not implemented | ADR-015 is Proposed; no corresponding PostAdjustment RPC |
| G-12 | Court-order remittance (NBE garnishment compliance) | Live | apps/api/src/payroll/consumers/court-order-remit.controller.ts; PayrollCourtOrderSubmission Prisma model |
| G-13 | Maker-checker for KYC approvals | Live | packages/kyc/ claim-for-review use case |
| G-14 | Encryption at rest of national-id, phone, email | Not implemented | Stored verbatim in Postgres; relies on disk encryption only |
| G-15 | Encryption in transit between services | Partial | HMAC over plain TCP today; mTLS year-1 work |
| G-16 | Right-to-be-forgotten / data subject access requests | Not implemented | No packages/dsar/; no admin tooling |
H. SCALING RISKS
| # | Bottleneck | When it bites | Suggested response |
|---|---|---|---|
| H-1 | Single Postgres cluster for apps/api | Tens of thousands of payroll runs / day | Carve out per-domain DBs as separate clusters as the modular monolith ages (matches the philosophy of ledger + gateway having their own clusters) |
| H-2 | Outbox publisher disabled + in-process notification consumers | Once SMS/email volume > one process can serve | Spin up the Go services/notifications properly; publisher to Kafka; per-domain consumer fleets |
| H-3 | gRPC reflection on in production | Low — but expands attack surface | Disable in prod build |
| H-4 | All packages use in-memory adapters for unit tests; production paths only have integration coverage via apps/api | A change inside the package that satisfies in-memory contract but breaks the Prisma binding | Add contract tests where the in-memory implementation is fuzz-checked against a Prisma adapter |
| H-5 | Recon-runner is a per-(tenant, partner) loop with no parallelism | Once partners > ~5 or tenants > ~1k | Make it goroutine-fan-out + bounded; or push to a queue |
| H-6 | LedgerAccount.balance column hits zero callers — the read path is ledger_account_balance view aggregated on every read | Aggregation cost balloons at >10M entries | View → materialised view + refresh on settled-write; per-account snapshot table |
| H-7 | Better-auth session table not partitioned | Multi-million active sessions | Standard Postgres partitioning approach when needed |
| H-8 | Outbox table grows monotonically | Months of high traffic | Archival + TTL after published_at cutoff |
| H-9 | No CDN / edge caching docs | Day one of public launch | Front the static frontend bundles with a CDN |
| H-10 | Reconciliation CLI is one process per invocation — no horizontal scale | Pre-pilot is fine; post-pilot bottleneck | Kubernetes CronJob or per-partner pods |
I. Cross-cutting findings
-
Code is in better shape than the wallet/BNPL/savings narratives suggest — the infrastructure layer is materially Live: ledger Go server, integration-gateway Go server, gRPC HMAC, RLS, outbox, audit, KYC, sanctions, payroll engine, EWA, lending, Equb Phase 1 Alpha.
-
The product layer is hollow. No frontend is wired to the API. Today's flows are exercised by tests + curl, not by a customer. Pilot is a frontend + bank-contract problem, not an architecture problem.
-
Legacy Prisma debt is real but quarantined. 13 models have zero callers and most violate ADR-005. ESLint blocks new imports. They are safe to drop in a clean-up migration once their domains ship (BNPL, Wallet, Savings, Settlement legacy).
-
The doc layer has drifted in places.
restructure-2026-05.mdcalls the Go ledger "Compile-only" — that's nowLive(8 RPCs implemented, 7 tests pass).CLAUDE.mdsays "Payroll: Planned" — payroll isLive(E3 closed 2026-05-29). This audit + the companion docs supersede those. -
The R1 work (gRPC HMAC) is fresh and complete. TS signers + Go verifier + tests + docs. Default mode is log-only; flip strict after a 24-48h observation window.
-
No DELETE on financial rows is genuinely enforced. Triggers in the ledger DB, ESLint guard in the API. Same for FORCE RLS — the migration's verify block rolls back on missing policy.
-
The most consequential risk on the path to first dollar moved is the bank partner contract. Code is ready; the contract is not.