Skip to main content

DemozPay — Executive System Overview

Snapshot date: 2026-05-31. Source of truth: code, schema, migrations, tests. Every claim below is traceable to a file:line. Where docs and code disagree, the code wins and the mismatch is flagged.

Companion documents in this set:

  • docs/architecture/HANDBOOK.md — architect's deep dive
  • docs/onboarding/DEVELOPER_GUIDE.md — new-hire ramp
  • docs/onboarding/DOMAIN_KNOWLEDGE_BASE.md — domain-by-domain business primer
  • docs/audits/CURRENT_STATE_AUDIT.md — Live/Partial/Stub/Dead inventory
  • docs/audits/API_INVENTORY_FRESH.md — every HTTP endpoint with auth
  • docs/audits/ROADMAP_REALITY_CHECK.md — claims vs. reality

1. What DemozPay is

DemozPay is a payroll-linked fintech infrastructure platform for Ethiopia (CLAUDE.md:11, README.md:1). It is not a generic wallet, not a bank, and not a card issuer.

The core thesis (CLAUDE.md:9, README.md:5):

Payroll is the trust anchor. Income history is the underwriting signal. Financial access is the product surface.

The intended end-to-end model is payroll → wallet → EWA → salary-backed loans → BNPL → Equb/savings, where wallet ([Planned]) is a read-only projection of the employee's ledger position (available headroom, outstanding advances) — not a fund-holding account (DemozPay is an orchestrator, never a custodian — ADR-014; wallet is derived from the ledger — ADR-006). Of these, payroll, EWA, lending, KYC, sanctions, and Equb have code today. BNPL, generic Wallet, and Savings remain Planned — see §3 below.

Architectural posture (ADR-014, docs/adr/ADR-014-orchestrator-not-custodian.md):

DemozPay is a bank-to-bank orchestrator, NOT a custodian of customer cash. Money lives in partner banks. DemozPay owns the journal of who owes what to whom, and the orchestration of moving it. This shapes every other ADR.


2. Core business domains

┌──────────────────────────────────────────────────────────────────┐
│ DemozPay platform │
│ │
│ ┌──────────┐ ┌─────┐ ┌─────────┐ ┌──────┐ │
│ │ Payroll │──▶│ EWA │ │ Lending │ │ Equb │ │
│ │ (E3) │ │ │ │ │ │ │ │
│ └────┬─────┘ └──┬──┘ └────┬────┘ └──┬───┘ │
│ │ │ │ │ │
│ └────────────┴───────────┴────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ KYC + Sanctions (gate every disbursement) │
│ └──────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────┐ │
│ │ Ledger (Go, double-entry, append-only) │ │
│ └────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────┐ │
│ │ Integration-Gateway (Go, partner banks) │ │
│ └────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘

Six implemented domains (one package each under packages/):

DomainPackageAggregatesPurpose
Payrollpackages/payroll/41+ across 8 subdomains (PayrollRun, SalaryComponent, DeductionMandate, OvertimePolicy, TaxRule, PensionRule, PayrollAdjustment, PayrollEmployeeTransfer)Compute net pay, emit deductions for EWA + lending repayment
EWApackages/ewa/EwaRequestEarned-wage access between paydays, repaid via payroll deduction
Lendingpackages/lending/Loan, LoanRepayment, RepaymentScheduleSalary-backed loans with installment repayment via payroll
KYCpackages/kyc/KycSubmission, KycDocument, NationalIdIdentity verification, prerequisite for every disbursement
Sanctionspackages/sanctions/ScreeningCheck, SanctionsListEntryOFAC/UN/Ethiopia screening, prerequisite for KYC approval + Equb payout
Equbpackages/equb/EqubCycle, DrawRotating-savings pool (Ethiopian cultural finance product) with cryptographic lottery

Planned but not implemented (zero code):

  • BNPL (packages/bnpl/ — does not exist)
  • Generic Wallet (per ADR-006 will be a read-only view over the ledger, not a separate aggregate)
  • Savings (packages/savings/ — does not exist)

3. Status matrix (LIVE / PARTIAL / PLANNED)

Status tags follow docs/STATUS_LEGEND.md — only Live if runtime-verified on at least one host.

Backend domains (under packages/)

DomainStatusEvidence
EWALive4 use cases, 8 ports, 7 outbox events, 100% use-case test coverage; HTTP controller apps/api/src/products/ewa/ewa-api.module.ts
LendingLive5 use cases (request-loan, quote-loan, disburse-loan, record-repayment, remit-installment-to-fi), 9 ports, 8 outbox events; apps/api/src/products/lending/
KYCLive (backend)6 use cases, 5 ports, 6 events, prisma adapter + HTTP controller; gates EWA + lending disburse + Equb payout
SanctionsLive (backend)2 use cases, OFAC/UN/Ethiopia ingest CLI (apps/api/src/compliance/sanctions/cmd/); HTTP screening + CSV ingest
PayrollLive (backend)38 use cases, 20 ports, 22+ outbox events; HTTP controllers (audit, settlement, PDF, court-orders, tenant-settings, auto-lock, platform-admin); 24 Prisma models
EqubLive (Phase 1 Alpha)8 use cases, 5 ports, 8 events; PRIVATE invitation flow (T2-J) + cryptographic draw (commit/reveal); partner-bank escrow binding

Infrastructure

CapabilityStatusEvidence
NestJS modular monolith bootLiveapps/api/src/main.ts:87 global prefix api, three APP_GUARDs chained, /healthz + /readyz real
Prisma + PostgresLive73 models, 41 migrations all applied (apps/api/prisma/migrations/)
Tenant isolation (RLS)LiveForced on 20+ financial tables (20260526030000_apply_tenant_rls/migration.sql); verify guards roll back on missing policy
Ledger schema + invariantsLiveAppend-only triggers, deferred sum-to-zero, partial unique on reverses_transaction_id (services/ledger/migrations/0001_init.up.sql)
Ledger Go serverLive8 RPCs implemented (services/ledger/internal/server/); HMAC auth verified (services/ledger/cmd/ledger/main.go:124-145); 7 store integration tests pass
Integration-gateway Go serverLive4 RPCs (Initiate, Lookup, Status, AdapterStatus); mock + dashen adapters; reconciliation runner (cmd/recon-runner/); webhook receiver
Bank-sandbox dev harnessLive6 HTTP routes + failure-injection by account prefix (services/bank-sandbox/internal/handler/handler.go)
Notifications Go serviceStubservices/notifications/cmd/notifications/main.go is /health-only — no Kafka consumer, no SMS dispatch
In-process notification dispatcherLiveapps/api/src/_infra/notification-consumers/ polls outbox + dispatches via SMS/email modules
Outbox + audit (single txn)LiveOutboxEvent + AuditEntry models written in same transaction as state change (ADR-008)
Better-auth (email + phone OTP + org)Liveapps/api/src/identity/auth/, plugins wired; email verification + password reset live via pluggable email module
Better-auth 2FA (TOTP)PartialTwoFactor Prisma model exists, plugin not yet wired (docs/STATUS_LEGEND.md:21 "2FA Planned"); AdminMfaGuard enforces TOTP on @RequirePlatformAdmin routes
gRPC service-to-service HMAC authLiveTS signer (apps/api/src/_infra/grpc-auth/grpc-hmac.ts), Go verifier (packages/grpcauth-go/), defaults to log-only with strict-mode rollout
Prometheus /metricsLiveAPI monolith (/api/metrics) + ledger (:50054/metrics) + integration-gateway (:50053/metrics)
Bank-statement reconciliationPartialDashen CSV ingester + matcher + runner exist (services/integration-gateway/cmd/recon-runner/main.go); cron schedule pending

Frontends (apps/*-web)

AppStatusEvidence
admin-webPartial — UI shell onlyNext.js scaffolds; data is hardcoded mocks (per CLAUDE.md:24)
employer-web, employee-web, fi-web, merchant-webPartial — UI shell onlySame shape as admin-web
docs-webStubDocusaurus template, no content

Planned (zero code)

CapabilityWhere it would liveStatus note
BNPL domainpackages/bnpl/Not in codebase. Legacy Prisma models BNPLPurchase/BNPLPayment/Merchant exist but use Decimal(15,2) (ADR-005 violation) and have zero callers. (BNPLPartner was collapsed into Merchant in migration 20260613140000.)
Generic Wallet aggregatepackages/wallet/Per ADR-006 wallet is a derived read over the ledger, not a separate aggregate. Existing Wallet Prisma model is @deprecated.
Savingspackages/savings/Not in codebase.
mTLS service-to-serviceinfraYear-1 work behind cert-rotation runbook (docs/security/AUTH_MIGRATION_STRATEGY.md). HMAC is the current intermediate.
Adjustment-journal processimplementation of ADR-015ADR-015 is Proposed; no code yet.
Dispute workflowpackages/dispute/ (ADR-016)ADR-016 is Proposed; no code yet.

4. Current product capabilities (what you could actually do today, end-to-end)

The runtime story today is infrastructure-complete, product-thin. You can:

  1. Create an organisation and sign in via email + password (better-auth, Live).
  2. Onboard employees (POST /api/employees, apps/api/src/workforce/employee/employee.controller.ts).
  3. Submit + approve KYC with sanctions screening (apps/api/src/compliance/kyc/, apps/api/src/compliance/sanctions/).
  4. Compute a payroll run (CRUD salary components, tax rules, deduction mandates, then calculate-payroll-runapprovedisburse). Emits payroll.deductions_taken.v1 consumed by EWA/lending. End-to-end as of E3 2026-05-29 per CLAUDE.md:35.
  5. Issue an EWA advance (request-ewadisburse-ewa gated by KYC + account lookup; record-ewa-repayment ties to payroll deduction).
  6. Issue a loan with full lifecycle (request-loanquote-loandisburse-loanrecord-repaymentremit-installment-to-fi).
  7. Run an Equb cycle (CORPORATE or PRIVATE with invitations + commit/reveal draw + payout gated by KYC + sanctions; partner-bank escrow binding).
  8. Reconcile a bank statement vs the ledger (CSV ingest, matcher flags drift).

What you cannot do today:

  • Send money to a real bank from a real customer. The integration-gateway has Go RPCs implemented; the mock and dashen adapters are the only registered partners. dashen returns ErrNotConfigured without GATEWAY_DASHEN_BASE_URL + GATEWAY_DASHEN_SIGNING_KEY — i.e., no real Dashen contract is live in this repo.
  • Buy-now-pay-later anything.
  • Pay a salary through a real bank rail end-to-end (the Go gateway works, the partner contract doesn't exist).

5. Major money flows


6. How payroll supports the rest of the platform

Payroll is the trust anchor + repayment rail for the entire product surface.

  • Trust anchor. Employers run payroll on DemozPay; the run engine emits payroll.deductions_taken.v1 per entry on ApprovePayrollRun. That event is the canonical signal that an employee has earned X net pay this period (packages/payroll/.../events.ts:19).
  • Repayment rail. EWA + lending both subscribe to payroll.deductions_taken.v1 and use it to close out their receivables (packages/lending/.../record-repayment.usecase.ts, packages/ewa/.../record-ewa-repayment.usecase.ts). Lending also reads the deduction-mandate registry to know which installment to attribute. The integration is wired through outbox + Kafka-style consumers (apps/api/src/payroll/consumers/).
  • Underwriting signal. Lending's IncomePort and EqubBehaviorSignalPort together build a credit picture (income history + Equb participation reliability). Both are derivative of payroll data.
  • Cash-flow truth. The Ledger's payroll-clearing account is the per-employer holding account that payroll debits + the disburse-side credits. Reconciliation drift between payroll-clearing and the partner bank's statement is the daily go-live monitoring signal (services/ledger/internal/server/reconcile_with_bank.go).

The architecture inverts the usual fintech onboarding funnel — instead of "sign up, deposit, then we offer you products," the funnel is "your employer runs payroll on us → we already know your income → you qualify for EWA + a loan today."


7. Per-domain summary

The docs/onboarding/DOMAIN_KNOWLEDGE_BASE.md companion goes deep. Here is the one-page version:

Payroll [Live]

24 Prisma models (PayrollRun, PayrollRunEntry, PayrollTaxRule, PayrollPensionRule, PayrollOvertimePolicy, …, PayrollEmployeeTransfer). Lifecycle DRAFT → CALCULATED → APPROVED → LOCKED → DISBURSED. Locks block all mutation (20260530200000_payroll_run_immutability/migration.sql). Court-order remit + auto-lock-policy + tenant-settings live.

Ledger [Live]

Go service. Double-entry, deferred-trigger balanced commit, append-only triggers on ledger_transaction + ledger_entry, RLS-forced. 8 RPCs (PostTransaction, GetBalance, GetEntries, Reverse, ReconcileAccount, ReconcileWithBank, ConfirmSettlement, MarkSettlementFailed). 7 store integration tests.

Wallet [Planned]

No packages/wallet/. Per ADR-006 wallet is derived from the ledger, not a separate aggregate. The legacy Wallet/WalletTransaction/WithdrawalRequest Prisma models are @deprecated (apps/api/prisma/schema.prisma:797,828,865), have zero callers, and remain only for archival.

EWA [Live]

Earned-wage access. Aggregate EwaRequest, 4 use cases, 7 events. Disburse gated by KYC + sanctions + account-lookup. Repayment via payroll deduction.

Lending [Live]

Salary-backed loans. Aggregates Loan, LoanRepayment, RepaymentSchedule. Underwriting consumes Equb + payroll signals. Installment-level remit to financial institutions (remit-installment-to-fi.usecase.ts).

BNPL [Planned]

No packages/bnpl/. Legacy Prisma models exist (BNPLPurchase, BNPLPayment, Merchant) but use Decimal(15,2) (ADR-005 violation), have no tenantId, are not under RLS, and have zero callers. (BNPLPartner was collapsed into Merchant; in v1 the merchant IS the BNPL-accepting entity — no separate underwriter org.)

Equb [Live — Phase 1 Alpha]

Rotating-savings circle. EqubCycle aggregate, draw via SHA-256 commit/reveal (seed hash committed at activation, seed + nonce revealed at draw). 6 Prisma models including PartnerBankEscrowBinding + EqubReconciliationRun (Phase 1b). PRIVATE flow includes invitation lifecycle.

Savings [Planned]

No packages/savings/. Legacy SavingGoal model exists; Decimal(15,2); zero callers.

Treasury [Not modelled]

There is no packages/treasury/ and no domain entity called "treasury". The closest construct is the partner-bank escrow binding (PartnerBankEscrowBinding) for Equb pools, plus the per-employer payroll-clearing ledger account. No automated cash-management or float-deployment surface exists.

Settlement [Live as a ledger primitive]

Settlement is the ledger's PENDING → POSTED state transition (ConfirmSettlement RPC) driven by partner-bank webhooks at the integration-gateway. Legacy SettlementBatch + SettlementRecord Prisma models exist for the dead BNPL/lending lineage and have zero callers.


8. High-level component diagram


9. Engineering stance (non-negotiables)

These are enforced in CI, in the Postgres triggers, or both:

  1. Money is NUMERIC(20,0) in santim (1 ETB = 100 santim). Never floats. (ADR-005, Live)
  2. Ledger is sole source of money truth. No derived balance columns on new schema. (ADR-006, Live for new schema; legacy Wallet.balance and LedgerAccount.balance are Deferred for removal.)
  3. Idempotency-Key required on every money-moving POST. (ADR-007, Partial at the API gateway, Live at the ledger.)
  4. Audit + outbox event live in same DB transaction as the state change. (ADR-008, Live)
  5. No DELETE on financial rows. Reversals create new entries. (ADR-009, Live — DB triggers + ESLint guard.)
  6. Tenant scoping mandatory via Postgres RLS forced on every financial table. (ADR-013, Live — runtime-proven by verify guards.)
  7. No cross-domain imports. Cross-domain communication via outbox events. (ADR-011, Live — Nx + ESLint module boundaries.)
  8. Two-language ceiling: TypeScript + Go. (ADR-010, Live)

10. Reading order for a new contributor

  1. This document.
  2. docs/onboarding/DEVELOPER_GUIDE.md — run it locally.
  3. docs/onboarding/DOMAIN_KNOWLEDGE_BASE.md — understand the business.
  4. docs/architecture/HANDBOOK.md — internalise the patterns.
  5. docs/adr/* in numeric order — the "why" behind every rule.
  6. docs/audits/CURRENT_STATE_AUDIT.md — what is and isn't real.