Seller eligibility & capabilities

The single rule that decides who can buy, sell, and withdraw, and where it is enforced.

Cüte has exactly one definition of "what can this user do". It lives in deriveEligibility (packages/commerce/onboarding/onboarding.eligibility.ts), a pure function over the user's data, and every gate conforms to it: the publish/checkout/bot gates call it directly, and the onboarding projection is derived from the same inputs. It is never mirrored into the JWT — see below.

A capability is enough data AND good standing. See onboarding for the two-axis model (setup progress vs standing).

The three capabilities

CapabilityRequires
canBuyfull name + CPF + phone + birthday, and account not banned/suspended
canListCPF + payout recipient + shipping address, and account standing = active
canWithdraweverything in canList, plus an active recipient and an active provider-owned payout destination

By construction canWithdraw implies canList implies the buyer identity is in place. Selling needs the recipient to merely exist; withdrawal needs it active, because funds cannot move to an unconfirmed recipient.

Suspension has teeth

account_status is read by the predicate and the JWT hook, so changing it actually changes what a user can do:

account_statuscanBuycanListcanWithdraw
activeyesyes (if data complete)yes (if active recipient + provider payout destination)
restrictedyesnono
suspendednonono
bannednonono
deactivatedyes*nono
* a deactivated account is auto-reactivated to active on next login.

This closes the old gap where a suspended seller could still publish, receive orders, and withdraw.

Where each gate is enforced

SurfaceGateReads
Route access to /items/newproxy.ts middlewaresession presence only — the proxy does an optimistic auth redirect and never reads a capability
Publishing an itempublish-item.action.tscheckSellerCanListderiveEligibility
Draft → available transitionlisting.service.ts guardderiveEligibility
Checkout (seller can receive)checkSellerCanReceiveOrdersderiveEligibility
WhatsApp listing flowwhatsapp/flow + whatsapp/queriesderiveEligibility
Withdrawalrequest-withdrawal.action.tsderiveEligibility
Onboarding progress checklistcheckSellerEligibility (UI hints)the same data requirements

checkSellerCanList (commerce/listings/listing.repository.ts) reads the canonical snapshot and calls deriveEligibility, so the route gate, the publish gate, and the progress checklist can never disagree about who can sell.

Capabilities are NOT in the JWT

Capabilities are never claims. custom_access_token_hook.sql stamps exactly two fields into app_metadataapp_role and country_code — and actively strips can_buy / can_sell / can_withdraw / onboarding_state from any token that carries them.

The reason is staleness: a token lives up to an hour, and a capability that says "can withdraw" an hour after a suspension is a hole. Every gate therefore derives capabilities from live DB state immediately before the mutation, via deriveEligibility over a fresh getOnboardingSnapshot. There is no mirror to keep in sync.

onboarding_state is likewise not a claim, and is not something to gate on in any case: it is a progress label, not standing.

Payment account activation and withdrawal

A provider seller identity (Pagar.me calls it a recipient, OPP a merchant) is created during seller setup (ensure-seller-payment-account.action.ts) and its status is stored in seller_payment_accounts.merchant_status, reached through the user's active seller_accounts row for their derived market. Selling unlocks as soon as the payment account exists with a shipping address. Withdrawal additionally requires that account and its provider-owned payout destination to be active. Cüte stores only safe payout metadata; the provider retains full bank credentials.

The account often starts pending at creation and is confirmed active later. The normalized seller.updated provider webhook (handled in process-normalized-webhook.action.ts) syncs merchant_status and recomputes the projection, so an account that activates after creation flips canWithdraw on at the next gate check — no token refresh involved, because capabilities are not claims. This is the path that makes withdrawal reachable for accounts that are not active immediately.

Data model

Every input the predicate reads, and where it lives:

InputTable.column
full name, CPF, phone, birthdayuser_profiles.full_name / cpf / phone / birthday
provider seller identity + statusseller_payment_accounts.provider_seller_id / merchant_status
payout destination + statusseller_payment_accounts.provider_payout_destination_id / payout_destination_status
shipping address (presence)shipping_addresses
standingusers.account_status

CPF is a single column, user_profiles.cpf. (It was previously split across cpf and cpf_cnpj, where the payment form wrote one column while every eligibility check read the other, so a user could enter their CPF and still be blocked. The columns are now merged.)

Code map

ConcernFile
Canonical predicate + projectionpackages/commerce/onboarding/onboarding.eligibility.ts
Recompute / write the projectionpackages/commerce/onboarding/onboarding.service.ts
Read the snapshotpackages/commerce/onboarding/onboarding.repository.ts
Seller gatepackages/commerce/listings/listing.repository.ts (checkSellerCanList)
Claim stamping (app_role + country_code only)packages/supabase/functions/sql/functions/custom_access_token_hook.sql
Progress checklist (UI hints)packages/features/onboarding/queries/check-can-sell.query.ts