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 ⨯
| Piece | Status | Where |
|---|---|---|
| Dashen Go adapter | ✓ scaffolded | services/integration-gateway/internal/adapters/dashen/ |
bank-sandbox service for safe testing | ✓ live | services/bank-sandbox/ |
| Ledger service (PENDING / POSTED / FAILED) | ✓ live, used by EWA | services/ledger/ |
IntegrationGateway gRPC (InitiateDisbursement, LookupAccount) | ✓ contract done | packages/contracts/grpc/integration_gateway.proto |
| NestJS clients (ledger + gateway) | ✓ live | apps/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 build | new Prisma model |
| Employer Payroll Banking page | ⨯ to build | new in apps/employer-web/src/app/settings/banking/ |
| Add-account modal (2 steps) | ⨯ to build | new component |
| Payroll uses connected account as source | ⨯ to wire | extend payroll service |
| Seeded test bank accounts (so dashboard shows real numbers) | ⨯ to seed | extend 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 # | Holder | Balance (ETB) | Purpose |
|---|---|---|---|
FAKE-HABESHA-001 | Habesha Technologies PLC | 5,000,000 | Habesha's primary payroll account |
FAKE-HABESHA-002 | Habesha Technologies PLC | 2,000,000 | Habesha's secondary failover account |
FAKE-EMP-101 | Abel Bekele | 12,000 | Employee #1 destination |
FAKE-EMP-102 | Hawi Tadesse | 8,500 | Employee #2 destination |
FAKE-EMP-103 … FAKE-EMP-130 | (28 more demo employees) | varied | Employees #3–30 destinations |
reject-insufficient-001 | n/a | n/a | Built-in failure trigger — bank-sandbox returns INSUFFICIENT_FUNDS |
reject-invalid-001 | n/a | n/a | Built-in failure trigger — bank-sandbox returns INVALID_ACCOUNT |
fail-after-accept-001 | n/a | n/a | Built-in failure trigger — accepts then webhooks FAILED |
slow-001 | n/a | n/a | Built-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
| Table | Row | Field values |
|---|---|---|
Organization (FI catalog) | Dashen Bank | kind=FI_PARTNER, fiType=BANK, adapterKey="dashen", swiftCode="AWINETAA", status=ACTIVE |
BusinessBankAccount | Habesha primary | organizationId=<habesha>, fiPartnerOrganizationId=<dashen>, accountNumber="FAKE-HABESHA-001", role=PRIMARY, status=ACTIVE, verifiedHolderName="Habesha Technologies PLC" |
BusinessBankAccount | Habesha secondary | same shape, accountNumber="FAKE-HABESHA-002", role=SECONDARY |
Employee.payoutFIPartnerOrganizationId + .payoutAccountNumber | 30 employees | each 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
| # | Scenario | What happens | How we trigger it |
|---|---|---|---|
| 1 | Happy path | 30 transfers go ACCEPTED → 5–10s later all POSTED. Ledger nets to zero. | Run payroll against the seeded employees |
| 2 | Primary insufficient funds | Primary 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 |
| 3 | Account 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 |
| 4 | Slow settlement | Employee #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
| # | Step | Files / surfaces | Time |
|---|---|---|---|
| 1 | Add 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 |
| 2 | Create BusinessBankAccount table + Prisma model + RLS policy | schema.prisma + 1 migration | ½ day |
| 3 | Build 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 |
| 4 | Build employer-web Settings → Payroll Banking page + 2-step modal | apps/employer-web/src/app/settings/banking/ | 1½ days |
| 5 | Wire payroll engine to use BusinessBankAccount rows as the disbursement source | extend existing payroll service | 1 day |
| 6 | Seed bank-sandbox with 35 accounts + DemozPay DB with the catalog + Habesha's 2 accounts + 30 employees | services/bank-sandbox/internal/seed/, apps/api/prisma/seed.ts | ½ day |
| 7 | End-to-end test the 4 scenarios in §5 | new file scripts/test-payroll-mvp.mjs | 1 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.Reverseto 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
BusinessBankAccountrow, different caller. Newpurpose=EXPENSESvalue. - 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
telebirrConfigJSON blob onBusinessas part of this. - Cross-partner failover — primary at Dashen, secondary at CBE. Same
role=PRIMARY/SECONDARYlogic 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).