Contents

Settle payout

The webhook-driven step that settles a submitted payout: the reserve clears to revenue and the gross USD leaves trust.

Source src/operations/settlePayout.ts#L41-L105settlePayoutsrc/operations/settlePayout.ts#L130-L146postSettlementEntriessrc/webhooks.ts#L171-L182toSettlePayoutsrc/worker/payouts.ts

API settlePayout

settlePayout runs the SUBMITTED → SETTLED step of the payout saga. It empties the seller’s reserved credits into platform revenue and moves the matching USD out of trust.

It does not call the rail. The rail has already paid. This operation records that fact and posts the money.

A verified “payout settled” webhook from the rail drives it. The webhook maps to a system-actor operation, named only by the payout’s saga id:

const outcome = await economy.submit({
  kind: 'settlePayout',
  idempotencyKey: '550e8400-e29b-41d4-a716-446655440002',
  actor: { kind: 'system', service: 'webhook:tilia' },
  sagaId: 'pay_9f2c1b',
  providerRef: 'acct_77/ps_8821',
  providerAmount: toAmount('USD', 4_850n),
});
// → { status: "committed", transaction: { id: "txn_…", … } }
curl -s https://economy.example/submit \
  -H 'content-type: application/json' \
  -d '{
    "kind": "settlePayout",
    "idempotencyKey": "550e8400-e29b-41d4-a716-446655440002",
    "actor": { "kind": "system", "service": "webhook:tilia" },
    "sagaId": "pay_9f2c1b",
    "providerRef": "acct_77/ps_8821",
    "providerAmount": "USD:48.50"
  }'

The mapping from webhook to operation lives in toSettlePayout; the background worker’s inbox drain applies the result off the request path.

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. For a webhook-driven settle the key derives from the provider’s event id, so a redelivered settlement applies at most once.
actorPrincipal(required)Who is asking. Must be system or operator; a user is refused.
sagaIdstring(required)The payout to settle. The operation names no account directly. The saga record holds the seller and the reserved amount.
providerRefstring(required)The rail’s own id for the disbursement. Carried on the operation from the inbound webhook for the audit trail.
providerAmountAmount(absent)The USD the provider reported settling, when its callback carries a figure. Recorded for reconciliation, but never used as a posted figure (see Preconditions).

Returns

settlePayout resolves to an Outcome.

  • committed: the success result. Its transaction is the credit-side posting: the primary settle entry that empties the reserve into revenue.
  • duplicate: a redelivered settle. The same idempotency key replays the recorded outcome, and a redelivery under a fresh event id finds the payout already SETTLED and answers duplicate with an empty transaction — an at-least-once rail re-sending a settlement is normal traffic, not a fault. Either way the settlement applies at most once.

settlePayout returns no rejected outcome. A malformed request throws instead; see reason codes.

Postings

A settle posts two balanced entries, one per currency, in a single database transaction (see postSettlementEntries).

Settlement lives on this operation, driven by the provider’s webhook. The background worker only re-drives or force-fails a stuck payout (src/worker/payouts.ts); it never settles one itself.

The credit-side entry empties the reserve into revenue. The seller’s set-aside credits become platform earnings, because the platform now owes the seller real money instead:

AccountSideAmount
SYSTEM.PAYOUT_RESERVEdebitsaga.reserve
SYSTEM.REVENUEcreditsaga.reserve

The USD-side entry records the cash leaving trust. USD_CLEARING mirrors money flowing out of the trust account; crediting TRUST_CASH lowers the real cash the platform holds for users:

AccountSideAmount
SYSTEM.USD_CLEARINGdebitusd
SYSTEM.TRUST_CASHcreditusd

The gross usd is the reserve converted at the payout rate. The rail’s fee and the seller’s net split downstream at the rail; both are recorded as metadata, never as legs.

The same operation also queues an internal economy.payout.settled event in this transaction, so the event is saved if and only if the payout actually settled.

Authorization

Only a system or operator actor may call settlePayout. The kind is listed in RESTRICTED_TO_PRIVILEGED, so a user actor is rejected at the authorization gate before any work runs.

Reason codes

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

A settle is driven by a verified webhook or an operator. So a settle that can’t apply is a mapping or timing error; the engine throws it rather than handing back a normal “no”. The faults it can throw:

CodeWhen
OP.MALFORMEDsagaId names a payout that does not exist. The webhook mapping or operator supplied it, so a missing saga is a caller error.
SAGA.INVALID_TRANSITIONThe saga was never submitted — thrown retryably, since the webhook raced the submit sweep and a retry settles once the sweep submits — or already FAILED and returned its reserve, a hard conflict for an operator. Also thrown retryably when the SUBMITTED → SETTLED compare-and-set loses to a concurrent settle; the retry finds the payout settled and answers duplicate.

Preconditions and invariants

A settle posts only against a payout in the SUBMITTED state: the one state with a disbursement the rail has reported settled. Each other state resolves by what it means: SETTLED answers duplicate (the work is already done), a pre-submit state refuses retryably (the webhook arrived before the sweep submitted), and FAILED — whose reserve was already returned — throws a hard fault for an operator, because a rail claiming a failed payout settled is a real money conflict. No path posts a second settle’s worth of entries.

The SUBMITTED → SETTLED move is a compare-and-set against the saga’s current state. If two settles race the same saga, only the first wins. The loser’s compare-and-set fails, which rolls back its two postings and its queued event, so the seller is never paid twice (see assertAdvanced); its retry finds the payout settled and answers duplicate.

Because the posted amounts come from the reserve and the locked rate, the ledger’s conservation, backing, and no-overdraft invariants hold unchanged after a settle.

See also