Dispute region

A dispute is not a machine of its own. It is the fifth region of the order machine (packages/commerce/orders/order.status-projection.ts), next to order, payment, fulfillment and payout. The region mirrors disputes.status; none means the order has no disputes row yet. Every dispute event is an order event: transitionOrder writes the dispute row in the same transaction as the other status columns, and the history is the from_dispute_status / to_dispute_status pair on order_events, with metadata.disputeId naming the row. There is no separate dispute log and no separate dispute workflow: the order's Restate object runs it.

LegendWaitingCompletedCancelled / failedClosed / inactive

Statuses

StatusMeaning
noneNo disputes row. The only state from which dispute_opened is accepted.
openThe buyer raised the dispute. Awaiting admin triage.
under_reviewAn admin picked the case up. The only state a resolution can leave from.
waiting_buyerThe admin asked the buyer for more.
waiting_sellerThe admin asked the seller for more.
resolved_refund_buyerClosed for the buyer. Terminal.
resolved_release_sellerClosed for the seller. Terminal.
resolved_partial_refundClosed with a split: the buyer keeps the item and part of the money. Terminal.
withdrawn_by_buyerThe buyer took it back. Terminal, seller-favourable.
dismissed_by_adminAn admin closed it without a resolution. Terminal, seller-favourable.

While the region is in open, under_review, waiting_buyer or waiting_seller (ACTIVE_DISPUTE_STATUSES), the order sits at disputed, payout_status = blocked, and the auto-accept timer is fenced by the noActiveDispute guard.

Who moves it

The role rules live on the region, not in a table: every dispute event travels with the role that fires it, and the guards (byAdmin, byBuyerOrAdmin, bySellerOrAdmin, byBuyer, bySeller) read it.

EventFired byFromTo
dispute_openedbuyer, seller or admin (never a webhook or cron)noneopen
dispute_triagedadminopenunder_review
dispute_party_asked { party }adminunder_review, waiting_buyer, waiting_sellerwaiting_buyer or waiting_seller
dispute_party_respondedthe awaited party, or an admin for themwaiting_buyer / waiting_sellerunder_review
dispute_evidence_addedthe awaited partywaiting_buyer / waiting_sellerunder_review (from anyone else it is logged, not a move)
dispute_withdrawnbuyer or adminopen, under_reviewwithdrawn_by_buyer
dispute_dismissedadminopen, under_reviewdismissed_by_admin
dispute_resolved_refundadminunder_reviewresolved_refund_buyer
dispute_resolved_selleradminunder_reviewresolved_release_seller
dispute_resolved_partialadminunder_reviewresolved_partial_refund
dispute_archivedsystem, after the hold periodany closureno move: stamps disputes.closed_at, the status keeps saying which decision was reached

The generated table is DISPUTE_REGION_TRANSITIONS in order.status-transitions.generated.ts.

A closure is fired twice

The five closure events (dispute_withdrawn, dispute_dismissed, dispute_resolved_refund, dispute_resolved_seller, dispute_resolved_partial) each move two things, and they move them on two separate fires of the same event.

  1. The first fire, by the deciding side (an admin, or the buyer withdrawing), moves the dispute region into its closure and nothing else. The settling guards on the order and payout regions read the region as it stands before the transition, so on this fire they do not match.
  2. The second fire, by the saga's settlement actor (role: system) after the settlement claim and the provider call, settles the order and the payout. Each closure is guarded on its own persisted status (RESOLUTION_STATUS_BY_EVENT): a dispute_resolved_refund cannot cancel an order whose dispute was resolved for the seller, and a retried first fire can never be taken for the second.

The money fence

The guards do not move money; packages/commerce/orders/order.claims.ts does, under the order-row lock and before any provider call.

  • fenceDisputeOpen decides whether dispute_opened may create the row: it refuses an open once a settlement claim is standing, so a refused open writes nothing (there is no open_failed status).
  • claimSettlement records which closure is settling (orders.payout_status = releasable for the seller-favourable ones, SELLER_FAVOURABLE_CLOSURES) before the gateway is touched. The second fire of the closure completes the move once the provider confirms.

packages/features/orders/__tests__/settlement-claims.integration.test.ts is the contract for this ordering.

What the order does with each closure

LegendWaitingCompletedCancelled / failedDecision
ClosureOrderPaymentPayout
resolved_refund_buyercancelledrefundedstays blocked (nothing to release)
resolved_release_sellercompletedunchangedreleased
resolved_partial_refundcompletedpartially_refundedreleased (the refunded portion is clawed back in the saga's partial-refund effect)
withdrawn_by_buyercompletedunchangedreleased
dismissed_by_admincompletedunchangedreleased

See Order lifecycle for the other four regions, and the admin /statecharts/orders simulator, whose dispute buttons carry the firing role and offer both fires of each closure.

On this page