Skip to main content

01 — What is DemozPay?

In one sentence

DemozPay is payroll-linked fintech infrastructure for Ethiopia: employers run payroll through us, and that gives their employees access to financial products their salary entitles them to — earned-wage access, salary-backed loans, BNPL, group savings (Equb).

In plain English (no jargon)

In Ethiopia, salary workers face a structural problem:

  • Their pay comes once a month, but bills don't.
  • Banks don't lend small amounts to people without collateral.
  • Loan apps charge predatory rates because they have no proof of income.
  • Saving culturally happens in Equb (rotating group savings) but the books are still in paper notebooks.

DemozPay's bet: your verified payroll IS the collateral. If we can see the employer pay the salary, we know the income is real, we know when it lands, and we can let the employee draw against it safely — before payday, at fair rates, with deductions automatically taken at payroll time.

The trust anchor is the employer. The trust signal is income history. The product surface is everything else.

The product model (intended end-to-end)

Employer Employee
│ │
│ pay salaries via DemozPay │
▼ ▼
┌──────────────────────────────────────────────────┐
│ 1. PAYROLL │
│ Employer schedules monthly run; we credit │
│ each employee's wallet. │
└──────────────────────────────────────────────────┘
│ │
▼ ▼
┌──────────────────────────────────────────────────┐
│ 2. WALLET (derived from ledger) │
│ "Available now" = whatever has accrued or │
│ been paid in; spendable. │
└──────────────────────────────────────────────────┘
│ │
▼ ▼
┌──────────────────────────────────────────────────┐
│ 3. EARNED WAGE ACCESS (EWA) │
│ Mid-month: employee draws against earnings │
│ ALREADY worked but not yet paid out. Fee │
│ deducted at payroll on payday. │
└──────────────────────────────────────────────────┘
│ │
▼ ▼
┌──────────────────────────────────────────────────┐
│ 4. SALARY-BACKED LOANS │
│ Multi-month installments, underwritten on │
│ income history. Deductions at each payroll. │
└──────────────────────────────────────────────────┘
│ │
▼ ▼
┌──────────────────────────────────────────────────┐
│ 5. BNPL (Buy Now Pay Later) │
│ Employee buys at a merchant, repays in │
│ installments via payroll deductions. │
└──────────────────────────────────────────────────┘
│ │
▼ ▼
┌──────────────────────────────────────────────────┐
│ 6. EQUB / GROUP SAVINGS │
│ Digital version of rotating Ethiopian │
│ savings clubs. │
└──────────────────────────────────────────────────┘

What's actually built today

Be brutally honest with yourself before assuming any of these products work end-to-end. As of 2026-05-28:

StepStatusReality
1. PayrollPlannedNo payroll domain package exists. EWA reads Employee.baseSalary and pro-rates accrued earnings as a placeholder. There is no actual payroll engine, no payroll-run, no deduction calculator.
2. WalletBy design (derived from ledger)Per ADR-006, there is no separate Wallet entity. Balances are derived from the ledger. The legacy Wallet.balance column exists for historical reasons (Deferred for removal).
3. EWAPartialDomain package is complete: hexagonal layers, eligibility policy, fee calculator, 11 unit tests pass. The HTTP routes (/api/ewa/eligibility, /api/ewa/requests, /api/ewa/requests/:id/disburse) are wired and require auth. But: end-to-end disbursement is blocked on the Go ledger gRPC server being deployed. So you can call the route, get past auth, get into the use case, and fail at the ledger call.
4. LoansPartialSame shape as EWA. 7 unit tests pass. Flat-rate amortisation with exact-santim allocation is implemented. Same ledger-gRPC blocker.
5. BNPLPlannedNo domain package. apps/merchant-web exists but it's mock data.
6. EqubPlannedNo domain package. A legacy Equb Prisma model exists but no domain code.

Who uses the platform

DemozPay has six stakeholder roles. Today, only the first three have any backend code; the rest are UI shells with mock data.

StakeholderFrontendBackend reality
Employee (the salary worker)apps/employee-web (port 4202)Auth Live; UI mock data only.
Employer / HR / Finance (the company paying salaries)apps/employer-web (port 4201)Auth + Business CRUD Live (/api/business/*); UI mock data only.
Platform Admin (DemozPay internal)apps/admin-web (port 4200)Auth Live; admin tools Planned.
Financial Institution (partner bank / MFI)apps/fi-web (port 4203)Planned. No FI-specific endpoints.
Merchant (BNPL acceptance)apps/merchant-web (port 4204)Planned. No BNPL backend at all.
Compliance / AuditNo dedicated app yetPlanned. Audit trail infrastructure is Live (every state change writes an audit_entry); no UI for it.

In product conversations, the platform claims to serve all six. In code, only Employee + Employer + Admin have meaningful surface, and even then only auth + basic CRUD are exercised end-to-end.

How money is supposed to move

This is the intended flow. None of it runs end-to-end today because the Go ledger server isn't yet deployed. But the design and the schema are real.

EWA disbursement (one transaction's worth of money flow)

Employee taps "Get 1,500 ETB now" in employee-web


POST /api/ewa/requests { amount: 150000 santim,
payPeriodId: "...",
idempotencyKey: "uuid-v4" }

▼ (auth + tenant + idempotency checks pass)

EwaController → RequestEwaUseCase


PrismaTransactionRunner.runInTransaction(tx => {
// ① Domain rules
Money checks against accrued earnings, fee calc.

// ② Write to ledger (the source of truth)
gRPC: LedgerService.PostTransaction({
entries: [
DEBIT ewa_advances_receivable 1500.00 ETB
CREDIT employee_wallet 1485.00 ETB // amount minus fee
CREDIT ewa_fee_revenue 15.00 ETB
],
idempotency_key: <same key as the API request>,
tenant_id: <employer's businessId>
});

// ③ State change in monolith DB
prisma.ewa_request.create({
status: "DISBURSED",
ledger_transaction_id: <returned from gRPC>,
...
});

// ④ Audit row in same txn
audit_entry.create({ action: "EWA_DISBURSED", actor: ... });

// ⑤ Outbox event in same txn
outbox_event.create({
type: "ewa.disbursed",
payload: { ewaRequestId, employeeId, amount: "150000" }
});
});

▼ txn commits; deferred trigger ledger_assert_balanced
would have raised at COMMIT if entries ≠ 0


Outbox publisher (separate process) drains the row to Kafka:
└─→ Notification consumer → SMS to employee
└─→ Reconciliation consumer → ledger drift check
└─→ (future) BI consumer → data warehouse

The keys to this design are:

  1. One source of money truth. The ledger is the only place that knows balances. Nothing else stores them.
  2. Atomic state + audit + outbox. ADR-008. They commit together or roll back together.
  3. Idempotency-Key end-to-end. Same key on the API call, same key on the gRPC ledger call. A retry never double-posts.
  4. Money is integer santim. No floats, ever.

Why this matters for someone reading the code

When you see EwaController.create in packages/ewa/backend/ presentation/, you're not looking at "the EWA feature." You're looking at one orchestrator that walks through the five-step dance above. Most of the safety lives in the database (RLS, deferred triggers, unique constraints), not in the controller. Trust the database; it's the only thing that can't forget.

How stakeholders interact (intended workflows)

Employee

  1. Onboarded by their employer (a Business). Receives invitation via phone / email.
  2. Verifies phone via OTP, sets up better-auth account.
  3. Sees their wallet balance (derived from ledger).
  4. Mid-month: requests EWA. Funds land in their wallet (or get routed to mobile money via the integration-gateway service — currently a stub).
  5. End-of-month: deductions auto-apply at payroll.

Today, in code: auth flow works (Employee can sign up). UI is mock. Backend EWA route exists, fails at the ledger call.

Employer / HR / Finance

  1. Registers a Business via admin onboarding.
  2. Uploads or syncs employee roster.
  3. Configures payroll schedule (monthly cycle).
  4. Approves payroll runs. Funds employee wallets.
  5. Reviews reconciliation: who took EWA, what was deducted, etc.

Today, in code: /api/business/* and /api/employees/* endpoints work for CRUD. Payroll engine doesn't exist.

Merchant (BNPL)

  1. Onboards as a merchant. Gets a payment terminal / QR.
  2. Customer scans, picks installment plan.
  3. Merchant receives settlement from DemozPay (we collect from the employee via payroll).

Today, in code: Planned end-to-end. UI mock only.

Platform Admin

  1. Onboards new Businesses (manual KYC for now).
  2. Monitors ledger health, outbox lag, failed disbursements.
  3. Manages BYPASSRLS roles for reconciliation tooling.
  4. Resolves disputes.

Today, in code: Auth works. Admin tools Planned.

Finance / Compliance / Ops

  1. Runs reconciliation reports: ledger sum vs. derived view, outbox stale rows, failed integration-gateway calls.
  2. Reviews audit_entry for any flagged actions.
  3. Files regulatory reports (NBE).

Today, in code: audit_entry writes are Live (every EWA / lending state change emits one). The ReconcileAccount RPC on the Go ledger is written but not deployed. No reporting UI.

How this translates to the codebase

The plain-English flow maps to code as follows:

ConceptLives in
"An employee can request an advance"packages/ewa/backend/application/request-ewa.usecase.ts
"...if they have enough accrued earnings"packages/ewa/backend/application/ports/accrued-earnings.port.ts (interface) + apps/api/src/products/ewa/payroll-accrued-earnings.adapter.ts (the placeholder that reads Employee.baseSalary)
"...with a fee of X bp + min Y santim"packages/ewa/backend/domain/eligibility.ts (pure policy)
"...subject to tenant isolation"apps/api/src/_infra/shared-infra/prisma-transaction-runner.ts sets app.tenant_id per txn (see ADR-013)
"...recorded in the ledger"apps/api/src/products/ewa/ledger.grpc-client.ts calls the Go service at services/ledger/internal/server/post_transaction.go
"...with an audit row + outbox event"apps/api/src/_infra/shared-infra/prisma-audit.emitter.ts + apps/api/src/_infra/shared-infra/prisma-outbox.repository.ts — both write in the same tx via the runner
"...idempotent"apps/api/src/_infra/shared-infra/prisma-idempotency.store.ts (API gateway) + (tenant_id, idempotency_key) UNIQUE on ledger_transaction (Go side)
"...notifications fan out via Kafka"apps/api/src/_infra/outbox/outbox-publisher.service.ts drains rows; services/notifications/ is the consumer (currently Stub)

If you can read those eight files and understand them, you understand 85% of the platform.

Continue reading

Next: 02-running-locally.md — how to actually boot this thing.