UMMAH FLOWS · 09 Verified against code · Aug 2026

Money-out architecture · flows & guards

Balances, Payouts & Money-Out

Where a merchant's money sits once the split has booked, and every road it takes out of the platform: live balance mirrors per account and currency, verified UK bank accounts, manual payouts behind an ops approval gate, native Adyen sweeps, internal transfers, and monthly statements. This page walks each flow with the exact endpoints, queues, locks and guards the code enforces — and is honest about where the safety net thins.

Section 1

The money-out model in one view

Ummah never holds merchant money in its own bank account. Every merchant's funds live in Adyen balance accounts — one main balance account per merchant, plus optional per-store accounts — credited directly by the payment split at authorisation time. Money-out is everything that happens after that credit lands.

How a payment divides itself before it reaches a balance account — commission, the marketplace Client's cut, scheme-by-scheme rates — is the subject of Pricings, Splits & Fees and is not re-explained here. This page starts at the moment the seller leg books, and covers four ways money leaves a balance account: a manual payout to a verified bank, a native Adyen sweep (automatic payout rule executed by Adyen itself), an internal transfer to another balance account, and the recovery and fee transfers the platform initiates (covered where they hand off).

Money-out: every road from a balance account

FIG 1 · overview
%%{init:{"theme":"base","themeVariables":{"fontFamily":"Bricolage Grotesque, Inter, Segoe UI, sans-serif","fontSize":"13.5px","primaryColor":"#FFFFFF","primaryTextColor":"#00273A","primaryBorderColor":"#D2D8DE","lineColor":"#8195A1","secondaryColor":"#F1F4F8","tertiaryColor":"#F7F8FA","clusterBkg":"#F7F8FA","clusterBorder":"#E5E8EC","edgeLabelBackground":"#FFFFFF"},"flowchart":{"curve":"basis","nodeSpacing":36,"rankSpacing":48,"padding":10}}}%%
flowchart LR
  SPLITS["Payment split
seller leg books"] -->|"credits"| BA["Store or main BA
at Adyen"] BA -->|"balance.updated
webhook"| SNAP["BalanceSnapshot
per BA and currency"] BA --> PO["Manual payout
worker-driven"] BA --> SW["Native sweep
Adyen executes"] BA --> TR["Internal transfer"] PO --> TI["Bank account via
transferInstrument"] SW --> TI TR --> BA2["Another BA
same merchant"] SNAP -.->|"soft balance check"| PO classDef ummah fill:#FBE5D6,stroke:#E86C2B,stroke-width:1.5px,color:#00273A; classDef liable fill:#E86C2B,stroke:#B84E1F,stroke-width:1.5px,color:#FFFFFF; classDef adyen fill:#00273A,stroke:#00273A,color:#FFFFFF; classDef client fill:#E8F0FE,stroke:#1A73B8,stroke-width:1.5px,color:#00273A; classDef sub fill:#E6F4EA,stroke:#1E8E3E,stroke-width:1.5px,color:#00273A; classDef warn fill:#FEF3DC,stroke:#B26A00,stroke-width:1.5px,color:#00273A; classDef danger fill:#FCE8E6,stroke:#C5221F,stroke-width:1.5px,color:#00273A; classDef plain fill:#FFFFFF,stroke:#D2D8DE,stroke-width:1.5px,color:#00273A; class SPLITS,SNAP,PO ummah; class BA,BA2 sub; class SW,TI adyen; class TR plain;
Ummah-owned record / action Merchant money Adyen / external rails Neutral step
The snapshot only soft-checks a payout at creation — the worker sizes allAvailable payouts from Adyen's live balance, never the cache, so a stale snapshot can delay a payout but cannot oversend one.

BalanceSnapshot

The balance mirror

One row per (balance account, currency) — a single BA can hold several currency balances. Fed by Adyen's balanceAccount.balance.updated webhook, with a full re-pull fallback in the daily reconciliation run. Carries availableMinor and the dispute earmark disputeReservedMinor.

Payout

The money-out instruction

A row per withdrawal, kind BANK (to a bank via transferInstrument) or INTERNAL (BA to BA). Walks a strict state machine — PENDING_OPS, AUTO_APPROVED / APPROVED, PROCESSING, SENT, then SETTLED or FAILED — executed by a single-writer worker.

BankAccount → transferInstrument

The destination

A UK sort code and account number become an Adyen LEM transferInstrument on the merchant's legal entity. Verification is webhook-driven (verifiedAt); removal is guarded so a bank in active use can never silently vanish.

Section 2

The vocabulary

Eight nouns carry this whole page. All names are verbatim from the schema and services.

TermWhat it isWhere it lives
BalanceSnapshotCached Adyen balance, unique per (balanceAccountId, currency); holds availableMinor and disputeReservedMinor.prisma/schema.prisma, balances module
PayoutOne money-out instruction with kind BANK or INTERNAL and the status machine of FIG 2; reference PO-<uuid> doubles as Adyen's idempotency key.payouts module + PayoutProcessor
payoutAutoApprovalCapMinorBigInt on Merchant, default 0 — the threshold below which a payout skips ops review. Default 0 means everything goes to ops until deliberately raised.schema.prisma:361
BankAccount / TILocal bank row paired with an Adyen LEM transferInstrument (ukLocalAccount, accountType business); verifiedAt set by webhook, disabledAt on soft removal, withdrawnTotalMinor sums its SENT/SETTLED bank payouts.banks service, payouts module
Sweep / SweepExecutionA native Adyen auto-payout rule (kinds SCHEDULED / THRESHOLD) and the mirrored record of each server-side run.balances module, worker webhook handler
QUEUE_PAYOUTBullMQ queue carrying payout.process jobs; consumed by the worker's PayoutProcessor at concurrency 2.ummah-platform-worker
payout:ba:{id}:lockPer-balance-account Redis lock (SET NX EX 60, compare-and-delete Lua release) making the payout worker a single writer per BA.payout.processor.ts
Liable BAUmmah's own balance account (ADYEN_LIABLE_BALANCE_ACCOUNT_ID) — where commission books and where recovery transfers land. Its economics belong to Pricings, Splits & Fees.platform-earnings, recovery

Section 3

Balances & snapshots

Merchant Marketplace Client Ummah ops Adyen webhooks

The platform never computes a balance itself — it mirrors Adyen. Every balancePlatform.balanceAccount.balance.updated event upserts the matching BalanceSnapshot row, keyed by balance account and currency, so one account can carry GBP, EUR and USD balances side by side.

Because webhooks are at-least-once but not guaranteed-timely, the mirror has a fallback: the daily 02:00 UTC reconciliation run's balance-refresh check calls BalanceSnapshotService.refreshAll() to re-pull every balance account from Adyen — if the entire re-pull fails, the run is marked DEGRADED and ops are alerted. Anything that must be exact at spend time (payout sizing, recovery tranches) reads Adyen live instead of the snapshot.

Who sees what

  • Merchant — own balances at GET /api/portal/balances and GET /v1/balances (sk_ key).
  • Marketplace Client — additionally a read-only subMerchants roll-up of its children's balances. Read-only is deliberate: payout rights stay with each sub-merchant; the parent sees, but cannot spend.
  • Staff — per-merchant view at GET /api/admin/merchants/:merchantId/balances, plus the liable-account panel on the platform-earnings screen.

The snapshot also carries disputeReservedMinor — while a dispute is open, the disputed amount is earmarked on the snapshot and Dispute.reserveHeldMinor records the hold, released exactly once at resolution. Today that earmark is bookkeeping only: nothing subtracts it from availableMinor when a payout is created or executed (see Section 11).

WatchThe snapshot is a cache with a daily safety re-pull — good enough for display and soft checks. But the dispute earmark it carries is advisory: a merchant can still withdraw funds the platform has mentally set aside for an open dispute, and the recovery waterfall then has to chase siblings or the parent Client.

ummah-platform-backend/src/modules/balances/portal-balances.controller.ts · ummah-platform-worker/src/workers/reconciliation.processor.ts:16-294 · ummah-platform-backend/prisma/schema.prisma:1300-1325 · ummah-platform-worker/src/modules/disputes/disputes-intake.service.ts:66-343

Section 4

Bank accounts

Merchant Adyen LEM Ummah ops (read-only)

A payout destination is born in one call and verified by webhook. BanksService.add (POST /api/portal/banks, permission merchant.write) validates the UK details — a 6-digit sortCode and an 8-digit accountNumber — then creates an Adyen LEM transferInstrument (ukLocalAccount, accountType business) on the merchant's legal entity and stores the BankAccount row with verifiedAt null.

Verification is not synchronous. Adyen runs its own bank verification and reports back via PAYMENT_INSTRUMENT.UPDATED; when the webhook's verificationStatus is valid, active or verified, the worker stamps BankAccount.verifiedAt. Until then the bank exists but is unproven — and, as Section 11 documents, the payout path does not currently insist on the stamp.

Guarded removal

DELETE /api/portal/banks/:bankId refuses to remove a bank that is still load-bearing:

  • Not while it is a sweep destination — an active auto-payout rule would silently start failing.
  • Not with an in-flight payout against it — any payout in PENDING_OPS, AUTO_APPROVED, APPROVED, PROCESSING or SENT.
  • Never the last enabled verified bank while any BalanceSnapshot.availableMinor > 0 — rejected as last_verified_bank_with_balance, so money can never be stranded with no road out.

Removal that passes the guards is a soft disable: the row gets disabledAt, the transferInstrument is deleted at Adyen, and history survives — including withdrawnTotalMinor, the per-bank lifetime sum of SENT/SETTLED bank payouts. Staff have a read-only mirror at GET /api/admin/merchants/:merchantId/banks.

BitesThe removal guards are thorough; the verification stamp is not enforced where it matters. verifiedAt gates nothing at payout time — the TI fallback picks any enabled bank, verified or not, and failure is deferred to Adyen's rejection.

ummah-platform-backend/src/modules/payouts/banks.service.ts:60-234 · ummah-platform-worker/src/modules/webhooks-in/adyen-webhook.service.ts:625-649 · ummah-platform-backend/src/modules/payouts/portal-banks.controller.ts:21-53 · ummah-platform-backend/src/modules/payouts/admin-banks.controller.ts:14-21

Section 5

Manual payouts

Merchant (portal) sk_ API key (/v1) Ummah ops Payout worker

Three surfaces feed one service. POST /api/portal/payouts (RequireScope('MERCHANT') + merchant.write, behind JwtAuthGuard+RolesGuard, and @Idempotent so an Idempotency-Key header is honoured), POST /v1/payouts (ApiKeyGuard, sk_ key, same caps and approval gates as the portal), and staff oversight at /api/admin/payouts — a filterable list plus POST :id/approve and POST :id/reject. All of them call the same PayoutsService.create.

The request body is small and strict: an optional storeId, exactly one of amountMinor or allAvailable, an optional transferInstrumentId, and an optional currency — defaulting to GBP, restricted by the DTO to PAYOUT_CURRENCIES ['GBP','EUR','USD'].

One economic fact worth stating plainly: payouts are free. The FeeChargeEvent.PAYOUT row exists in the fee schema but is reserved and disabled — a business decision of 2026-08-11. The fee machinery that does bite (transfer fees, chargeback fees) is covered in Pricings, Splits & Fees.

5.1 · Creation guards, in execution order

  • Merchant gate — MerchantGateService.assertOperational, and the merchant must be ACTIVE.
  • Source balance account — with a storeId, the store's adyenBalanceAccountId (which must belong to this merchant and be provisioned); otherwise the merchant's main BA.
  • Transfer instrument resolution chain — an explicit transferInstrumentId must be one of the merchant's enabled banks (else 422 transfer_instrument_not_owned); otherwise the store's own TI; otherwise the newest enabled bank. No candidate at all is a 422 no_transfer_instrument. Note: this chain checks enabled, not verified — the fallback ignores verifiedAt entirely.
  • Amount — amountMinor XOR allAvailable. Fixed amounts are soft-checked against the BalanceSnapshot (422 insufficient_balance); a non-GBP currency with no snapshot row for that currency is a 422 no_balance_in_currency.

5.2 · The approval gate

Step 4 of create computes effectiveMinor = amountMinor ?? snapshot.availableMinor and routes on the merchant's cap: when 0 < effectiveMinor ≤ payoutAutoApprovalCapMinor, the payout is born AUTO_APPROVED and enqueued to QUEUE_PAYOUT immediately; otherwise it is born PENDING_OPS and admins are notified via payout.requested. An allAvailable request with no snapshot at all routes to PENDING_OPS by design — the platform refuses to auto-approve an amount it cannot even estimate.

Staff act on PENDING_OPS rows only, and both verbs are written as updateMany-with-status-guard — a double-click or two racing staff members cannot approve twice. The cap itself is editable by staff (PUT under /api/admin/merchants/:merchantId, permission merchant.pricing.write) and by a marketplace Client for its own sub-merchants only at PUT /api/portal/payouts/submerchants/:subId/cap, guarded by CapsService.assertParentOf — the same pattern the per-sub refund caps use over in Refunds & Approvals.

Payout state machine with the approval gate

FIG 2 · state machine
%%{init:{"theme":"base","themeVariables":{"fontFamily":"Bricolage Grotesque, Inter, Segoe UI, sans-serif","fontSize":"13.5px","primaryColor":"#FFFFFF","primaryTextColor":"#00273A","primaryBorderColor":"#D2D8DE","lineColor":"#8195A1","secondaryColor":"#F1F4F8","tertiaryColor":"#F7F8FA","clusterBkg":"#F7F8FA","clusterBorder":"#E5E8EC","edgeLabelBackground":"#FFFFFF"},"flowchart":{"curve":"basis","nodeSpacing":36,"rankSpacing":48,"padding":10}}}%%
flowchart LR
  GATE{"amount within
auto-approval cap?"} -->|"yes"| AUTO["AUTO_APPROVED"] GATE -->|"no, cap 0, or allAvailable
with no snapshot"| PEND["PENDING_OPS"] PEND -->|"POST :id/approve"| APPR["APPROVED"] PEND -->|"POST :id/reject"| REJ["REJECTED"] AUTO -->|"QUEUE_PAYOUT"| PROC["PROCESSING"] APPR -->|"QUEUE_PAYOUT"| PROC PROC -->|"transfer accepted"| SENT["SENT"] PROC -->|"Adyen 4xx"| FAILED["FAILED"] PROC -.->|"5xx: back to APPROVED
+ BullMQ backoff"| APPR SENT -->|"webhook booked"| SETTLED["SETTLED"] SENT -->|"failed / returned /
cancelled / refused"| FAILED classDef ummah fill:#FBE5D6,stroke:#E86C2B,stroke-width:1.5px,color:#00273A; classDef liable fill:#E86C2B,stroke:#B84E1F,stroke-width:1.5px,color:#FFFFFF; classDef adyen fill:#00273A,stroke:#00273A,color:#FFFFFF; classDef client fill:#E8F0FE,stroke:#1A73B8,stroke-width:1.5px,color:#00273A; classDef sub fill:#E6F4EA,stroke:#1E8E3E,stroke-width:1.5px,color:#00273A; classDef warn fill:#FEF3DC,stroke:#B26A00,stroke-width:1.5px,color:#00273A; classDef danger fill:#FCE8E6,stroke:#C5221F,stroke-width:1.5px,color:#00273A; classDef plain fill:#FFFFFF,stroke:#D2D8DE,stroke-width:1.5px,color:#00273A; class GATE,AUTO,APPR ummah; class PEND warn; class REJ,FAILED danger; class PROC,SENT plain; class SETTLED sub;
Platform decision / approval Waiting on ops Money landed Terminal failure In flight
The 5xx path is the only backwards edge: a transient Adyen error re-parks the row at APPROVED and rethrows into BullMQ's backoff, so a retry re-enters through the same lock-and-claim discipline instead of a bespoke retry state.
Worked example — merchant cap set to £500.00 (payoutAutoApprovalCapMinor = 50000)
RequestSnapshot availableEffective amountRoute
amountMinor: 25000£3,200.00£250.00AUTO_APPROVED → QUEUE_PAYOUT
amountMinor: 75000£3,200.00£750.00PENDING_OPS + payout.requested
allAvailable: true£120.00£120.00AUTO_APPROVED
allAvailable: trueno snapshot rowunknownPENDING_OPS by design
anything, cap at default 0—anyPENDING_OPS — ops review everything
StandsThe gate defaults closed (cap 0 sends everything through ops), the cap is delegated safely (a Client can only cap its own children, enforced by assertParentOf), and approve/reject are idempotent by construction. The one soft spot is upstream: the balance check it routes on is the cached snapshot, not live money.

ummah-platform-backend/src/modules/payouts/portal-payouts.controller.ts:20-44 · ummah-platform-backend/src/modules/payouts/v1-payouts.controller.ts:12-31 · ummah-platform-backend/src/modules/payouts/admin-payouts.controller.ts:19-70 · ummah-platform-backend/src/modules/payouts/payouts.service.ts:80-232 · ummah-platform-backend/src/modules/payouts/payouts.dto.ts:5-50 · ummah-platform-backend/src/modules/balances/caps.service.ts:29-81

Section 6

Execution & settlement

PayoutProcessor (worker) Redis Adyen Transfers API TransferEventService Merchant webhooks

The worker's PayoutProcessor consumes QUEUE_PAYOUT at concurrency 2, but is a single writer per balance account: before touching anything it takes the Redis lock payout:ba:{id}:lock (SET NX EX 60, released by a compare-and-delete Lua script), then re-claims the row — AUTO_APPROVED/APPROVED → PROCESSING via updateMany — so a duplicate job finds nothing to do.

Sizing is deliberately pessimistic in the right direction: an allAvailable payout is resolved from Adyen's live balances (adyen.config.getBalanceAccountBalances), never the snapshot. The transfer itself goes to Adyen's Transfers API (/btl/v4) via adyen.transfers.createTransfer, with the payout reference PO-<uuid> doubling as the Adyen Idempotency-Key — a crashed-and-retried send can only ever create one transfer.

Payout execution: lock, live-size, send, settle

FIG 3 · sequence
%%{init:{"theme":"base","themeVariables":{"fontFamily":"Bricolage Grotesque, Inter, Segoe UI, sans-serif","fontSize":"13px","primaryColor":"#FBE5D6","primaryTextColor":"#00273A","primaryBorderColor":"#E86C2B","lineColor":"#8195A1","actorBkg":"#FFFFFF","actorBorder":"#D2D8DE","actorTextColor":"#00273A","actorLineColor":"#D2D8DE","signalColor":"#33505F","signalTextColor":"#33505F","activationBkgColor":"#FBE5D6","activationBorderColor":"#E86C2B","noteBkgColor":"#FEF3DC","noteBorderColor":"#E8D5A8","noteTextColor":"#00273A","labelBoxBkgColor":"#F1F4F8","labelBoxBorderColor":"#D2D8DE","loopTextColor":"#33505F"},"sequence":{"mirrorActors":false,"actorMargin":46,"messageMargin":30,"boxMargin":8}}}%%
sequenceDiagram
  participant W as PayoutProcessor
  participant R as Redis
  participant DB as Postgres
  participant A as Adyen Transfers API
  participant TE as TransferEventService
  participant M as Merchant endpoint
  W->>R: SET payout:ba:baId:lock NX EX 60
  W->>DB: claim AUTO_APPROVED or APPROVED to PROCESSING
  alt allAvailable payout
    W->>A: getBalanceAccountBalances
    A-->>W: live available balance
  end
  W->>A: createTransfer, reference PO-uuid
  Note over W,A: reference doubles as the Idempotency-Key
  alt accepted
    A-->>W: transfer id
    W->>DB: SENT + adyenTransferId + resolved amountMinor
  else 4xx
    W->>DB: FAILED, permanent
  else 5xx or timeout
    W->>DB: back to APPROVED, BullMQ backoff
  end
  A-->>TE: transfer webhook, category bank, status booked
  TE->>DB: SETTLED + settledAt
  TE-->>M: payout.settled webhook + notification
Settlement resolves the payout by adyenTransferId first, then by the unique reference — the second path heals a worker that crashed after Adyen accepted but before SENT was persisted.

Failure handling splits on the class of error: an AdyenTransferError with httpStatus < 500 is a permanent FAILED; transient errors put the row back to APPROVED and rethrow into BullMQ's exponential backoff; exhausted attempts land on FAILED. On the settlement side, TransferEventService.handle maps webhook status booked to SETTLED (with settledAt, the payout.settled merchant webhook, and an email notification), and any of failed | returned | cancelled | refused | error to FAILED with failureReason and payout.failed. The outbound event catalogue also defines payout.sent for merchant endpoints.

A bank transfer that matches no payout is not an error — it is treated as a native sweep run and mirrored into SweepExecution, which is Section 7's story.

StandsDouble-send is guarded twice over — the per-BA Redis lock plus the reference-as-idempotency-key — and live-balance sizing means a stale snapshot can never oversend. This is the strongest state machine on the platform's money paths.

ummah-platform-worker/src/workers/payout.processor.ts:86-208 · ummah-platform-worker/src/modules/webhooks-in/transfer-event.service.ts:45-219

Section 7

Sweeps — auto payout, executed by Adyen

Ummah ops (CRUD) Adyen balance platform Merchant (read-only)

Sweeps are native Adyen auto-payout rules: once configured, Adyen executes them server-side — even while the whole Ummah platform is down. The local Sweep row (with adyenSweepId) is the source of truth for what was configured; SweepsService wraps adyen.config.createSweep / updateSweep / deleteSweep on /balanceAccounts/{id}/sweeps.

Two kinds exist. SCHEDULED runs on a frequency (HOURLY/DAILY/WEEKLY/MONTHLY) with an optional trigger acting as a minimum; THRESHOLD forces HOURLY and requires triggerAmountMinor. In both, targetAmountMinor is the keep-behind amount and must be ≤ the trigger — violations are a 422 target_exceeds_trigger, mirroring Adyen's own error code 1011_002. Destinations are ownership-checked: a TRANSFER_INSTRUMENT destination must be one of the merchant's enabled banks; a BALANCE_ACCOUNT destination must be another BA of the same merchant — cross-merchant is refused as destination_not_owned. Currency is hardcoded 'GBP'.

CRUD is admin-only at /api/admin/merchants/:merchantId/sweeps (RequireScope('STAFF'); PATCH pauses/resumes via Adyen's active/inactive status, DELETE removes at Adyen then drops the local rule — history survives in AdyenTransferLeg and the audit log). Merchants get a read-only GET /api/portal/sweeps listing rules and the last five executions; self-service sweep configuration is a stated MVP gap.

Sweep lifecycle: configured locally, executed remotely, mirrored back

FIG 4 · lifecycle
%%{init:{"theme":"base","themeVariables":{"fontFamily":"Bricolage Grotesque, Inter, Segoe UI, sans-serif","fontSize":"13.5px","primaryColor":"#FFFFFF","primaryTextColor":"#00273A","primaryBorderColor":"#D2D8DE","lineColor":"#8195A1","secondaryColor":"#F1F4F8","tertiaryColor":"#F7F8FA","clusterBkg":"#F7F8FA","clusterBorder":"#E5E8EC","edgeLabelBackground":"#FFFFFF"},"flowchart":{"curve":"basis","nodeSpacing":36,"rankSpacing":48,"padding":10}}}%%
flowchart LR
  STAFF["Staff POST /api/admin/
merchants/:id/sweeps"] --> VAL{"destination owned +
target ≤ trigger?"} VAL -->|"fail"| ERR["422 target_exceeds_trigger /
destination_not_owned"] VAL -->|"ok"| CREATE["adyen.config.createSweep"] CREATE --> ROW["Sweep row +
adyenSweepId"] ROW -.->|"rule lives at Adyen"| RUN["Adyen executes
server-side"] RUN --> WH["transfer webhook with
no Payout match"] WH --> EXEC["SweepExecution upsert vs
newest Sweep on source BA"] EXEC --> VIEW["Merchant read-only
GET /api/portal/sweeps"] classDef ummah fill:#FBE5D6,stroke:#E86C2B,stroke-width:1.5px,color:#00273A; classDef liable fill:#E86C2B,stroke:#B84E1F,stroke-width:1.5px,color:#FFFFFF; classDef adyen fill:#00273A,stroke:#00273A,color:#FFFFFF; classDef client fill:#E8F0FE,stroke:#1A73B8,stroke-width:1.5px,color:#00273A; classDef sub fill:#E6F4EA,stroke:#1E8E3E,stroke-width:1.5px,color:#00273A; classDef warn fill:#FEF3DC,stroke:#B26A00,stroke-width:1.5px,color:#00273A; classDef danger fill:#FCE8E6,stroke:#C5221F,stroke-width:1.5px,color:#00273A; classDef plain fill:#FFFFFF,stroke:#D2D8DE,stroke-width:1.5px,color:#00273A; class STAFF,ROW ummah; class VAL plain; class ERR danger; class CREATE,RUN adyen; class WH plain; class EXEC warn; class VIEW sub;
Ummah config Adyen-side Attribution by inference Merchant view Rejected
The platform never sees a sweep run directly — it infers one from a bank or internal transfer webhook that matches no Payout, then attributes it to the newest Sweep on the source BA. With two rules on one BA, that attribution is a guess.
WatchExecution mirroring is inference, not identification: recordSweepExecution matches by source balance account only and pins the run on the newest rule. Money is safe — the transfer happened at Adyen regardless — but the audit trail can name the wrong rule.

ummah-platform-backend/src/modules/balances/sweeps.service.ts:66-281 · ummah-platform-backend/src/modules/balances/admin-sweeps.controller.ts:28-94 · ummah-platform-backend/src/modules/balances/portal-sweeps.controller.ts:13-27 · ummah-platform-worker/src/modules/webhooks-in/transfer-event.service.ts:198-203 · ummah-platform-backend/prisma/schema.prisma:1386-1441

Section 8

Internal transfers

Merchant (portal) Ummah ops (admin) Payout worker

Moving money between balance accounts rides the same payout machinery with kind INTERNAL and reference TR-<uuid> — same worker, same lock, same settlement webhook.

Merchant surface

POST /api/portal/transfers moves between the merchant's own accounts. There are no caps and no ops gate — funds never leave the merchant's boundary, so every transfer is born AUTO_APPROVED. Two guards still apply: ownership of both ends is enforced, and a ring-fenced store refuses to be a source — ring-fencing exists so a store's funds answer for that store's disputes, and letting it drain itself would defeat the point. Currency is hardcoded GBP.

Admin surface

POST /api/admin/transfers moves between any two platform balance accounts. A cross-merchant move demands an explicit confirmCrossMerchant flag — without it, a 422 cross_merchant_confirmation_required — so treasury corrections cannot cross a merchant boundary by accident. An optional chargeFee flag books the source merchant's TRANSFER-event fee from its FeeProfile; by the same 2026-08-11 decision that made payouts free, admin corrections stay free by default, and a fee-booking failure never blocks the transfer itself.

ummah-platform-backend/src/modules/payouts/payouts.service.ts:240-448 · ummah-platform-backend/src/modules/payouts/portal-transfers.controller.ts:21-48 · ummah-platform-backend/src/modules/payouts/admin-transfers.controller.ts:21-47

Section 9

Monthly statements

Merchant Ummah ops (mirror)

A statement is the merchant's month as Adyen booked it — not as the platform priced it. StatementsService.generate (portal POST /api/portal/statements/generate, needing only merchant.read; admin mirror at /api/admin/merchants/:merchantId/statements) builds monthly, GBP-only statements from booked data.

  • Payment activity — platformPayment AdyenTransferLeg rows on the merchant's BAs: incoming legs are Payments, outgoing legs are Refunds (the approval flow behind those lives in Refunds & Approvals). AdditionalCommission legs are labelled Marketplace cut — that is a Client seeing its share of sub-merchant payments arrive.
  • Payouts — BANK payouts in SENT/SETTLED.
  • Internal transfers — in/out per end; a transfer whose both ends are in scope is skipped rather than double-counted.
  • Sweep executions — to a transferInstrument as a payout line (Sweep — Auto payout), to an out-of-scope BA as a transfer out.

Generation is an idempotent upsert per (merchantId, storeId, year, month, currency) — an empty storeId meaning the whole merchant — with the PDF stored inline (pdfBytes). One thing a statement is not: a fee breakdown. Lines are the merchant's own booked legs, so a leaf merchant sees net seller amounts, never the commission that was deducted upstream — the decomposition of that commission is Pricings, Splits & Fees territory, and a structured per-transaction fee view is a known platform gap.

StandsAs a mirror of Adyen bookings the statement is honest and idempotent. Just never sell it as a fee invoice — merchants are not invoiced for platform fees anywhere; fees leave via splits and FeeCharge transfers.

ummah-platform-backend/src/modules/statements/statements.service.ts:24-338 · ummah-platform-backend/src/modules/statements/statements.controller.ts:48-127 · ummah-platform-backend/prisma/schema.prisma:1443-1475

Section 10

Implementation notes

Every money-out surface in one table. Guards are named as the code names them.

Money-out endpoints
EndpointMethod · guardPurpose
/api/portal/balancesGET · MERCHANTOwn balances; Clients also get the read-only subMerchants roll-up
/v1/balancesGET · ApiKeyGuard (sk_)Balances over the public API
/api/admin/merchants/:merchantId/balancesGET · STAFFStaff per-merchant balance view
/api/portal/banksPOST · merchant.writeAdd UK bank → Adyen LEM transferInstrument
/api/portal/banks/:bankIdDELETE · merchant.writeGuarded soft-disable (sweep destination / in-flight payout / last verified bank)
/api/admin/merchants/:merchantId/banksGET · STAFFRead-only bank list
/api/portal/payoutsPOST · merchant.write, @IdempotentCreate payout (list + CSV export at GET /api/portal/payouts/export)
/v1/payoutsPOST · ApiKeyGuard (sk_)Same caps and approval gates as the portal
/api/admin/payouts + :id/approve / :id/rejectGET/POST · STAFF + merchant.writeOps queue; verbs act on PENDING_OPS only, idempotent
/api/portal/payouts/submerchants/:subId/capPUT · CapsService.assertParentOfClient sets a sub-merchant's auto-approval cap
/api/admin/merchants/:merchantIdPUT · merchant.pricing.writeStaff edit of payoutAutoApprovalCapMinor
/api/admin/merchants/:merchantId/sweepsPOST/PATCH/DELETE · STAFFSweep CRUD, pushed to Adyen
/api/portal/sweepsGET · MERCHANTRead-only rules + last 5 executions
/api/portal/transfersPOST · MERCHANTOwn-account BA→BA; ring-fenced stores blocked as source
/api/admin/transfersPOST · STAFF, confirmCrossMerchantAny-BA transfer; optional chargeFee (TRANSFER event)
/api/portal/statements/generatePOST · merchant.readMonthly GBP statement upsert + PDF

Data model notes

  • BalanceSnapshot — unique (balanceAccountId, currency); fields include availableMinor, disputeReservedMinor.
  • Merchant.payoutAutoApprovalCapMinor — BigInt, default 0 (schema.prisma:361).
  • Payout — kind BANK/INTERNAL; reference (PO-/TR- prefixed) is unique and doubles as the Adyen idempotency key; adyenTransferId backfilled by webhook if the worker crashed first.
  • BankAccount — verifiedAt (webhook-set), disabledAt (soft removal), withdrawnTotalMinor (sum of SENT/SETTLED bank payouts).
  • Sweep/SweepExecution — local rule with adyenSweepId; executions upserted by adyenTransferId, latest status wins (schema.prisma:1386-1441).

Section 11

Gaps & recommendations

Everything below was verified in code, not inferred. Priorities: p0 = correctness or money at risk, p1 = commercial or ops friction, p2 = polish.

P0

Bank verification is not enforced at payout time

The TI fallback (payouts.service.ts:131-132) picks any enabled bank ignoring verifiedAt, and an explicit transferInstrumentId is checked for ownership only (lines 124-129) — despite the endpoint's own Swagger promising "422 no_transfer_instrument until a verified bank exists". Failure is deferred to Adyen. Fix: filter every TI candidate on verifiedAt and reject an explicit unverified TI with a dedicated 422, so the documented contract becomes the enforced one.

P0

The dispute earmark never reduces payable balance

BalanceSnapshot.disputeReservedMinor is set while a dispute is open, but neither PayoutsService.create's soft check nor the worker's live-balance sizing subtracts it — a merchant can withdraw funds earmarked for an open dispute, leaving the recovery waterfall to chase siblings, the parent Client, or a platform write-off. Fix: subtract disputeReservedMinor from available in both the creation soft check and allAvailable sizing.

P1

GBP is hardcoded across money-out

Sweeps (sweeps.service.ts:127), portal and admin internal transfers (payouts.service.ts:300,380) and statements (statements.service.ts:93) all pin 'GBP' — while checkout accepts any ISO currency, balance accounts hold multi-currency snapshots, and payouts already support GBP/EUR/USD. FX-converted settlements additionally settle unverified and skip reserve accrual. Fix: parameterise currency through sweeps, transfers and statements, and give Reserve a per-currency denomination before non-GBP volume grows.

P1

Sweep execution attribution is guesswork

recordSweepExecution (transfer-event.service.ts:198-203) matches an unmatched bank transfer to the newest Sweep on the source BA — with two rules on one account, runs can be logged against the wrong rule. Fix: match on the sweep identifier Adyen includes in the transfer payload where available, or constrain the model to one active sweep per balance account.

P2

Statement periods bucket on local timestamps

Statement lines bucket by local createdAt/executedAt of legs and payouts rather than Adyen's booking date — a late-arriving webhook can shift a line into the wrong month. Fix: persist and bucket on Adyen's booking timestamp from the transfer payload.

P2

Merchants still lack a structured fee breakdown

Per-transaction fees are visible only as raw splitsApplied JSON on GET /api/portal/payments/:id, and the fees-and-commissions report sums Ummah and Client cuts into one Commission column; statements show net legs only. Fix: a dedicated fee-breakdown read model — the decomposition rules already live in Pricings, Splits & Fees.