Payout lifecycle
Payout Lifecycle
This document maps every step of the payout lifecycle, showing exactly where the system touches our database (Drizzle/Supabase) versus the Pagar.me API.
Legend
- DB = Our database (Drizzle ORM / Supabase)
- PAGAR.ME = Pagar.me REST API (
https://api.pagar.me/core/v5/)
0. Seller eligibility: can_sell vs can_withdraw
Two separate capabilities, both DERIVED from live DB state by deriveEligibility immediately before the mutation they gate. They are not JWT claims — the access-token hook actively strips can_sell / can_withdraw, because a token can be an hour stale and must never gate money:
can_sell — can list items for sale
Computed as:
user_can_sell := (
user_cpf IS NOT NULL
AND user_seller_id IS NOT NULL
AND has_shipping_address = true
AND user_account_status = 'active'
);Requires: CPF + Pagar.me seller (any status). The seller must also have a shipping address and good account standing.
can_withdraw — can request payouts
Computed as:
user_can_withdraw := (
user_can_sell
AND user_seller_status = 'active'
AND NULLIF(BTRIM(user_payout_destination_id), '') IS NOT NULL
AND user_payout_destination_status = 'active'
);Requires: sell eligibility + a Pagar.me recipient with status exactly "active" + an active provider-owned payout destination.
The gap between selling and withdrawing
A seller can list items (can_sell = true) before they can withdraw, because:
- The Pagar.me recipient starts in
"pending"status after creation — this is enough forcan_sellbut not forcan_withdraw(needs"active"). - Pagar.me owns the bank credentials. Cüte retains only the returned provider reference and safe masked metadata.
- The recipient transitions
pending → activeasynchronously on Pagar.me's side (KYC approval).
So the full onboarding timeline looks like:
User provides CPF and bank details in Cüte's payout form
↓
Server sends the bank details transiently to Pagar.me while creating the recipient
↓
Pagar.me stores the bank account; Cüte stores providerSellerId plus safe destination metadata
↓
can_sell = true ✅ (can list items)
can_withdraw = false ❌ (payment account is not active yet)
↓
Pagar.me KYC approves → recipient status changes to "active"
↓
can_sell = true ✅
can_withdraw = true ✅ (all conditions met)What each step stores in the DB
| Step | Table | Fields set |
|---|---|---|
| CPF | user_profiles | cpf |
| Payment account creation | seller_payment_accounts | providerSellerId + merchantStatus |
| KYC approval | seller_payment_accounts | merchantStatus updated to "active" (via status check or webhook) |
| Payout destination | seller_payment_accounts | provider destination ID, normalized status, bank code, account last four digits, account type, update timestamp |
Full branch/account numbers and holder documents are never persisted in Cüte's database. They exist only in the transient server request to the payment gateway and in Pagar.me's vault.
Key files:
packages/commerce/onboarding/onboarding.eligibility.ts—deriveEligibility, the canonical capability predicatepackages/features/payments/actions/ensure-seller-payment-account.action.ts— payment account creationpackages/features/payments/actions/configure-payout-destination.action.ts— payout-destination updatepackages/payments/providers/pagarme/adapter.ts— provider mapping and custody boundarypackages/db/schema/seller-payment-accounts.ts— payment account schema
1. Seller onboarding (seller creation)
Before a seller can receive payouts, they need a Pagar.me seller.
| Step | System | What happens |
|---|---|---|
| 1 | DB | Read user profile, address, CPF from users + addresses tables |
| 2 | DB | Check if seller_payment_accounts.providerSellerId already exists |
| 3 | PAGAR.ME | POST /core/v5/recipients — create recipient with register_information and default_bank_account |
| 4 | DB | Store returned seller ID/status and safe payout-destination reference/metadata in seller_payment_accounts, under the user's active seller_accounts row |
| 5 | DB | recomputeOnboardingState — the next deriveEligibility reads the new row and canList becomes true |
Note: can_withdraw remains false until both the payment account and its provider-owned payout destination have normalized status "active".
Key files:
packages/features/payments/actions/ensure-seller-payment-account.action.tspackages/payments/providers/pagarme/adapter.ts
2. Order payment → seller balance credit
When a buyer pays, Pagar.me processes the payment and notifies us via webhook.
| Step | System | What happens |
|---|---|---|
| 1 | PAGAR.ME | Pagar.me processes the buyer's payment |
| 2 | PAGAR.ME → us | Webhook order.paid / charge.paid hits POST /api/v1/webhooks/pagarme |
| 3 | DB | Log webhook to webhook_logs (idempotency check) |
| 4 | DB | Find order by providerOrderId in payments table |
| 5 | DB | Transition order status: pending_payment → paid |
| 6 | DB | Credit seller's user_balances.held_balance via creditBucket({bucket: "held"}) — atomic with a sale_hold ledger (append-only audit table) entry |
Funds stay in the held bucket until the order reaches completed. On completion, the order workflow calls releaseHold() to move them held → available.
Key files:
apps/web/app/api/v1/webhooks/pagarme/route.tspackages/features/payments/actions/webhook-handlers.action.ts→handlePaymentPaid()packages/commerce/wallet/wallet-service.ts→creditBucket(),releaseHold()
3. Withdrawal request
The seller requests a withdrawal from their available balance. This has the most guards of any flow.
Guard chain (all must pass)
| # | Layer | Check | Fails with |
|---|---|---|---|
| 1 | API route | Rate limiting (IP-based, Upstash Redis) | 429 Too Many Requests |
| 2 | API route | Session exists | 401 Unauthorized |
| 3 | API route | Schema validation (amountCents is int, within R$10–R$50k) | 400 Invalid request |
| 4 | Server action | deriveEligibility(...).canWithdraw over a fresh snapshot | "Withdrawal unavailable: <reason codes>" |
| 4b | Server action | market_payment_providers payouts switch (admin kill switch) | "Withdrawals are temporarily unavailable" |
| 5 | Server action | Amount bounds check (R$10 min, R$50k max) | Amount error message |
| 6 | Server action | Active seller account has a provider identity for the market's rail | "Please complete your payment information" |
| 7 | Server action | Environment-aware providerSellerId exists AND merchantStatus = "active" | "Please complete your payment information" |
| 8 | Server action | Pagar.me balance ≥ requested amount | "Withdrawal temporarily unavailable" |
| 9 | Server action | Local available_balance ≥ requested amount (debitBucket throws inside the tx) | "Insufficient available balance" |
Flow (after guards pass)
| Step | System | What happens |
|---|---|---|
| 1 | — | Seller clicks "Withdraw" in /my/wallet |
| 2 | DB | Guards 1–7 above (session, deriveEligibility over a fresh snapshot, payment account lookup, seller status) |
| 3 | PAGAR.ME | GET /core/v5/balance/\{providerSellerId\} — guard 8, sanity-check Pagar.me balance |
| 4 | DB | Atomic transaction: debitBucket({bucket: "available"}) (throws if insufficient) + insert payouts row with status: "pending". The payouts row is the source of truth for in-flight withdrawals — there is no separate "reserved" bucket. |
| 5 | PAGAR.ME | POST /core/v5/transfers — create transfer (amount, providerSellerId) |
| 6a | DB | On API success: update payout with transferId, set status: "processing" |
| 6b | DB | On API failure: compensating transaction — creditBucket({bucket: "available"}) writes a withdrawal_failed ledger (append-only audit table) entry, then mark payout status: "failed" |
Key files:
apps/web/app/api/v1/withdraw/route.ts— rate limiting, schema validationpackages/features/payments/actions/request-withdrawal.action.ts— all business logic guards + gateway callspackages/commerce/wallet/wallet.service.ts→debitBucket(),creditBucket()packages/features/payments/queries/update-payout.query.ts
4. Transfer settlement (webhook)
Pagar.me settles the transfer (typically 1-2 business days) and notifies us.
4a. Transfer succeeded (transfer.paid)
| Step | System | What happens |
|---|---|---|
| 1 | PAGAR.ME → us | Webhook transfer.paid hits POST /api/v1/webhooks/pagarme |
| 2 | DB | Log webhook to webhook_logs + idempotency check |
| 3 | DB | Find payout by transferId in payouts table |
| 4 | DB | completeWithdrawal() — no bucket change (the debit happened at request time). Writes a withdrawal_complete ledger (append-only audit table) entry for the trail. |
| 5 | DB | Update payout: status: "completed", set completedAt |
4b. Transfer failed (transfer.failed)
| Step | System | What happens |
|---|---|---|
| 1 | PAGAR.ME → us | Webhook transfer.failed hits POST /api/v1/webhooks/pagarme |
| 2 | DB | Log webhook to webhook_logs + idempotency check |
| 3 | DB | Find payout by transferId in payouts table |
| 4 | DB | Compensating transaction: creditBucket({bucket: "available"}) returns funds to the seller and writes a withdrawal_failed ledger (append-only audit table) entry |
| 5 | DB | Update payout: status: "failed" |
Key files:
packages/features/payments/actions/webhook-handlers.action.ts→handleTransferPaid(),handleTransferFailed()packages/commerce/wallet/wallet-service.ts→completeWithdrawal(),creditBucket()
5. Payout status machine
pending ──→ processing ──→ completed
│ │
└──→ failed ←──┘
│
└──→ cancelled| Status | Meaning | DB state | Pagar.me state |
|---|---|---|---|
pending | Payout row created; available_balance already debited | In-flight funds tracked by the payouts row itself | No transfer exists yet |
processing | Transfer created on Pagar.me, awaiting settlement | Same — payouts row is the in-flight source of truth | Transfer created |
completed | Transfer settled, money at seller's bank | No balance change; withdrawal_complete ledger entry written for audit | transfer.paid |
failed | Transfer failed or API call failed | available_balance credited back; withdrawal_failed ledger entry written | transfer.failed or API error |
cancelled | Admin-cancelled | Depends on when cancelled | N/A |
Balance buckets
The wallet uses two buckets (collapsed from the earlier 5-bucket shape). 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.
| Field | Type | Purpose |
|---|---|---|
available_balance | integer (cents) | Ready to withdraw or use for balance payments |
held_balance | integer (cents) | Held until the order completes; released held → available by releaseHold() |
In-flight withdrawals are not a bucket. The payouts row (with status in pending / processing) is the source of truth for money en route to the seller's bank.
All Pagar.me API touchpoints (summary)
| When | Method | Endpoint | Purpose |
|---|---|---|---|
| Seller onboarding | POST | /core/v5/sellers | Create seller |
| Seller onboarding | GET | /core/v5/sellers/\{id\} | Check seller status |
| Withdrawal request | GET | /core/v5/balance/\{sellerId\} | Sanity-check balance |
| Withdrawal request | POST | /core/v5/transfers | Create transfer |
| Status check | GET | /core/v5/transfers/\{id\} | Check transfer status |
| Incoming webhooks | — | — | order.paid, charge.paid, transfer.paid, transfer.failed, transfer.created, order.payment_failed, order.cancelled |
Safeguards
- Atomic DB transactions for all balance movements — funds never "disappear"
- Compensating transactions on API failure — if Pagar.me rejects, funds are immediately unreserved
- Idempotent webhooks via
webhook_logs— duplicate deliveries won't double-credit - Environment-aware provider gateway — sandbox/live endpoints and credentials are selected inside the gateway while application data remains canonical
- Rate limiting on withdrawal endpoint via Upstash Redis
- RLS policies — users can only view their own payouts and balances