Onboarding & seller readiness
How a user progresses from signup to selling, and the two axes that decide what they can do.
In plain words
Every user travels a path: they sign up, fill in who they are (so they can buy), then set up how they get paid (so they can sell). We track where each person is on that path.
Separately, every account has a standing: in good standing, suspended, or banned. That is a different thing from how far along they are.
What a person is actually allowed to do depends on both. To sell, they need to have finished seller setup and be in good standing. So a fully set-up seller who gets suspended can no longer sell, even though their setup is complete. The two are tracked separately on purpose: one answers "how far did they get?", the other answers "are they allowed right now?".
The journey, and the three ways in
Onboarding is really two phases, and the common mistake is to draw it as one forced wizard.
Phase 1, create your account. You sign up and confirm the one channel signup used: phone signup confirms the phone by SMS, email signup confirms the email via the magic link, Google or Facebook arrive with the email already confirmed by the provider. Then you are simply in, the callback drops you on the home page and you can browse. There is no phone step here.
Phase 2, complete your profile, and only when you go to buy or sell. Personal data (name, birthday, CPF) makes you a buyer; seller setup (shipping address and Pagar.me recipient) makes you a seller. The only branch is for phone-only signups, who add and verify an email here (everyone else already confirmed theirs in Phase 1). We deliberately do not ask for a phone number, it's optional and added later in profile settings if you want it, so onboarding stays short.
The shape tells you the kind of node: a hexagon is a verification gate (yellow = you act, grey = automatic), a diamond is a branch, a rectangle is a form, a rounded pill is a state you land in.
No diagram code provided
Two things the picture makes concrete:
- The provider only changes the starting point, never the finish line. Whichever path you take, selling needs the same set: a verified email, CPF, a payout recipient, and a shipping address. Personal data is the convergence point.
- A stored value is a well-formed value. A CPF only lands once its check digits hold, email only once Supabase confirms it. Eligibility can stay "is the column filled in?" precisely because nothing writes a malformed value. Note what the CPF gate is not: it does not call a registry. That check ran on every BR signup, cost money per call, and stamped
cpf_verified_at, which no rule ever read — while the party whose verdict actually gates payouts is Pagar.me at recipient creation. An admin can still spend a Receita lookup on a specific seller from the admin panel. The columns each step writes, and therecomputeOnboardingStatethat turns them into capabilities, are in the tables below.
The rest of this page is the engineer's view of that path.
The screens
Here is what the user actually sees, one frame per node in the journey above. These are captured from the running app by the onboarding-screens Playwright spec (apps/web/e2e), so they stay honest, re-run it to refresh them.
Phase 1 — create your account



Phase 2 — complete your profile (to buy or sell)




For engineers
Onboarding answers two different questions, and Cüte keeps them on two separate axes:
| Axis | Column | Question it answers | Owner |
|---|---|---|---|
| Setup progress | users.onboarding_state | "How far has this user gotten?" | the data the user fills in |
| Standing | users.account_status | "Is this user allowed right now?" | admins / system enforcement |
A capability (can they buy, sell, withdraw) is the AND of the two: enough data and good standing. That is why a fully set-up but suspended seller is seller_ready on the progress axis while canList is false. The two are not "out of sync", they answer different questions. The capability rules live on the seller eligibility page; this page covers the progress axis.
The progress ladder
onboarding_state is a pure projection of the user's data, computed by deriveOnboardingState in packages/commerce/onboarding/onboarding.eligibility.ts.
No diagram code provided
| State | Meaning | Data behind it |
|---|---|---|
unverified | buyer identity incomplete | missing any of full name, CPF, birthday (phone is not required) |
buyer_ready | can buy; no seller setup yet | full buyer identity, no payout recipient |
seller_pending | seller setup started, not yet sellable | recipient created, but shipping address missing |
seller_ready | can list items | CPF + recipient + shipping address (this is exactly the canList data set) |
seller_suspended | reflects account_status = "suspended" | sticky while suspended; auto-clears to the data-derived state when standing returns to active |
Active-recipient and provider-owned payout-destination status are deliberately not part of this ladder. They gate withdrawal only (see seller eligibility).
Set-to-target: the projection is written directly
recomputeOnboardingState (onboarding.service.ts) reads the source columns, computes the derived state, and writes it directly with optimistic concurrency. There is no step-by-step edge walking:
- A user who completes everything at once jumps straight to
seller_readyin a single recompute. - The one-shot backfill of existing users lands each user on the correct rung immediately.
- Removing a required field drops the user back to the matching rung, however many levels down.
The transition table in onboarding-machine.ts (and the diagram above) documents the atomic legal edges for the /god-view/statecharts simulator and for human reading. It is not consulted at runtime.
Who triggers a recompute
The projection is recomputed by every server action that writes a source column, inside the same transaction as the write:
| Trigger | Action | Columns written |
|---|---|---|
| Personal data saved | onboarding.action.ts (per-step) | full name, CPF, birthday |
| CPF verified / removed | verify-cpf.action.ts, remove-cpf-verification.action.ts | CPF |
| Payment info saved | update-user-payment-info.action.ts | full name and CPF |
| Seller payment account created | ensure-seller-payment-account.action.ts | provider seller id + status |
| Recipient activated (provider) | webhook-handlers.action.ts (recipient.*) | recipient status |
| Profile edited | update-user-profile.action.ts | identity fields |
| Admin suspend / restore | toggle-user-field.action.ts (updateUserStatusAction) | account_status |
| Admin deactivate / reactivate | admin-user.action.ts | account_status |
After a recompute that affects the current user, the action also calls refreshSession(reason) so the JWT capability claims update immediately. Admin actions targeting another user cannot refresh that user's token; their claims re-derive from columns on the next natural token rotation.
Suspension
Admins set account_status, then recompute in the same transaction. deriveOnboardingState maps account_status = "suspended" to seller_suspended, and back to the data-derived state when standing returns to active. Suspension is not just a label: it removes canList and canWithdraw through the eligibility predicate (see seller eligibility).
Audit trail
Every net transition is written to user_onboarding_events with the actor (system or admin), the from/to states, and metadata describing the trigger. resolveOnboardingEvent maps any net transition (including multi-rung jumps) to a domain event name (buyer_data_completed, seller_activated, seller_suspended, seller_restored, and so on).