Reverse payout

Reverse an in-flight payout; refuses one that has already disbursed USD.

Source src/operations/reversePayout.ts#L60 · reversePayoutsrc/contract.ts#L179 · kind 'reversePayout'src/operations/registry.ts#L55 · REGISTRY

Reverse payout

reverse-payout undoes an in-flight payout by hand. It does two things in one Transaction: it marks the payout's Saga as FAILED, and it returns the reserved credits to the seller's earned account.

It is the manual version of the undo the background payout worker does automatically when it gives up on a payout. Use it to pull a payout back before any USD has left trust.

You name the payout by its sagaId and the seller by userId. You must also record a reason:

let outcome = await economy.submit({
  kind: "reversePayout",
  idempotencyKey: "idem_0",
  actor: { kind: "operator", operatorId: "op_1" },
  userId: "usr_seller",
  sagaId: "pay_1",
  reason: "fraud hold",
});
// → { status: "committed", transaction: { … } }
// The reserve returned to usr_seller's earned account; the saga is now FAILED.

Parameters

The payload fields, beyond the kind tag, are:

FieldTypeDefaultDescription
idempotencyKeystring— (required)A retried submit with the same key runs at most once. See idempotency.
actorActor— (required)Who is asking. Must be operator or system.
userIdstring— (required)The seller whose payout this is. Names the account the engine locks.
sagaIdstring— (required)The payout to reverse, of the form pay_<uuid>.
reasonstring— (required)An audit note for the reversal. Must be non-empty after trimming whitespace.

The operation names no account directly. The seller's earned account and the PAYOUT_RESERVE account both come off the loaded Saga inside the handler.

Returns

It returns an Outcome:

  • committed — the saga moved to FAILED and the undo posting committed.
  • duplicate — there was nothing left to undo (see preconditions below); no posting was made.

A reversePayout never returns rejected. Its refusals are thrown faults, not declined outcomes — see reason codes.

Postings

On commit, the undo posts one balanced transaction that mirrors the original reservation. It moves the full reserved amount out of PAYOUT_RESERVE and back into the seller's earned account:

LegAccountAmount
DebitPAYOUT_RESERVEsaga.reserve
Creditearned(saga.userId)saga.reserve

Both legs are in CREDIT and sum to zero, so the books stay balanced.

The FAILED state change and this posting commit in the same transaction. So the credits return only if the saga also stops — the two can never come apart.

Authorization

reverse-payout is restricted to a privileged Actor: an operator running a manual correction, or a trusted system service. An end user can never reverse a payout, even their own. A user principal is rejected with UNAUTHORIZED.

Reason codes

reverse-payout does not return a RejectionCode. Its refusals are thrown faults — genuine "you can't do that" answers, not the declined-but-expected outcomes that reason codes describe. The faults it can throw:

CodeWhen
INVALID_TRANSITIONThe saga is SETTLED (already paid out — undoing would credit the seller twice), or it is SUBMITTED and still within its provider settlement window. Nothing is posted.
MALFORMED_OPERATIONsagaId names no payout, userId does not match the saga's seller, or reason is empty. Nothing is posted.
UNAUTHORIZEDThe actor is an end user rather than operator or system.

Preconditions and invariants

Only a payout with credits still sitting in PAYOUT_RESERVE can be reversed. The handler keys off the saga's state:

  • RESERVED — the reserve is held but not yet handed to the provider. Reversal moves the saga to FAILED and posts the undo.
  • SUBMITTED, aged past config.maxPayoutAgeMs — the provider is presumed never to have paid, so reversal is allowed. This mirrors the worker's own timeout cutoff. The age is measured from updatedAt, set when the payout entered SUBMITTED.
  • SUBMITTED, still within the window — refused with INVALID_TRANSITION. The disbursement is in the provider's hands and may still settle externally; returning the reserve now risks a double-pay.
  • SETTLED — refused with INVALID_TRANSITION. Real USD already left trust; there is no reserve to return.
  • Any other state (REQUESTED, or an already-FAILED saga) — returned as duplicate with no posting. There is nothing left in reserve to give back.

The state change uses the same guarded advance the worker uses. It moves the saga to FAILED only if the saga is still in the state we read.

If a concurrent worker advanced it first, the guard fails. The operation then returns duplicate and posts nothing, so two attempts can never both undo the same payout.

The default for maxPayoutAgeMs is 24 hours, set from the MAX_PAYOUT_AGE_MS environment variable.

See also