UMMAH FLOWS · 08 Verified against code · Aug 2026

Dispute machine · defence & loss recovery

Disputes & Recovery

What happens when a cardholder disputes a payment on Ummah: the full chargeback webhook family lands in a forward-only intake machine, the disputed amount is earmarked locally while the case is open, the merchant defends with an S3 dossier submitted to Adyen by a worker, and on a loss a five-tranche recovery waterfall claws the money back to the platform's liable balance account — reserve first, absorption last. The machine is idempotent end to end, but the earmark it holds is bookkeeping only — and this page names exactly where that bites.

Section 1

The dispute machine in one view

A dispute is the card issuer pulling money back through the scheme — unlike a refund, the merchant does not choose it. Adyen relays every stage of that fight as a family of classic webhooks; Ummah folds them all into one Dispute row per payment and drives everything else — earmarks, defence, fees, recovery — off that row's forward-only status.

Intake → earmark → defence → outcome → recovery

FIG 1 · the 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
  WH["Adyen dispute webhooks
CHARGEBACK family"]:::adyen --> IN["DisputesIntakeService.ingest
forward-only, one Dispute per payment"]:::ummah IN --> EAR["Local earmark
disputeReservedMinor held"]:::warn IN -.->|"on CHARGEBACK"| FEE["Chargeback FeeCharge
books win or lose"]:::warn EAR --> DEF["Defence
dossier upload + submit"]:::ummah DEF --> WON["WON
earmark released, Transaction SETTLED"]:::sub DEF --> LOST["LOST or SECOND_CHARGEBACK
earmark released"]:::danger LOST --> REC["Recovery waterfall
QUEUE_RECOVERY_TRANSFER"]:::ummah REC --> LIA["Platform liable BA
recovered or absorbed"]:::liable 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;
Adyen / external rails Ummah-owned processing pending / caution good outcome loss Ummah's money (liable BA)
Two things happen at intake, not at outcome: the local earmark is placed while the case is still open, and the chargeback fee books the moment the actual CHARGEBACK arrives — because that is when Adyen pulls the funds and bills the platform its fee, win or lose.

Dispute

One row per disputed payment

Anchored on the payment's pspReference (unique paymentPspReference), because dispute events carry the dispute's own pspReference, not the payment's. Its balanceAccountId is the paid store's balance account, falling back to the merchant's main BA — the account that will answer first when money must move.

RecoveryEvent

The waterfall's ledger

One per dispute, created when the loss lands and updated tranche by tranche: fromReserve, fromStore, fromSiblings, fromClient, absorbedMinor, finishing as RECOVERED or WRITTEN_OFF. The recovery and platform-pnl reports read this ledger.

Reserve & earmark

Two different holds

The rolling Reserve (heldMinor, accrued at settlement) pre-funds tranche one of the waterfall. The per-dispute earmark (BalanceSnapshot.disputeReservedMinor) merely flags the disputed amount while the case is undecided. Neither moves money at Adyen.

Section 2

The vocabulary

Every term below is a real identifier from the platform code. The economics of the split legs these mechanisms defend — commission, client cut, seller net — are covered on Pricings, Splits & Fees and are not re-explained here.

Dispute-machine terms
TermWhat it is
DisputeThe single row per disputed payment: status, disputed amount, balanceAccountId (store BA, else merchant BA), reserveHeldMinor, final outcome.
applyDisputeEventThe forward-only transition function inside intake. A redelivered or out-of-order webhook that would move the dispute backwards is a no-op.
DisputesIntakeService.ingestWorker-side intake, running inside the Adyen webhook consumer. Resolves by the dispute's pspReference first, then by payment psp/merchantReference.
BalanceSnapshot.disputeReservedMinorThe local earmark counter on a balance-account snapshot — incremented when a dispute opens, decremented exactly once on resolution. Local only; no Adyen hold exists.
Dispute.reserveHeldMinorThe idempotency record of that earmark: how much this dispute is currently holding, so hold and release each happen exactly once.
ReserveThe rolling reserve row per merchant: heldMinor plus configuration rollingPercentBps, hold-days window and minimum hold. Accrues at settlement, re-targets daily.
RecoveryEventThe per-dispute recovery ledger: tranche amounts by source plus absorbedMinor, status RECOVERED or WRITTEN_OFF.
Ring-fenced storeA store whose funds are never drawn to cover a sibling's dispute (and which refuses to be an internal-transfer source). It still answers for its own disputes.
Liable BAThe platform's own balance account (ADYEN_LIABLE_BALANCE_ACCOUNT_ID). Chargeback losses land here first at Adyen; the waterfall refills it; absorbed losses stay on it.
FeeChargeThe booked chargeback fee: a FeeProfileRow with event CHARGEBACK, snapshotted once per dispute under unique sourceRef chargeback-<disputeId>.
DossierThe S3-backed set of defence documents uploaded per dispute, replayed to Adyen file by file when the defence is submitted.

Section 3

The webhook family & the intake state machine

Adyen ummah-platform-webhook worker · DisputesIntakeService queue webhook-in.adyen

Every dispute event arrives as a classic Adyen notification on the single ingress endpoint POST /webhooks/adyen: HMAC-verified per item, persisted as an AdyenWebhookEvent with dedupe key eventCode:pspReference:success, and enqueued to BullMQ queue webhook-in.adyen with jobId = sha256(dedupeKey) so redeliveries collapse. The worker re-checks processedAt before dispatching — the whole pipe is at-least-once with idempotent handlers.

The dispatcher first runs handlePayment, marking the Transaction DISPUTED (a win or reversal later restores SETTLED), then hands the event to DisputesIntakeService.ingest. Note the sibling events that look similar but belong elsewhere: REFUND, REFUND_FAILED and REFUNDED_REVERSED are the merchant-initiated flow, covered in Refunds & Approvals.

The dispute eventCode family, as consumed
Event codeStageLocal effect
NOTIFICATION_OF_CHARGEBACKAdvance warningDispute opened/updated; earmark held; merchant webhook dispute.opened + email.
CHARGEBACKFunds pulledTransaction DISPUTED; earmark held if not already; chargeback FeeCharge books now (sourceRef chargeback-<disputeId>), win or lose.
REQUEST_FOR_INFORMATIONIssuer asks (RFI)Dispute needs evidence; dispute.evidence_required webhook + email.
INFORMATION_SUPPLIEDEvidence acknowledgedAdyen confirms the supplied information is registered on the case.
CHARGEBACK_REVERSEDFirst-round winDispute WON; earmark released; Transaction restored to SETTLED; dispute.won.
SECOND_CHARGEBACKIssuer disputes againTreated as a loss: earmark released, recovery waterfall enqueued; the one-per-dispute fee guard means no second fee.
PREARBITRATION_WON / PREARBITRATION_LOSTPre-arbitrationOutcome settles to WON (release + restore SETTLED) or LOST (recovery).
SCHEME_ARBITRATION_WON / SCHEME_ARBITRATION_LOSTScheme arbitrationFinal scheme ruling — same win/lose handling as pre-arbitration.
ISSUER_RESPONSE_TIMEFRAME_EXPIREDDeadlineThe issuer's window closed without response — the dispute settles as an outcome event.
DISPUTE_DEFENSE_PERIOD_ENDEDDeadlineThe defence window closed — an undefended dispute settles as an outcome event.

Dispute status flow — forward only

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
  CB["CHARGEBACK or
NOTIFICATION_OF_CHARGEBACK"]:::adyen --> OPEN["OPEN
earmark held"]:::warn OPEN -->|"REQUEST_FOR_INFORMATION"| AE["AWAITING_EVIDENCE"]:::warn AE -->|"dossier submitted"| SUB["SUBMITTED"]:::ummah OPEN -->|"dossier submitted"| SUB SUB -->|"CHARGEBACK_REVERSED
PREARBITRATION_WON
SCHEME_ARBITRATION_WON"| WON["WON
earmark released"]:::sub SUB -->|"PREARBITRATION_LOST
SCHEME_ARBITRATION_LOST
defence period ends"| LOST["LOST
earmark released"]:::danger WON -->|"SECOND_CHARGEBACK"| SCB["SECOND_CHARGEBACK"]:::danger LOST --> RW["Recovery waterfall
and write-off ledger"]:::liable SCB --> RW 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;
Adyen event undecided, earmark held defended won lost money moves to liable BA
A representative path — the authoritative per-event transition table lives in applyDisputeEvent, which orders statuses forward-only so Adyen redeliveries and late deliveries can never move a dispute backwards. Each named status fans out its dispute.* merchant webhook and email (OPEN, AWAITING_EVIDENCE, WON, LOST, SECOND_CHARGEBACK).

Resolution order inside ingest matters: the service looks up an existing Dispute by the dispute's own pspReference first, then falls back to the payment's psp or merchantReference — which is how the second and later events of a case find the same row the first event created. Sub-merchant dispute events deliver to the parent Client's webhook endpoints, like all sub-merchant events.

Verdict · soundIntake is redelivery-proof by construction: ingress dedupe, BullMQ jobId collapse, a processedAt re-check, one row per payment via the unique paymentPspReference, and forward-only transitions. Replaying the entire webhook stream produces the same Dispute.

ummah-platform-worker/src/modules/disputes/disputes-intake.service.ts:66-343 · ummah-platform-worker/src/modules/webhooks-in/adyen-webhook.service.ts:180-194 · ummah-platform-webhook/src/webhooks/ingress.service.ts:50-107 · ummah-platform-worker/src/workers/webhook-in-adyen.processor.ts:56-62 · ummah-platform-backend/prisma/schema.prisma:1300-1329

Section 4

The local earmark & rolling reserves

worker · intake + reconciliation Ummah ops (reserve config) BalanceSnapshot · Reserve

The earmark. While a dispute is undecided, the disputed amount is held in bookkeeping: BalanceSnapshot.disputeReservedMinor on the dispute's balance account is incremented, and Dispute.reserveHeldMinor records how much this dispute holds. The pair is an idempotent hold/release — hold happens once no matter how many events arrive, and release happens exactly once at resolution (won or lost). Crucially this is local only: no Adyen-side hold is placed, no money moves, and the funds remain spendable at Adyen.

The rolling reserve. Separately, staff can configure a rolling reserve per merchant at /api/admin/merchants/:merchantId/reserve: a percentage (rollingPercentBps), a trailing hold-days window, and a minimum hold. It fills at settlement — when a payment's split legs reconcile cleanly and the Transaction promotes to SETTLED, Reserve.heldMinor is atomically incremented by rollingPercentBps of the seller leg (the store's net — see Pricings, Splits & Fees for how that leg is computed). The daily 02:00 UTC reconciliation run then re-targets it: heldMinor := max(minHold, rollingPercentBps of trailing hold-days settled seller net), so the reserve tracks recent volume rather than growing forever. Two caveats verified in code: FX-converted settlements skip accrual entirely (the reserve is single-denomination), and the reserved funds physically sit in the store's balance account — the Reserve row is an accounting slice, which is why the waterfall's first tranche is capped by both heldMinor and the store's live available balance.

Verdict · bitesThe earmark is invisible to money-out. Nothing in PayoutsService.create or the payout worker subtracts disputeReservedMinor from availableMinor, and Adyen-side sweeps execute server-side regardless — so a merchant can pay out or sweep away funds earmarked for an open dispute, leaving the eventual loss to fall on siblings, the Client, or the platform. See Gaps.

ummah-platform-worker/src/modules/disputes/disputes-intake.service.ts:66-343 · ummah-platform-worker/src/modules/webhooks-in/transfer-event.service.ts:246-470 · ummah-platform-worker/src/workers/reconciliation.logic.ts:15-31 · ummah-platform-backend/src/modules/balances/admin-reserves.controller.ts · ummah-platform-backend/prisma/schema.prisma:2290

Section 5

Defending a dispute

Merchant (portal) Ummah staff (admin) v1 · read-only worker · dispute-submit Adyen Disputes API

Three surfaces share one controller file (disputes.controllers.ts): the merchant portal and the back-office get the full defence toolkit — list, detail, GET :id/defense-reasons, evidence upload via POST :id/defence-doc (an S3-backed dossier, each file downloadable by index), and POST :id/submit — while the /v1 API surface is read-only. Submission never calls Adyen inline: it enqueues QUEUE_DISPUTE_SUBMIT and the worker does the talking.

Defence submission — portal to Adyen

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 M as Merchant portal
  participant B as DisputesService
  participant S as S3 dossier
  participant W as dispute-submit worker
  participant A as Adyen Disputes API
  M->>B: GET disputes/:id/defense-reasons
  M->>B: POST disputes/:id/defence-doc
  B->>S: store file in the dossier
  M->>B: POST disputes/:id/submit
  B-->>W: enqueue QUEUE_DISPUTE_SUBMIT
  W->>A: retrieveApplicableDefenseReasons
  loop each dossier file
    W->>S: fetch file, base64 encode
    W->>A: supplyDefenseDocument
  end
  Note over W,A: document types cycle Required, then OneOrMore, then Optional
  W->>A: defendDispute
  alt accepted
    A-->>W: ok
    W-->>M: Dispute SUBMITTED + dispute.submitted webhook
  else Adyen 4xx
    A-->>W: 4xx
    W-->>B: permanent failure + audit row
  end
A 4xx from any Adyen call is treated as permanent — the job is not retried and the failure is audited; only transient errors ride BullMQ backoff. A malformed dossier therefore needs a human to fix and resubmit, which is the safe default for a scheme deadline-driven process.

On acceptance the dispute moves to SUBMITTED and the dispute.submitted merchant webhook fires. The outcome then arrives asynchronously through the webhook family of Section 3 — WON and LOST each fan out their webhook and email.

Verdict · soundClean separation: the backend owns the dossier and the submit intent, the worker owns the Adyen conversation, and permanent-vs-transient failure is decided by HTTP class. The dossier living in S3 means resubmission after a fix loses nothing.

ummah-platform-backend/src/modules/disputes/disputes.controllers.ts:47-246 · ummah-platform-backend/src/modules/disputes/disputes.service.ts:36-120 · ummah-platform-worker/src/workers/dispute-submit.processor.ts:1-17

Section 6

Losing: the recovery waterfall

worker · RecoveryTransferProcessor QUEUE_RECOVERY_TRANSFER Adyen Transfers API · /btl/v4

When Adyen loses a chargeback, it has already debited the platform's liable balance account — the waterfall is Ummah getting that money back from whoever should bear it. A status of LOST or SECOND_CHARGEBACK enqueues QUEUE_RECOVERY_TRANSFER with jobId recovery-<disputeId> (one job, one RecoveryEvent, resumable), releases the earmark, and planRecovery walks five tranches in strict order:

  • RESERVE — capped by both Reserve.heldMinor and the original store BA's live available balance, because the held funds physically sit in the store's account. This tranche also decrements Reserve.heldMinor.
  • STORE — the rest of the original store's available balance.
  • SIBLINGS — the merchant's other stores, richest first. Ring-fenced siblings are excluded (decision L25) — but the original store always answers for its own dispute, ring-fenced or not.
  • CLIENT — the parent Client's balance account, for sub-merchants.
  • ABSORBED — whatever remains is written off against the platform. No transfer happens; absorbedMinor is recorded and admins are notified (recovery.written_off).

Every funded tranche is an idempotent internal Adyen transfer from the source BA to the liable BA: per-BA Redis lock recovery:ba:{id}:lock, reference-as-idempotency-key rcv-<disputeId last12>-<sourceBA last8>, and the result persisted on the RecoveryEvent before the next tranche runs — a crashed job resumes without double-drawing. Tranche sizing always reads live Adyen balances, never snapshots. The final status — RECOVERED or WRITTEN_OFF — is stamped on both the RecoveryEvent and the Dispute. The daily reconciliation run flags recoveries stuck beyond 24 hours.

The waterfall with worked amounts — a £420.00 loss

FIG 4 · money movement
%%{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
  LOSS["Dispute LOST
£420.00 owed to the platform"]:::danger --> T1["1 · RESERVE — £150.00
min of Reserve.heldMinor and store available"]:::sub T1 --> T2["2 · STORE — £80.00
rest of the original store BA"]:::sub T2 --> T3["3 · SIBLINGS — £160.00
richest first"]:::sub T3 --> T4["4 · CLIENT — £20.00
parent Client BA"]:::client T4 --> T5["5 · ABSORBED — £10.00
write-off, no transfer"]:::liable T1 -->|"£150.00"| LIA["Platform liable BA
£410.00 recovered"]:::liable T2 -->|"£80.00"| LIA T3 -->|"£120.00 + £40.00"| LIA T4 -->|"£20.00"| LIA XF["Ring-fenced sibling
excluded from the draw"]:::warn -.- T3 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;
loss trigger merchant / store money Client money Ummah's liable BA ring-fence exclusion
Pricings, Splits & Fees carries a compact sketch of this waterfall in its chargeback scenario; this is the full mechanism. Each transfer edge is one idempotent rcv-… internal transfer, sized from live Adyen balances at execution time and persisted before the next tranche runs.

Worked example, step by step

A sub-merchant's store loses a £420.00 dispute. At execution time: Reserve.heldMinor is £150.00; the store's live available is £230.00; sibling stores hold £120.00 and £40.00 (a third sibling is ring-fenced and excluded); the parent Client's BA holds £20.00.

£420.00 loss — tranche by tranche
StepTrancheCap ruleDrawsStill owed
1RESERVEmin of Reserve.heldMinor (£150.00) and store live available (£230.00); decrements heldMinor£150.00£270.00
2STOREthe store's remaining live available£80.00£190.00
3SIBLINGSrichest first; ring-fenced siblings skipped£160.00£30.00
4CLIENTparent Client BA live available£20.00£10.00
5ABSORBEDunconditional remainder — no transfer£10.00£0.00

The finished RecoveryEvent reads fromReserve 15000 · fromStore 8000 · fromSiblings 16000 · fromClient 2000 · absorbedMinor 1000, status WRITTEN_OFF (any non-zero absorption is a write-off), mirrored onto the Dispute, with the recovery.written_off notification sent to admins.

Verdict · sound but silentThe engineering is right — ordered liability, idempotent tranches, live-balance sizing, incremental persistence. The gap is accounting: an ABSORBED remainder simply stays as a debit on the liable BA with a ledger row and an email — no receivable, no write-off account, no follow-up collection path against the merchant.

ummah-platform-worker/src/modules/disputes/recovery-plan.ts:23-153 · ummah-platform-worker/src/workers/recovery-transfer.processor.ts:80-333 · ummah-platform-worker/src/modules/disputes/disputes-intake.service.ts:171-177 · ummah-platform-worker/src/core/adyen/adyen-transfers.service.ts:39-112

Section 7

The chargeback fee — booked win or lose

worker · FeeTriggerService + FeeChargeProcessor FeeProfileRow · event CHARGEBACK

Dispute fees are driven by the merchant's fee profile: a FeeProfileRow with event CHARGEBACK (the FeeChargeEvent enum also covers TRANSFER, DECLINE and REFUND — the refund fee is covered in Refunds & Approvals; PAYOUT is reserved and disabled, payouts are free). Rates and profile management live on Pricings, Splits & Fees; what matters here is the timing and the plumbing:

  • When: the fee books the moment the actual CHARGEBACK (or SECOND_CHARGEBACK) arrives — the point at which Adyen pulls the funds and bills the platform its own fee — win or lose. The unique sourceRef chargeback-<disputeId> guarantees exactly one fee per dispute.
  • How much: computeFee takes the disputed amount as base: platform fee = floor(base × platformPercentBps / 10000) + platformFixedMinor. For a sub-merchant with a parentClientId, a client fee (clientPercentBps/clientFixedMinor) is added on top.
  • Where: the FeeChargeProcessor books two resumable internal transfers — platform leg from the dispute's BA to the liable BA (reference fee-<chargeId>-p), client leg to the parent Client's BA (fee-<chargeId>-c). Each Adyen transfer id is stamped on the FeeCharge the moment Adyen accepts, so re-runs skip completed legs; empty balances retry with exponential backoff up to 30 attempts, then the charge goes FAILED and is visible in the back-office for retry.
  • Opt-out: no profile or no CHARGEBACK row means the dispute is explicitly free.

Illustration only (rates are per-merchant configuration): with a row of 0 bps + £15.00 fixed, a £420.00 dispute books a £15.00 platform fee; if the payer is a sub-merchant and the row carries a £5.00 client fixed fee, a further £5.00 moves to the parent Client.

Verdict · one-wayBooking win-or-lose deliberately mirrors how Adyen bills the platform — defensible. But there is no reversal path: a dispute later WON keeps its fee, because FeeCharge has no credit mechanism. If the commercial promise is ever "fee only on loss", this needs building, not configuring.

ummah-platform-worker/src/modules/fees/fee-trigger.service.ts:36-113 · ummah-platform-worker/src/modules/fees/fee-resolution.ts:16-46 · ummah-platform-worker/src/workers/fee-charge.processor.ts:63-192 · ummah-platform-worker/src/modules/disputes/disputes-intake.service.ts:179-209 · ummah-platform-backend/prisma/schema.prisma:922-1010

Section 8

Implementation notes

HTTP surfaces
EndpointGuard / scopePurpose
POST /webhooks/adyenHMAC (per-item classic verification)Ingress: verify, persist AdyenWebhookEvent, dedupe, enqueue webhook-in.adyen.
GET /api/portal/disputes · GET /api/portal/disputes/:idJWT · MERCHANT scopeList and detail, including the dossier index (per-file download).
GET /api/portal/disputes/:id/defense-reasonsJWT · MERCHANTApplicable Adyen defence reasons for this dispute.
POST /api/portal/disputes/:id/defence-docJWT · MERCHANTUpload one dossier file to S3.
POST /api/portal/disputes/:id/submitJWT · MERCHANTEnqueue QUEUE_DISPUTE_SUBMIT for the worker to defend at Adyen.
Admin twins of the aboveSTAFF scopeSame verbs from the shared disputes.controllers.ts; back-office screen disputes/[id].
GET /v1/disputessk_ · ApiKeyGuardRead-only listing for merchant servers.
/api/admin/merchants/:merchantId/reserveSTAFFRolling reserve configuration: rollingPercentBps, hold-days window, minimum hold.
Queues, jobs and idempotency keys
Queue / jobConsumerIdempotency
webhook-in.adyenwebhook dispatcher → DisputesIntakeServicejobId = sha256(dedupeKey); processedAt re-check; forward-only applyDisputeEvent.
QUEUE_DISPUTE_SUBMITdispute-submit processorAdyen 4xx permanent + audit; transient errors ride BullMQ backoff.
QUEUE_RECOVERY_TRANSFERRecoveryTransferProcessorjobId recovery-<disputeId>; lock recovery:ba:{id}:lock; transfer refs rcv-<disputeId last12>-<sourceBA last8>; tranches persisted incrementally.
Fee-charge queueFeeChargeProcessorUnique sourceRef chargeback-<disputeId>; transfer refs fee-<chargeId>-p / -c; 30 attempts then FAILED.
QUEUE_RECONCILIATIONReconciliationProcessor (02:00 UTC)Flags recoveries stuck >24h; re-targets Reserve.heldMinor daily.

Data model, in brief

  • Dispute — unique paymentPspReference, own pspReference, balanceAccountId, status, reserveHeldMinor, mirrored recovery outcome.
  • RecoveryEvent — one per dispute: fromReserve / fromStore / fromSiblings / fromClient / absorbedMinor, status RECOVERED | WRITTEN_OFF.
  • FeeCharge — unique sourceRef, adyenPlatformTransferId / adyenClientTransferId stamped as legs complete.
  • Reserve — heldMinor plus rollingPercentBps, hold-days and minimum-hold configuration.
  • BalanceSnapshot.disputeReservedMinor — the per-BA earmark counter.

Events out

Merchant webhooks: payment.disputed, dispute.opened, dispute.evidence_required, dispute.submitted, dispute.won, dispute.lost — sub-merchant events deliver to the parent Client's endpoints. Emails: dispute.opened/evidence_required/won/lost to the merchant, recovery.written_off and reconciliation.alert to admins.

ummah-platform-backend/src/modules/disputes/disputes.controllers.ts:47-246 · ummah-platform-backend/src/modules/webhooks-out/webhook-events.ts:1-162 · ummah-platform-backend/src/core/notifications/notification-templates.ts:238-253 · ummah-platform-backend/prisma/schema.prisma:1300-1329, 922-1010, 2284-2309

Section 9

Gaps & recommendations

Everything below is verified against the code as of August 2026 — these are the places where the machine's guarantees stop short of what the surrounding pages might lead you to assume.

P0

The dispute earmark is not enforced at payout time

BalanceSnapshot.disputeReservedMinor is held faithfully, but nothing in PayoutsService.create's balance check or the payout worker's allAvailable sizing subtracts it — a merchant can pay out funds earmarked for an open dispute, pushing the eventual loss onto siblings, the Client, or the platform. Fix: deduct the earmark from availableMinor in both the create-time soft check and the worker's live-balance resolution.

P1

The earmark is local-only — no Adyen-side hold

No money is held at Adyen while a dispute is open, and native sweeps execute server-side even when the platform is down — disputed funds can leave the balance platform entirely before a loss is recovered. Fix: pause sweeps on balance accounts with open disputes (or place an Adyen-side hold where the API allows), and resume on resolution.

P1

No fee credit when a dispute is later won

The chargeback FeeCharge books on the first CHARGEBACK, win or lose, and FeeCharge has no refund/credit mechanism — a merchant who wins still paid. Deliberate today (it mirrors Adyen's own billing), but invisible as policy. Fix: either document the win-or-lose fee as explicit commercial policy, or add a compensating credit charge (e.g. sourceRef chargeback-<disputeId>-credit) issued on WON.

P1

ABSORBED write-offs land silently on the liable BA

An uncollectable remainder becomes absorbedMinor plus an admin email — no receivable against the merchant, no write-off ledger, and the liable BA simply drains. platform-pnl nets it out, but treasury has no follow-up path. Fix: post write-offs to a dedicated receivable/write-off ledger and surface an absorbed-losses panel (with per-merchant attribution) in platform earnings.

P2

Deadline-event semantics live only in code

ISSUER_RESPONSE_TIMEFRAME_EXPIRED and DISPUTE_DEFENSE_PERIOD_ENDED both settle disputes, in opposite directions, via the transition table in applyDisputeEvent — there is no test or doc asserting which way each falls. Fix: add explicit unit tests over the event-to-status table so a scheme-behaviour change cannot silently flip an outcome.