DemozPay — Architecture Handbook
Audience: backend engineers and architects who will be touching code this week.
Snapshot: 2026-05-31. Code is the source of truth. Every claim cites a file:line or migration.
1. Monorepo layout (actual, not aspirational)
apps/ composition roots — bootstrap + wiring
api/ NestJS modular monolith [Live]
admin-web/ platform admin :4200 [Partial — UI shell only]
employer-web/ employer admin :4201 [Partial]
employee-web/ employee app :4202 [Partial]
fi-web/ financial-institution :4203 [Partial]
merchant-web/ BNPL / merchant :4204 [Partial]
docs-web/ Docusaurus template :4205 [Stub]
*-e2e/ Playwright per app
services/ independently deployable Go services
ledger/ double-entry journal gRPC :50051, HTTP :50054 [Live]
integration-gateway/ bank/wallet adapters gRPC :50052, HTTP :50053 [Live]
notifications/ SMS/email/push worker HTTP :3003 [Stub — /health only]
bank-sandbox/ dev test harness HTTP [Live]
packages/
ewa/ [Live]
lending/ [Live]
kyc/ [Live]
sanctions/ [Live]
payroll/ [Live]
equb/ [Live — Phase 1 Alpha]
contracts/ grpc/openapi
grpc/ proto sources (ledger.proto, integration_gateway.proto)
gen/go/ generated Go stubs (consumed via go.mod replace)
shared/ cross-cutting infra
audit/ outbox + audit record [Live]
auth/ RBAC decorators + identity ports [Live — superseded the JWT auth]
config/ envSchema + AppConfig [Live]
database/ TransactionRunner [Live]
events/ OutboxEvent / OutboxRepository / EventPublisher [Live]
idempotency/ IdempotencyStore [Live]
logging/ Logger + PII mask [Live]
money/ Money class — bigint santim [Live]
tenant-context/ AsyncLocalStorage shim [Live]
ui/ shadcn/ui components [Live - frontend]
validation/ zod schemas [Live but no consumers — appears dead]
grpcauth-go/ Go HMAC verifier + interceptor [Live] (R1)
infra/
docker-compose.full.yml pg + kafka + redis + observability stack
docker-compose.test.yml
prometheus/
migrations/ cluster-level SQL helpers
sql/
docs/
adr/ 16 ADRs (ADR-001 through ADR-016)
architecture/ this doc + restructure log + companion docs
audits/ current-state, API inventory, roadmap reality check
onboarding/ developer guide, domain knowledge base
runbooks/ 1 Live (payroll-migration-deploy), 9 Stub templates
security/ AUTH_* prior review work
2. Package boundaries (ADR-011)
The hard rule (docs/adr/ADR-011-cross-domain-event-only.md): no domain package imports another domain package directly. Cross-domain communication is via:
- Outbox events (
packages/shared/events/) for async / eventual consistency. - Ports + adapters wired in the apps/api composition root for in-process synchronous calls.
Verified by:
- Nx project graph (
nx graph) — enforces target dependencies. - ESLint
no-restricted-importsrules ineslint.config.mjs:204-235blocking@demoz-pay/<other-domain>/**. - Manual audit: zero cross-domain imports detected (each
packages/<domain>/only imports from@demoz-pay/shared-*).
Allowed cross-domain reads via ports (not imports):
packages/lending/→EqubBehaviorSignalPort(consumes Equb behaviour, port-based).packages/lending/+packages/ewa/→KycReadPort(consumes KYC status).packages/equb/→KycVerificationPort,SanctionsScreeningPort.
3. Domain package shape (ADR-003)
Every domain package follows:
packages/<domain>/
backend/
domain/ aggregates, value objects, domain services, errors
ports/ interfaces the application layer depends on
application/
use-cases/ one file per use case, returns a result + emits events
ports/ additional ports the application owns (repository, clock, outbox)
infrastructure/ in-memory adapters for unit tests (Prisma adapters live in apps/api)
presentation/ DTOs if the domain owns its own HTTP shape
frontend/ optional; rarely used
contracts/ schemas + types shared with consumers
tests/ integration spec scaffolding
Key rule: @prisma/client may NOT be imported anywhere under backend/domain/ or backend/application/. Only backend/infrastructure/ may touch Prisma — and in practice domain packages only ship in-memory infrastructure adapters. The real Prisma adapter lives in apps/api/src/<domain>/ (composition root). This keeps domain logic database-agnostic. (CLAUDE.md:107)
4. Bounded contexts (one section each)
4.1 EWA — Earned Wage Access [Live]
Aggregate: EwaRequest (packages/ewa/backend/domain/ewa-request.ts). Lifecycle: PENDING → APPROVED/REJECTED → DISBURSED → REPAID.
Use cases (4):
request-ewa.usecase.tsdisburse-ewa.usecase.ts— gated by KYC (disburse-ewa.kyc-gate.spec.ts:9) and account lookup (disburse-ewa.lookup-gate.spec.ts)record-ewa-repayment.usecase.tsget-eligibility.usecase.ts
Ports (8): clock, disbursement, ewa-request.repository, ledger, ledger-accounts, kyc-read, eligibility-cache, accrued-earnings.
Events emitted (7): ewa.requested.v1, ewa.bank_transfer_submitted.v1, .accepted.v1, .settled.v1, .failed.v1, ewa.repaid.v1, ewa.rejected.v1.
Prisma models: EwaRequest (apps/api/prisma/schema.prisma).
Composition root in apps/api: apps/api/src/products/ewa/ewa-api.module.ts. Adapters: Prisma repository, ledger gRPC client (apps/api/src/products/ewa/ledger.grpc-client.ts), integration-gateway gRPC client (apps/api/src/products/ewa/integration-gateway.grpc-client.ts — implements both DisbursementPort and the account-lookup gate).
Tests: 5 unit specs, 100% use-case coverage. No integration tests in the package; integration coverage lives at apps/api level.
Dependencies (port-only): KYC (read), Sanctions (read).
4.2 Lending [Live]
Aggregates: Loan, LoanRepayment, RepaymentSchedule, UnderwritingPolicy (packages/lending/backend/domain/).
Use cases (5): request-loan, quote-loan, disburse-loan, record-repayment, remit-installment-to-fi. Plus 1 read query: list-loans.query.ts.
Ports (9): clock, disbursement, loan.repository, loan-repayment.repository, ledger, ledger-accounts, kyc-read, income, equb-behavior-signal.
Events emitted (8): loan.requested.v1, .bank_transfer_submitted.v1, .accepted.v1, .settled.v1, .failed.v1, loan.installment_repaid.v1, loan.installment_remitted_to_fi.v1, loan.closed.v1.
Prisma models: Loan, LoanRepayment.
Composition root: apps/api/src/products/lending/lending-api.module.ts. Has its own copy of ledger.grpc-client.ts and reuses the EWA gateway client via DI (CLAUDE.md note: each domain owns its own port copy per ADR-011, with a future plan to consolidate under apps/api/src/grpc/).
Tests: 6 unit specs (domain + application).
Dependencies (port-only): KYC (read), Equb (behaviour signal for underwriting), Payroll (via deduction events).
4.3 KYC [Live (backend)]
Aggregates: KycSubmission, KycDocument, NationalId.
Use cases (6): submit-kyc, approve-kyc, reject-kyc, claim-for-review, request-more-info, get-kyc-status.
Ports (5): clock, kyc-submission.repository, document-storage, sanctions-screen, outbox.
Events emitted (6): kyc.submitted.v1, .claimed.v1, .approved.v1, .rejected.v1, .more_info_requested.v1, .expired.v1.
Prisma model: KycSubmission (apps/api/prisma/schema.prisma:2311).
Composition root: apps/api/src/compliance/kyc/kyc-api.module.ts. Maker-checker workflow at claim-for-review.usecase.ts. Sanctions integration calls sanctions-screen.port inline during approve.
Tests: 4 unit specs (gap: approve-kyc, claim-for-review, request-more-info, get-kyc-status lack use-case specs).
4.4 Sanctions [Live (backend)]
Aggregates: ScreeningCheck, SanctionsListEntry, NameNormaliser.
Use cases (2): screen-identity (returns MATCH/NO_MATCH decision), ingest-sanctions-list (CSV from OFAC/UN/Ethiopia/custom).
Ports (4): clock, id-generator, sanctions-list.repository, screening-check.repository.
Events emitted (2): sanctions.hit.v1, sanctions.list_ingested.v1 — publishing is composition-root responsibility, not in the package itself (packages/sanctions/.../events.ts:1-4).
Prisma models: ScreeningCheck, SanctionsListEntry. GIN index on normalisedAliases (in migration, not via Prisma DDL).
Composition root: apps/api/src/compliance/sanctions/sanctions-api.module.ts. CLI ingester at apps/api/src/compliance/sanctions/cmd/ for bulk loads.
Tests: 2 unit specs (gap: ingest-sanctions-list lacks coverage).
4.5 Payroll [Live (backend)]
Aggregates (41+ across 8 subdomains):
- Core:
PayrollRun,PaymentPeriod - Compensation:
SalaryComponent,SalaryStructure,EmployeeCompensation,Formula - Deductions:
DeductionMandate,DeductionCarryForward - Earnings:
OneTimeEarning - Overtime:
OvertimePolicy,OvertimeEntry - Rules:
TaxRule,PensionRule - Protection:
PayrollProtectionPolicy - Adjustments:
PayrollAdjustment,PayrollEntry - Disbursement:
PayrollEmployeeTransfer
Use cases (38): create/activate/approve flows for every aggregate; compute (calculate-payroll-run, compute-employee-earnings); control (lock, cancel, disburse); deduction mandate lifecycle; overtime; adjustments (post, reverse, void).
Ports (20): 14 repositories + clock, payroll-ledger, disbursement, employee-salary, deductions, outbox.
Events emitted (22+ types): Run lifecycle (run_created, calculated, approved, run_completed, run_cancelled); canonical deduction signal payroll.deductions_taken.v1 consumed by EWA + lending; one-time earnings; salary component/structure lifecycle; deduction mandate lifecycle; overtime; protection policy.
Prisma models (24): all tenantId-scoped, all NUMERIC(20,0) santim, all FORCE RLS. payroll_run immutability migration (20260530200000_payroll_run_immutability/migration.sql:40-60) prevents UPDATE/DELETE once lockedAt is set.
Composition root: apps/api/src/payroll/payroll-api.module.ts. Multiple HTTP controllers: payroll-audit, payroll-settlement, payroll-pdf, tenant-settings, auto-lock-policy, platform-admin, payroll-health. Plus apps/api/src/payroll/consumers/court-order-remit.controller.ts.
Tests: 34 unit specs (14 domain + 20 application). Integration tests: payroll-run-immutability-trigger.integration.spec.ts, payroll-deductions-poller.integration.spec.ts, court-order-auto-submit.integration.spec.ts — all gated by RUN_INTEGRATION=1.
4.6 Equb (Phase 1 Alpha) [Live]
Aggregates: EqubCycle (with invariants I1-I10), Draw.
Use cases (8): create-equb-cycle, activate-equb-cycle (commits seed hash), add-equb-member, accept-equb-invitation (PRIVATE), decline-equb-invitation, record-equb-contribution, run-equb-draw (commit/reveal lottery), close-equb-round-payout.
Ports (5): clock, equb-cycle.repository, kyc-verification, sanctions-screening, ledger, outbox.
Events emitted (8): equb.cycle_created.v1, .cycle_activated.v1, .contribution_received.v1, .draw_completed.v1, .payout_completed.v1, .cycle_closed.v1, .cycle_aborted.v1, equb.invitation_accepted.v1/.declined.v1, .payout_blocked.v1 (P2-2 compliance gate).
Prisma models (6): EqubCycle, EqubMember, EqubRound, EqubContribution, PartnerBankEscrowBinding, EqubReconciliationRun. Cryptographic invariant: seedHash committed at activation; revealedSeed + revealedNonce stored on draw for independent verification.
Composition root: apps/api/src/products/equb/equb-api.module.ts. HTTP controller: EqubEscrowController gated @RequireOrgRole('admin','owner').
Tests: 5 unit specs (gap: draw, invitation acceptance, contribution, payout lack standalone specs).
5. Ledger architecture (services/ledger)
The ledger is the single source of money truth (ADR-006). It runs in its own Postgres cluster (separate role, no BYPASSRLS).
Wire shape: gRPC service demozpay.ledger.v1.Ledger at :50051. 8 RPCs:
| RPC | Source | Purpose |
|---|---|---|
PostTransaction | services/ledger/internal/server/post_transaction.go:30 | Multi-leg atomic insert; idempotent by (tenant_id, idempotency_key); optional PENDING for pre-bank confirmation |
GetBalance | internal/server/get_balance.go | Reads ledger_account_balance view (excludes PENDING/FAILED) |
GetEntries | internal/server/get_entries.go | Cursor-paginated journal scan via int64 entry_id |
Reverse | internal/server/reverse.go | Compensating-entry RPC; SELECT FOR UPDATE + partial unique on reverses_transaction_id prevents double-reversal |
ReconcileAccount | internal/server/reconcile_account.go | Independent SUM of entries vs derived view; drift > 0 raises ALERT |
ReconcileWithBank | internal/server/reconcile_with_bank.go | Compares POSTED-entries sum against a partner-bank statement total for a date range |
ConfirmSettlement | internal/server/confirm_settlement.go | PENDING → POSTED |
MarkSettlementFailed | internal/server/mark_failed.go | PENDING → FAILED |
DB invariants (services/ledger/migrations/0001_init.up.sql):
- Deferred balance trigger —
ledger_assert_balanced()fires AFTER INSERT at COMMIT; raises if a transaction's debits ≠ credits per currency. - Append-only trigger —
ledger_block_mutation()blocks UPDATE/DELETE onledger_transaction+ledger_entry. - RLS policy — every query must set
app.tenant_id; fail-closed. - Status state machine (
0003_pending_posted.up.sql) —PENDING → POSTED|FAILED,POSTED → REVERSED. - Balance view excludes PENDING and FAILED — only POSTED + REVERSED count toward derived balances.
Auth (R1): Every RPC carries HMAC metadata (x-demoz-client-id, x-demoz-timestamp, x-demoz-signature). Canonical: clientId.timestamp.rpcMethod.tenantId. Verifier: packages/grpcauth-go/. Server defaults to log-only; flip GRPC_AUTH_MODE=strict after rollout (services/ledger/README.md §Service-to-service auth).
Tests: 7 store integration tests (services/ledger/internal/store/postgres_store_test.go) — happy path, idempotency replay, idempotency mismatch, unbalanced rejection, reverse happy path, double-reversal blocked, reconcile drift = 0.
6. Integration-gateway architecture
Wire shape: gRPC service demozpay.integration.v1.IntegrationGateway at :50052. 4 RPCs (InitiateDisbursement, LookupAccount, GetDisbursementStatus, GetAdapterStatus). HTTP listener at :50053 for partner webhooks + /metrics.
Adapters (services/integration-gateway/internal/adapters/):
mock/— deterministic stub. Account prefix drives outcome (acct-valid-*,acct-missing-*, etc.).dashen/— real HTTP adapter for Dashen Bank. HMAC-signed requests/webhooks. ReturnsErrNotConfiguredifGATEWAY_DASHEN_BASE_URL/GATEWAY_DASHEN_SIGNING_KEYunset — i.e., the adapter is wired in code but no production Dashen contract is configured in this repo.
Store state machine (migrations/0001_init.up.sql:145-193): INITIATED → SUBMITTED → ACCEPTED → SETTLED|FAILED. Forward-only via disbursement_transition_status() function.
Reconciliation runner (cmd/recon-runner/main.go + internal/reconciliation/runner.go):
- Standalone CLI invoked by cron / Kubernetes CronJob (not auto-scheduled inside the gateway process).
- Per (tenant, partner) loop:
ListUnmatched → Matcher.Match → MarkMatched/MarkFlagged. - Drift flagged when amount/currency/
settled_atdiverges. - Emits structured JSON + Prometheus metrics.
Webhook receiver (internal/webhook/handler.go):
- HTTP
POST /webhooks/<partner>. - Verifies HMAC (per partner).
- Stores raw payload as
bank_eventrow. - Apps/api consumes via
apps/api/src/money/integration/bank-webhook.controller.ts(replay-defended byWebhookNonceUsemodel).
Tests: 6 test files in services/integration-gateway/. Notably: dashen_ingester_test.go (CSV ingest), matcher_test.go (drift flag), runner_test.go, lookup_account_test.go.
7. Cross-cutting infrastructure
7.1 Outbox + audit (ADR-008)
The single transaction rule: every state-change SQL statement must be accompanied by an outbox event INSERT and an audit row INSERT — all three commit together.
BEGIN;
-- 1. domain mutation
UPDATE loan SET status='APPROVED' WHERE id=$1 AND tenant_id=$2;
-- 2. outbox event
INSERT INTO outbox_event(id, tenant_id, type, payload, occurred_at, published_at)
VALUES (uuid, $tenant, 'loan.approved.v1', $json, now(), NULL);
-- 3. audit row
INSERT INTO audit_entry(id, tenant_id, actor_id, action, entity_type, entity_id, before, after)
VALUES (uuid, $tenant, $actor, 'loan.approve', 'loan', $loanId, $before, $after);
COMMIT;
A separate publisher worker (apps/api/src/_infra/outbox/) tails the outbox, ships to Kafka, marks published_at. Failures are retried; the worker can be disabled by OUTBOX_PUBLISHER_ENABLED=false.
7.2 Tenant context + RLS (ADR-013)
packages/shared/tenant-context/exposesrunWithTenant(ctx, fn)(AsyncLocalStorage) andgetTenantId().- A request-entry middleware in
apps/api/src/identity/tenant/extracts the tenant from the verified session and pins it into ALS for the request lifetime. - Every Prisma transaction calls
SET LOCAL app.tenant_idbefore any query (packages/shared/database/transaction runner). - The DB enforces — every financial table has FORCE RLS with a
USING (tenant_id = current_setting('app.tenant_id')::uuid)policy (migration20260526030000_apply_tenant_rls/migration.sql). - A boot-time verify guard refuses to start if any tier-1 financial table is missing its policy.
7.3 Idempotency (ADR-007)
IdempotencyRecordPrisma model with composite PK(tenantId, scope, key).- Money-moving POSTs accept
Idempotency-Keyheader. - Ledger RPCs accept
idempotency_keyfield; the Go store checks fingerprint (internal/fingerprint/fingerprint.go) so the same key with a different payload is rejected. - Coverage today: enforced strictly in EWA + lending; not yet on every API route — see
docs/audits/CURRENT_STATE_AUDIT.md.
7.4 Auth (better-auth)
apps/api/src/identity/auth/. Three APP_GUARDs registered in order:
AuthGuard— requires a valid session;@Public()exempts.OrgRoleGuard— enforces@RequireOrgRole('admin','owner')/@RequirePlatformAdmin()decorators.AdminMfaGuard— enforces TOTP on@RequirePlatformAdminroutes only.
Plugins wired: emailAndPassword, emailVerification, organization, phoneNumber, magicLink. The twoFactor plugin row exists (TwoFactor model) but the plugin is not yet wired (docs/STATUS_LEGEND.md).
Identity is consumed via the IdentityProvider port (packages/shared/auth/src/lib/identity.ts). The only file that imports better-auth/node directly is apps/api/src/identity/auth/better-auth.identity-provider.ts — this is the vendor neutralisation layer (R-series fixes).
Auth events are appended hash-chained to auth_event_entry (AuthEventEntry Prisma model, hash-chain enforced application-side via PrismaAuthEventSink).
7.5 gRPC service-to-service auth (R1)
- Signer:
apps/api/src/_infra/grpc-auth/grpc-hmac.ts(TS). - Verifier:
packages/grpcauth-go/(Go). - Canonical:
clientId.timestamp.rpcMethod.tenantId. - Headers:
x-demoz-client-id,x-demoz-timestamp,x-demoz-signature. - Replay defence: 5-min skew window. Beyond that, idempotency_key contract on money-moving RPCs.
- Mode:
disabled/log-only(default) /strict. Observegrpc_auth_outcome_total{outcome="ok"}before flipping.
7.6 Money type (ADR-005)
packages/shared/money/exposesMoneyas a bigint-backed value type.- Construction via
Money.fromMinor(n, 'ETB')orMoney.fromMajor('123.45', 'ETB'). - Operations:
add,sub,mul,neg, comparisons,allocate(shares)(split without precision loss). - All money columns on new schema use
NUMERIC(20,0)in santim. Float and Decimal money is permitted only on legacy/deprecated tables (Wallet*, BNPL*, BillPayment, Expense, SavingGoal, SettlementBatch/Record).
8. Database (Postgres)
73 models defined in apps/api/prisma/schema.prisma. 41 migrations applied (apps/api/prisma/migrations/).
By domain:
| Domain | Model count | Examples |
|---|---|---|
| Auth (better-auth) | 7 | User, Role, AdminProfile, Session, Account, Verification, TwoFactor |
| Org (better-auth) | 3 | Organization, Member, Invitation |
| Core/Legacy | 5 | Business, Department, PlatformConfig, LedgerAccount, Transaction |
| Employee + deprecated | 4 | Employee, EmployeeAbsence, Wallet (@deprecated), WithdrawalRequest (@deprecated) |
| Legacy fintech (zero callers) | 9 | BNPL*, Merchant, BillPayment, Expense, SavingGoal, FinancialInstitution |
| Settlement (legacy) | 2 | SettlementBatch, SettlementRecord (zero callers) |
| Canonical financial | 11 | EwaRequest, Loan, LoanRepayment, OutboxEvent, IdempotencyRecord, AuditEntry, ScreeningCheck, SanctionsListEntry, KycSubmission, BankWebhookEvent, PayrollEmployeeTransfer |
| Payroll | 24 | PayrollRun, PayrollRunEntry, …, NotificationEventState |
| Equb | 6 | EqubCycle, EqubMember, EqubRound, EqubContribution, PartnerBankEscrowBinding, EqubReconciliationRun |
| Audit/auth | 3 | AuditLog, AuthEventEntry, WebhookNonceUse |
Money column types:
- 37 columns use NUMERIC(20,0) santim (correct).
- 12 columns use Decimal(15,2) — all on deprecated/legacy models.
- 5 columns use Decimal(20,2) — all on deprecated models.
RLS-protected tables (20+ with FORCE ROW LEVEL SECURITY):
- All canonical financial tier-1 (ewa_request, loan, outbox_event, idempotency_record, audit_entry, loan_repayment).
- All payroll_* tables.
- All equb_* tables + partner_bank_escrow_binding.
- kyc_submission, screening_check.
Better-auth tables (Session, Account, etc.) are intentionally cross-tenant (identity tier).
Append-only enforcement: payroll_run_immutability migration is the master enforcement; LoanRepayment / PayrollAdjustment / EqubContribution / AuthEventEntry use status- or hash-chain-based immutability.
9. Dependency diagrams
9.1 apps/api module dependency
9.2 Cross-domain port dependencies
9.3 Service deployment topology
10. Engineering rules (CI- or DB-enforced)
These are not aspirational — they're enforced. The full list lives in CLAUDE.md and the ADRs; this is the operator's cheat sheet:
| Rule | Enforcement | Bypass cost |
|---|---|---|
Money is NUMERIC(20,0) santim | ADR-005 + manual code review; legacy Decimal allowed only in @deprecated models | High — diverges from the rest of money truth |
| Ledger is sole source of money truth | ADR-006 + linter ban on new balance columns | High — drift becomes invisible |
Idempotency-Key on money-moving POST | ADR-007 + use-case-level checks (not yet on every route) | Medium — replay risk |
| Audit + outbox + state in same txn | ADR-008 + code-review pattern | High — audit loss / event loss |
| No DELETE on financial rows | ADR-009 + DB triggers + ESLint guard | Compile-time + DB-level fail |
| Tenant scoping mandatory (RLS) | ADR-013 + FORCE RLS + boot verify guard | Boot-time fail |
| No cross-domain imports | ADR-011 + Nx + ESLint module boundaries | Compile-time fail |
| TS + Go only | ADR-010 + reviewer judgement | Cultural — no other language has a runtime |
11. Where to look next
docs/onboarding/DEVELOPER_GUIDE.md— first-week ramp.docs/onboarding/DOMAIN_KNOWLEDGE_BASE.md— business + money flow per domain.docs/audits/CURRENT_STATE_AUDIT.md— what's real, what's stubbed, what's dead.docs/audits/API_INVENTORY_FRESH.md— every HTTP endpoint with auth/role.docs/audits/ROADMAP_REALITY_CHECK.md— claims vs reality, recommended priorities.- ADRs
docs/adr/ADR-001throughADR-016.