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.

LegendLiveWaitingCompletedDecisionClosed / inactive

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 the recomputeOnboardingState that 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

Choose how to sign up
Choose how to sign upPhone, email, Google or Facebook.
Sign up with email
Sign up with emailName, username, email, password.
Confirm your email
Confirm your emailThe channel you signed up with is confirmed first.

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

Seller setup checklist
Seller setup checklistWhat's left before you can list: data and address.
Personal data
Personal dataName, birthday and CPF make you a buyer.
Shipping origin address
Shipping origin addressWhere your parcels ship from (used for labels).
Review & confirm
Review & confirmConfirming creates your payout account and you can sell.

For engineers

Onboarding answers two different questions, and Cüte keeps them on two separate axes:

AxisColumnQuestion it answersOwner
Setup progressusers.onboarding_state"How far has this user gotten?"the data the user fills in
Standingusers.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.

LegendLiveWaitingCompletedCancelled / failedClosed / inactive
StateMeaningData behind it
unverifiedbuyer identity incompletemissing any of full name, CPF, birthday (phone is not required)
buyer_readycan buy; no seller setup yetfull buyer identity, no payout recipient
seller_pendingseller setup started, not yet sellablerecipient created, but shipping address missing
seller_readycan list itemsCPF + recipient + shipping address (this is exactly the canList data set)
seller_suspendedreflects 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_ready in 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:

TriggerActionColumns written
Personal data savedonboarding.action.ts (per-step)full name, CPF, birthday
CPF verified / removedverify-cpf.action.ts, remove-cpf-verification.action.tsCPF
Payment info savedupdate-user-payment-info.action.tsfull name and CPF
Seller payment account createdensure-seller-payment-account.action.tsprovider seller id + status
Recipient activated (provider)webhook-handlers.action.ts (recipient.*)recipient status
Profile editedupdate-user-profile.action.tsidentity fields
Admin suspend / restoretoggle-user-field.action.ts (updateUserStatusAction)account_status
Admin deactivate / reactivateadmin-user.action.tsaccount_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).