Skip to main content

DemozPay — Current State Audit

Snapshot: 2026-05-31. Branch: restructure/modular-monolith-scaffold. Method: parallel code exploration of apps/, packages/, services/, apps/api/prisma/, docs/. Every claim cites a file:line, migration, or test.

Reading guide. Use docs/STATUS_LEGEND.md for 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

CapabilityEvidence
NestJS modular monolith boots cleanapps/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 appliedapps/api/prisma/migrations/ — every migration directory present; migration_lock.toml is the standard Prisma marker
73 Prisma modelsschema.prisma
Tenant isolation via Postgres RLS, FORCEd20260526030000_apply_tenant_rls/migration.sql:28-47 + per-table verify guards in subsequent migrations
Outbox + audit single-txnOutboxEvent + AuditEntry models; pattern enforced across all domain use cases
/healthz + /readyz + /api/metricsapps/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-chainapps/api/src/identity/auth/prisma-auth-event-sink.ts; AuthEventEntry Prisma model with sequenceNo autoincrement
Webhook nonce replay defenceapps/api/src/money/integration/webhook-nonce.repository.ts; WebhookNonceUse Prisma model
Pluggable SMS moduleapps/api/src/_infra/sms/sms.module.ts:87-182; providers LoggingSmsSender / EthioTelecomSmsSender / HttpSmsSender
Pluggable email moduleapps/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 dispatcherapps/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)

PackageAggregatesUse casesEventsTestsNotes
packages/ewa/EwaRequest475 (100% use-case coverage)Disburse gated by KYC + lookup
packages/lending/Loan, LoanRepayment, RepaymentSchedule, UnderwritingPolicy5 + 1 query86FI-remit + per-installment lifecycle
packages/kyc/KycSubmission, KycDocument, NationalId664Maker-checker (claim-for-review)
packages/sanctions/ScreeningCheck, SanctionsListEntry, NameNormaliser222OFAC/UN/Ethiopia/custom list ingestion
packages/payroll/41+ across 8 subdomains3822+34 (53% use-case coverage)E3 closed 2026-05-29; deductions_taken.v1 wired to EWA + lending
packages/equb/EqubCycle, Draw885Phase 1 Alpha; commit/reveal lottery; PRIVATE invitations

A.3 Go services

ServiceRPCs / endpointsTestsStatus
services/ledger/8 RPCs (PostTransaction, GetBalance, GetEntries, Reverse, ReconcileAccount, ReconcileWithBank, ConfirmSettlement, MarkSettlementFailed)7 store integration testsLive — all RPCs implemented, DB invariants proven
services/integration-gateway/4 RPCs (Initiate, Lookup, Status, AdapterStatus) + webhook receiver + recon-runner CLImatcher / runner / ingester / lookup testsLive — mock + dashen adapters; dashen ErrNotConfigured without partner contract
services/bank-sandbox/6 HTTP routes; failure injection by account prefixlookup testLive dev harness

A.4 Shared packages

All packages/shared/* are Live except as noted:

PackageEvidenceTest count
moneyMoney bigint class + allocate-without-loss1
idempotencyIdempotencyStore interface + in-memory impl1
auditAuditEmitter interface + in-memory impl1
eventsOutboxRepository + EventPublisher interfaces + in-memory impls1
tenant-contextrunWithTenant AsyncLocalStorage wrapper + getTenantId()1
authRBAC decorators (@RequireOrgRole, @RequirePlatformAdmin) + identity ports — actively imported by apps/api (5+ sites)0 (covered by apps/api integration tests)
databaseTransactionRunner interface1
loggingLogger + PII mask functions2
configenvSchema + AppConfig1
uishadcn/ui components (40+) for frontends0
grpcauth-go (Go)HMAC verifier + interceptor + config parser11 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-loader at runtime instead — see apps/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

CapabilityWhat's LiveWhat's missingEvidence
Better-auth 2FA (TOTP)TwoFactor Prisma model + AdminMfaGuard enforces TOTP on @RequirePlatformAdmin routes via the identity provider portThe better-auth twoFactor plugin is not yet wired into the auth factorySTATUS_LEGEND.md:21; apps/api/src/identity/auth/admin-mfa-enforcer.service.ts
Idempotency-Key on every money-moving POSTCLOSED 2026-05-31Enforced 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 reconciliationrecon-runner CLI exists with matcher + ingester; tests pass; integration-gateway exposes the dataNo cron schedule wired; no admin UI; first real partner statement still pendingservices/integration-gateway/cmd/recon-runner/main.go:1-75
Frontend appsNext.js shells boot, route, renderAll data is hardcoded mocks; no API integration to apps/apiCLAUDE.md:24
Equb partner-bank escrowPartnerBankEscrowBinding + EqubReconciliationRun Prisma models live; admin controller EqubEscrowController liveRecon 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 publisherOutboxEvent writes are Live; outbox poller code presentDisabled by default (OUTBOX_PUBLISHER_ENABLED=false); Kafka topic-name conventions undocumentedrestructure-2026-05.md:92
Dashen bank adapterHTTP HMAC adapter wired; registry includes it on dashen partnerReturns ErrNotConfigured without GATEWAY_DASHEN_BASE_URL + GATEWAY_DASHEN_SIGNING_KEY; no real Dashen contract presentservices/integration-gateway/internal/adapters/dashen/dashen.go
gRPC service-to-service strict modeTS signs every outbound call; Go verifiesDefault mode is log-only; flip to strict is a 2-step rollout that has not been doneservices/ledger/README.md §Service-to-service auth
EWA bank-rejection reversalOn 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

ItemEvidenceWhat's stubbed
services/notifications/ Go serviceservices/notifications/cmd/notifications/main.go:9-24Only 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 templateNo content; never deployed
StubPartnerBankStatementReaderapps/api/src/products/equb/partner-bank-statement-reader.stub.tsAlways returns null; the worker logs "skipped" and retries next day
9 of 10 runbooksdocs/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}.mdTemplates 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/:

ModelEvidenceMoney column problemADR violation
Walletschema.prisma:797 @deprecated; 0 callersDecimal(20,2)ADR-005, ADR-006
WalletTransactionschema.prisma:828 @deprecated; 0 callers; ESLint no-restricted-imports blocks new importsDecimal(20,2)ADR-005, ADR-006
WithdrawalRequestschema.prisma:865 @deprecated; 0 callerspre-bank-orchestrator
BNPLPurchase0 callersDecimal(15,2), no tenantId, no RLSADR-005, ADR-013
BNPLPayment0 callersDecimal(15,2)ADR-005
Merchant0 callers; now holds the merged BNPLPartner fields (legalName, website, settlementAccount, settings, settlements)
FinancialInstitution0 callers (despite being referenced by Loan.fiPartnerId indirectly)
SettlementBatch0 callersDecimal(20,2)ADR-005
SettlementRecord0 callers; line 954 note "legacy BNPL/lending payouts"Decimal(15,2)ADR-005
BillPayment0 callersDecimal(15,2)ADR-005
Expense0 callersDecimal(15,2)ADR-005
SavingGoal0 callersDecimal(15,2)ADR-005

D.2 Deprecated columns

ColumnEvidenceStatus
User.passwordschema.prisma:38-42Kept for migration window; better-auth now owns Account.password
LedgerAccount.balanceschema.prisma:486-490 @deprecatedADR-006 forbids stored balances; column never written by new code; reads return stale 0

D.3 Likely-dead packages

PackageEvidence
@demoz-pay/shared-validation0 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/ — dropped EmployeeLoan, BusinessLoan, CapitalFlowLog.
  • 20260530100000_drop_legacy_payroll/ (69 lines) — dropped legacy Payroll + 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

PackageUse cases without specs
KYCapprove-kyc (already had), claim-for-review (new), reject-kyc (new), request-more-info (new), get-kyc-status (new) — 100% coverage
Sanctionsingest-sanctions-list (new) — 100% coverage
Payroll~18 of 38 (mostly state-transition + serializer code) — unchanged
Equbrun-equb-draw (new), accept-equb-invitation (new), record-equb-contribution (new); still gaps: decline-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)

#RiskLikelihoodImpactMitigation
F-1No real bank partner contract — disbursements only via mock or ErrNotConfiguredCertain (planned state)Cannot move real moneyFirst-bank contract is the unblocking task; Dashen adapter is ready
F-2Outbox publisher disabled — events written, never shipped to KafkaCertainCross-process consumers blind to eventsFlip OUTBOX_PUBLISHER_ENABLED=true once a real consumer exists
F-3services/notifications/ is a stub. SMS/email dispatch lives in-process — single point of failure with apps/apiHighIf apps/api goes down, no SMS/email deliveryMove into the Go service before scaling
F-4gRPC strict mode not yet enabled — log-only accepts unsigned callsHighAnyone reaching the gRPC port without HMAC is acceptedFlip GRPC_AUTH_MODE=strict after observing grpc_auth_outcome_total{outcome="ok"}
F-59 of 10 runbooks are templates — first responders have nothing to followHighLong MTTR on first incidentWrite the first 3 (bank-statement-parse-failed, gateway-down, webhook-failure) before pilot
F-6Frontends are mocked. No customer can self-serve through the webCertainBlocked from pilotWire one frontend (employer-web) to apps/api before pilot
F-7Two payroll controllers lack @RequireOrgRoleCLOSED 2026-05-31Both controllers now carry @RequireOrgRole('admin','owner') at the class level
F-8Stub partner-bank statement reader for EqubCertainEqub reconciliation produces "skipped" rows foreverImplement reader before Equb Phase 1b
F-9No backups documented for ledger + gateway Postgres clustersUnknownTotal data loss possibleOperator runbook
F-10gRPC reflection is enabled on production buildsLowAttacker enumerates RPC surface; not a vuln on its ownDisable in prod main.go path

G. REGULATORY RISKS

#ConcernStatusEvidence
G-1NBE (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-signADR-014; SYSTEM_GAP.md mentions ADR-014 as Tier 1 pilot-blocker
G-2Sanctions screening (OFAC, UN, NBE Ethiopia list)Backend Livepackages/sanctions/ ingester + screen; integrated into KYC approve + Equb payout
G-3KYC (identity, address, nationality, age >= 18)Livepackages/kyc/; gates every disbursement
G-4Data residency — Ethiopian customer PII must stay in Ethiopia per NBE directiveNot enforced in codeNo infra/ docs describing region; recommend a runbook + secret-manager controls
G-5Audit trail completeness — every state change in the canonical financial tier is auditedLiveADR-008; AuditEntry written in same txn as state change
G-6No DELETE on financial rows — preserves audit historyLiveADR-009; DB triggers in services/ledger/migrations/0001_init.up.sql:88-101
G-7PII masking in logsLivepackages/shared/logging/ mask helpers; Go services redact in redactPII slog ReplaceAttr (services/ledger/cmd/ledger/main.go:50-72)
G-8Hash-chained auth eventsLiveAuthEventEntry with sequenceNo + sha256 chain; apps/api/src/identity/auth/prisma-auth-event-sink.ts
G-9Reconciliation drift detection — required to prove our book matches the bank's bookLive (engine), Partial (cron)ReconcileWithBank RPC + recon-runner CLI; no scheduled execution yet
G-10Dispute workflow — required by NBENot implementedADR-016 is Proposed; no packages/dispute/
G-11Adjustment journal process — codified rules for ops to post corrective entriesNot implementedADR-015 is Proposed; no corresponding PostAdjustment RPC
G-12Court-order remittance (NBE garnishment compliance)Liveapps/api/src/payroll/consumers/court-order-remit.controller.ts; PayrollCourtOrderSubmission Prisma model
G-13Maker-checker for KYC approvalsLivepackages/kyc/ claim-for-review use case
G-14Encryption at rest of national-id, phone, emailNot implementedStored verbatim in Postgres; relies on disk encryption only
G-15Encryption in transit between servicesPartialHMAC over plain TCP today; mTLS year-1 work
G-16Right-to-be-forgotten / data subject access requestsNot implementedNo packages/dsar/; no admin tooling

H. SCALING RISKS

#BottleneckWhen it bitesSuggested response
H-1Single Postgres cluster for apps/apiTens of thousands of payroll runs / dayCarve out per-domain DBs as separate clusters as the modular monolith ages (matches the philosophy of ledger + gateway having their own clusters)
H-2Outbox publisher disabled + in-process notification consumersOnce SMS/email volume > one process can serveSpin up the Go services/notifications properly; publisher to Kafka; per-domain consumer fleets
H-3gRPC reflection on in productionLow — but expands attack surfaceDisable in prod build
H-4All packages use in-memory adapters for unit tests; production paths only have integration coverage via apps/apiA change inside the package that satisfies in-memory contract but breaks the Prisma bindingAdd contract tests where the in-memory implementation is fuzz-checked against a Prisma adapter
H-5Recon-runner is a per-(tenant, partner) loop with no parallelismOnce partners > ~5 or tenants > ~1kMake it goroutine-fan-out + bounded; or push to a queue
H-6LedgerAccount.balance column hits zero callers — the read path is ledger_account_balance view aggregated on every readAggregation cost balloons at >10M entriesView → materialised view + refresh on settled-write; per-account snapshot table
H-7Better-auth session table not partitionedMulti-million active sessionsStandard Postgres partitioning approach when needed
H-8Outbox table grows monotonicallyMonths of high trafficArchival + TTL after published_at cutoff
H-9No CDN / edge caching docsDay one of public launchFront the static frontend bundles with a CDN
H-10Reconciliation CLI is one process per invocation — no horizontal scalePre-pilot is fine; post-pilot bottleneckKubernetes CronJob or per-partner pods

I. Cross-cutting findings

  1. 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.

  2. 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.

  3. 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).

  4. The doc layer has drifted in places. restructure-2026-05.md calls the Go ledger "Compile-only" — that's now Live (8 RPCs implemented, 7 tests pass). CLAUDE.md says "Payroll: Planned" — payroll is Live (E3 closed 2026-05-29). This audit + the companion docs supersede those.

  5. 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.

  6. 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.

  7. The most consequential risk on the path to first dollar moved is the bank partner contract. Code is ready; the contract is not.