Section 1
What a refund is on Ummah
A refund is not a single Adyen call. It is a small state machine with an approval gate in front and a money-routing decision behind: every refund row is checked against what is genuinely still refundable, routed past a per-merchant approval cap, and then submitted to Adyen with an explicit, bearer-aware set of split legs that reverse the payment-time split recorded on the transaction.
Three things make the design distinctive. First, the platform never trusts Adyen's default refund funding: it always sends its own splits[], because letting Adyen reverse proportionally would claw back Ummah's and the marketplace Client's cuts. Second, who pays for the fee portion of a refund is configuration, not code — a two-axis bearer choice per merchant. Third, refunds above a cap stop and wait for a member of staff. How those payment-time cuts were computed in the first place is the subject of Pricings, Splits & Fees; this page only reverses them.
Approval routing — the cap gate
FIG 1 · cap gate
%%{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 TD
API["POST /v1/refunds · sk key
POST /api/portal/refunds · portal JWT"]:::plain
ADMIN["Back-office createByAdmin"]:::ummah
CREATE["RefundsService.create
remaining = captured − refunded − in-flight"]:::plain
GATE{"amount within
refundAutoApprovalCapMinor?"}:::warn
PEND["PENDING
auto-approved, submits at once"]:::sub
OPS["PENDING_OPS
ops review queue"]:::warn
APPR["APPROVED
staff may override feeBearer"]:::sub
REJ["Rejected — terminal"]:::danger
SUBMIT["submitToAdyen
explicit bearer-aware splits"]:::ummah
API --> CREATE
CREATE --> GATE
GATE -->|"yes"| PEND
GATE -->|"no"| OPS
OPS -->|"approve"| APPR
OPS -->|"reject"| REJ
ADMIN --> APPR
PEND --> SUBMIT
APPR --> SUBMIT
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;
createByAdmin lands directly at APPROVED, so the back-office is both the reviewer of large refunds and a bypass of its own queue. The staff approval step is also the only place a refund's fee bearer can be changed after creation.Refund
The row itself
One Refund per request, linked to its Transaction; a payment can carry many. It snapshots the money decision at submit time — feeBearer, clientFeeBearer and the exact legs sent to Adyen in splitsReversed — so later reporting never has to re-derive who funded what.
RefundFeeBearer · ClientRefundFeeBearer
The two bearer axes
Per-merchant configuration deciding who eats the fee portion: Ummah's fee via Merchant.refundFeeBearer (MERCHANT or PLATFORM), and — for marketplace payments only — the Client's cut via Merchant.clientRefundFeeBearer (SUBMERCHANT or CLIENT), which the Client chooses per sub-merchant.
refundAutoApprovalCapMinor
The approval cap
A per-merchant threshold: at or under it, refunds go straight out; over it, they queue as PENDING_OPS for staff. A marketplace Client can set this cap on each of its own sub-merchants, mirroring the payout caps on the money-out side.
Section 2
The vocabulary
Nine terms carry this whole feature. Everything else on the page is these objects interacting.
| Term | Lives on | What it means |
|---|---|---|
Refund | Prisma model | One refund request: amount, status, feeBearer, clientFeeBearer, splitsReversed. Several can exist per payment. |
| Remaining amount | RefundsService.create | (capturedMinor || amountMinor) − refundedMinor − in-flight, where in-flight sums refunds still in PENDING, PENDING_OPS or APPROVED. A request over this is refused. |
refundAutoApprovalCapMinor | Merchant | Auto-approval threshold. At or under: status PENDING and immediate submission. Over: PENDING_OPS and an ops notification. |
RefundFeeBearer | Merchant.refundFeeBearer → Refund.feeBearer | MERCHANT (default): the store funds the whole refund and Ummah keeps its fee. PLATFORM: the liable account returns the pro-rata Ummah fee. API callers may pass it per refund; portal callers cannot. |
ClientRefundFeeBearer | Merchant.clientRefundFeeBearer → Refund.clientFeeBearer | Marketplace axis, chosen by the Client per sub-merchant: SUBMERCHANT (the sub's store funds the client-cut share) or CLIENT (the Client's balance account returns its pro-rata cut). Snapshotted onto the refund at submit. |
splitsApplied | Transaction | The payment-time split mirror — the input to every reversal. Written by the checkout flow in one of two leg dialects (see Section 4); economics explained in Pricings, Splits & Fees. |
splitsReversed | Refund | The exact split legs sent to Adyen for this refund — the audit snapshot that platform earnings later subtracts from collected commission. |
refundedMinor | Transaction | Running total of completed refunds. When it reaches the payment total, the transaction's status becomes REFUNDED. |
FeeCharge · event REFUND | Worker fee engine | An optional per-refund service fee (separate from the bearer logic), booked as internal transfers when the refund completes. No fee-profile row means explicitly free. |
Section 3
Creating a refund & the approval gate
Three surfaces feed one service. POST /v1/refunds (sk-key API), POST /api/portal/refunds (portal JWT) and the back-office's createByAdmin all land in RefundsService.create — but they are not equals.
The remaining-amount maths
Before anything is written, the service computes what is genuinely still refundable: remaining = (capturedMinor || amountMinor) − refundedMinor − in-flight. "In-flight" counts every existing refund on the payment still in PENDING, PENDING_OPS or APPROVED — so two racing refund requests cannot together exceed the captured amount, even before either reaches Adyen. Partially captured payments (see Captures & Holds) refund against the captured figure, not the authorised one.
Who chooses the fee bearer
At create time the bearer resolves as input ?? Merchant.refundFeeBearer ?? MERCHANT — and portal callers cannot pass it at all. Only the sk-key API and staff can deviate from the merchant's configured default, which keeps a portal operator from quietly shifting refund costs onto the platform.
The cap gate
- At or under
Merchant.refundAutoApprovalCapMinor— the refund is created asPENDINGand submitted to Adyen immediately. No human touches it. - Over the cap — the refund parks as
PENDING_OPS: the merchant gets arefund.pending_opsoutbound webhook, ops get arefund.pending_approvalemail, and the row waits. - Staff decision —
RefundsService.approvemoves it toAPPROVEDand submits; approval is also the one moment staff may override the fee bearer (for instance making a goodwill refund PLATFORM-borne). Reject terminates the refund with no money movement. - Admin creation —
createByAdminskips the gate and lands straight atAPPROVED: the back-office does not queue for its own approval.
Per-sub refund caps
A marketplace Client governs its sub-merchants' gate: PUT /api/portal/refunds/submerchants/:subId/cap lets the Client set a sub's refundAutoApprovalCapMinor (parent-of check enforced), mirroring the payout caps at PUT /api/portal/payouts/submerchants/:subId/cap. Admin can edit any merchant's cap under the merchant-settings surface.
ummah-platform-backend/src/modules/refunds/refunds.service.ts:104-157 · ummah-platform-backend/src/modules/refunds/refunds.controller.ts:17-37 · ummah-platform-backend/prisma/schema.prisma:185, 197-206, 358, 1236
Section 4
Bearer-aware split reversal
Once a refund is approved, submitToAdyen turns the payment's recorded split into a mirror-image funding plan. The shopper's side is fixed — Adyen returns the full refund amount to the customer. The splits only decide which balance accounts that money is drawn from.
The sequence: resolveClientFeeBearer snapshots the sub-merchant's Merchant.clientRefundFeeBearer onto Refund.clientFeeBearer; then AdyenSplitService.buildBearerRefundSplits reads Transaction.splitsApplied, finds the payee leg, separates Ummah's Commission legs from the client-fee legs, and constructs funding legs according to the two bearers. The refund goes to Adyen with those explicit splits[] and idempotency key refund-<refundId>, and the legs are saved to Refund.splitsReversed.
Why not let Adyen split the refund proportionally by default?
Because Adyen's default reuses the authorisation's split ratio — which would pull the refund pro-rata from every beneficiary, clawing back Ummah's commission and the Client's cut on each refund regardless of policy. Ummah deliberately never relies on that default: explicit legs are always sent, and the bearer configuration decides whether the fee portions come back or stay put. This applies even to split-profile stores, which send no splits at payment time — refunds always carry them.
Refund split-reversal, end to end
FIG 2 · 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 C as Merchant caller
participant R as RefundsService
participant S as AdyenSplitService
participant A as Adyen
participant W as Worker
C->>R: POST /v1/refunds
R->>R: remaining check + cap gate
Note over R: PENDING, or PENDING_OPS then staff approve
R->>R: resolveClientFeeBearer snapshot
R->>S: buildBearerRefundSplits from splitsApplied
S-->>R: payee leg + Commission leg + client leg
R->>A: POST refund with explicit splits
Note over R,A: idempotency key refund-refundId
R->>R: record Refund.splitsReversed
A-->>W: REFUND webhook, async
W->>W: settleRefund, status COMPLETED
W->>W: Transaction.refundedMinor += amount
Note over W: fully refunded means status REFUNDED
W-->>C: refund.completed outbound webhook
refundedMinor — the same figure the next refund's remaining-amount check reads.Two dialects, one parser
splitsApplied comes in two leg vocabularies depending on how the payment was recorded: profile stores carry 'seller-split' / 'ummah-fixed' / 'ummah-variable' / 'client-fixed' / 'client-variable'; explicit-splits payments carry 'sub-net' / 'client-fee' / 'ummah-fee'. buildBearerRefundSplits matches both reference names to find the payee and fee legs. If it finds no payee leg at all — a legacy pre-split transaction — the refund is submitted without splits, logged, and Adyen's proportional default funds it: exactly the claw-back the explicit legs exist to avoid.
splitsApplied being present and in a recognised dialect: legacy unsplit rows fall back silently to proportional funding, and a future third dialect would do the same. Both are catalogued in Section 8.ummah-platform-backend/src/modules/refunds/refunds.service.ts:239-293 · ummah-platform-backend/src/core/adyen/adyen-split.service.ts:375-463 · ummah-platform-backend/prisma/schema.prisma:1250-1255
Section 5
The bearer matrix
Two independent axes, four combinations. The first axis is Ummah's fee; the second exists only on marketplace payments and belongs to the Client. Direct merchants have no client leg, so only the first axis applies to them.
Who funds a refund — the two bearer axes
FIG 3 · bearer matrix
%%{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
REF["Refund of amount R
customer always receives R in full"]:::plain
FB{"feeBearer
Ummah fee axis"}:::warn
MER["MERCHANT · default
store BA funds all of R
Ummah keeps its fee"]:::sub
PLAT["PLATFORM
store BA funds the net
liable BA returns the pro-rata fee
as a Commission leg"]:::liable
CFB{"clientFeeBearer
marketplace payments only"}:::warn
CSUB["SUBMERCHANT · default
sub store BA funds
the client-cut share"]:::sub
CCL["CLIENT
Client BA returns
its pro-rata cut"]:::client
REF --> FB
FB -->|"MERCHANT"| MER
FB -->|"PLATFORM"| PLAT
REF --> CFB
CFB -->|"SUBMERCHANT"| CSUB
CFB -->|"CLIENT"| CCL
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;
floor((feeAtPay × refundAmount) / totalMinor), clamped, from the legs actually recorded at pay time.£40 partial refund of a £100 marketplace payment
Recorded at pay time (Ummah at the Tier-1 headline 1.15% + £0.15; Client rate illustrative at 1.85% + £0.10): Ummah fee £1.30, Client cut £1.95, sub-merchant net £96.75. Pro-rata shares for a £40.00 refund: Ummah £0.52, Client £0.78. The customer receives £40.00 in every row.
| feeBearer × clientFeeBearer | Sub store BA funds | Liable BA returns | Client BA returns | Net effect on fees |
|---|---|---|---|---|
| MERCHANT × SUBMERCHANT (defaults) | £40.00 | £0.00 | £0.00 | Sub eats everything; Ummah and Client keep their full cuts. |
| MERCHANT × CLIENT | £39.22 | £0.00 | £0.78 | Client hands back its share; Ummah keeps its fee. |
| PLATFORM × SUBMERCHANT | £39.48 | £0.52 | £0.00 | Ummah hands back its share; Client keeps its cut. |
| PLATFORM × CLIENT | £38.70 | £0.52 | £0.78 | Both fee-takers refund their shares pro-rata. |
The PLATFORM-bearer leg is a Commission split on the refund — Adyen deducts Commission-on-refund from the liable account, which is precisely how the money physically leaves Ummah. The CLIENT-bearer leg is funded from the Client's balance account. Where these accounts sit in the split architecture, and why Commission legs auto-book to the liable account, is covered in Pricings, Splits & Fees.
ummah-platform-backend/src/core/adyen/adyen-split.service.ts:375-439 · ummah-platform-backend/src/modules/refunds/refunds.service.ts:247, 259 · ummah-platform-backend/prisma/schema.prisma:197-206
Section 6
Settlement, webhooks & the refund fee
Submission is a promise; the webhook is the truth. The worker's webhook-in.adyen queue consumes Adyen's REFUND and REFUND_FAILED notifications and drives every terminal effect from there.
Webhook-driven settlement and the optional REFUND fee
FIG 4 · settlement
%%{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
SUB["Refund submitted
splitsReversed recorded"]:::ummah
ADY["Adyen processes
the refund"]:::adyen
OK["settleRefund
status COMPLETED"]:::sub
FAIL["REFUND_FAILED
status FAILED"]:::danger
INC["Transaction.refundedMinor
+= amount"]:::plain
FULL["refundedMinor ≥ total
status REFUNDED"]:::sub
FEE["REFUND FeeCharge
fee-charge queue"]:::ummah
SUB --> ADY
ADY -.->|"REFUND webhook"| OK
ADY -.->|"REFUND_FAILED webhook"| FAIL
OK --> INC
INC --> FULL
OK --> FEE
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;
- Status settles —
settleRefundmoves the row to COMPLETED or FAILED, andTransaction.refundedMinoris incremented by the refunded amount; when it covers the full payment, the transaction's own status flips toREFUNDED. Merchants hear about it via the signed outbound webhooksrefund.completed/refund.failedand matching emails. - The optional REFUND FeeCharge — a COMPLETED refund triggers
FeeTriggerService.createChargewith the idempotentsourceRefrefund-<refundId>. If the merchant'sFeeProfilehas a REFUND row,FeeChargeProcessorbooks up to two internal Adyen transfers — platform fee to the liable account, and (for sub-merchants) a client fee to the parent Client's account — as a resumable two-phase job with per-leg idempotency keysfee-<chargeId>-p/-cand 31 attempts of exponential backoff. - Earnings stay honest —
PlatformEarningsServicesubtracts the Commission legs recorded inRefund.splitsReversedon COMPLETED PLATFORM-bearer refunds from collected commission, so the admin earnings panel reflects fees genuinely handed back. The wider earnings pipeline (and its known overcount) belongs to Pricings, Splits & Fees and Settlement & Reconciliation. - The safety net — the daily 02:00 UTC reconciliation run includes a refunds-stuck check, so a refund that never receives its webhook surfaces in the ops panel rather than lingering silently. Adyen's
REFUNDED_REVERSEDevent code is also ingested by the same worker dispatcher.
settleRefund is a guarded transition, and the fee booking is uniquely keyed per refund. Chargebacks — the other way money goes back — run on an entirely separate pipeline, covered in Disputes & Recovery.ummah-platform-worker/src/modules/webhooks-in/adyen-webhook.service.ts:387-391, 457-492 · ummah-platform-worker/src/modules/fees/fee-trigger.service.ts:36-113 · ummah-platform-worker/src/workers/fee-charge.processor.ts:20-80 · ummah-platform-backend/src/modules/balances/platform-earnings.service.ts:117-132
Section 7
Implementation notes
The complete surface area of the feature, with guards, and the columns that carry the state.
| Surface | Method · guard | Purpose |
|---|---|---|
POST /v1/refunds | sk key · ApiKeyGuard | Create a refund; may pass feeBearer per request. |
POST /api/portal/refunds | Portal JWT · MERCHANT scope | Create a refund; cannot set feeBearer (merchant default applies). |
Back-office create (createByAdmin) | STAFF scope | Creates the refund directly at APPROVED — no cap gate. |
Staff approve / reject (RefundsService.approve) | STAFF scope · PENDING_OPS rows only | Approve submits (and may override feeBearer); reject terminates. |
PUT /api/portal/refunds/submerchants/:subId/cap | Portal JWT · parent Client only | Client sets a sub-merchant's refundAutoApprovalCapMinor. |
REFUND · REFUND_FAILED · REFUNDED_REVERSED | Worker · queue webhook-in.adyen | Settle refund status; increment refundedMinor; trigger the REFUND FeeCharge. |
refund.pending_ops · refund.completed · refund.failed | Outbound · signed X-Ummah-Signature | Merchant webhooks at queue-for-review and terminal states (sub-merchant events deliver to the parent Client's endpoints). |
Data model
Refund—status(including the PENDING_OPS review stage),feeBearer,clientFeeBearer(snapshotted at submit), andsplitsReversed: the exactAdyenSplit[]legs sent on the refund.Merchant—refundFeeBearer(default MERCHANT),clientRefundFeeBearer(per-sub, Client-governed),refundAutoApprovalCapMinor.Transaction—refundedMinorrunning total;splitsAppliedas the reversal's input, in either the profile-store or explicit-splits leg dialect.- Idempotency — Adyen refund submission keys on
refund-<refundId>; the fee booking keys onsourceRef refund-<refundId>and per-legfee-<chargeId>-p/-c.
ummah-platform-backend/src/modules/refunds/refunds.service.ts · ummah-platform-backend/src/modules/refunds/refunds.controller.ts:17-37 · ummah-platform-backend/prisma/schema.prisma:185, 197-206, 358, 1236, 1250-1255 · ummah-platform-worker/src/modules/webhooks-in/adyen-webhook.service.ts:387-391
Section 8
Gaps & recommendations
Three verified defects, in priority order. All three live in the reversal path — the approval gate itself is clean.
Partial-capture pro-rata under-scaling
Bearer scaling passes totalMinor: txn.amountMinor (refunds.service.ts:259) — the original authorised amount — even when the payment was partially captured and splitsApplied was re-recorded for the captured amount. The denominator is too large, so PLATFORM- and CLIENT-bearer refunds of partially captured payments return too little fee: the merchant silently over-funds while Ummah or the Client keeps a slice they should hand back. Fix: use capturedMinor ?? amountMinor as the scaling base — the same figure the remaining-amount maths already uses.
Legacy unsplit transactions submit without splits
When buildBearerRefundSplits finds no payee leg in splitsApplied — legacy pre-split payments, or rows that missed both the payment-time write and the capture re-record — the refund goes to Adyen with no splits[], only a log line. Adyen then funds it proportionally against the auth ratio, clawing back Ummah's and the Client's cuts: the precise outcome the explicit-splits rule exists to prevent. There is no splitsApplied backfill job, so these rows can never heal. Fix: route split-less refunds to PENDING_OPS instead of auto-submitting, and add a one-off backfill for rows with reconcilable Adyen legs.
Two split-leg dialects parsed by reference string
The reversal identifies legs by matching both naming dialects ('sub-net'-family and 'seller-split'-family) on the free-text reference field. Any future producer that writes a third vocabulary breaks refund funding silently — the builder returns no legs and the refund falls through to Adyen's proportional default, with no error raised. Fix: add a structured role field to recorded legs (or one shared classification helper used by every consumer) so an unrecognised dialect fails loudly at submit time rather than leaking money quietly.