Skip to main content

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:

  1. Outbox events (packages/shared/events/) for async / eventual consistency.
  2. 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-imports rules in eslint.config.mjs:204-235 blocking @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.ts
  • disburse-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.ts
  • get-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.v1publishing 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:

RPCSourcePurpose
PostTransactionservices/ledger/internal/server/post_transaction.go:30Multi-leg atomic insert; idempotent by (tenant_id, idempotency_key); optional PENDING for pre-bank confirmation
GetBalanceinternal/server/get_balance.goReads ledger_account_balance view (excludes PENDING/FAILED)
GetEntriesinternal/server/get_entries.goCursor-paginated journal scan via int64 entry_id
Reverseinternal/server/reverse.goCompensating-entry RPC; SELECT FOR UPDATE + partial unique on reverses_transaction_id prevents double-reversal
ReconcileAccountinternal/server/reconcile_account.goIndependent SUM of entries vs derived view; drift > 0 raises ALERT
ReconcileWithBankinternal/server/reconcile_with_bank.goCompares POSTED-entries sum against a partner-bank statement total for a date range
ConfirmSettlementinternal/server/confirm_settlement.goPENDING → POSTED
MarkSettlementFailedinternal/server/mark_failed.goPENDING → FAILED

DB invariants (services/ledger/migrations/0001_init.up.sql):

  1. Deferred balance triggerledger_assert_balanced() fires AFTER INSERT at COMMIT; raises if a transaction's debits ≠ credits per currency.
  2. Append-only triggerledger_block_mutation() blocks UPDATE/DELETE on ledger_transaction + ledger_entry.
  3. RLS policy — every query must set app.tenant_id; fail-closed.
  4. Status state machine (0003_pending_posted.up.sql) — PENDING → POSTED|FAILED, POSTED → REVERSED.
  5. 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. Returns ErrNotConfigured if GATEWAY_DASHEN_BASE_URL/GATEWAY_DASHEN_SIGNING_KEY unset — 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_at diverges.
  • Emits structured JSON + Prometheus metrics.

Webhook receiver (internal/webhook/handler.go):

  • HTTP POST /webhooks/<partner>.
  • Verifies HMAC (per partner).
  • Stores raw payload as bank_event row.
  • Apps/api consumes via apps/api/src/money/integration/bank-webhook.controller.ts (replay-defended by WebhookNonceUse model).

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/ exposes runWithTenant(ctx, fn) (AsyncLocalStorage) and getTenantId().
  • 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_id before 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 (migration 20260526030000_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)

  • IdempotencyRecord Prisma model with composite PK (tenantId, scope, key).
  • Money-moving POSTs accept Idempotency-Key header.
  • Ledger RPCs accept idempotency_key field; 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:

  1. AuthGuard — requires a valid session; @Public() exempts.
  2. OrgRoleGuard — enforces @RequireOrgRole('admin','owner') / @RequirePlatformAdmin() decorators.
  3. AdminMfaGuard — enforces TOTP on @RequirePlatformAdmin routes 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. Observe grpc_auth_outcome_total{outcome="ok"} before flipping.

7.6 Money type (ADR-005)

  • packages/shared/money/ exposes Money as a bigint-backed value type.
  • Construction via Money.fromMinor(n, 'ETB') or Money.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:

DomainModel countExamples
Auth (better-auth)7User, Role, AdminProfile, Session, Account, Verification, TwoFactor
Org (better-auth)3Organization, Member, Invitation
Core/Legacy5Business, Department, PlatformConfig, LedgerAccount, Transaction
Employee + deprecated4Employee, EmployeeAbsence, Wallet (@deprecated), WithdrawalRequest (@deprecated)
Legacy fintech (zero callers)9BNPL*, Merchant, BillPayment, Expense, SavingGoal, FinancialInstitution
Settlement (legacy)2SettlementBatch, SettlementRecord (zero callers)
Canonical financial11EwaRequest, Loan, LoanRepayment, OutboxEvent, IdempotencyRecord, AuditEntry, ScreeningCheck, SanctionsListEntry, KycSubmission, BankWebhookEvent, PayrollEmployeeTransfer
Payroll24PayrollRun, PayrollRunEntry, …, NotificationEventState
Equb6EqubCycle, EqubMember, EqubRound, EqubContribution, PartnerBankEscrowBinding, EqubReconciliationRun
Audit/auth3AuditLog, 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:

RuleEnforcementBypass cost
Money is NUMERIC(20,0) santimADR-005 + manual code review; legacy Decimal allowed only in @deprecated modelsHigh — diverges from the rest of money truth
Ledger is sole source of money truthADR-006 + linter ban on new balance columnsHigh — drift becomes invisible
Idempotency-Key on money-moving POSTADR-007 + use-case-level checks (not yet on every route)Medium — replay risk
Audit + outbox + state in same txnADR-008 + code-review patternHigh — audit loss / event loss
No DELETE on financial rowsADR-009 + DB triggers + ESLint guardCompile-time + DB-level fail
Tenant scoping mandatory (RLS)ADR-013 + FORCE RLS + boot verify guardBoot-time fail
No cross-domain importsADR-011 + Nx + ESLint module boundariesCompile-time fail
TS + Go onlyADR-010 + reviewer judgementCultural — 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-001 through ADR-016.