Skip to main content

Payout Routing — Employer Bank Integration, Disbursement, and the Test-Money Loop

Schema rename pass — 2026-06-15

Between the original draft (2026-06-13) and today, the polymorphic-Organization refactor landed (a52faa4 feat(api): polymorphic Organization as root + slim Business extension). Three name changes flow through this plan; the semantics are unchanged, only the FK target and Prisma model name are different:

Old name (still in §3.x below)Current Prisma reality
FIPartner (standalone model)Organization row with kind=FI_PARTNER. Fields fiType, swiftCode, shortCode, licenseNumber, sortOrder live on Organization directly (lines 343–360 of schema.prisma).
Business (standalone model)Organization row with kind=BUSINESS, optionally extended by the slim Business row (Business.id == Organization.id). For payout routing we key off Organization.id with the kind check, not off Business.id.
Employee.payoutFIPartnerId → FIPartnerEmployee.payoutFIPartnerId → Organization (with app-side or DB-CHECK enforcement of kind=FI_PARTNER). FK target column type stays String.

The BusinessBankAccount schema in §3.2 below should be read with businessId → organizationId (referencing Organization where kind=BUSINESS) and fiPartnerId → Organization (where kind=FI_PARTNER). The DB-CHECK pattern from 1077721 chore(api): add DB-level CHECK constraints on Organization.kind shape is the precedent — same CHECK (kind = 'BUSINESS') style applied to a foreign-key column.

All other content in §§4–15 stands unchanged.

Q2 from §12 is resolved as: Dashen-hosted redirect (OAuth 2.0 with PKCE), per-employer consent. See new §3.5 below for the data-model addition + flow. The consent token itself never enters the monolith — only an opaque consentExternalId reference does. This keeps BANK_ORCHESTRATION §9 whole (no partner credentials in the monolith) and matches Path A of BANK_ORCHESTRATION §2 (funds stay at the employer's bank; we instruct).

MVP scope — 2026-06-15 (Dashen first, generalize later)

We're going to ship Phase A against Dashen only, not 6 partners at once. Reasoning:

  • Dashen is the only adapter scaffolded today (services/integration-gateway/internal/adapters/dashen/dashen.go + signing.go). Mock + bank-sandbox exist for testing, but those don't move real money.
  • Schema and UX are partner-agnostic from day one — adding M-Birr / CBE / Awash / Telebirr later is a Go adapter + an admin-added catalog row. Zero schema change. Zero employer-web change. The dropdown adds another row automatically.
  • Payment use cases covered at MVP: payroll and general expenses (vendor payments, refunds, reimbursements). Both routes share the same BusinessBankAccount-as-source pattern; the only difference is what API call the employer hits (POST /payroll/run vs POST /expenses/payout). Same InitiateDisbursement gRPC call ends up at the same Dashen adapter.

What "Dashen only at MVP" means concretely for what we build:

LayerMVP behaviorGeneralization later (no code change needed)
admin-web FI catalogAdmin adds one FI: "Dashen Bank" with fiType=BANK, adapterKey="dashen"Add more partners — they just appear in the same list
employer-web partner dropdownShows whatever the admin added — at MVP that's just DashenWhen admin adds Telebirr, it shows up automatically
Connect-account flowPick Dashen → enter account → LookupAccount via Dashen adapter → authorize via Dashen OAuth → doneIdentical for every future partner
Payout reasonsPAYROLL and EXPENSES (renamed from the older OPERATIONS)A RESERVE purpose stays available for held-balance / escrow patterns once we need them
FailoverEmployer can connect a secondary Dashen account today — failover works between two accounts at the SAME partnerWhen a second adapter exists, failover can cross partners (Dashen primary → Telebirr secondary) without code change — the failover logic keys off the FI_PARTNER row, not the brand

The story walkthrough (§0) shows Telebirr as a future example for clarity — but §0.1, §0.2, §0.4, §0.5, §0.6, §0.7 are what Phase A actually builds. §0.3 (Telebirr) is illustrative of the next-partner pattern, not part of MVP.

Many partners, one flow — 2026-06-15 (replaces the Telebirr-only thinking)

The original Business.telebirrConfig Json? field (line 528 of schema.prisma) was a placeholder from when the product roadmap said "integrate Telebirr first, figure out banks later." That assumption is gone. The plan below treats every payout destination identically — Dashen, CBE, Awash, Telebirr, M-Birr, microfinance institutions, savings cooperatives — they're all just rows in the Organization table with kind=FI_PARTNER and a fiType enum value (the 6-value FIPartnerType enum already exists: BANK | MICROFINANCE | WALLET_PROVIDER | CREDIT_UNION | SAVINGS_COOPERATIVE | INSURANCE).

What this means concretely:

  • The "Add Payroll Bank Account" dropdown shows every active FI_PARTNER, whether it's a bank or a wallet. Telebirr appears in the same list as Dashen.
  • The BusinessBankAccount table (§3.2) carries either kind. A row pointing to Telebirr (a WALLET_PROVIDER) and a row pointing to Dashen (a BANK) are stored the same way; the rail (BANK_TRANSFER vs MOBILE_MONEY) is derived from the partner's fiType, not from a separate user choice.
  • The integration-gateway already supports plug-in adapters: services/integration-gateway/internal/adapters/dashen/ exists today; telebirr/, cbe/, awash/ slot in next to it without touching the monolith. The proto contract (InitiateDisbursement, LookupAccount) is partner-agnostic.
  • Business.telebirrConfig is retired (migration P5, see §6). Its data (if any) migrates into a single BusinessBankAccount row with fiPartnerOrganizationId pointing to the Telebirr FI_PARTNER and rail=MOBILE_MONEY.

A founder-readable walkthrough sits in the new §0 (Story walkthrough) below — read that first if you want to know what this looks like for the employer. Everything from §1 onwards is the implementation detail.

0. Story walkthrough — how the employer actually connects an account

Meet Eden. She's the HR manager at Habesha Technologies PLC. She wants to set up payroll so DemozPay can pay her 30 employees every month. This is what she sees.

0.1 First time — connect the primary account at Dashen Bank

Eden signs in to employer.demozpay.com and goes to Settings → Payroll Banking. The page is empty:

┌──────────────────────────────────────────────┐
│ Payroll Bank Accounts │
│ │
│ You haven't connected any accounts yet. │
│ │
│ [ + Add Payroll Bank Account ] │
└──────────────────────────────────────────────┘

She clicks the button. A modal opens. Step 1 of 2 — enter the account:

┌─────────────────────────────────────────────────┐
│ Step 1 of 2: Account details │
│ │
│ Partner: [ Dashen Bank ▼ ] │
│ ↑ dropdown lists every active │
│ partner: banks, microfinance,│
│ Telebirr, M-Birr, CBE … │
│ │
│ Account #: [ 5577 8899 4421 0033 ] │
│ Account holder: [ Habesha Technologies PLC ] │
│ │
│ Use as: ( ● ) Primary ( ○ ) Secondary │
│ │
│ [ Cancel ] [ Continue → ] │
└─────────────────────────────────────────────────┘

She clicks Continue. The screen shows a spinner for ~2 seconds while DemozPay asks Dashen "does this account exist, and what's the holder name on file?" That's the LookupAccount RPC against the Dashen adapter (§3.2 — flow). Catches typos and wrong-account-number entry before any money moves.

Dashen confirms. The modal updates to Step 2 of 2 — authorize:

┌─────────────────────────────────────────────────┐
│ Step 2 of 2: Authorize DemozPay │
│ │
│ ✓ Confirmed as Habesha Technologies PLC │
│ Account ending in •••• 0033 at Dashen Bank │
│ │
│ To run payroll, DemozPay needs permission to │
│ instruct Dashen to send transfers from this │
│ account. You'll sign in to Dashen to authorize.│
│ │
│ [ Back ] [ Authorize at Dashen → ] │
└─────────────────────────────────────────────────┘

Eden clicks Authorize at Dashen. The browser leaves DemozPay's domain and lands on Dashen's site (Dashen's URL, Dashen's branding, Dashen's security):

┌─────────────────────────────────────────────────┐
│ 🏦 Dashen Bank │
│ │
│ Sign in to authorize DemozPay │
│ │
│ Username: [____________] │
│ Password: [____________] │
│ │
│ ────────────────────────────────────────── │
│ │
│ DemozPay is requesting permission to: │
│ • View this account's balance │
│ • Initiate outbound transfers from this │
│ account on behalf of the holder │
│ │
│ [ Cancel ] [ Authorize ] │
└─────────────────────────────────────────────────┘

Eden signs in at Dashen, clicks Authorize. Dashen never tells DemozPay what her password is. It sends her back to DemozPay with a short-lived authorization code that DemozPay exchanges (via the gateway adapter) for a long-lived permission slip. The permission slip lives in the integration-gateway's vault, not in the monolith DB.

She lands back on employer.demozpay.com/banking/consent/callback. A 1-second spinner, then she's back at the banking page:

┌──────────────────────────────────────────────┐
│ Payroll Bank Accounts │
│ │
│ ┌────────────────────────────────────────┐ │
│ │ 🏦 Dashen Bank •••• 0033 │ │
│ │ Habesha Technologies PLC │ │
│ │ 🟢 Primary · Active │ │
│ └────────────────────────────────────────┘ │
│ │
│ [ + Add Payroll Bank Account ] │
└──────────────────────────────────────────────┘

Primary connected. Total time: under 3 minutes.

0.2 Add a secondary account at CBE for failover

Eden clicks Add Payroll Bank Account again. Same modal, same flow — but she picks Commercial Bank of Ethiopia from the dropdown, enters her CBE account, and ticks Secondary:

│ ┌────────────────────────────────────────┐ │
│ │ 🏦 Dashen Bank •••• 0033 │ │
│ │ 🟢 Primary · Active │ │
│ └────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────┐ │
│ │ 🏦 CBE •••• 1188 │ │
│ │ 🟡 Secondary · Active │ │
│ └────────────────────────────────────────┘ │

Now Habesha has redundancy. If Dashen rejects a payroll transfer (insufficient funds, account suspended, partner outage), the payroll engine automatically retries against CBE.

0.3 The same flow, but with Telebirr (a wallet provider, not a bank)

Some employers don't have a corporate bank account at all — they pay employees from a Telebirr business wallet. Eden could connect Telebirr the same way:

│ Partner: [ Telebirr ▼ ] │
│ Account #: [ 0911234567 ] │
│ ↑ this is a phone number, not │
│ an IBAN — the UI knows from │
│ Telebirr's fiType= │
│ WALLET_PROVIDER │
│ Account holder: [ Habesha Technologies PLC ] │

LookupAccount works the same way (the Telebirr adapter asks Telebirr "does this MSISDN belong to a verified merchant wallet, and what's the registered name?"). Authorization works the same way (Telebirr-hosted OAuth screen). The Payroll Banking list ends up looking like:

│ ┌────────────────────────────────────────┐ │
│ │ 📱 Telebirr 0911 ••• 4567 │ │
│ │ 🟢 Primary · Active │ │
│ └────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────┐ │
│ │ 🏦 Dashen Bank •••• 0033 │ │
│ │ 🟡 Secondary · Active │ │
│ └────────────────────────────────────────┘ │

Different partner, different rail, same code path on our side.

0.4 Payroll day — Eden clicks "Run March 2026 payroll"

She doesn't need to think about which account to use. Behind the scenes:

For each employee:
1. Pick the PRIMARY active BusinessBankAccount → source
2. Look up the employee's payoutFIPartner + account → destination
3. Compute net pay (gross − tax − pension − deductions)
4. Call IntegrationGateway.InitiateDisbursement with:
source = Eden's Dashen account (•••• 0033)
destination = the employee's account
amount = net pay in santim
idempotencyKey = (payrollRunId, employeeId)
5. Dashen adapter signs the request, calls Dashen's API
6. Dashen returns ACCEPTED + partnerReference
7. Hours later, Dashen webhook arrives → settlement applier flips
ledger PENDING → POSTED
8. If Dashen rejects (insufficient funds, etc.) → auto-retry
against the SECONDARY (CBE) account. If THAT fails too →
pause that employee, alert Eden.

Money path: Dashen's books (Eden's account •••• 0033) → Dashen's books (employee's account at Dashen). If the employee banks at CBE, it's Dashen → CBE via the inter-bank rail. DemozPay is never in the middle of the money — only in the middle of the instructions.

0.4b The same accounts, but for paying a vendor

Habesha doesn't only pay employees. They also pay rent, supplier invoices, refunds. Eden goes to Expenses → New Payout:

┌─────────────────────────────────────────────────┐
│ New Expense Payout │
│ │
│ Pay from: [ Dashen Bank •••• 0033 ▼ ] │
│ ↑ same connected accounts she set │
│ up in §0.1 — no re-onboarding │
│ │
│ Pay to: [ Acme Suppliers PLC ] │
│ Their bank: [ Dashen Bank ▼ ] │
│ Account #: [ 7733 4499 0011 2255 ] │
│ Amount: [ 45,000.00 ETB ] │
│ Reason: [ Office rent — March 2026 ] │
│ │
│ [ Cancel ] [ Send Payout → ] │
└─────────────────────────────────────────────────┘

Eden clicks Send Payout. Same machinery: LookupAccount validates Acme Suppliers' account first, then InitiateDisbursement instructs Dashen to move 45,000 ETB from •••• 0033 to •••• 2255. Webhook arrives hours later, ledger flips PENDING → POSTED, audit trail recorded. Eden can see the status in real time:

│ ┌────────────────────────────────────────┐ │
│ │ Office rent — March 2026 │ │
│ │ 45,000.00 ETB · Acme Suppliers PLC │ │
│ │ 🟡 Pending settlement at Dashen │ │
│ │ Initiated 14:22 · Ref DSH-2026-04-A82 │ │
│ └────────────────────────────────────────┘ │

The key point: Eden didn't connect a new account, didn't go through OAuth again, didn't touch the banking page. She reused the same BusinessBankAccount row that powers payroll. The purpose field on that row is PAYROLL, but it's accepted as a source for any expense payout as long as the connected partner allows outbound transfers. If Habesha wanted a dedicated expenses-only account (e.g., to keep payroll funds untouchable from accidental vendor payments), they could connect a second Dashen account with purpose=EXPENSES — the dropdown filters by purpose so payroll only ever debits the payroll account.

Partners often issue 90-day OAuth tokens. When the token's about to expire, Eden sees a banner at the top of every employer-web page:

┌──────────────────────────────────────────────────────────┐
│ ⚠ Re-authorize Habesha's Dashen payroll account. │
│ The current authorization expires on 2026-08-30. After │
│ that, payroll transfers will pause until you renew. │
│ [ Re-authorize → ] │
└──────────────────────────────────────────────────────────┘

She clicks Re-authorize → same flow as step 0.1 step 6 (skip the LookupAccount confirm — the account is already verified; just redo the OAuth round-trip). 30 seconds. Done.

0.6 Admin override — when a platform admin needs to intervene

A DemozPay platform admin can see the same accounts from admin.demozpay.com/businesses/<id>/banking (a new tab on the existing business detail page). They can:

  • View account status + consent state (ACTIVE / PENDING_CONSENT / CONSENT_EXPIRED / SUSPENDED)
  • Suspend a fraudulent account (immediate stop on payroll)
  • Trigger a re-verification (force LookupAccount + holder check again)
  • Use the test-money tool (§5.2) to send a small probe transfer to confirm the account works end-to-end before payroll runs against it

Everything the admin does is audited + emitted to outbox (ADR-008).

0.7 Recap — what changes vs today

TodayAfter Phase A
Organization.settlementAccount — single free-text string, not actually used by BUSINESS orgsBusinessBankAccount rows — 1 to N per employer, primary/secondary, verified holder name, consent state
Business.telebirrConfig Json? — placeholder, no backend logicRetired by migration P5; existing data (if any) migrates into a BusinessBankAccount row pointing at the Telebirr FI_PARTNER
Employee bank fields: 4 free strings (bankName, bankAccount, mobileMoneyNumber, mobileMoneyProvider) — no validationEmployee.payoutFIPartnerOrganizationId (FK to a partner row) + payoutAccountNumber (verified). Old strings dropped in P3b after a dev-validation window
No "Add Payroll Bank Account" page in employer-webNew /settings/banking page + 2-step modal + /banking/consent/callback route
Admin sees nothing about which account an employer pays fromNew banking tab on /businesses/[id] in admin-web
Platform-admin can't easily test "does this adapter actually work end-to-end?"Test-money tool (§5.2) issues a 10–100 ETB probe through the same pipe payroll uses

Now the rest of this document covers how we build that — schema, services, APIs, frontend components, migrations, phasing.

1. Why this doc exists

The Employee Management plan originally modelled a generic PaymentMethod table with a four-value kind enum (BANK_TRANSFER | MOBILE_MONEY | CASH | INTERNAL_WALLET). After re-reading BANK_ORCHESTRATION.md and ADR-014, that was the wrong shape:

  • DemozPay never holds customer money. Funds at rest live at the employer's bank account (Path A in BANK_ORCHESTRATION §2). DemozPay instructs transfers from the employer's account → the employee's account; it never debits its own balance because it has no balance.
  • The adapter contract already lives in Go. services/integration-gateway/internal/adapters/<partner>/ is where partner-bank credentials, HMAC signing, API URLs, batch-file specs, and webhook parsing live. The monolith's job is to route a disbursement request to the right adapter — not to know how Dashen's signatures work.
  • The PartnerAdapter interface already exists in packages/contracts/grpc/integration_gateway.proto (InitiateDisbursement, LookupAccount, GetDisbursementStatus, GetAdapterStatus).

So the API-side concern reduces to:

  1. The catalog of partner banks (FIPartner — built).
  2. Which integration adapter handles each partner (one missing column).
  3. The employer's bank account(s) at chosen partners (missing model).
  4. The employee's payout bank account at a chosen partner (currently free string, needs promotion).
  5. A test loop that proves "DemozPay can instruct a transfer from employer A's account at Dashen to employee B's account at Dashen, and we observe the money arrive."

This doc covers all five.

2. Architectural anchor — what we are and what we are not

Reprinting the rule from BANK_ORCHESTRATION §11 because every line below is a consequence of it:

The bank statement is the truth of money. The ledger is the truth of obligations. When they disagree, the bank wins until we prove otherwise.

DemozPayEmployerEmployeePartner bank (Dashen, CBE, …)
Holds moneyNOYES (payroll account)YES (their account)YES (custodian for all)
Initiates transferYES (via gRPC → adapter)NO (instructs us via UI)NONO (executes)
Records obligationYES (ledger)NONONO
Bears settlement riskNOYES (Path A)NOYES (partner-rejected)
Provides authoritative balanceNONONOYES

Nothing in this plan changes that table. The employer's bank account is registered with us; the partner adapter authenticates as the employer (or as DemozPay-on-behalf-of-employer per the bank's API model); the money moves out of the employer's account at the partner's books.

3. Domain model

// On FIPartner (existing model):
adapterKey String? // e.g., "dashen", "cbe", "telebirr", "awash".
// Matches services/integration-gateway/internal/adapters/<name>/.
// Null → catalog entry only; employer can pick this FI for their
// account, but disbursement scheduling is refused with
// DISBURSEMENT_NOT_INTEGRATED.
// Set → InitiateDisbursement gRPC calls route through this adapter.

This is the only piece of integration metadata that lives in the monolith DB. Everything else (HMAC keys, base URLs, batch-file specs) belongs in the Go service's config/secret store — that's the entire reason for the modular-monolith split (BANK_ORCHESTRATION §9, ADR-001).

Admin-web surface: new field on the FI detail page under a "Integration" subsection. Platform admin picks from a dropdown of registered adapters (the monolith reads the list from GetAdapterStatus so we don't drift from what's actually compiled into the gateway).

3.2 BusinessBankAccount — the employer's account(s)

This is the model the entire payroll flow keys off. Without it, we can't answer "which account do we debit when Acme Co runs March payroll?".

model BusinessBankAccount {
id String @id @default(cuid())
businessId String
business Business @relation(fields: [businessId], references: [id])

// Which partner bank holds this account. ACTIVE FIs with adapterKey
// set are the only ones eligible for disbursement scheduling.
fiPartnerId String
fiPartner FIPartner @relation(fields: [fiPartnerId], references: [id])

accountNumber String
accountHolderName String

// Per the founder's request: every employer registers at least a primary;
// secondary is the failover when the primary is suspended or low on funds.
role String @default("PRIMARY") // PRIMARY | SECONDARY
// What this account is used for. Lets the same employer hold separate
// accounts for payroll vs operations vs FX reserves without conflating.
purpose String @default("PAYROLL") // PAYROLL | EXPENSES | RESERVE
// (was OPERATIONS — renamed 2026-06-15 to
// match founder vocabulary: "pay employees"
// vs "pay vendors / refunds / reimbursements")
status String @default("PENDING") // PENDING | ACTIVE | SUSPENDED

// Cache from gateway.LookupAccount. We never trust the employer's typed
// value — every account is verified via the adapter before it goes ACTIVE.
verifiedAt DateTime?
verifiedHolderName String?
lookupFailureReason String? // last failure code from LookupAccount

// Audit
createdById, updatedById, ...

@@unique([businessId, fiPartnerId, accountNumber])
@@index([businessId, purpose, status])
@@map("business_bank_account")
}

Why no credentials or signing keys here: the adapter at the gateway handles authentication. The monolith only knows "this account exists, belongs to this employer, lives at this bank". If the bank requires an OAuth consent from the employer (real direct-debit case), the consent token is held by the adapter's vault — never in the monolith DB.

RLS: tenant-scoped on businessId like every financial table (ADR-013).

3.3 Employee.payoutFIPartnerId + payoutAccountNumber

Promote the existing free strings:

// On Employee — replaces bankName / bankAccount free strings.
payoutFIPartnerId String?
payoutFIPartner FIPartner? @relation(fields: [payoutFIPartnerId], references: [id])
payoutAccountNumber String?
payoutVerifiedAt DateTime?
payoutVerifiedHolderName String?

// Existing mobile-money fallback fields stay as-is.
mobileMoneyNumber String?
mobileMoneyProvider String?

Service-side rule: when both payoutFIPartnerId and payoutAccountNumber are set on create/update, the service calls IntegrationGateway.LookupAccount synchronously. If the holder name doesn't fuzzy-match the employee's first/father name, return a 400 with the resolved name so the employer can confirm before saving. If the adapter is DOWN, accept the save but mark payoutVerifiedAt = null and require a re-verification before the first disbursement.

3.4 What we explicitly do NOT add

  • ❌ Per-bank API URLs, batch formats, HMAC keys, OAuth client IDs — gateway-side, not monolith-side.
  • ❌ A DemozPay-corporate-account concept — we are not a custodian.
  • ❌ A PaymentMethod table — the Employee → payoutFIPartnerId FK is the routing key by itself.
  • ❌ A CASH or INTERNAL_WALLET enum value anywhere — not a product.

For partners that require per-employer authorization to instruct transfers (the common shape — Dashen, CBE), the employer must authorize DemozPay against their account at the partner. We do this via OAuth 2.0 with PKCE, partner-hosted. The employer's credentials never touch DemozPay; only an opaque consentExternalId reference reaches the monolith DB.

Schema addition to BusinessBankAccount:

// Set only when the partner requires per-employer consent.
// Opaque ref to a consent record held by the integration-gateway's vault;
// the monolith never stores the access/refresh tokens themselves.
consentExternalId String?
consentObtainedAt DateTime?
consentExpiresAt DateTime? // partners that issue time-bound consent
consentScope String? // serialised scopes the employer granted

Status discriminator: an account is ACTIVE only when BOTH verifiedAt (from §3.2 — LookupAccount succeeded) AND consentObtainedAt (this section — OAuth round-trip completed) are set. If the partner doesn't require consent (e.g., the mock adapter, or a wallet provider with merchant-level auth), consentObtainedAt is set immediately on adapter callback so the same gate logic works for every partner.

End-to-end consent round-trip:

1. Employer is on the "Add Payroll Bank Account" modal
2. Picks Dashen → enters account # + holder name → clicks Continue
3. POST /api/business-bank-accounts { fiPartnerId, accountNumber, accountHolderName }
4. API → gateway.LookupAccount → resolves holder name (§3.2 flow)
5. UI shows "Confirmed as Acme Co Ltd" → employer clicks Authorize at Dashen
6. POST /api/business-bank-accounts/:id/start-consent
→ API → gateway.StartConsent { businessBankAccountId, returnTo: 'https://employer.demozpay.com/banking/consent/callback' }
→ Adapter generates PKCE verifier+challenge, stores in gateway vault keyed on (businessId, accountId)
→ Returns { consentUrl } — Dashen's hosted consent screen, pre-filled with account context
7. UI hard-redirects browser to consentUrl
8. Employer signs in at Dashen, clicks Authorize
9. Dashen redirects browser to https://employer.demozpay.com/banking/consent/callback?state=<opaque>&code=<grant>
10. Frontend POSTs the query string verbatim to /api/business-bank-accounts/:id/complete-consent { state, code }
11. API → gateway.CompleteConsent → adapter exchanges code+verifier for access+refresh token
→ Adapter stores tokens in vault, returns { consentExternalId, consentExpiresAt, consentScope }
12. API persists consentExternalId, consentObtainedAt=now, consentExpiresAt, consentScope on the row
13. Row transitions PENDING_CONSENT → ACTIVE (if verifiedAt is also set)
14. Outbox event business.bank-account.consent-obtained.v1 fires for audit

Error / abandon paths:

  • Employer hits "Deny" at Dashen → callback arrives with error=access_denied → API marks the row CONSENT_DENIED. Row stays in DB so the employer can retry without re-entering the account number.
  • Employer never returns from the redirect → row stays PENDING_CONSENT. A background job sweeps rows older than 30 minutes and transitions them to CONSENT_ABANDONED. Same retry semantics.
  • Partner consent expires (Dashen issues 90-day tokens) → settlement-applier surfaces a CONSENT_EXPIRED error on the next disbursement attempt. UI shows a banner "Re-authorize Acme Co's payroll account" linking back to step 6.

Gateway-side state — does NOT live in the monolith:

  • The PKCE verifier (step 6) — vault, keyed on (accountId, attempt-id), TTL 10 minutes.
  • The access + refresh tokens (step 11) — vault, keyed on consentExternalId. Adapter reads them on every InitiateDisbursement call and refreshes when within 24 hours of expiry.
  • The partner's OAuth client_id / client_secret — per-partner adapter config (env or vault), not per-employer.

Frontend (employer-web) impact:

  • New route /banking/consent/callback that only forwards the query string to complete-consent and shows a spinner. After the API returns, redirect back to /settings/banking.
  • The "Add Payroll Bank Account" modal needs a two-step submit: first the LookupAccount confirm step (no consent), then the "Authorize at Dashen" button that triggers the hard-redirect. The modal MUST persist its state (the account id we just created) across the redirect — the simplest pattern is keeping the modal state in URL params or sessionStorage and re-opening it on the callback page.
  • The ACTIVE / PENDING_CONSENT / CONSENT_DENIED / CONSENT_ABANDONED / CONSENT_EXPIRED chips on the account list — same component as the LookupAccount verification chips from §3.2, just one more status.

Partners that don't need this: the mock adapter (L1) and bank-sandbox adapter (L2) skip the consent step — they expose StartConsent as a no-op that immediately returns a synthetic consentExternalId='mock-<accountId>'. The frontend flow is identical so the integration test exercises the same code path.

4. The end-to-end happy path

Employer onboarding (one-time):
1. Acme Co signs in to employer-web
2. Settings → Payroll Banking → "Add Payroll Bank Account"
3. Picks Dashen from the FI dropdown (only adapter-enabled FIs are listed)
4. Enters account number + holder name
5. Frontend calls POST /api/business-bank-accounts
→ API service calls IntegrationGateway.LookupAccount via gRPC
→ Dashen adapter calls Dashen's account-lookup API
→ Returns resolved holder name + checked_at timestamp
6. UI shows "Confirmed as Acme Co" → employer clicks Confirm
7. Account row written ACTIVE; verifiedAt + verifiedHolderName populated
8. Same flow for the SECONDARY account (optional)

Employee onboarding (each employee):
1. Employer-web wizard step 5 → "Payout bank"
2. Picks Dashen from the same FI dropdown
3. Enters employee's account number
4. Same LookupAccount call → resolves holder name
5. UI shows "Confirmed as Bekele Tadesse" + fuzzy-match score
6. Employer confirms → Employee row written with payoutFIPartnerId,
payoutAccountNumber, payoutVerifiedAt, payoutVerifiedHolderName

Payroll run (cyclical):
1. Employer-web → Run March 2026 payroll
2. API computes per-employee gross → deductions → net (per packages/shared/compliance)
3. For each Employee with net > 0:
source = BusinessBankAccount where purpose=PAYROLL, role=PRIMARY, status=ACTIVE
destination = Account { partner: fi.adapterKey, account_number: emp.payoutAccountNumber, ... }
amount = Money { santim: net, currency: "ETB" }
4. Pre-commit PENDING ledger entries (DR receivable-from-employee / CR payable-to-employer-bank — Path A taxonomy)
5. Call IntegrationGateway.InitiateDisbursement with idempotency key
6. Adapter signs the request to Dashen → returns ACCEPTED + partner_reference
7. Store partner_reference on the Disbursement row
8. (asynchronous, minutes-to-hours later)
→ Webhook OR poller → GetDisbursementStatus → COMPLETED
→ BankSettlementApplier flips ledger PENDING → POSTED
→ Outbox event payroll.disbursement.settled.v1 fires
9. (failure case) async FAILED → MarkSettlementFailed + Reverse the PENDING entry.
Employee notified, employer can retry or change account.

The asynchrony in step 8 is non-negotiable per BANK_ORCHESTRATION §3 — sync settlement is the bug. The ledger holds PENDING for hours and nothing in the UI tells the employee the money has arrived until POSTED.

5. Test-money flow — "did the employee actually receive it?"

The user explicitly wants a way to verify, end-to-end, that a transfer initiated by DemozPay reaches the destination account. This is the integration-test bridge that closes the loop between our code and the partner's books.

5.1 Three concentric test environments

LayerSourceDestinationSettlementUse
L1 — mock adapternothing realnothing realforce-settled by admin endpointunit tests; CI
L2 — bank-sandbox (existing)mock Postgres "account" with a balancemock Postgres "account"services/bank-sandbox/ accepts InitiateDisbursement, settles after a delayintegration tests; staging smoke
L3 — partner sandboxsandbox account at Dashen sandboxanother sandbox accountDashen's sandbox API responds with real-shaped settlement timingonboarding new partners
L4 — partner productionreal moneyreal moneybank's production railspost-launch

Today the dashen adapter talks to bank-sandbox in dev and can be config-swapped to Dashen's sandbox or production (per BANK_ORCHESTRATION §4). So L1 + L2 already exist; L3 + L4 are real-world onboarding.

5.2 The "test-money" admin tool

A platform-admin-only endpoint + UI for issuing a small probe transfer between two known accounts:

POST /api/admin/payout-probe
{
fiPartnerId: "...", // which adapter to test
sourceAccountId: "...", // a BusinessBankAccount (could be a test-tenant account)
destinationAccountNumber: "...", // someone's account at the same FI
destinationHolderName: "...", // for the LookupAccount step
amountSantim: 10000 // 100 ETB nominal — small enough to absorb if lost
}

→ Performs the full happy path in §4 but tagged as `payout.probe`.
→ Records to a special `payout_probe` table so probe entries stay out of
real payroll reporting.
→ Returns the disbursement_id + partner_reference for tracking.
→ Webhook / poller settles normally.

Acceptance criteria for a partner integration going to L4 (production):

  1. 3 successful probes in L3 → COMPLETED end-to-end.
  2. 1 forced-failure probe in L3 (closed destination account) → FAILED + Reverse → ledger entries net to zero.
  3. 1 unmatched-webhook probe (manually replay) → idempotent no-op.
  4. Reconciliation of the next morning's statement matches the probes.

These are the steps that turn "we wrote a Go adapter" into "we can carry real payroll for an employer". They're not glue code — they're the partnership readiness gate.

5.3 What the test-money tool does NOT do

  • ❌ It does not bypass the gateway or talk to the partner directly.
  • ❌ It does not skip the ledger pre-commit / settlement flip — the whole point is to exercise the ledger lifecycle.
  • ❌ It is not exposed to employer accounts. Platform-admin only, with @RequirePlatformAdmin() and TOTP.

6. Schema migration plan

#MigrationWhat
P1add_fi_partner_adapter_keyOne nullable column on financial_institution.
P2add_business_bank_account_tableBusinessBankAccount + RLS + indexes. Includes the four consent columns from §3.5 (consentExternalId, consentObtainedAt, consentExpiresAt, consentScope). FK columns named organizationId (kind=BUSINESS) and fiPartnerOrganizationId (kind=FI_PARTNER), with DB-CHECK constraints in the style of 1077721 chore(api): add DB-level CHECK constraints on Organization.kind shape.
P3promote_employee_bank_to_fkAdd payoutFIPartnerId + payoutAccountNumber + verification cache columns. Backfill: best-effort match of the existing bankName string against FIPartner.name; account number copies verbatim. Drop the old bankName + bankAccount columns in P3b after a dev-validation window.
P4add_payout_probe_tablepayout_probe audit-table for the test-money flow. Probe rows tagged so they never appear in real payroll exports.
P5retire_business_telebirr_configDrop Business.telebirrConfig Json?. The original Telebirr-only data shape is replaced by a BusinessBankAccount row pointing at the Telebirr FI_PARTNER row (whatever adapterKey="telebirr" resolves to). The migration includes a one-pass backfill: for every Business with a non-null telebirrConfig.accountNumber, materialize a BusinessBankAccount row with role=PRIMARY, status=PENDING_CONSENT (force re-authorization on next login — the legacy data didn't capture an OAuth grant). Drop the column after the backfill. Done in P5 rather than P2 so we can ship the new schema first, do the data migration as a follow-on under a feature flag, then drop the column in P5b only after confirming zero remaining references in the frontend (TelebirrConfigCard.tsx removal).

All five are reversible-ish (a P3 rollback would need to materialize bankName back from FIPartner.name; a P5 rollback would need to materialize the JSON blob from the BusinessBankAccount row — doable but lossy). M2 from the original Employee plan is killed; this file owns the schema work for the payout layer.

Multi-partner posture, locked in by the migration order: P1 + P2 give us the catalog (FI_PARTNER row per partner) + the per-employer account table. P3 promotes Employee bank fields to the same pattern. P4 enables platform-admin probing. P5 drops the Telebirr-only legacy. After all five, the schema has no single-partner assumptions left — adding M-Birr, CBE Birr, Awash, a new microfinance partner, or a savings co-op is purely:

  1. Create an Organization row with kind=FI_PARTNER, fiType=<one of 6>, adapterKey="<partner>".
  2. Drop a Go adapter into services/integration-gateway/internal/adapters/<partner>/ implementing the existing PartnerAdapter interface.
  3. No schema change. No new migrations. The dropdown picks up the new partner automatically.

7. Service-layer changes

7.1 New BusinessBankAccountService

CRUD with verification-via-LookupAccount integrated:

  • create({ fiPartnerId, accountNumber, holderName, role, purpose }) — calls gateway.LookupAccount; on success writes row ACTIVE + verifiedAt set; on adapter-down writes PENDING + lookupFailureReason='PARTNER_UNAVAILABLE'.
  • revalidate(id) — re-runs LookupAccount (used when an account has been pending too long, or before the first disbursement).
  • suspend(id) / reactivate(id) — platform-or-business-admin operation; suspending the PRIMARY blocks payroll scheduling.
  • listForBusiness(businessId) — used by the wizard + payroll engine.

Wraps everything in ADR-008 (audit + outbox + state in one tx).

7.2 EmployeeService changes (additive)

When payoutFIPartnerId + payoutAccountNumber are set:

  1. Validate FI is ACTIVE + has adapterKey. Otherwise return 400 with code=FI_NOT_INTEGRATED.
  2. Call gateway.LookupAccount. Pass employee name; require failure_reason to be UNSPECIFIED.
  3. Persist payoutVerifiedAt + payoutVerifiedHolderName.
  4. If holder name fuzzy-match score < 0.6 → 400 with the resolved name in the response body so the employer can confirm before re-submit.

Don't block on adapter-down — accept the save with payoutVerifiedAt=null and surface a "verify before payroll runs" warning in the UI.

7.3 New PayoutProbeService (platform-admin only)

Backs the test-money loop in §5.2. Same shape as a real disbursement but tagged so it never lands in employer-facing reports.

7.4 Payroll engine (out of scope here — but anchored)

This plan does NOT cover the payroll computation engine. It DOES anchor the contract: the engine takes (employer, period) and produces a list of (employeeId, sourceBusinessBankAccountId, destinationAccount, amountSantim) tuples, then hands them to a thin PayrollDisbursementService that issues InitiateDisbursement per row with one idempotency key per (period, employeeId).

8. API contract (additions)

BusinessBankAccount:
GET /api/business-bank-accounts owner+
POST /api/business-bank-accounts owner (calls LookupAccount)
GET /api/business-bank-accounts/:id owner+
PATCH /api/business-bank-accounts/:id owner (limited; holder-name/purpose only)
POST /api/business-bank-accounts/:id/revalidate owner (re-runs LookupAccount)
POST /api/business-bank-accounts/:id/suspend owner
POST /api/business-bank-accounts/:id/reactivate owner

FIPartner (additions):
PATCH /api/financial-institutions/:id platform-admin (extends existing — accepts adapterKey)
GET /api/financial-institutions/adapters platform-admin (proxies GetAdapterStatus — UI dropdown)

PayoutProbe (test-money):
POST /api/admin/payout-probe platform-admin
GET /api/admin/payout-probe/:id platform-admin

All employer endpoints go through OrgRoleGuard; all platform-admin endpoints go through AdminMfaGuard + @RequirePlatformAdmin().

9. Frontend changes

9.1 admin-web

  • FI detail page → Integration tab (or sub-section on the existing detail layout). Form field: "Adapter key" with autocomplete dropdown populated from GET /financial-institutions/adapters. Shows live AdapterHealth next to each option.
  • Test-money tool (new, under platform-admin nav). Single page with: pick FI → pick source → enter destination → pick amount → submit. Shows the disbursement_id, partner_reference, current status; auto-refreshes every 5s until COMPLETED or FAILED.

9.2 employer-web

  • Settings → Payroll Banking (new page). List of BusinessBankAccount rows with PRIMARY/SECONDARY badges + ACTIVE / PENDING_LOOKUP / PENDING_CONSENT / CONSENT_DENIED / CONSENT_ABANDONED / CONSENT_EXPIRED / SUSPENDED chips. "Add Payroll Bank Account" button opens a two-step modal: (a) LookupAccount confirm (§3.2), then (b) "Authorize at Dashen" hard-redirect (§3.5). Modal state (the in-progress accountId) persists across the redirect via sessionStorage so the callback page can re-open it.
  • New route /banking/consent/callback — receives ?state=...&code=... from the partner, POSTs verbatim to complete-consent, shows a spinner, then redirects back to /settings/banking. Pure plumbing; no business logic on the page. Required for §3.5.
  • Onboarding wizard step 5 rewritten: replaces the placeholder PaymentMethod dropdown from the original employee plan. New flow is: pick FI (only adapter-enabled options) → enter account → confirm holder name from LookupAccount → save. Mobile money number stays as an optional fallback field on the same step. No consent flow on the employee step — employees are payee accounts, not authorizers. Employer-level consent (§3.5) authorizes outbound transfers from the employer's account at the same partner.
  • Employee detail page shows payoutFIPartner.name + masked account number + verification chip.
  • Banner pattern for re-authorization — when a BusinessBankAccount enters CONSENT_EXPIRED, the page-level banner says "Re-authorize Acme Co's payroll account at Dashen" with a "Re-authorize" button that re-enters the consent flow from step 6 of §3.5 (skip LookupAccount — the account is already verified).
  • Retire TelebirrConfigCard.tsx under apps/employer-web/src/components/settings/. The settings page currently has a Telebirr config card that wrote into Business.telebirrConfig (a JSON blob); it has no backend wiring and would surface as a duplicate of the new Payroll Banking page if left in place. Remove the card and its tab entry as part of Phase A. Telebirr is just one of N partners now — it shows up in the Add Payroll Bank Account dropdown like any other.
  • Partner-agnostic dropdown. The "Partner" selector in the Add modal lists every Organization with kind=FI_PARTNER, status=ACTIVE, and adapterKey IS NOT NULL. The icon (🏦 vs 📱) is rendered from fiTypeBANK | CREDIT_UNION | MICROFINANCE | SAVINGS_COOPERATIVE get the bank icon, WALLET_PROVIDER gets the phone icon. Account-number placeholder text adapts the same way: an IBAN/account-number mask for banks, a phone-number mask for wallet providers. None of this is hardcoded per partner — it's keyed off the enum value, so a new partner showing up in the catalog renders correctly without any frontend change.

9.3 fi-web

No changes required by this plan. The FI portal admin still cares about loans + employees routed to their bank, both of which are queryable via the existing FIAdmin → FIPartner.id link.

10. Build phases

Phase A.0 — admin-web: register Dashen as an integrated partner (½ day)

The smallest possible first step that unlocks the rest of Phase A. No new tables, no new services — only:

  1. Add adapterKey: String? column to Organization (or wherever the FI metadata lives — line 311–394 in schema.prisma). One migration. The field stays nullable so old FIs without an adapter still validate.
  2. Extend CreateFIDto and UpdateFIDto in apps/api/src/identity/financial-institution/dto/ with an @IsOptional() @IsString() @Matches(/^[a-z0-9_-]+$/) adapterKey?: string. The format constraint matches Go package names so we can't accept "Dashen Bank!" as a key.
  3. Add one input field to the admin-web FI create/edit form: "Adapter key" with a helper sub-label "Leave blank if this FI is catalog-only (employer can pick it but disbursements are refused). Set to the Go adapter name (e.g., dashen) to enable disbursement scheduling."
  4. Optionally add a real-time validator: after the admin types dashen, the form calls GET /api/financial-institutions/adapters (which proxies GetAdapterStatus from the gateway) and shows the live adapter health next to the input. If the typed adapter key doesn't exist in the registry, the form refuses to save.

After this ships, a platform admin can sign in to admin-web, go to Companies & Partners → Financial Institutions, click Add Financial Institution, fill in:

Name: Dashen Bank
Type: BANK
Adapter key: dashen ← the only new field
SWIFT code: AWINETAA
Short code: DSH
...

…and save. Now Dashen exists in the catalog AND is wired to a Go adapter. Every employer-web Add-Account modal will see Dashen in the partner dropdown the next time it loads. Phase A.1 (schema + service) can land next without further admin work.

Phase A — schema + service foundation (3 days)

  • P1 migration (FIPartner.adapterKey).
  • P2 migration (BusinessBankAccount).
  • BusinessBankAccountService with gateway-LookupAccount integration (uses the existing gRPC client).
  • Endpoints behind @RequireOrgRole('owner').
  • Audit + outbox wiring.
  • Integration tests using the mock adapter (L1).

Phase B — employee promotion + verification (2 days)

  • P3 migration (Employee bank-string → FK).
  • EmployeeService verification call.
  • Employer-web wizard step 5 rewrite.

Phase C — admin tooling + adapter health (2 days)

  • FI detail Integration tab.
  • GET /financial-institutions/adapters proxy to GetAdapterStatus.
  • admin-web display of per-adapter health.

Phase D — test-money loop (2 days)

  • P4 migration (payout_probe).
  • PayoutProbeService.
  • admin-web test-money tool UI.
  • Runbook entry: docs/runbooks/test-money-probe.md — how to onboard a new partner adapter via the probe gauntlet.

Phase E — Dashen sandbox bring-up (per-partner, 1–2 weeks)

External work, not code:

  • Dashen sandbox credentials + IP allowlist.
  • HMAC key exchange + rotation procedure.
  • Webhook delivery URL handshake.
  • L3 probe gauntlet (§5.2 acceptance criteria).
  • Sign-off from BD + Compliance.

Phase F — Production cutover (per-partner, 1 day + 30-day soak)

  • Adapter config swap (sandbox → prod).
  • One real probe (10 ETB to a Demoz employee, employer's books).
  • 30-day soak with no employer customers attached.
  • BD greenlight → adapter available to all employers.

11. Test plan

#TestLayerRequired for
T1Service-level: BusinessBankAccountService.create calls LookupAccount and persists verifiedAtL1 mockPhase A merge
T2Service-level: account-number / holder-name mismatch returns 400 with resolved nameL1 mockPhase A merge
T3RLS: tenant B cannot read tenant A's BusinessBankAccount rowsL1Phase A merge
T4EmployeeService verification: holder fuzzy-match below threshold → 400L1 mockPhase B merge
T5EmployeeService verification: adapter-down → accept save + payoutVerifiedAt=nullL1 mockPhase B merge
T6End-to-end: employer creates account → employee created → probe disbursement → ledger PENDING → mock settlement → ledger POSTEDL2 bank-sandboxPhase D merge
T7End-to-end forced-failure: probe → REJECTED → Reverse → ledger nets to zeroL2 bank-sandboxPhase D merge
T8Idempotency: replay the same probe with the same idempotency key → 200 with same disbursement_id, no double settlementL2 bank-sandboxPhase D merge
T9Dashen sandbox: 3 successful + 1 forced-failure + 1 unmatched-webhook probesL3Phase E merge
T10Statement reconciliation: morning-after pull matches all probesL3Phase E merge

T1–T8 belong in scripts/test-prN.mjs style smoke tests; T9–T10 require sandbox bring-up.

12. Open questions

  1. Authentication model with the partner bank. Does Dashen's API authenticate as DemozPay (one cert, we authorize transfers on behalf of all employers via OAuth consent), OR as the employer (each employer sets up an integration token with Dashen and gives it to us)? The schema accommodates both — BusinessBankAccount carries no creds — but the LookupAccount + InitiateDisbursement adapter code differs significantly. Needs BD/legal confirmation per partner before Phase E.
  2. OAuth consent UX. Resolved 2026-06-15 — see §3.5. Partner-hosted redirect (OAuth 2.0 with PKCE). Consent tokens live in the integration-gateway's vault; the monolith stores only an opaque consentExternalId. Employer-web gets a new /banking/consent/callback route to catch the partner's redirect-back.
  3. Limits and daily caps. What's the per-tenant disbursement cap before the partner requires escalation? We need a FIPartner.dailyLimitSantim (or per-BusinessBankAccount) plus a circuit breaker in the API service that pauses scheduling at 80% utilization.
  4. Reconciliation pull cadence. BANK_ORCHESTRATION §8 lists this as PLANNED. Probes prove transfers; reconciliation proves the books match — without it, we can't go live. Order: Phase A–D ships; reconciliation pull is a parallel workstream owned by whoever picks up RECONCILIATION_ARCHITECTURE.md.
  5. Mobile money parity. The plan covers BANK partners. Telebirr / M-Birr / CBE Birr go through the same gateway interface but with Rail = RAIL_MOBILE_MONEY. Does an employer's "primary account" concept apply when the source is a Telebirr business wallet? The model accommodates it (BusinessBankAccount.fiPartnerId can point to a WALLET_PROVIDER FI), but the LookupAccount semantics differ and the test-money loop needs separate probes per rail.

13. Non-goals for v1

  • DemozPay-issued employee cards. Not custodian, not card issuer.
  • Real-time push notifications to employees on settle. Webhook is plumbed; the SMS/push trigger is notifications service's concern.
  • Inter-bank transfers we initiate from employer A at Dashen → employer B at CBE. All flows are intra-partnership (employer's bank → employee's bank at THE SAME partner) unless the bank's API supports cross-bank rails. Path D from BANK_ORCHESTRATION is reconciliation-only; this plan doesn't expand it.
  • Direct debit FROM the employee back to the employer (e.g., loan repayment). Per Path D, repayment is logical-only — the employer's payroll engine withholds the amount before depositing net. No bank operation needed on our side beyond ledger entries.
  • Multi-currency. ETB only. Money.currency is in the proto but the API rejects non-ETB at the boundary.

14. Done criteria

This plan is complete when:

  1. A platform admin can add adapterKey="dashen" to the Dashen catalog row and see live adapter health from GetAdapterStatus in the FI detail page.
  2. An employer can sign in, go to Settings → Payroll Banking, add their Dashen business account, see the holder name resolved via LookupAccount, and confirm.
  3. An employer can onboard an employee, the wizard refuses to save a bank-account that fails LookupAccount, and the saved record carries payoutVerifiedAt + payoutVerifiedHolderName.
  4. A platform admin can submit a test-money probe and see it transit PENDING → COMPLETED end-to-end against the mock adapter and the bank-sandbox service.
  5. The probe gauntlet (3 success + 1 forced-failure + 1 unmatched-webhook) passes against Dashen's sandbox.
  6. Reversing a failed probe leaves the ledger net-zero for that disbursement.
  7. RLS test confirms BusinessBankAccount and payout_probe rows are tenant-isolated end-to-end.
  8. No occurrence of "PaymentMethod" or "PaymentMethodKind" remains in apps/api/src/.
  9. ADR-014 (orchestrator-not-custodian) is cited at the top of this doc AND in the BusinessBankAccount service header — so future contributors see the rule before they touch the file.

15. Relationship to other plans

  • Supersedes EMPLOYEE_MGMT_BUILD_PLAN.md §4.2 (PaymentMethod table). That section is now marked DROPPED in the parent doc with a pointer here.
  • Inherits from BANK_ORCHESTRATION.md (custody boundary, async settlement, failure matrix) — every choice in this doc is downstream of that one.
  • Anchored by ADR-014 (orchestrator-not-custodian) — DemozPay holds no funds.
  • Blocks the payroll-engine workstream — that engine consumes BusinessBankAccount + Employee.payoutFIPartnerId to issue InitiateDisbursement calls. The engine doc is TBD.

Next action: ship Phase A (P1 + P2 migrations + BusinessBankAccountService + LookupAccount wiring + admin-web Integration field). Phase B follows once Phase A's tests are green against the mock adapter.