UMMAH FLOWS · 07 Verified against code · Aug 2026

Refund flows · approval routing & split reversal

Refunds & Approvals

When money goes back, someone has to fund it. This page walks Ummah's refund machinery end to end: the three creation surfaces and the cap that routes large refunds to ops review, the bearer-aware split reversal that decides which balance accounts pay, and the webhooks that settle everything. The invariant throughout: the customer is always made whole — the bearers only decide who eats the fees.

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;
Neutral step Ummah-owned action Pending review Approved state Terminal failure
The admin path skips the cap gate entirely — 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.

TermLives onWhat it means
RefundPrisma modelOne refund request: amount, status, feeBearer, clientFeeBearer, splitsReversed. Several can exist per payment.
Remaining amountRefundsService.create(capturedMinor || amountMinor) − refundedMinor − in-flight, where in-flight sums refunds still in PENDING, PENDING_OPS or APPROVED. A request over this is refused.
refundAutoApprovalCapMinorMerchantAuto-approval threshold. At or under: status PENDING and immediate submission. Over: PENDING_OPS and an ops notification.
RefundFeeBearerMerchant.refundFeeBearer → Refund.feeBearerMERCHANT (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.
ClientRefundFeeBearerMerchant.clientRefundFeeBearer → Refund.clientFeeBearerMarketplace 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.
splitsAppliedTransactionThe 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.
splitsReversedRefundThe exact split legs sent to Adyen for this refund — the audit snapshot that platform earnings later subtracts from collected commission.
refundedMinorTransactionRunning total of completed refunds. When it reaches the payment total, the transaction's status becomes REFUNDED.
FeeCharge · event REFUNDWorker fee engineAn 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

Merchant server · sk key Portal user Ummah ops staff Marketplace Client

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 as PENDING and submitted to Adyen immediately. No human touches it.
  • Over the cap — the refund parks as PENDING_OPS: the merchant gets a refund.pending_ops outbound webhook, ops get a refund.pending_approval email, and the row waits.
  • Staff decision — RefundsService.approve moves it to APPROVED and 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 — createByAdmin skips the gate and lands straight at APPROVED: 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.

SolidThe gate is race-safe (in-flight refunds count against remaining), the portal cannot self-serve a bearer change, and staff actions are guarded status transitions. The only sharp edge is deliberate: admin-created refunds bypass review entirely, so back-office permissions are the real control there.

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

RefundsService AdyenSplitService Adyen Worker · webhook-in.adyen

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
The dotted arrows are asynchronous: submission success only means Adyen accepted the request. Truth arrives on the REFUND / REFUND_FAILED webhook, which is also what increments 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.

WatchThe reversal is only as good as its input. It hinges on 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;
Merchant / sub-merchant money Ummah's liable account Marketplace Client's account Configuration decision
The axes compose independently — a PLATFORM-bearer refund can still leave the Client's cut untouched, and a CLIENT-bearer sub can still eat Ummah's fee. Each pro-rata portion scales as floor((feeAtPay × refundAmount) / totalMinor), clamped, from the legs actually recorded at pay time.
WORKED EXAMPLE

£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.

Funding of the £40.00, by bearer combination
feeBearer × clientFeeBearerSub store BA fundsLiable BA returnsClient BA returnsNet effect on fees
MERCHANT × SUBMERCHANT (defaults)£40.00£0.00£0.00Sub eats everything; Ummah and Client keep their full cuts.
MERCHANT × CLIENT£39.22£0.00£0.78Client hands back its share; Ummah keeps its fee.
PLATFORM × SUBMERCHANT£39.48£0.52£0.00Ummah hands back its share; Client keeps its cut.
PLATFORM × CLIENT£38.70£0.52£0.78Both 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.

One defectThe pro-rata denominator is wrong for partially captured payments: scaling divides by the original authorised amount rather than the captured amount the splits were re-recorded against, so PLATFORM- and CLIENT-bearer reversals under-return fees on partial captures. Detailed with a fix in Section 8.

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

Adyen webhooks Worker · webhook-in.adyen Worker · fee-charge queue

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;
Ummah-owned step Adyen rails Success state Failure state
The fee branch is optional twice over: it fires only on COMPLETED refunds, and only for merchants whose fee profile carries a REFUND row — no row means explicitly free. It is entirely separate money from the bearer legs above.
  • Status settles — settleRefund moves the row to COMPLETED or FAILED, and Transaction.refundedMinor is incremented by the refunded amount; when it covers the full payment, the transaction's own status flips to REFUNDED. Merchants hear about it via the signed outbound webhooks refund.completed / refund.failed and matching emails.
  • The optional REFUND FeeCharge — a COMPLETED refund triggers FeeTriggerService.createCharge with the idempotent sourceRef refund-<refundId>. If the merchant's FeeProfile has a REFUND row, FeeChargeProcessor books 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 keys fee-<chargeId>-p / -c and 31 attempts of exponential backoff.
  • Earnings stay honest — PlatformEarningsService subtracts the Commission legs recorded in Refund.splitsReversed on 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_REVERSED event code is also ingested by the same worker dispatcher.
SolidSettlement is webhook-driven and idempotent end to end: the ingress dedupes on event identity, 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.

Endpoints & events
SurfaceMethod · guardPurpose
POST /v1/refundssk key · ApiKeyGuardCreate a refund; may pass feeBearer per request.
POST /api/portal/refundsPortal JWT · MERCHANT scopeCreate a refund; cannot set feeBearer (merchant default applies).
Back-office create (createByAdmin)STAFF scopeCreates the refund directly at APPROVED — no cap gate.
Staff approve / reject (RefundsService.approve)STAFF scope · PENDING_OPS rows onlyApprove submits (and may override feeBearer); reject terminates.
PUT /api/portal/refunds/submerchants/:subId/capPortal JWT · parent Client onlyClient sets a sub-merchant's refundAutoApprovalCapMinor.
REFUND · REFUND_FAILED · REFUNDED_REVERSEDWorker · queue webhook-in.adyenSettle refund status; increment refundedMinor; trigger the REFUND FeeCharge.
refund.pending_ops · refund.completed · refund.failedOutbound · signed X-Ummah-SignatureMerchant 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), and splitsReversed: the exact AdyenSplit[] legs sent on the refund.
  • Merchant — refundFeeBearer (default MERCHANT), clientRefundFeeBearer (per-sub, Client-governed), refundAutoApprovalCapMinor.
  • Transaction — refundedMinor running total; splitsApplied as 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 on sourceRef refund-<refundId> and per-leg fee-<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.

P0

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.

P1

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.

P2

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.