DemozPay — Canonical Money Flows
Snapshot: 2026-05-29 Companion to:
BANK_ORCHESTRATION.md,RECONCILIATION_ARCHITECTURE.md, ADR-012 (ledger taxonomy).
Twelve canonical flows. Each one with: actors, APIs, DB writes, outbox events, settlement states, reconciliation states, idempotency, retry, recovery, and what's actually built vs what's gapped.
Notation:
[L]= LIVE end-to-end.[P]= PARTIAL — primitive exists, downstream gap.[S]= STUB — looks alive, isn't.[X]= PLANNED — no code.[D]= DANGEROUS — present but with an exploit / bug / model violation.
Every ledger entry uses ADR-012 account taxonomy: ASSET/EXPENSE debit-normal; LIABILITY/EQUITY/REVENUE credit-normal. All amounts in santim (NUMERIC(20,0)).
Flow 1 — EWA Disbursement (employer-funded)
Actors: Employee, DemozPay API, ledger, integration-gateway, Dashen, employer's pre-funded payroll account.
| Step | Component | Operation | Status |
|---|---|---|---|
| 1 | Employee → API | POST /api/ewa/requests with Idempotency-Key | [L] |
| 2 | API | RequestEwaUseCase — eligibility check (accrued earnings minus prior outstanding), persist EwaRequest in PENDING | [P] — accrued-earnings stub is Employee.baseSalary pro-rated |
| 3 | API | Outbox event ewa.requested.v1 | [L] (produced; consumer absent) |
| 4 | (separate request) Employee/admin → API | POST /api/ewa/requests/:id/disburse | [L] |
| 5 | API | DisburseEwaUseCase — load EwaRequest, transition APPROVED | [L] |
| 6 | API → ledger | PostTransaction(status=PENDING) — DR receivable-from-employee (P+F) / CR payable-to-business-bank (P) / CR fee-revenue (F) | [L] |
| 7 | API → gateway | InitiateDisbursement(source=business-pool, dest=employee-bank, amount=P) | [L] |
| 8 | gateway → Dashen | HMAC-signed HTTPS POST to Dashen's /transfers | [L] (via bank-sandbox in test) |
| 9 | Dashen → gateway | sync response: ACCEPTED + partner_reference (or REJECTED + reason) | [L] |
| 10 | gateway → ledger | (none yet — settlement deferred to webhook/poll) | [L] |
| 11 | API | Persist EWA SUBMITTED_TO_BANK, emit ewa.bank_transfer_submitted.v1 | [L] |
| 12 | Dashen → gateway webhook | Async: event=COMPLETED with HMAC | [L] |
| 13 | gateway → API webhook controller | POST /api/integration/bank-callback/:partner | [L] |
| 14 | API | BankSettlementApplier.apply() — load EwaRequest by partner_reference, transition DISBURSED | [L] |
| 15 | API → ledger | ConfirmSettlement(tx_id) → PENDING → POSTED | [L] |
| 16 | API | Outbox event ewa.bank_transfer_settled.v1 | [L] |
| 17 | API | Audit row in same tx as state change | [L] |
Recovery paths:
- Step 9 returns REJECTED:
MarkSettlementFailed+Reverseon ledger, EWA →BANK_REJECTED, emitewa.bank_transfer_failed.v1.[L] - Step 12 webhook never arrives: settlement-poller picks it up via
GetDisbursementStatus.[L] - Step 9 throws (network): EWA stays
APPROVED, ledger PENDING orphaned. Operator manually re-disburses or calls Reverse.[P]— no auto-cleanup.
Idempotency: 4-layer (API/ledger/gateway/recon). [L]
Reconciliation tag: the gateway disbursement row's partner_reference matches a bank_statement_line row on T+1 → marked RECONCILED. [L] primitive; [X] cadence.
Verdict: [L].
Flow 2 — EWA Repayment (payroll-cycle deduction)
Actors: Employer's payroll engine, payroll-deduction event consumer, API, ledger.
| Step | Component | Operation | Status |
|---|---|---|---|
| 1 | Payroll engine | At payroll-run, compute net-pay = gross - (EWA outstanding + loan installments + tax). Deduct from gross before depositing employee's net. | [L] — payroll engine is Live (@demoz-pay/payroll). |
| 2 | Employer payroll engine → payroll outbox | Emit payroll.deductions_taken.v1 per employee, naming EWA IDs + amounts deducted. | [X] |
| 3 | API — payroll consumer | Consume event, route to EWA's RecordEwaRepaymentUseCase. | [~] — PayrollConsumersModule exists but is disabled pending a DI fix (plan A2); the use case is Live. |
| 3' | Admin/operator → API | (pilot path) POST /api/ewa/requests/:id/record-repayment (admin/owner) | [L] (Phase B1, 2026-05-29) |
| 4 | API | Load EwaRequest, transition DISBURSED → REPAID. | [L] |
| 5 | API → ledger | PostTransaction — DR payroll-clearing (P+F) / CR receivable-from-employee (P+F). | [L] (Phase B1, 2026-05-29) |
| 6 | API → ledger | (later, separate transfer to employer's bank) PostTransaction — DR payable-to-business-bank / CR payroll-clearing. | [X] — Phase B continuation (EWA's equivalent of the lending remit) |
| 7 | Outbox event ewa.repaid.v1 | [L] (Phase B1, 2026-05-29) |
Verdict (post-B1): [P] — the receivable-side journal closes via admin endpoint; the payroll-event consumer path AND the PayrollClearing → BusinessBank remit remain Planned. Pilot-viable: operator-triggered repayment recording works end-to-end at single-employer scale.
Flow 3 — Salary-backed Loan Disbursement (FI-funded)
Actors: Borrower, API, ledger, gateway, FI's pool account at FI's bank, employee's bank.
Identical to Flow 1 except:
- Source account: FI's pool, not employer's payroll account.
- Ledger entries:
DR receivable-from-borrower (P) / CR payable-to-fi-partner[fiPartnerId] (P)— no fee leg (fee is collected over installments). - Loan status flows:
PENDING → APPROVED → SUBMITTED_TO_BANK → ACCEPTED_BY_BANK → DISBURSED.
Verdict: [L] for disburse. Repayment is Flow 4.
Flow 4 — Loan Repayment (single installment)
Actors: Employer payroll engine (planned), API, ledger.
| Step | Component | Operation | Status |
|---|---|---|---|
| 1 | Payroll engine | Deduct installment from employee's gross. | [X] |
| 2 | Payroll outbox → API consumer | payroll.deductions_taken.v1. | [X] |
| 3 | API | Route to RecordRepaymentUseCase(loanId, installmentIndex). | [L] — use case exists, caller is admin endpoint only. |
| 4 | API | Load Loan; verify installment is unpaid; idempotent on (loanId, installmentIndex). | [L] |
| 5 | API → ledger | PostTransaction — DR payroll-clearing (full) / CR receivable-from-borrower (principal portion) / CR interest-revenue (interest portion). | [L] |
| 6 | API | Persist LoanRepayment row (append-only). | [L] |
| 7 | API | If last installment → loan → CLOSED; emit loan.closed.v1. | [L] |
| 8 | API | Always emit loan.installment_repaid.v1. | [L] |
| 9a | API → ledger (NEW Phase B2) | RemitInstallmentToFiUseCase posts DR payable-to-fi-partner[fi_id] (principal) / CR payroll-clearing (principal). Admin endpoint POST /api/loans/:id/installments/:idx/remit-to-fi. | [L] (Phase B2, 2026-05-29) |
| 9b | API → gateway → bank | Outbound bank transfer Employer payroll account → FI's pool account. | [X] — Phase B continuation. Requires schema change for employer-payroll-account modelling. |
| 10 | API | Emit loan.installment_remitted_to_fi.v1. | [L] (Phase B2, 2026-05-29) |
Verdict (post-B2): [P]. Steps 3–8, 9a, 10 are LIVE if invoked via admin endpoints. Steps 1–2 (payroll engine + consumer) remain Planned. Step 9b (bank-side remit) remains Planned. PayrollClearing on the ledger no longer accumulates — the ledger-side phantom asset is structurally closed. The actual money still needs to physically move via operator-driven bank ops until 9b ships; recon catches divergence.
Flow 5 — BNPL Merchant Settlement
Actors: Employee (purchase time), merchant, BNPL underwriter (FI partner), API, ledger, gateway.
| Step | Component | Operation | Status |
|---|---|---|---|
| 1 | Employee at merchant POS → API | POST /api/bnpl/purchases (does not exist) | [X] |
| 2 | API | Eligibility against income; pre-commit DR receivable-from-employee / CR payable-to-merchant (status=PENDING). | [X] |
| 3 | (next batch, T+1) | API → gateway | InitiateDisbursement(source=underwriter-pool, dest=merchant-bank) |
| 4 | gateway → partner | Settle. | [X] |
| 5 | API | On settled: ledger DR payable-to-merchant / CR payable-to-bnpl-underwriter. Status → SETTLED. | [X] |
| 6 | Payroll-cycle | Per installment, DR payroll-clearing / CR receivable-from-employee. Remit to underwriter via gateway. | [X] |
Verdict: [X] — no code. Do not promise BNPL externally.
Flow 6 — Payroll Processing (the trust anchor)
Actors: Payroll engine, API, employee bank accounts.
This is the flow that anchors the entire deduction-driven business model. The payroll engine is Live (@demoz-pay/payroll — rule lifecycle, calculation, disbursement) and emits payroll.deductions_taken.v1. The remaining gap is the cross-domain repayment consumer (disabled pending a DI fix — plan A2), so deductions don't yet auto-settle EWA/loan receivables.
| Step | Component | Operation | Status |
|---|---|---|---|
| 1 | Employer admin → employer-web → API | Start payroll run for period. | [X] |
| 2 | API — payroll-run use case | For each employee: compute gross + deductions (EWA outstanding, loan installments due this period, BNPL installments due, statutory deductions, voluntary deductions). | [X] |
| 3 | API → ledger | Per employee, draft journal entries (PENDING status). | [X] |
| 4 | API → gateway | Per employee, InitiateDisbursement(source=employer-payroll-account, dest=employee-bank, amount=net_pay). | [X] |
| 5 | Per disbursement settles → confirm ledger entries → emit payroll.deductions_taken.v1 per (employee, deduction-category, amount). | [X] | |
| 6 | Downstream consumers (EWA, lending, BNPL) | Receive event → mark obligations satisfied. | [X] (consumers don't exist either) |
| 7 | API | Emit payroll.run_completed.v1 per (tenant, period). | [X] |
Verdict: [X]. The single largest functional gap in the platform. Without this flow:
- EWA repayment cannot happen automatically.
- Loan repayment cannot happen automatically.
- BNPL repayment cannot happen automatically.
- "Salary-backed underwriting" is a marketing claim, not a runtime guarantee.
This flow MUST be built before opening any rail in production. See 90_DAY_EXECUTION_PLAN.md.
Flow 7 — Failed Bank Transfer (sync rejection at submit)
| Step | Component | Operation | Status |
|---|---|---|---|
| 1 | Use case → gateway → partner | Initiate returns REJECTED + reason | [L] |
| 2 | Gateway | Persist disbursement.status=FAILED, failure_code, bank_event row | [L] |
| 3 | Use case | Catch + call ledger.MarkSettlementFailed + ledger.Reverse(tx_id) | [L] |
| 4 | Use case | Persist EwaRequest/Loan → BANK_REJECTED | [L] |
| 5 | Outbox event *.bank_transfer_failed.v1 | [L] | |
| 6 | Notify employee | [X] — notifications service is stub |
Verdict: [P]. Ledger + state machine are LIVE; user-facing notification is gapped.
Flow 8 — Async Failed Bank Transfer (post-accept failure)
Same as Flow 7 except triggered by webhook or poll instead of sync response. The path through BankSettlementApplier.apply() is identical.
Verdict: [L] end-to-end through the ledger; [X] for user notification.
Flow 9 — Reversal (post-settlement correction)
Actors: Operator (manually triggers today), API admin endpoint (planned), ledger.
| Step | Component | Operation | Status |
|---|---|---|---|
| 1 | Operator | Identifies a POSTED transaction that needs reversal (e.g., bank-side chargeback) | manual |
| 2 | Operator | Calls ledger Reverse(tx_id, reason) via grpcurl or admin tool | [L] raw RPC; [X] admin tool |
| 3 | Ledger | Appends compensating transaction; reverses_transaction_id set; double-reversal lockout | [L] |
| 4 | API | Update domain aggregate via a forward-motion use case (e.g., MarkEwaReversed) | [X] — use case does not exist |
| 5 | Audit | Operator-provided note attached to audit row | [X] — no schema field for it |
Verdict: [P]. The ledger primitive is LIVE; the operational wrapping (admin UI, sign-off, audit trail) is missing.
Flow 10 — Reconciliation Correction (post-drift remediation)
This is not a separate flow — every correction is a forward-motion entry through one of the use cases above. Specifically:
- Drift > 0 + cause "errant ConfirmSettlement" → run Flow 9 (Reverse).
- Drift < 0 + cause "missed ConfirmSettlement" → run Flow 1 step 14 manually with the correct
partner_reference(admin endpoint, planned). - Drift caused by un-modelled bank fee → post a fee-recognition use case (PLANNED).
Verdict: see docs/runbooks/drift-detected.md for incident triage; no Live remediation tooling exists.
Flow 11 — FI-funded Disbursement (close-up on capital movement)
The same as Flow 3 with one extra ledger leg per partner contract:
- At request time, the FI's pool obligation is incurred:
DR receivable-from-borrower / CR payable-to-fi-partner[fi_id]. - At repayment time, the obligation is reduced:
DR payroll-clearing / CR receivable-from-borrower + CR interest-revenue(Flow 4). - At remittance time (PLANNED), funds physically move:
DR payable-to-fi-partner[fi_id] / CR payroll-clearing. The gateway initiates the transfer to FI's bank account.
Verdict: disburse + record-repayment [L]; remittance step [X].
Flow 12 — Employer-funded Disbursement (close-up — EWA shape)
Same as Flow 1 except the source is the employer's pre-funded payroll account, and the obligation account is payable-to-business-bank (not payable-to-fi-partner). Recovered through payroll deduction (Flow 2 — PLANNED).
Verdict: disburse [L]; recovery [X].
Cross-flow analysis
| Flow | Disburse-side | Repay-side | End-to-end |
|---|---|---|---|
| EWA | [L] | [X] | broken at repayment |
| Lending | [L] | [P] (admin-driven) | broken at automation |
| BNPL | [X] | [X] | does not exist |
| Payroll (anchor) | [X] | [X] | breaks every other flow's repayment |
| Reversal | [P] | n/a | usable raw, no tooling |
| Reconciliation correction | manual | n/a | uses flows above |
Read the column "End-to-end" carefully. The platform can disburse. It cannot collect. The collect side requires payroll. Building payroll is therefore the highest-leverage single piece of work remaining.
Account taxonomy reference
Per ADR-012 + GAP-04. Each tenantId provisions these accounts on onboarding:
| Code | Account name | Type | Used by |
|---|---|---|---|
1050 | payroll-clearing | ASSET | EWA + lending repayment (intermediate) |
1200 | ewa-receivable-from-employee | ASSET | EWA disburse / repay |
1300 | loan-receivable | ASSET | Lending disburse / repay |
2100 | payable-to-business-pool | LIABILITY | EWA (employer is the funder) |
2200-<fi_id> | payable-to-fi-<fi_id> | LIABILITY | Lending (one per FI partner) |
2300-<merchant_id> | payable-to-merchant-<merchant_id> | LIABILITY | BNPL (PLANNED) |
4100 | ewa-fee-revenue | REVENUE | EWA fees |
4200 | loan-interest-revenue | REVENUE | Lending interest |
4210 | loan-servicing-fee | REVENUE | Lending servicing |
There is no cash account. Anywhere cash appears in code, it's a bug (see SYSTEM_GAP §6). GAP-04 removed cashAccountId from the EWA + lending adapters; do not re-introduce.
Outbox event catalog (canonical names)
Per GAP-06 — all in lifecycle order. Consumer of each is a humble TODO (services/notifications/ is Stub).
| Domain | Event | Emitted by |
|---|---|---|
| EWA | ewa.requested.v1 | RequestEwaUseCase |
| EWA | ewa.bank_transfer_submitted.v1 | DisburseEwaUseCase (ACCEPTED branch) |
| EWA | ewa.bank_transfer_accepted.v1 | (same as above; sometimes equivalent — see implementation) |
| EWA | ewa.bank_transfer_settled.v1 | BankSettlementApplier |
| EWA | ewa.bank_transfer_failed.v1 | BankSettlementApplier / DisburseEwaUseCase (REJECTED) |
| EWA | ewa.repaid.v1 | PLANNED |
| EWA | ewa.rejected.v1 | PLANNED |
| Lending | loan.requested.v1 | RequestLoanUseCase |
| Lending | loan.bank_transfer_{submitted,accepted,settled,failed}.v1 | Same pattern as EWA |
| Lending | loan.installment_repaid.v1 | RecordRepaymentUseCase |
| Lending | loan.closed.v1 | RecordRepaymentUseCase (last installment) |
| Lending | loan.defaulted.v1 | PLANNED |
| BNPL | bnpl.*.v1 | PLANNED |
| Payroll | payroll.deductions_taken.v1 | PLANNED — upstream signal that EWA + lending repayment depends on |
| Payroll | payroll.run_completed.v1 | PLANNED |
There are no consumers. All emitted events fall into a void today. services/notifications/ is /health only; no reconciliation consumer; no BI consumer.
Cross-references
- The bank-orchestration model these flows operate under →
BANK_ORCHESTRATION.md. - The reconciliation lifecycle that catches when these flows lie →
RECONCILIATION_ARCHITECTURE.md. - Per-flow status in one table →
DOMAIN_COMPLETENESS_MATRIX.md.