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.
| Step | Where | What happens |
|---|---|---|
| 1 | DB | Read the seller's profile, address and identity |
| 2 | DB | Does a seller_payment_accounts row already exist for this market's rail? |
| 3 | Provider | Create the provider's seller identity, with the bank details passed straight through |
| 4 | DB | Store the provider's seller id, the normalized status, and safe payout-destination metadata under the user's active seller_accounts row |
| 5 | DB | recomputeOnboardingState — 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
| Step | Where | What happens |
|---|---|---|
| 1 | Provider | The buyer's payment settles |
| 2 | Provider → us | A webhook arrives at the rail's route under /api/v1/webhooks/ |
| 3 | DB | Log it to webhook_logs — the idempotency check |
| 4 | DB | Find the order through the payments row's provider id |
| 5 | DB | Advance the order machine |
| 6 | DB | creditBucket({ 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.
| # | Layer | Check |
|---|---|---|
| 1 | Route | Rate limit |
| 2 | Route | Session |
| 3 | Route | Schema — an integer amount inside the market's withdrawal bounds |
| 4 | Service | deriveEligibility(...).canWithdraw over a fresh snapshot |
| 5 | Service | The market's payouts switch (market_payment_providers — the admin kill switch) |
| 6 | Service | Amount within the market's configured minimum and maximum |
| 7 | Service | The active seller account has an identity on this market's rail, and it is operational |
| 8 | Service | The provider's reported balance covers the amount |
| 9 | Service | The local available bucket covers it — debitBucket throws inside the transaction otherwise |
Then:
| Step | Where | What happens |
|---|---|---|
| 1 | DB | One transaction: debitBucket({ bucket: "available" }) and insert the payouts row as pending |
| 2 | Provider | Create the transfer |
| 3a | DB | On success: store the transfer id, move the payout to processing |
| 3b | DB | On 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| Status | Money is | Provider |
|---|---|---|
pending | debited from available, held by the payout row | no transfer yet |
processing | same | transfer created |
completed | at the seller's bank | settled |
failed | credited back | rejected, or the call failed |
cancelled | depends when | — |
All transitions are system-driven. The machine is used for validation; there is no separate transition service.
The two buckets
| Column | Means |
|---|---|
available_balance | Withdrawable now, or spendable where the rail supports it |
held_balance | Held 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_transactionsledger is append-only; onlywallet.events.insertBalanceEventwrites to it. - RLS keeps a user's payouts and balances to that user.
Where things live
| Concern | Where |
|---|---|
| Wallet operations | packages/commerce/wallet/wallet.service.ts |
| Payout statuses | packages/commerce/payouts/ |
| The withdrawal | packages/features/wallet/services/withdrawal.service.ts |
| Route | packages/features/wallet/api/withdraw.route.ts, which web's and cute-api's withdraw/route.ts re-export |
| Webhooks | apps/api/app/api/v1/webhooks/<rail>/route.ts → payments/services/process-normalized-webhook.service.ts |
| The capability rule | packages/commerce/onboarding/onboarding.eligibility.ts |