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
| Capability | Requires |
|---|---|
canBuy | a tax identifier only where the market asks for one (identityRules.taxIdRequiredToBuy: BR yes, EU no), and the account not banned |
canList | a live market, a place (the town), good standing, and no identity hold. Nothing else: no name, no birthday, no address, no phone, no payment account. The first publish asks for the person in one dialog and registers them before the listing goes live |
canReceivePayments | everything in canList, plus the payment account must exist (the first publish creates it) |
canWithdraw | everything in canReceivePayments, plus an operational account, an active payout destination and compliance |
Widening rings. Withdrawing needs somewhere for the money to actually land, which an operational account does not by itself guarantee — a Stripe account can be verified with no bank attached.
A few of these surprise people:
- Nothing is asked of a seller before the listing form, on any rail. Who the payment provider pays is asked once, at the first publish, in one dialog: the legal name, birthday and address everywhere (Stripe DE refuses the recipient service agreement for DE accounts, so transfers stay inactive until the person is known), plus the CPF, phone and bank account where the rail wants them (Pagar.me takes them in the recipient payload). The fields are the market's
identityRulesand the gateway's capabilities, never a market name. - A parked market fails
canListoutright (market_unavailable). A preview account in AR can fill in identity fields and still have no seller capability. - Standing and identity holds are added at the eligibility layer, not in the data requirements. Otherwise a hold would knock a fully set-up seller back down the progress ladder — enforcement leaking into a progress label.
Suspension has teeth
account_status is read by the predicate on every gate, so changing it changes what a user can do immediately:
account_status | canBuy | canList | canWithdraw |
|---|---|---|---|
active | yes | yes, if setup is complete | yes, if the payout destination is active |
restricted | yes | no | no |
suspended | no | no | no |
banned | no | no | no |
deactivated | yes* | no | no |
- a
deactivatedaccount is auto-reactivated toactiveon next login.
This closes the old gap where a suspended seller could still publish, receive orders, and withdraw.
Where each gate is enforced
| Surface | Gate | Reads |
|---|---|---|
Route access to /items/new | proxy.ts middleware | session presence only — an optimistic auth redirect, never a capability |
| Publishing a listing | publish-listing.action.ts → checkSellerCanList | deriveEligibility |
| Draft → available transition | listing.service.ts guard | deriveEligibility |
| Checkout (seller can receive) | checkSellerCanReceiveOrders | deriveEligibility |
| WhatsApp listing flow | whatsapp/flow + whatsapp/queries | deriveEligibility |
| Withdrawal | request-withdrawal.action.ts | deriveEligibility |
| The identity dialog at the first publish | resolveSellerPublishability (needs_identity with the rail's missing fields) | deriveEligibility, then collectSellerIdentityGaps |
checkSellerCanList (commerce/listings/listing.service.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. The access-token hook stamps exactly two fields into app_metadata — app_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, Stripe a connected account — is created at the first publish, from the identity dialog, on every rail, and its status is stored in seller_payment_accounts.merchant_status, reached through the user's active seller_accounts row for their derived market. Receiving money unlocks as soon as the payment account exists. 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 payments/services/process-normalized-webhook.service.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:
| Input | Table.column |
|---|---|
| tax id, phone | user_tax_identifiers (the CPF), user_profiles.phone |
| the place | user_profiles.postal_code_id / city_id |
| provider seller identity + status | seller_payment_accounts.provider_seller_id / merchant_status |
| payout destination + status | seller_payment_accounts.provider_payout_destination_id / payout_destination_status |
| what the rail asks before registering | not a column: the market's identityRules.taxIdRequiredToBuy / phoneRequiredToSell (packages/commerce/config/<market>.ts) and the gateway's payoutDestinationRequiredToRegister / payoutDestinationKind, read by sellerIdentityRulesFor |
| standing | users.account_status |
Code map
| Concern | File |
|---|---|
| Canonical predicate + projection | packages/commerce/onboarding/onboarding.eligibility.ts |
| Recompute / write the projection | packages/commerce/onboarding/onboarding.service.ts |
| Read the snapshot | packages/commerce/onboarding/onboarding.repository.ts |
| Seller gate | packages/commerce/listings/listing.service.ts (checkSellerCanList) |
Claim stamping (app_role + country_code only) | the auth migration in packages/db/migrations/ |
| The identity dialog | packages/features/onboarding/components/seller-identity-dialog.tsx; the fields packages/features/wallet/services/seller-registration.service.ts |