Contents

Reverse payout

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

Source src/operations/reversePayout.ts#L42reversePayoutsrc/contract.ts#L178kind 'reversePayout'src/operations/registry.ts#L70REGISTRY

API reversePayout

reversePayout 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:

const 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: { id: "txn_…", … } }
curl -s https://economy.example/submit \
  -H 'content-type: application/json' \
  -d '{
    "kind": "reversePayout",
    "idempotencyKey": "idem_0",
    "actor": { "kind": "operator", "operatorId": "op_1" },
    "userId": "usr_seller",
    "sagaId": "pay_1",
    "reason": "fraud hold"
  }'

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.
actorPrincipal(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

reversePayout resolves to 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.

reversePayout returns no rejected outcome. A malformed request throws instead; 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:

AccountSideAmount
SYSTEM.PAYOUT_RESERVEdebitsaga.reserve
earned(saga.userId)creditsaga.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.

Authorization

reversePayout is restricted to a privileged Principal: 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 AUTH.UNAUTHORIZED.

Reason codes

reversePayout returns no reason codes: it has no rejected path. It fails only by throwing.

CodeWhen
SAGA.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.
OP.MALFORMEDsagaId names no payout, userId does not match the saga’s seller, or reason is empty. Nothing is posted.
AUTH.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 SAGA.INVALID_TRANSITION. The disbursement is in the provider’s hands and may still settle externally; returning the reserve now risks a double-pay. One exception: an operation carrying providerReported — set only by the payout-failed webhook mapper, never by hand — waives this refusal, because the rail itself has reported it will not settle the payout.
  • SETTLED: refused with SAGA.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 the handler 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 72 hours, set from the MAX_PAYOUT_AGE_MS environment variable.

See also