Skip to main content

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.

StepComponentOperationStatus
1Employee → APIPOST /api/ewa/requests with Idempotency-Key[L]
2APIRequestEwaUseCase — eligibility check (accrued earnings minus prior outstanding), persist EwaRequest in PENDING[P] — accrued-earnings stub is Employee.baseSalary pro-rated
3APIOutbox event ewa.requested.v1[L] (produced; consumer absent)
4(separate request) Employee/admin → APIPOST /api/ewa/requests/:id/disburse[L]
5APIDisburseEwaUseCase — load EwaRequest, transition APPROVED[L]
6API → ledgerPostTransaction(status=PENDING)DR receivable-from-employee (P+F) / CR payable-to-business-bank (P) / CR fee-revenue (F)[L]
7API → gatewayInitiateDisbursement(source=business-pool, dest=employee-bank, amount=P)[L]
8gateway → DashenHMAC-signed HTTPS POST to Dashen's /transfers[L] (via bank-sandbox in test)
9Dashen → gatewaysync response: ACCEPTED + partner_reference (or REJECTED + reason)[L]
10gateway → ledger(none yet — settlement deferred to webhook/poll)[L]
11APIPersist EWA SUBMITTED_TO_BANK, emit ewa.bank_transfer_submitted.v1[L]
12Dashen → gateway webhookAsync: event=COMPLETED with HMAC[L]
13gateway → API webhook controllerPOST /api/integration/bank-callback/:partner[L]
14APIBankSettlementApplier.apply() — load EwaRequest by partner_reference, transition DISBURSED[L]
15API → ledgerConfirmSettlement(tx_id) → PENDING → POSTED[L]
16APIOutbox event ewa.bank_transfer_settled.v1[L]
17APIAudit row in same tx as state change[L]

Recovery paths:

  • Step 9 returns REJECTED: MarkSettlementFailed + Reverse on ledger, EWA → BANK_REJECTED, emit ewa.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.

StepComponentOperationStatus
1Payroll engineAt 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).
2Employer payroll engine → payroll outboxEmit payroll.deductions_taken.v1 per employee, naming EWA IDs + amounts deducted.[X]
3API — payroll consumerConsume 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)
4APILoad EwaRequest, transition DISBURSEDREPAID.[L]
5API → ledgerPostTransactionDR payroll-clearing (P+F) / CR receivable-from-employee (P+F).[L] (Phase B1, 2026-05-29)
6API → ledger(later, separate transfer to employer's bank) PostTransactionDR payable-to-business-bank / CR payroll-clearing.[X] — Phase B continuation (EWA's equivalent of the lending remit)
7Outbox 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.

StepComponentOperationStatus
1Payroll engineDeduct installment from employee's gross.[X]
2Payroll outbox → API consumerpayroll.deductions_taken.v1.[X]
3APIRoute to RecordRepaymentUseCase(loanId, installmentIndex).[L] — use case exists, caller is admin endpoint only.
4APILoad Loan; verify installment is unpaid; idempotent on (loanId, installmentIndex).[L]
5API → ledgerPostTransactionDR payroll-clearing (full) / CR receivable-from-borrower (principal portion) / CR interest-revenue (interest portion).[L]
6APIPersist LoanRepayment row (append-only).[L]
7APIIf last installment → loan → CLOSED; emit loan.closed.v1.[L]
8APIAlways emit loan.installment_repaid.v1.[L]
9aAPI → 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)
9bAPI → gateway → bankOutbound bank transfer Employer payroll account → FI's pool account.[X] — Phase B continuation. Requires schema change for employer-payroll-account modelling.
10APIEmit 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.

StepComponentOperationStatus
1Employee at merchant POS → APIPOST /api/bnpl/purchases (does not exist)[X]
2APIEligibility against income; pre-commit DR receivable-from-employee / CR payable-to-merchant (status=PENDING).[X]
3(next batch, T+1)API → gatewayInitiateDisbursement(source=underwriter-pool, dest=merchant-bank)
4gateway → partnerSettle.[X]
5APIOn settled: ledger DR payable-to-merchant / CR payable-to-bnpl-underwriter. Status → SETTLED.[X]
6Payroll-cyclePer 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.

StepComponentOperationStatus
1Employer admin → employer-web → APIStart payroll run for period.[X]
2API — payroll-run use caseFor each employee: compute gross + deductions (EWA outstanding, loan installments due this period, BNPL installments due, statutory deductions, voluntary deductions).[X]
3API → ledgerPer employee, draft journal entries (PENDING status).[X]
4API → gatewayPer employee, InitiateDisbursement(source=employer-payroll-account, dest=employee-bank, amount=net_pay).[X]
5Per disbursement settles → confirm ledger entries → emit payroll.deductions_taken.v1 per (employee, deduction-category, amount).[X]
6Downstream consumers (EWA, lending, BNPL)Receive event → mark obligations satisfied.[X] (consumers don't exist either)
7APIEmit 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)

StepComponentOperationStatus
1Use case → gateway → partnerInitiate returns REJECTED + reason[L]
2GatewayPersist disbursement.status=FAILED, failure_code, bank_event row[L]
3Use caseCatch + call ledger.MarkSettlementFailed + ledger.Reverse(tx_id)[L]
4Use casePersist EwaRequest/Loan → BANK_REJECTED[L]
5Outbox event *.bank_transfer_failed.v1[L]
6Notify 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.

StepComponentOperationStatus
1OperatorIdentifies a POSTED transaction that needs reversal (e.g., bank-side chargeback)manual
2OperatorCalls ledger Reverse(tx_id, reason) via grpcurl or admin tool[L] raw RPC; [X] admin tool
3LedgerAppends compensating transaction; reverses_transaction_id set; double-reversal lockout[L]
4APIUpdate domain aggregate via a forward-motion use case (e.g., MarkEwaReversed)[X] — use case does not exist
5AuditOperator-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

FlowDisburse-sideRepay-sideEnd-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/ausable raw, no tooling
Reconciliation correctionmanualn/auses 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:

CodeAccount nameTypeUsed by
1050payroll-clearingASSETEWA + lending repayment (intermediate)
1200ewa-receivable-from-employeeASSETEWA disburse / repay
1300loan-receivableASSETLending disburse / repay
2100payable-to-business-poolLIABILITYEWA (employer is the funder)
2200-<fi_id>payable-to-fi-<fi_id>LIABILITYLending (one per FI partner)
2300-<merchant_id>payable-to-merchant-<merchant_id>LIABILITYBNPL (PLANNED)
4100ewa-fee-revenueREVENUEEWA fees
4200loan-interest-revenueREVENUELending interest
4210loan-servicing-feeREVENUELending 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).

DomainEventEmitted by
EWAewa.requested.v1RequestEwaUseCase
EWAewa.bank_transfer_submitted.v1DisburseEwaUseCase (ACCEPTED branch)
EWAewa.bank_transfer_accepted.v1(same as above; sometimes equivalent — see implementation)
EWAewa.bank_transfer_settled.v1BankSettlementApplier
EWAewa.bank_transfer_failed.v1BankSettlementApplier / DisburseEwaUseCase (REJECTED)
EWAewa.repaid.v1PLANNED
EWAewa.rejected.v1PLANNED
Lendingloan.requested.v1RequestLoanUseCase
Lendingloan.bank_transfer_{submitted,accepted,settled,failed}.v1Same pattern as EWA
Lendingloan.installment_repaid.v1RecordRepaymentUseCase
Lendingloan.closed.v1RecordRepaymentUseCase (last installment)
Lendingloan.defaulted.v1PLANNED
BNPLbnpl.*.v1PLANNED
Payrollpayroll.deductions_taken.v1PLANNED — upstream signal that EWA + lending repayment depends on
Payrollpayroll.run_completed.v1PLANNED

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