Skip to main content

Payout Routing — Simple Plan (MVP)

Goal. Employer connects 1–2 bank accounts. Then runs payroll. Money lands in the employees' accounts.

MVP scope. Dashen Bank only. Payroll only. Tested against bank-sandbox with seeded test accounts — no real money moves.

Deep technical reference. docs/plans/archive/PAYOUT_ROUTING_PLAN_DETAILED.md (the original 800-line version). Read that when you implement; this file is the founder/PM/onboarding view.


1. The big picture

DemozPay never holds money. It tells the partner bank what to do. Funds move:

Employer's account at Dashen → Employee's account at Dashen
(Dashen's books) (Dashen's books)



"Pay this amount to that account"



DemozPay (instructions only)

Per ADR-014 — orchestrator, not custodian.


2. The 4-screen employer journey

Meet Eden, HR manager at Habesha Technologies PLC.

Screen 1 — Platform admin adds Dashen to the catalog (once, ever)

A DemozPay platform admin signs in to admin.demozpay.com → Companies & Partners → Financial Institutions → Add:

┌─────────────────────────────────────────┐
│ Add Financial Institution │
│ │
│ Name: [ Dashen Bank ] │
│ Type: [ BANK ▼ ] │
│ Adapter key: [ dashen ] │
│ ↑ this maps to the Go │
│ adapter that talks to │
│ Dashen's API │
│ SWIFT: [ AWINETAA ] │
│ Short code: [ DSH ] │
│ │
│ [ Cancel ] [ Save ] │
└─────────────────────────────────────────┘

Save. Dashen now exists in DemozPay's catalog and is wired to the Go adapter at services/integration-gateway/internal/adapters/dashen/.

Screen 2 — Eden connects her primary account

Eden signs in to employer.demozpay.com → Settings → Payroll Banking → Add Account:

┌─────────────────────────────────────────┐
│ Step 1 of 2 — Account details │
│ │
│ Bank: [ Dashen Bank ▼ ] │
│ Account #: [ FAKE-HABESHA-001 ] │
│ Holder name: [ Habesha Technologies ] │
│ Use as: ( ● ) Primary │
│ ( ○ ) Secondary │
│ │
│ [ Cancel ] [ Continue → ] │
└─────────────────────────────────────────┘

She clicks Continue. Behind the scenes: gRPC call to LookupAccount against Dashen. bank-sandbox confirms the account exists and returns the holder name. Step 2 opens:

┌─────────────────────────────────────────┐
│ Step 2 of 2 — Authorize │
│ │
│ ✓ Confirmed: Habesha Technologies PLC │
│ Account •••• 0001 at Dashen Bank │
│ Balance: 5,000,000.00 ETB │
│ │
│ Click below to authorize DemozPay to │
│ instruct transfers from this account. │
│ │
│ [ Back ] [ Authorize → ] │
└─────────────────────────────────────────┘

In MVP (bank-sandbox only) the "Authorize" button is a no-op stub that returns success instantly. When we wire real Dashen later, this button hard-redirects to Dashen's OAuth screen.

Back to the banking page:

┌─────────────────────────────────────────┐
│ Payroll Bank Accounts │
│ │
│ ┌───────────────────────────────────┐ │
│ │ 🏦 Dashen Bank •••• 0001 │ │
│ │ Balance: 5,000,000.00 ETB │ │
│ │ 🟢 Primary · Active │ │
│ └───────────────────────────────────┘ │
│ │
│ [ + Add Payroll Bank Account ] │
└─────────────────────────────────────────┘

Screen 3 — Eden adds a secondary (failover)

Same flow, picks Dashen again (only one partner in MVP), enters FAKE-HABESHA-002, ticks Secondary:

┌─────────────────────────────────────────┐
│ ┌───────────────────────────────────┐ │
│ │ 🏦 Dashen Bank •••• 0001 │ │
│ │ Balance: 5,000,000.00 ETB │ │
│ │ 🟢 Primary · Active │ │
│ └───────────────────────────────────┘ │
│ ┌───────────────────────────────────┐ │
│ │ 🏦 Dashen Bank •••• 0002 │ │
│ │ Balance: 2,000,000.00 ETB │ │
│ │ 🟡 Secondary · Active │ │
│ └───────────────────────────────────┘ │
└─────────────────────────────────────────┘

Screen 4 — Eden runs March 2026 payroll

She clicks Payroll → Run March 2026:

┌──────────────────────────────────────────────┐
│ Payroll Run: March 2026 │
│ │
│ Source account: 🏦 Dashen •••• 0001 │
│ │
│ Employees: 30 │
│ Total: 4,231,500.00 ETB │
│ │
│ [ Review ] [ Run Payroll → ] │
└──────────────────────────────────────────────┘

Click. The page becomes a live status board:

┌─────────────────────────────────────────────────────┐
│ Payroll Run — Live Status │
│ │
│ ✓ Abel Bekele 8,500 ETB POSTED │
│ ✓ Hawi Tadesse 12,000 ETB POSTED │
│ ⠋ Sara Lemma 15,750 ETB PENDING… │
│ ✓ Yonatan Girma 9,200 ETB POSTED │
│ ✗ Test Failure 50,000 ETB REJECTED │
│ (account closed — alert sent to Eden) │
│ … │
│ │
│ 29 / 30 settled · 1 failure │
└─────────────────────────────────────────────────────┘

Per-employee status updates live from the ledger's PENDING → POSTED / FAILED transitions.


3. What we already have ✓ vs. what we need to build ⨯

PieceStatusWhere
Dashen Go adapter✓ scaffoldedservices/integration-gateway/internal/adapters/dashen/
bank-sandbox service for safe testing✓ liveservices/bank-sandbox/
Ledger service (PENDING / POSTED / FAILED)✓ live, used by EWAservices/ledger/
IntegrationGateway gRPC (InitiateDisbursement, LookupAccount)✓ contract donepackages/contracts/grpc/integration_gateway.proto
NestJS clients (ledger + gateway)✓ liveapps/api/src/products/ewa/*.grpc-client.ts
Admin FI create form✓ exists, needs +1 field (adapterKey)apps/admin-web/src/components/companies-and-partners/FinancialInstitutionsTab.tsx
BusinessBankAccount table⨯ to buildnew Prisma model
Employer Payroll Banking page⨯ to buildnew in apps/employer-web/src/app/settings/banking/
Add-account modal (2 steps)⨯ to buildnew component
Payroll uses connected account as source⨯ to wireextend payroll service
Seeded test bank accounts (so dashboard shows real numbers)⨯ to seedextend apps/api/prisma/seed.ts + bank-sandbox seed

4. Seed data — so the dashboard shows real numbers from day 1

We pre-load both bank-sandbox and the DemozPay DB so that when Eden logs in as the seeded Habesha Technologies admin, the dashboard already has working accounts and balances.

4.1 Seeded into bank-sandbox (the test partner)

Account #HolderBalance (ETB)Purpose
FAKE-HABESHA-001Habesha Technologies PLC5,000,000Habesha's primary payroll account
FAKE-HABESHA-002Habesha Technologies PLC2,000,000Habesha's secondary failover account
FAKE-EMP-101Abel Bekele12,000Employee #1 destination
FAKE-EMP-102Hawi Tadesse8,500Employee #2 destination
FAKE-EMP-103FAKE-EMP-130(28 more demo employees)variedEmployees #3–30 destinations
reject-insufficient-001n/an/aBuilt-in failure trigger — bank-sandbox returns INSUFFICIENT_FUNDS
reject-invalid-001n/an/aBuilt-in failure trigger — bank-sandbox returns INVALID_ACCOUNT
fail-after-accept-001n/an/aBuilt-in failure trigger — accepts then webhooks FAILED
slow-001n/an/aBuilt-in delay trigger — 30s before settlement webhook

The 4 failure-injection prefixes (reject-*, fail-after-accept-*, slow-*) are already supported by bank-sandbox — we just point a few seeded test employees at them so we can demo every scenario from the live dashboard.

4.2 Seeded into DemozPay's DB

TableRowField values
Organization (FI catalog)Dashen Bankkind=FI_PARTNER, fiType=BANK, adapterKey="dashen", swiftCode="AWINETAA", status=ACTIVE
BusinessBankAccountHabesha primaryorganizationId=<habesha>, fiPartnerOrganizationId=<dashen>, accountNumber="FAKE-HABESHA-001", role=PRIMARY, status=ACTIVE, verifiedHolderName="Habesha Technologies PLC"
BusinessBankAccountHabesha secondarysame shape, accountNumber="FAKE-HABESHA-002", role=SECONDARY
Employee.payoutFIPartnerOrganizationId + .payoutAccountNumber30 employeeseach set to a FAKE-EMP-1xx matching the bank-sandbox seed

After seeding, Eden signs in and immediately sees the dashboard in Screen 2 of §2 without onboarding. She can run a test payroll the same minute.


5. The 4 scenarios to test

#ScenarioWhat happensHow we trigger it
1Happy path30 transfers go ACCEPTED → 5–10s later all POSTED. Ledger nets to zero.Run payroll against the seeded employees
2Primary insufficient fundsPrimary has 5M ETB. Set payroll target > 5M. Dashen rejects. System retries on secondary (2M ETB). Some transfers cover, some don't — covered are POSTED, uncovered are FAILED + employee is flagged with a "payment paused" badge.Seed one big-salary employee paying > 5M ETB
3Account closed (per-employee failure)Employee #5's destination is reject-invalid-001. That row goes REJECTED instantly, ledger entry reversed. Other 29 go through.Seed employee #5 with the failure-prefix account
4Slow settlementEmployee #6's destination is slow-001. Transfer goes ACCEPTED, dashboard shows PENDING for 30s, then POSTED. Demonstrates real-time UI updates.Seed employee #6 with the slow-prefix account

Every scenario is fully scripted by seed data. No manual setup. Just sign in and click.


6. Build order — 7 days for a full demoable MVP

#StepFiles / surfacesTime
1Add adapterKey to FI catalog (admin can register Dashen)apps/api/prisma/schema.prisma (1 column), CreateFIDto + UpdateFIDto (1 field), admin-web FI form (1 input)½ day
2Create BusinessBankAccount table + Prisma model + RLS policyschema.prisma + 1 migration½ day
3Build BusinessBankAccountService (CRUD + LookupAccount call via existing gateway client)apps/api/src/money/banking/ (delivered location after the bounded-context restructure; original plan said apps/api/src/business-bank-account/)1½ days
4Build employer-web Settings → Payroll Banking page + 2-step modalapps/employer-web/src/app/settings/banking/1½ days
5Wire payroll engine to use BusinessBankAccount rows as the disbursement sourceextend existing payroll service1 day
6Seed bank-sandbox with 35 accounts + DemozPay DB with the catalog + Habesha's 2 accounts + 30 employeesservices/bank-sandbox/internal/seed/, apps/api/prisma/seed.ts½ day
7End-to-end test the 4 scenarios in §5new file scripts/test-payroll-mvp.mjs1 day

Total: 7 working days. Eden has a working dashboard at the end, with concrete numbers, against bank-sandbox — no Dashen sandbox or production access required for the MVP demo.


7. What happens on payroll day — the short version

Eden clicks "Run March 2026 payroll"


For each employee in active employment:

├─ 1. Pick PRIMARY active BusinessBankAccount as the source

├─ 2. Compute net pay (gross − tax − pension − deductions)

├─ 3. Write PENDING ledger entry
│ (DR receivable-from-employee / CR payable-to-employer-bank)

├─ 4. gRPC: IntegrationGateway.InitiateDisbursement
│ idempotencyKey = (runId, employeeId)
│ source = FAKE-HABESHA-001
│ destination = FAKE-EMP-101
│ amount = 8,500 ETB

├─ 5. Gateway routes to dashen adapter → bank-sandbox → ACCEPTED

├─ 6. Store partnerReference on the row

└─ 7. Update UI: row goes from "queued" to "PENDING"

(seconds-to-minutes later)

Webhook from bank-sandbox arrives with status COMPLETED


BankSettlementApplier finds the EWA / payroll row by partnerReference


Ledger PENDING → POSTED


Outbox event payroll.disbursement.settled.v1


Dashboard row goes from "PENDING" to "POSTED" — green checkmark

If the partner returns FAILED instead of COMPLETED, the same applier:

  • Calls ledger.MarkSettlementFailed (entries excluded from balances)
  • Calls ledger.Reverse to net the PENDING entry to zero
  • Updates the dashboard row to "FAILED — " with a per-employee retry button

8. Done criteria for the MVP

  • Platform admin can add Dashen to the catalog with adapterKey="dashen".
  • Eden signs in to employer-web and immediately sees 2 pre-seeded accounts with balances in Settings → Payroll Banking.
  • Eden can connect a third account through the Add-Account modal (LookupAccount confirms it via bank-sandbox).
  • Eden can run March 2026 payroll and watch the per-employee status board.
  • All 4 scenarios in §5 pass — including the 3 failure injections.
  • No real money moves at any point; everything goes through bank-sandbox.

9. What comes AFTER the MVP (out of scope here)

  • Expense payouts — pay vendors, refunds, reimbursements. Same BusinessBankAccount row, different caller. New purpose=EXPENSES value.
  • Real Dashen sandbox — sandbox credentials + HMAC keys + webhook URL handshake. External BD/compliance work.
  • More partners — CBE, Awash, Telebirr, microfinance, savings cooperatives. Each is one Go adapter + one admin-added catalog row. Zero schema change. Drop the long telebirrConfig JSON blob on Business as part of this.
  • Cross-partner failover — primary at Dashen, secondary at CBE. Same role=PRIMARY / SECONDARY logic just keys off different partner rows.
  • Real OAuth consent flow with partner-hosted redirect. MVP stubs this; production wires it.
  • Reconciliation pulls — daily bank statement ingestion + drift check (parallel workstream).