Skip to main content

DemozPay — Domain Knowledge Base

Audience: any engineer who needs to understand the business in one day.

How this is different from the architecture handbook: the handbook is the wiring diagram. This is the business primer. Every domain section answers four questions: Why does this exist? How does the money flow? What does it depend on? How does it make money?

Snapshot: 2026-05-31. Status tags use docs/STATUS_LEGEND.md.


The thesis in one paragraph

DemozPay's bet is that payroll is the highest-trust signal in any emerging market. If the employer is willing to direct a salary to a system, that system has a real-time stream of who-earned-what, can underwrite credit from it, and can move money before the employee touches it. That inverts the usual fintech funnel ("sign up, give us money, then maybe a loan") into "your employer puts you on us — every other product follows for free." Ethiopia is a useful first market because:

  • The salary worker base is large and growing.
  • The mobile-money rail (Telebirr) is real but doesn't carry credit history.
  • Bank coverage is uneven — but partner banks accept large payroll-batched transfers cleanly.
  • Equb (rotating savings) is a culturally native financial product that no foreign neobank has shipped well.

The implementation that follows that thesis is bank-to-bank orchestration, not custody — money lives in partner banks; DemozPay owns the journal of who owes what to whom. (ADR-014.)


1. Payroll [Live (backend, E3 closed 2026-05-29)]

Why it exists

The trust anchor + repayment rail of the platform. Without a real payroll engine, EWA is a guess at someone's salary, and lending has no installment mechanism that doesn't require chasing the employee. With it, every other product is a side-effect of "we know what you'll earn this period."

How the money flows

1. Employer creates a PayrollRun for a period (e.g. 2026-06).
2. Operator (or scheduled cron) calls CalculatePayrollRun.
→ Engine sums salary components × structure × overtime × adjustments × one-time
earnings, applies tax + pension rules, applies deduction mandates (EWA, loans,
court orders), applies protection-policy floor (minimum-net guarantee).
→ Produces PayrollRunEntry per employee with gross / taxes / deductions / net.
3. Operator calls ApprovePayrollRun.
→ Engine emits `payroll.deductions_taken.v1` per entry → consumed by EWA + lending.
→ State machine: CALCULATED → APPROVED.
4. Operator calls DisbursePayrollRun (or auto-lock policy fires).
→ For each entry, calls integration-gateway.InitiateDisbursement
(rail = BANK_TRANSFER or MOBILE_MONEY) keyed by run-entry-id (idempotency).
→ Posts ledger: debit employer-payroll-clearing, credit per-employee payable-to-bank
(PENDING).
5. Webhook from partner bank arrives.
→ integration-gateway records bank_event, emits settlement signal.
→ apps/api ConfirmSettlement on ledger (PENDING → POSTED).
→ Money is now counted toward derived balances.

Dependencies

  • Ledger for every debit/credit.
  • Integration-gateway for each per-employee bank transfer.
  • KYC at the employee level (must be APPROVED to receive disbursement).
  • Sanctions transitively via KYC.
  • Receives no inputs from the other domains; EWA + lending consume payroll's outbox events for repayment.

Revenue impact

Three lanes:

  1. Per-employee processing fee charged to the employer for each payroll-run-entry.
  2. Float on payroll-clearing accounts (the gap between debiting employer and crediting employee bank account) — depends on banking partnership terms; can be material at scale.
  3. The hook for everything else. EWA + lending revenue only exist because payroll is the trust anchor. Without payroll the platform is a wallet competing with Telebirr.

Notable engineering invariants

  • PayrollRun.lockedAt is the immutability gate (20260530200000_payroll_run_immutability/migration.sql:40-60). Once locked, no row in the run can change.
  • 41 aggregates across 8 subdomains; do not flatten the model — each subdomain (compensation, deductions, earnings, overtime, rules, protection, adjustments, disbursement) has its own state machine.
  • Court-order remit is live (PayrollCourtOrderSubmission) — required for NBE garnishment compliance.
  • Tax + pension rules are versioned per tenant + effective-date (PayrollTaxRule, PayrollPensionRule). Old runs always recompute against the rules in force at the run period, not the latest.

2. Ledger [Live]

Why it exists

The sole source of money truth (ADR-006). Every domain that moves money posts double-entry transactions here. No domain stores its own balance column. This is what makes audit and reconciliation tractable.

How money flows through it

A PostTransaction is at least two entries that sum to zero per currency. Example for a payroll disbursement:

Transaction: payroll.run.X.entry.Y
Debit employer-clearing-acct 50_000_00 santim
Credit employee-payable-to-bank-acct 50_000_00 santim
status PENDING (until partner-bank webhook → ConfirmSettlement)

A balance is derived from a view that aggregates POSTED + REVERSED entries by account. PENDING + FAILED entries are excluded.

Dependencies

  • Own Postgres cluster (separate role, no BYPASSRLS).
  • Triggered by every money-moving call from EWA / lending / payroll / equb / integration-gateway settlement webhooks.

Revenue impact

The ledger is not a product — it's infrastructure. But it's the reason DemozPay can credibly say "we know to-the-santim how much we owe each bank counterparty at any moment" to regulators, partners, and auditors. Without this, you cannot scale.

Notable engineering invariants

  • Deferred sum-to-zero trigger fires at COMMIT (services/ledger/migrations/0001_init.up.sql:59-84). A transaction with unbalanced legs raises the error from inside the database, not the app.
  • Append-only triggers block UPDATE/DELETE on ledger_transaction + ledger_entry.
  • reverses_transaction_id partial unique index prevents double-reversal.
  • ReconcileWithBank compares POSTED total vs a partner-bank statement total over a date range — drift > 0 is the daily go-live alert.

3. Wallet [Planned — derived from ledger per ADR-006]

Why it doesn't exist as a separate aggregate

ADR-006: balances are derived from the ledger, not stored. There is no Wallet aggregate in any domain package. The legacy Wallet, WalletTransaction, WithdrawalRequest Prisma models (apps/api/prisma/schema.prisma:797,828,865) are @deprecated, have zero callers, and will be dropped in a clean-up migration.

How "wallet balance" actually works today

Any UI that wants "what's my balance?" reads:

  • For an employee — Ledger.GetBalance against their employee-receivable account, or the per-employer payroll-receivable account.
  • For an employer — Ledger.GetBalance against their payroll-clearing account.

That's all. Wallet as a product surface is a read view.


4. EWA (Earned Wage Access) [Live]

Why it exists

Salary workers in Ethiopia are paid monthly. Between paydays, expenses still arrive — school fees, medical, family transfers. The informal response is high-interest borrowing from local lenders. EWA gives the worker access to a portion of what they've already earned but haven't been paid yet, repaid as a payroll deduction next pay cycle.

The product position: not a loan. It's the worker's own money, advanced. Regulatory framing matters because true lending requires a credit licence in most jurisdictions.

How the money flows

1. Employee opens app → POST /api/me/ewa/requests (proxy through self-service).
2. Use case RequestEwa runs:
- eligibility check (income, days-in-period, prior outstanding)
- idempotency on Idempotency-Key header
- emits ewa.requested.v1
3. Operator (or auto-policy) calls DisburseEwa:
- KYC must be APPROVED (kyc-read.port)
- sanctions must be clear (composition-root step)
- integration-gateway.LookupAccount on the destination — fails closed
- integration-gateway.InitiateDisbursement (rail, partner, account_number)
- Ledger.PostTransaction PENDING:
Debit employer-payroll-clearing
Credit employee-payable-to-bank
4. Bank webhook → ConfirmSettlement on ledger → ewa.bank_transfer_settled.v1.
5. Next payroll cycle:
- ApprovePayrollRun emits payroll.deductions_taken.v1 per entry.
- EWA's RecordEwaRepayment use case consumes that event:
Debit employee-payable-to-employer (or payroll-clearing)
Credit ewa-receivable
EWA aggregate flips to REPAID.

Dependencies

  • KYC + sanctions at disburse.
  • Integration-gateway for account lookup + the actual disbursement.
  • Ledger for the two-step money posting.
  • Payroll for the repayment signal (payroll.deductions_taken.v1).

Revenue impact

Two lanes:

  1. Per-advance fee (flat or % of advanced amount).
  2. Increased payroll-runs retention — employers offering EWA see better retention; DemozPay can charge a higher per-employee processing fee.

Notable engineering invariants

  • Disburse is gated by KYC + account-lookup. A successful disburse therefore implies KYC was APPROVED at that point in time.
  • Repayment is event-driven — the EWA receivable closes when the matching payroll.deductions_taken.v1 event lands, not when the employee "does" anything.

5. Lending (Salary-Backed Loans) [Live]

Why it exists

EWA solves cash-flow gaps within a pay period. Lending solves bigger needs — appliances, education, weddings, business inputs — that need installment repayment over months. Underwriting uses payroll history as the primary signal, with Equb participation as a behavioural complement.

How the money flows

1. Employee requests a loan (POST /api/loans).
2. UnderwritingPolicy + IncomePort (from payroll) + EqubBehaviorSignalPort produce
a quote (QuoteLoan).
3. Customer accepts → RequestLoan.
4. Operator calls DisburseLoan — gated by KYC + account lookup like EWA.
5. Ledger.PostTransaction PENDING:
Debit loan-receivable
Credit employee-payable-to-bank
6. Bank webhook → ConfirmSettlement.
7. Per-installment repayment:
a. When payroll runs and emits payroll.deductions_taken.v1 carrying loan reference,
Lending.RecordRepayment fires per installment:
Debit employee-payroll-clearing
Credit loan-receivable + interest-income
b. For installments owed to a Financial Institution partner (the loan was
originated by an FI and DemozPay services it), RemitInstallmentToFi is called
separately to remit the FI's share of that installment.

Dependencies

  • KYC + sanctions at disburse.
  • Integration-gateway for account lookup + disbursement + (optionally) remit.
  • Ledger for every money posting.
  • Payroll for repayment signal.
  • Equb for credit-history signal (equb-behavior-signal.port).

Revenue impact

Three lanes:

  1. Interest spread — DemozPay's share of the interest charged.
  2. Origination fee charged per loan.
  3. FI partnership margin — when DemozPay services a loan originated by a third-party FI, it earns a service fee on each installment processed.

Notable engineering invariants

  • A loan with an FI partner ID (fiPartnerId) requires the per-installment remit call — that's the contract between DemozPay and the FI.
  • LoanRepayment rows are append-only with a composite unique on (tenantId, loanId, installmentIndex) — a single installment can never be paid twice.
  • RepaymentSchedule is computed at quote-time and frozen at request-time; rate changes do not reach already-issued loans.

6. BNPL (Buy Now, Pay Later) [Planned]

Why it doesn't exist yet

The thesis works (BNPL on a salary-backed credit signal is much safer than card-tap BNPL), but the merchant-side integration is a separate workstream. There is no packages/bnpl/ in the codebase.

The legacy Prisma models (BNPLPurchase, BNPLPayment, Merchant, BNPLPartner) exist but:

  • Use Decimal(15,2) (ADR-005 violation).
  • Have no tenantId (ADR-013 violation).
  • Are not under RLS.
  • Have zero callers.

Treat them as archaeology, not foundation.

How it would work (when shipped)

Same shape as lending — but the disbursement target is the merchant, not the employee, and the repayment trigger is still payroll.deductions_taken.v1. apps/merchant-web is the merchant-facing UI; today it's a UI shell with mock data.

Revenue impact (projected)

Merchant fee + interest spread, similar margin profile to lending.


7. Equb (Rotating Savings) [Live — Phase 1 Alpha]

Why it exists

Equb is the most popular informal-finance product in Ethiopia. A group of N people each contribute the same amount per round, and one member receives the pooled amount each round, until everyone has been paid out exactly once. Historically run on paper, with cash, with trust enforced socially.

DemozPay digitises this by:

  • Verifying every member via KYC + sanctions.
  • Using a cryptographic lottery to choose the round winner (commit/reveal via SHA-256 seed hash). The seed hash is committed at cycle activation and revealed at draw time; anyone can verify the draw was unbiased.
  • (Phase-2 target) Holding pool funds in a partner-bank escrow account (PartnerBankEscrowBinding) and reconciling escrow vs the per-round contribution journal daily (EqubReconciliationRun).

Custody status — read before quoting this section (ADR-014 / ADR-024). Phase-1 Equb pools contributions in a simulated internal-ledger account (equb:pool:{tenantId}:{cycleId}). There is no partner-bank escrow holding the pot yet, and DemozPay holds no member funds. Equb money movement is gated behind EQUB_MONEY_MOVEMENT_ENABLED (default off, hard-forbidden in production). The partner-bank escrow, daily reconciliation, and float-on-escrow described below are the Phase-2 design, not current behaviour. The equb:pool:… / wallet:member:… identifiers are ledger read-model keys, not stored value.

There are two cycle types:

  • CORPORATE — initiated by an employer for their employees (single-tenant pool).
  • PRIVATE — initiated by a member; invitations sent to others; only members who explicitly accept are committed.

How money flows

1. Create a cycle (CORPORATE: employer admin; PRIVATE: any member). Defaults to DRAFT.
2. Add members (CORPORATE) OR send invitations (PRIVATE).
3. PRIVATE: members accept/decline. Cycle proceeds only when fully accepted.
4. Activate the cycle:
- seedHash committed (a random value the operator holds; published as sha256(value)).
- Cycle state: DRAFT → ACTIVE.
5. Per round (N rounds total, N = member count):
a. Each member contributes (via payroll deduction or direct transfer):
Ledger PostTransaction:
Debit member-receivable
Credit equb-escrow-account
b. RunEqubDraw at end of round:
Operator reveals seed + nonce.
Server verifies sha256(seed+nonce) == seedHash.
Selects winner from members who haven't yet been paid out
(deterministic; index = hash(seed, round, eligible-set) % len(eligible-set)).
c. ClosEqubRoundPayout — gated by KYC + sanctions on the winner:
Ledger PostTransaction:
Debit equb-escrow-account
Credit winner-payable-to-bank
Integration-gateway InitiateDisbursement.
Webhook → ConfirmSettlement.
6. Cycle closes when all members have been paid out.

Dependencies

  • KYC + sanctions at every payout (equb.payout_blocked.v1 is emitted when sanctions hits a winner).
  • Ledger for every contribution + payout.
  • Integration-gateway for the payout transfer.
  • Partner bank escrow binding for the pool funds (Phase-2 — Phase-1 uses the simulated internal-ledger pool; see custody note above).

Revenue impact

Two lanes:

  1. Per-cycle fee charged to the employer (CORPORATE) or to members (PRIVATE).
  2. Float on escrow (Phase-2, contingent on real custody) — once pool balances sit in a partner bank between contributions and payout, banking terms may make this interest income. Not applicable to the Phase-1 simulated pool.

Notable engineering invariants

  • Draw is provably fair — anyone (including a regulator) can replay sha256(seed‖nonce) and reproduce the winner selection. Stored on EqubRound.
  • EqubContribution has dual uniqueness: (tenantId, idempotencyKey) for replay safety + (tenantId, cycleId, roundIndex, memberId) for once-per-round.
  • Activation commits the seed hash — operator cannot change the random seed after activation without invalidating every future draw.
  • Cycle invariants I1-I10 are encoded in EqubCycle domain methods.

8. Savings [Planned]

Why it doesn't exist yet

No packages/savings/. The legacy SavingGoal Prisma model exists with Decimal(15,2) and zero callers.

When built, the model is likely "interest-bearing goal-tied account" — money the employee earmarks per payroll cycle, with a target amount and target date. The mechanics are similar to a recurring inverse-EWA (income → reserved). Whether DemozPay holds the funds (custody) or routes them to a partner-bank savings product (orchestration) is a regulatory decision.


9. Treasury [Not modelled as a separate domain]

Why it doesn't exist

"Treasury" in DemozPay's design language is split across:

  1. Per-employer payroll-clearing accounts in the ledger.
  2. Equb partner-bank escrow bindings (PartnerBankEscrowBinding + EqubReconciliationRun).
  3. DemozPay's own operating accounts with each partner bank — managed manually outside the system today.

There is no aggregated cash-position view, no float-deployment surface, no FX-risk tooling, no daily liquidity report. If/when needed, those become a packages/treasury/ and surface in admin-web.


10. Settlement [Live as a ledger primitive]

Why it exists

A ledger entry is not real until the partner bank confirms. Settlement is the moment that confirmation arrives — PENDING → POSTED for the originating transaction.

How money flows

1. Origination call (EWA disburse, lending disburse, payroll disburse) posts to ledger
with status PENDING.
2. Same call invokes integration-gateway.InitiateDisbursement.
Gateway returns ACCEPTED (or fails).
3. Partner bank webhook arrives at gateway HTTP listener.
4. Gateway emits outbox event (e.g. ewa.bank_transfer_settled.v1) and writes
bank_event row.
5. apps/api consumes the outbox event → calls Ledger.ConfirmSettlement → PENDING → POSTED.
6. Balance views now include the entry.

If the bank fails the transfer:
- Gateway records the failure in bank_event.
- apps/api calls Ledger.MarkSettlementFailed → PENDING → FAILED.
- Money never counted; the originator (e.g. EWA) sees the failure and either retries or aborts.

Dependencies

  • Integration-gateway for bank communication.
  • Ledger for state transition.

Revenue impact

None directly. But this is what makes the "we know to-the-santim" claim auditable.

Notable engineering invariants

  • Forward-only state machine. POSTED never goes back to PENDING. FAILED is terminal.
  • services/ledger/migrations/0003_pending_posted.up.sql:45-84 enforces the transitions.
  • The legacy SettlementBatch + SettlementRecord Prisma models are for the dead BNPL/lending lineage and have zero callers. They are not part of today's settlement story.

11. KYC [Live (backend)]

Why it exists

Every disbursement must be to a verified identity. Sanctions screening must run against the verified identity. KYC is the gate.

Flow

  1. Employee submits KYC documents (POST /api/kyc). State: PENDING.
  2. Compliance ops claims for review (POST /api/kyc/:id/claim). Maker-checker. State: CLAIMED.
  3. Reviewer either:
    • Approves (POST /api/kyc/:id/approve) — runs sanctions inline; if hit, blocks the approve.
    • Rejects (/reject).
    • Requests more info (/request-more-info).

Dependencies

Sanctions (inline during approve).

Revenue impact

None directly. But without KYC, no disbursement is legal — and every disbursement-revenue line dies.


12. Sanctions [Live (backend)]

Why it exists

Regulatory requirement: every disbursement target must be screened against OFAC, UN, and Ethiopian sanctions lists.

Flow

  1. Ingest: CLI under apps/api/src/compliance/sanctions/cmd/ consumes CSV exports of OFAC / UN / Ethiopia / custom lists. Persists SanctionsListEntry rows.
  2. Screen: screen-identity.usecase.ts normalises the name (NameNormaliser), looks up against SanctionsListEntry with a GIN index on normalisedAliases, returns MATCH or NO_MATCH.
  3. Composition root in apps/api/src/compliance/sanctions/ publishes sanctions.hit.v1 when MATCH (today the publication is composition-root responsibility, not in the package — a known tech-debt item).

Where it's used

  • KYC approve workflow.
  • Equb payout (winner screened before money leaves the pool).
  • EWA + lending disburse (via KYC, transitively).

Revenue impact

None directly. Same logic as KYC.


13. Notifications [Stub Go service; in-process dispatcher Live]

Why it exists

SMS/email/push for OTPs, payroll-completed receipts, EWA approvals, Equb draw results, etc.

Today's reality

  • services/notifications/ boots and exposes GET /healthnothing else.
  • Dispatch happens in-process inside apps/api via apps/api/src/_infra/notification-consumers/ (poller + dispatcher + per-event handler).
  • SMS provider plugs into apps/api/src/_infra/sms/sms.module.ts: LoggingSmsSender (dev), EthioTelecomSmsSender (SMPP to Ethio Telecom), HttpSmsSender (generic HTTP).
  • Email provider plugs into apps/api/src/_infra/email/email.module.ts: LoggingEmailSender, SmtpEmailSender, HttpEmailSender.

When the Go service is built out, the consumers move out of apps/api. Until then, expect notification dispatch to share fate with apps/api.


14. Putting it together — one new employee, one paycheck

A concrete end-to-end:

  1. Day 0 — Employer signs up (better-auth). Creates an organization. KYC review approves them.
  2. Day 1 — Employer onboards employee. Submits KYC documents. Compliance reviews. Approves. Sanctions screen runs inline — clears.
  3. Day 5 — Employee opens app, sees GET /api/me/profile. KYC status is APPROVED.
  4. Day 10 — Employee needs cash. Requests EWA via the UI (today: through the API; the frontend isn't wired yet). Use case checks eligibility, calls account-lookup on the destination, queues for operator approval (or auto-approves under policy).
  5. Day 10 (cont.) — Operator disburses. Ledger PENDING → integration-gateway → mock partner bank → webhook → ConfirmSettlement → POSTED. Outbox emits ewa.bank_transfer_settled.v1. Notification consumer sends "EWA advance received" SMS.
  6. Day 15 — Payroll period ends. Employer's payroll team runs CalculatePayrollRun (with deduction-mandate for the EWA receivable). Reviews report. Approves. payroll.deductions_taken.v1 emitted.
  7. Day 15 (cont.) — EWA consumer marks EWA REPAID. Ledger posts the reverse leg. Employer disburses net pay (gross − tax − pension − EWA-deduction) via per-employee bank transfers. Each transfer follows the same PENDING → webhook → POSTED dance.
  8. Day 16 — Employer reviews GET /api/payroll/:id/audit and /api/payroll/:id/settlement to confirm all entries settled. Reconciliation runner (cron) checks bank statement vs ledger; drift = 0.

Every step in this flow is implemented in code today. The only thing not yet live is the real bank rail — the mock + dashen-without-contract substitute for it.


15. Glossary

TermMeaning
santimThe minor currency unit of the Ethiopian birr. 1 ETB = 100 santim. All money is stored as NUMERIC(20,0) santim.
bank-to-bank orchestratorDemozPay's regulatory posture per ADR-014 — money lives in partner banks, DemozPay owns the journal. Not a custodian.
idempotency-keyA client-generated string that lets a money-moving POST be safely retried without double-execution.
outboxPattern: writes to OutboxEvent in the same transaction as state changes; a separate publisher ships them to Kafka.
RLSPostgres Row-Level Security — enforces tenant_id filtering inside the database, fail-closed.
maker-checkerKYC pattern — the person who submits is not the one who approves.
commit/revealEqub cryptographic lottery — operator commits sha256(seed) at activation, reveals seed at draw, anyone can verify.
PENDING → POSTEDLedger settlement state machine. Money is "real" only after POSTED (bank confirmed).
payroll-clearing accountThe per-employer ledger account that payroll debits and each employee's payable-to-bank credits, before bank settlement.
partner bankA bank (Dashen, CBE, …) with whom DemozPay has a contract to send / receive money on behalf of tenants.
HMAC service-to-service auth (R1)Today's gRPC inter-service auth; year-1 replacement is mTLS.