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;
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.
| Term | What it is |
|---|---|
Dispute | The single row per disputed payment: status, disputed amount, balanceAccountId (store BA, else merchant BA), reserveHeldMinor, final outcome. |
applyDisputeEvent | The forward-only transition function inside intake. A redelivered or out-of-order webhook that would move the dispute backwards is a no-op. |
DisputesIntakeService.ingest | Worker-side intake, running inside the Adyen webhook consumer. Resolves by the dispute's pspReference first, then by payment psp/merchantReference. |
BalanceSnapshot.disputeReservedMinor | The 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.reserveHeldMinor | The idempotency record of that earmark: how much this dispute is currently holding, so hold and release each happen exactly once. |
Reserve | The rolling reserve row per merchant: heldMinor plus configuration rollingPercentBps, hold-days window and minimum hold. Accrues at settlement, re-targets daily. |
RecoveryEvent | The per-dispute recovery ledger: tranche amounts by source plus absorbedMinor, status RECOVERED or WRITTEN_OFF. |
| Ring-fenced store | A 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 BA | The 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. |
FeeCharge | The booked chargeback fee: a FeeProfileRow with event CHARGEBACK, snapshotted once per dispute under unique sourceRef chargeback-<disputeId>. |
| Dossier | The 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
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.
| Event code | Stage | Local effect |
|---|---|---|
NOTIFICATION_OF_CHARGEBACK | Advance warning | Dispute opened/updated; earmark held; merchant webhook dispute.opened + email. |
CHARGEBACK | Funds pulled | Transaction DISPUTED; earmark held if not already; chargeback FeeCharge books now (sourceRef chargeback-<disputeId>), win or lose. |
REQUEST_FOR_INFORMATION | Issuer asks (RFI) | Dispute needs evidence; dispute.evidence_required webhook + email. |
INFORMATION_SUPPLIED | Evidence acknowledged | Adyen confirms the supplied information is registered on the case. |
CHARGEBACK_REVERSED | First-round win | Dispute WON; earmark released; Transaction restored to SETTLED; dispute.won. |
SECOND_CHARGEBACK | Issuer disputes again | Treated as a loss: earmark released, recovery waterfall enqueued; the one-per-dispute fee guard means no second fee. |
PREARBITRATION_WON / PREARBITRATION_LOST | Pre-arbitration | Outcome settles to WON (release + restore SETTLED) or LOST (recovery). |
SCHEME_ARBITRATION_WON / SCHEME_ARBITRATION_LOST | Scheme arbitration | Final scheme ruling — same win/lose handling as pre-arbitration. |
ISSUER_RESPONSE_TIMEFRAME_EXPIRED | Deadline | The issuer's window closed without response — the dispute settles as an outcome event. |
DISPUTE_DEFENSE_PERIOD_ENDED | Deadline | The 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;
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.
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
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.
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
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
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.
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
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.heldMinorand the original store BA's live available balance, because the held funds physically sit in the store's account. This tranche also decrementsReserve.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;
absorbedMinoris 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;
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.
| Step | Tranche | Cap rule | Draws | Still owed |
|---|---|---|---|---|
| 1 | RESERVE | min of Reserve.heldMinor (£150.00) and store live available (£230.00); decrements heldMinor | £150.00 | £270.00 |
| 2 | STORE | the store's remaining live available | £80.00 | £190.00 |
| 3 | SIBLINGS | richest first; ring-fenced siblings skipped | £160.00 | £30.00 |
| 4 | CLIENT | parent Client BA live available | £20.00 | £10.00 |
| 5 | ABSORBED | unconditional 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.
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
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(orSECOND_CHARGEBACK) arrives — the point at which Adyen pulls the funds and bills the platform its own fee — win or lose. The uniquesourceRefchargeback-<disputeId>guarantees exactly one fee per dispute. - How much:
computeFeetakes the disputed amount as base: platform fee =floor(base × platformPercentBps / 10000) + platformFixedMinor. For a sub-merchant with aparentClientId, a client fee (clientPercentBps/clientFixedMinor) is added on top. - Where: the
FeeChargeProcessorbooks two resumable internal transfers — platform leg from the dispute's BA to the liable BA (referencefee-<chargeId>-p), client leg to the parent Client's BA (fee-<chargeId>-c). Each Adyen transfer id is stamped on theFeeChargethe moment Adyen accepts, so re-runs skip completed legs; empty balances retry with exponential backoff up to 30 attempts, then the charge goesFAILEDand is visible in the back-office for retry. - Opt-out: no profile or no
CHARGEBACKrow 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.
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
| Endpoint | Guard / scope | Purpose |
|---|---|---|
POST /webhooks/adyen | HMAC (per-item classic verification) | Ingress: verify, persist AdyenWebhookEvent, dedupe, enqueue webhook-in.adyen. |
GET /api/portal/disputes · GET /api/portal/disputes/:id | JWT · MERCHANT scope | List and detail, including the dossier index (per-file download). |
GET /api/portal/disputes/:id/defense-reasons | JWT · MERCHANT | Applicable Adyen defence reasons for this dispute. |
POST /api/portal/disputes/:id/defence-doc | JWT · MERCHANT | Upload one dossier file to S3. |
POST /api/portal/disputes/:id/submit | JWT · MERCHANT | Enqueue QUEUE_DISPUTE_SUBMIT for the worker to defend at Adyen. |
| Admin twins of the above | STAFF scope | Same verbs from the shared disputes.controllers.ts; back-office screen disputes/[id]. |
GET /v1/disputes | sk_ · ApiKeyGuard | Read-only listing for merchant servers. |
/api/admin/merchants/:merchantId/reserve | STAFF | Rolling reserve configuration: rollingPercentBps, hold-days window, minimum hold. |
| Queue / job | Consumer | Idempotency |
|---|---|---|
webhook-in.adyen | webhook dispatcher → DisputesIntakeService | jobId = sha256(dedupeKey); processedAt re-check; forward-only applyDisputeEvent. |
QUEUE_DISPUTE_SUBMIT | dispute-submit processor | Adyen 4xx permanent + audit; transient errors ride BullMQ backoff. |
QUEUE_RECOVERY_TRANSFER | RecoveryTransferProcessor | jobId recovery-<disputeId>; lock recovery:ba:{id}:lock; transfer refs rcv-<disputeId last12>-<sourceBA last8>; tranches persisted incrementally. |
| Fee-charge queue | FeeChargeProcessor | Unique sourceRef chargeback-<disputeId>; transfer refs fee-<chargeId>-p / -c; 30 attempts then FAILED. |
QUEUE_RECONCILIATION | ReconciliationProcessor (02:00 UTC) | Flags recoveries stuck >24h; re-targets Reserve.heldMinor daily. |
Data model, in brief
Dispute— uniquepaymentPspReference, ownpspReference,balanceAccountId, status,reserveHeldMinor, mirrored recovery outcome.RecoveryEvent— one per dispute:fromReserve/fromStore/fromSiblings/fromClient/absorbedMinor, statusRECOVERED|WRITTEN_OFF.FeeCharge— uniquesourceRef,adyenPlatformTransferId/adyenClientTransferIdstamped as legs complete.Reserve—heldMinorplusrollingPercentBps, 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.
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.
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.
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.
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.
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.