Payout lifecycle

From a paid order to money in the seller's bank, and every guard on the way

Money reaches a seller in two moves: the sale credits their held balance, and completion releases it to available. Withdrawing is a third, separate act.

Nothing here is provider-specific. Each market's rail — Stripe in the EU, Pagar.me in BR — is reached through getPaymentGateway, and the words below are ours, not any provider's.

1. Becoming payable

Before a seller can receive anything they need an account with their market's payment provider.

StepWhereWhat happens
1DBRead the seller's profile, address and identity
2DBDoes a seller_payment_accounts row already exist for this market's rail?
3ProviderCreate the provider's seller identity, with the bank details passed straight through
4DBStore the provider's seller id, the normalized status, and safe payout-destination metadata under the user's active seller_accounts row
5DBrecomputeOnboardingState — the next deriveEligibility sees the new row

Full account numbers, branch numbers, holder documents, IBANs and BICs are never persisted. They exist in the transient request to the gateway and in the provider's vault. See Payment account custody.

Selling unlocks before withdrawing

Two different capabilities, and the gap between them is real.

  • Listing needs the payment account to exist and be operational.
  • Withdrawing needs, on top of that, an active payout destination — somewhere the money can actually land.

A newly created account is often pending while the provider runs its checks. That is enough to list, not enough to withdraw. When the provider approves, a normalized seller.updated webhook syncs the status and the next gate check lets the withdrawal through. No token refresh is involved — capabilities are never claims.

2. A paid order credits the held bucket

StepWhereWhat happens
1ProviderThe buyer's payment settles
2Provider → usA webhook arrives at the rail's route under /api/v1/webhooks/
3DBLog it to webhook_logs — the idempotency check
4DBFind the order through the payments row's provider id
5DBAdvance the order machine
6DBcreditBucket({ bucket: "held" }), atomic with a sale_hold ledger entry

The money stays held until the order completes, when the order machine calls releaseHold() to move it held → available.

3. Requesting a withdrawal

The most-guarded flow in the product.

#LayerCheck
1RouteRate limit
2RouteSession
3RouteSchema — an integer amount inside the market's withdrawal bounds
4ServicederiveEligibility(...).canWithdraw over a fresh snapshot
5ServiceThe market's payouts switch (market_payment_providers — the admin kill switch)
6ServiceAmount within the market's configured minimum and maximum
7ServiceThe active seller account has an identity on this market's rail, and it is operational
8ServiceThe provider's reported balance covers the amount
9ServiceThe local available bucket covers it — debitBucket throws inside the transaction otherwise

Then:

StepWhereWhat happens
1DBOne transaction: debitBucket({ bucket: "available" }) and insert the payouts row as pending
2ProviderCreate the transfer
3aDBOn success: store the transfer id, move the payout to processing
3bDBOn failure: compensate — creditBucket with a withdrawal_failed ledger entry, mark the payout failed

There is no "reserved" bucket. A payouts row in pending or processing is the record of money in flight.

4. Settlement

The provider settles, typically in a day or two, and tells us.

Paid — find the payout by its transfer id, write a withdrawal_complete ledger entry (no bucket changes; the debit happened at request time), mark it completed.

Failed — credit the seller back with a withdrawal_failed entry, mark it failed.

Both are idempotent through webhook_logs.

Payout statuses

pending ──→ processing ──→ completed
  │              │
  └──→ failed ←──┘
  │
  └──→ cancelled
StatusMoney isProvider
pendingdebited from available, held by the payout rowno transfer yet
processingsametransfer created
completedat the seller's banksettled
failedcredited backrejected, or the call failed
cancelleddepends when—

All transitions are system-driven. The machine is used for validation; there is no separate transition service.

The two buckets

ColumnMeans
available_balanceWithdrawable now, or spendable where the rail supports it
held_balanceHeld until the order completes

Standing is not on the wallet. A suspended member is stopped by deriveEligibility reading users.account_status before the wallet is asked to move anything.

Safeguards

  • Every balance movement is one transaction — money cannot vanish between two writes.
  • Every provider failure has a compensating write.
  • Every webhook is idempotent through webhook_logs.
  • The user_balance_transactions ledger is append-only; only wallet.events.insertBalanceEvent writes to it.
  • RLS keeps a user's payouts and balances to that user.

Where things live

ConcernWhere
Wallet operationspackages/commerce/wallet/wallet.service.ts
Payout statusespackages/commerce/payouts/
The withdrawalpackages/features/wallet/services/withdrawal.service.ts
Routepackages/features/wallet/api/withdraw.route.ts, which web's and cute-api's withdraw/route.ts re-export
Webhooksapps/api/app/api/v1/webhooks/<rail>/route.ts → payments/services/process-normalized-webhook.service.ts
The capability rulepackages/commerce/onboarding/onboarding.eligibility.ts