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:
| Field | Type | Default | Description |
|---|---|---|---|
idempotencyKey | string | (required) | A retried submit with the same key runs at most once. See idempotency. |
actor | Principal | (required) | Who is asking. Must be operator or system. |
userId | string | (required) | The seller whose payout this is. Names the account the engine locks. |
sagaId | string | (required) | The payout to reverse, of the form pay_<uuid>. |
reason | string | (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 toFAILEDand 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:
| Account | Side | Amount |
|---|---|---|
SYSTEM.PAYOUT_RESERVE | debit | saga.reserve |
earned(saga.userId) | credit | 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.
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.
| Code | When |
|---|---|
SAGA.INVALID_TRANSITION | The 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.MALFORMED | sagaId names no payout, userId does not match the saga’s seller, or reason is empty. Nothing is posted. |
AUTH.UNAUTHORIZED | The 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 toFAILEDand posts the undo.SUBMITTED, aged pastconfig.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 fromupdatedAt, set when the payout enteredSUBMITTED.SUBMITTED, still within the window: refused withSAGA.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 carryingproviderReported— 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 withSAGA.INVALID_TRANSITION. Real USD already left trust; there is no reserve to return.- Any other state (
REQUESTED, or an already-FAILEDsaga): returned asduplicatewith 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.