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;
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.
| Term | What it is | Where it lives |
|---|---|---|
BalanceSnapshot | Cached Adyen balance, unique per (balanceAccountId, currency); holds availableMinor and disputeReservedMinor. | prisma/schema.prisma, balances module |
Payout | One 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 |
payoutAutoApprovalCapMinor | BigInt 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 / TI | Local 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 / SweepExecution | A native Adyen auto-payout rule (kinds SCHEDULED / THRESHOLD) and the mirrored record of each server-side run. | balances module, worker webhook handler |
QUEUE_PAYOUT | BullMQ queue carrying payout.process jobs; consumed by the worker's PayoutProcessor at concurrency 2. | ummah-platform-worker |
payout:ba:{id}:lock | Per-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 BA | Ummah'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
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/balancesandGET /v1/balances(sk_ key). - Marketplace Client — additionally a read-only
subMerchantsroll-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).
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
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,PROCESSINGorSENT. - Never the last enabled verified bank while any
BalanceSnapshot.availableMinor > 0— rejected aslast_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.
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
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 beACTIVE. - Source balance account — with a
storeId, the store'sadyenBalanceAccountId(which must belong to this merchant and be provisioned); otherwise the merchant's main BA. - Transfer instrument resolution chain — an explicit
transferInstrumentIdmust be one of the merchant's enabled banks (else 422transfer_instrument_not_owned); otherwise the store's own TI; otherwise the newest enabled bank. No candidate at all is a 422no_transfer_instrument. Note: this chain checks enabled, not verified — the fallback ignoresverifiedAtentirely. - Amount —
amountMinorXORallAvailable. Fixed amounts are soft-checked against theBalanceSnapshot(422insufficient_balance); a non-GBP currency with no snapshot row for that currency is a 422no_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;
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.| Request | Snapshot available | Effective amount | Route |
|---|---|---|---|
amountMinor: 25000 | £3,200.00 | £250.00 | AUTO_APPROVED → QUEUE_PAYOUT |
amountMinor: 75000 | £3,200.00 | £750.00 | PENDING_OPS + payout.requested |
allAvailable: true | £120.00 | £120.00 | AUTO_APPROVED |
allAvailable: true | no snapshot row | unknown | PENDING_OPS by design |
| anything, cap at default 0 | — | any | PENDING_OPS — ops review everything |
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
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
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.
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
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;
Payout, then attributes it to the newest Sweep on the source BA. With two rules on one BA, that attribution is a guess.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
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
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 —
platformPaymentAdyenTransferLegrows on the merchant's BAs: incoming legs are Payments, outgoing legs are Refunds (the approval flow behind those lives in Refunds & Approvals).AdditionalCommissionlegs are labelled Marketplace cut — that is a Client seeing its share of sub-merchant payments arrive. - Payouts —
BANKpayouts inSENT/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.
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.
| Endpoint | Method · guard | Purpose |
|---|---|---|
/api/portal/balances | GET · MERCHANT | Own balances; Clients also get the read-only subMerchants roll-up |
/v1/balances | GET · ApiKeyGuard (sk_) | Balances over the public API |
/api/admin/merchants/:merchantId/balances | GET · STAFF | Staff per-merchant balance view |
/api/portal/banks | POST · merchant.write | Add UK bank → Adyen LEM transferInstrument |
/api/portal/banks/:bankId | DELETE · merchant.write | Guarded soft-disable (sweep destination / in-flight payout / last verified bank) |
/api/admin/merchants/:merchantId/banks | GET · STAFF | Read-only bank list |
/api/portal/payouts | POST · merchant.write, @Idempotent | Create payout (list + CSV export at GET /api/portal/payouts/export) |
/v1/payouts | POST · ApiKeyGuard (sk_) | Same caps and approval gates as the portal |
/api/admin/payouts + :id/approve / :id/reject | GET/POST · STAFF + merchant.write | Ops queue; verbs act on PENDING_OPS only, idempotent |
/api/portal/payouts/submerchants/:subId/cap | PUT · CapsService.assertParentOf | Client sets a sub-merchant's auto-approval cap |
/api/admin/merchants/:merchantId | PUT · merchant.pricing.write | Staff edit of payoutAutoApprovalCapMinor |
/api/admin/merchants/:merchantId/sweeps | POST/PATCH/DELETE · STAFF | Sweep CRUD, pushed to Adyen |
/api/portal/sweeps | GET · MERCHANT | Read-only rules + last 5 executions |
/api/portal/transfers | POST · MERCHANT | Own-account BA→BA; ring-fenced stores blocked as source |
/api/admin/transfers | POST · STAFF, confirmCrossMerchant | Any-BA transfer; optional chargeFee (TRANSFER event) |
/api/portal/statements/generate | POST · merchant.read | Monthly GBP statement upsert + PDF |
Data model notes
BalanceSnapshot— unique(balanceAccountId, currency); fields includeavailableMinor,disputeReservedMinor.Merchant.payoutAutoApprovalCapMinor—BigInt, default 0 (schema.prisma:361).Payout— kindBANK/INTERNAL; reference (PO-/TR-prefixed) is unique and doubles as the Adyen idempotency key;adyenTransferIdbackfilled by webhook if the worker crashed first.BankAccount—verifiedAt(webhook-set),disabledAt(soft removal),withdrawnTotalMinor(sum ofSENT/SETTLEDbank payouts).Sweep/SweepExecution— local rule withadyenSweepId; executions upserted byadyenTransferId, 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.
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.
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.
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.
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.
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.
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.