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:

  1. The Pagar.me recipient starts in "pending" status after creation — this is enough for can_sell but not for can_withdraw (needs "active").
  2. Pagar.me owns the bank credentials. Cüte retains only the returned provider reference and safe masked metadata.
  3. The recipient transitions pending → active asynchronously 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

StepTableFields set
CPFuser_profilescpf
Payment account creationseller_payment_accountsproviderSellerId + merchantStatus
KYC approvalseller_payment_accountsmerchantStatus updated to "active" (via status check or webhook)
Payout destinationseller_payment_accountsprovider 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.tsderiveEligibility, the canonical capability predicate
  • packages/features/payments/actions/ensure-seller-payment-account.action.ts — payment account creation
  • packages/features/payments/actions/configure-payout-destination.action.ts — payout-destination update
  • packages/payments/providers/pagarme/adapter.ts — provider mapping and custody boundary
  • packages/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.

StepSystemWhat happens
1DBRead user profile, address, CPF from users + addresses tables
2DBCheck if seller_payment_accounts.providerSellerId already exists
3PAGAR.MEPOST /core/v5/recipients — create recipient with register_information and default_bank_account
4DBStore returned seller ID/status and safe payout-destination reference/metadata in seller_payment_accounts, under the user's active seller_accounts row
5DBrecomputeOnboardingState — 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.ts
  • packages/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.

StepSystemWhat happens
1PAGAR.MEPagar.me processes the buyer's payment
2PAGAR.ME → usWebhook order.paid / charge.paid hits POST /api/v1/webhooks/pagarme
3DBLog webhook to webhook_logs (idempotency check)
4DBFind order by providerOrderId in payments table
5DBTransition order status: pending_payment → paid
6DBCredit 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.ts
  • packages/features/payments/actions/webhook-handlers.action.tshandlePaymentPaid()
  • packages/commerce/wallet/wallet-service.tscreditBucket(), 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)

#LayerCheckFails with
1API routeRate limiting (IP-based, Upstash Redis)429 Too Many Requests
2API routeSession exists401 Unauthorized
3API routeSchema validation (amountCents is int, within R$10–R$50k)400 Invalid request
4Server actionderiveEligibility(...).canWithdraw over a fresh snapshot"Withdrawal unavailable: <reason codes>"
4bServer actionmarket_payment_providers payouts switch (admin kill switch)"Withdrawals are temporarily unavailable"
5Server actionAmount bounds check (R$10 min, R$50k max)Amount error message
6Server actionActive seller account has a provider identity for the market's rail"Please complete your payment information"
7Server actionEnvironment-aware providerSellerId exists AND merchantStatus = "active""Please complete your payment information"
8Server actionPagar.me balance ≥ requested amount"Withdrawal temporarily unavailable"
9Server actionLocal available_balance ≥ requested amount (debitBucket throws inside the tx)"Insufficient available balance"

Flow (after guards pass)

StepSystemWhat happens
1Seller clicks "Withdraw" in /my/wallet
2DBGuards 1–7 above (session, deriveEligibility over a fresh snapshot, payment account lookup, seller status)
3PAGAR.MEGET /core/v5/balance/\{providerSellerId\} — guard 8, sanity-check Pagar.me balance
4DBAtomic 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.
5PAGAR.MEPOST /core/v5/transfers — create transfer (amount, providerSellerId)
6aDBOn API success: update payout with transferId, set status: "processing"
6bDBOn 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 validation
  • packages/features/payments/actions/request-withdrawal.action.ts — all business logic guards + gateway calls
  • packages/commerce/wallet/wallet.service.tsdebitBucket(), 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)

StepSystemWhat happens
1PAGAR.ME → usWebhook transfer.paid hits POST /api/v1/webhooks/pagarme
2DBLog webhook to webhook_logs + idempotency check
3DBFind payout by transferId in payouts table
4DBcompleteWithdrawal() — no bucket change (the debit happened at request time). Writes a withdrawal_complete ledger (append-only audit table) entry for the trail.
5DBUpdate payout: status: "completed", set completedAt

4b. Transfer failed (transfer.failed)

StepSystemWhat happens
1PAGAR.ME → usWebhook transfer.failed hits POST /api/v1/webhooks/pagarme
2DBLog webhook to webhook_logs + idempotency check
3DBFind payout by transferId in payouts table
4DBCompensating transaction: creditBucket({bucket: "available"}) returns funds to the seller and writes a withdrawal_failed ledger (append-only audit table) entry
5DBUpdate payout: status: "failed"

Key files:

  • packages/features/payments/actions/webhook-handlers.action.tshandleTransferPaid(), handleTransferFailed()
  • packages/commerce/wallet/wallet-service.tscompleteWithdrawal(), creditBucket()

5. Payout status machine

pending ──→ processing ──→ completed
  │              │
  └──→ failed ←──┘

  └──→ cancelled
StatusMeaningDB statePagar.me state
pendingPayout row created; available_balance already debitedIn-flight funds tracked by the payouts row itselfNo transfer exists yet
processingTransfer created on Pagar.me, awaiting settlementSame — payouts row is the in-flight source of truthTransfer created
completedTransfer settled, money at seller's bankNo balance change; withdrawal_complete ledger entry written for audittransfer.paid
failedTransfer failed or API call failedavailable_balance credited back; withdrawal_failed ledger entry writtentransfer.failed or API error
cancelledAdmin-cancelledDepends on when cancelledN/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.

FieldTypePurpose
available_balanceinteger (cents)Ready to withdraw or use for balance payments
held_balanceinteger (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)

WhenMethodEndpointPurpose
Seller onboardingPOST/core/v5/sellersCreate seller
Seller onboardingGET/core/v5/sellers/\{id\}Check seller status
Withdrawal requestGET/core/v5/balance/\{sellerId\}Sanity-check balance
Withdrawal requestPOST/core/v5/transfersCreate transfer
Status checkGET/core/v5/transfers/\{id\}Check transfer status
Incoming webhooksorder.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