Skip to content

Pro Onboarding — Design Spec

Date: 2026-04-19 Status: Draft — brainstormed 2026-04-19, not yet reviewed Scope: End-to-end professional onboarding flow from signup → operational (can accept bookings) Phase mapping: Core flow + ID verify + Stripe Connect = MVP 0. Background check + SPID integration = post-MVP. Related specs:

  • 2026-04-19-ux-phase-a-design.md (Auth + profile screens)
  • 2026-04-19-pro-credentials-design.md (Credentials upload, trust score)
  • ../ideony-mvp0-blueprint.md (overall product scope)

Get a professional from first app launch to accepting paid bookings in <15 minutes, with a clear gating structure that protects consumers (ID verified pros only) while staying open to occasional earners (no P_IVA required under €5k/year threshold).

Why: Italian gig economy has two distinct pro segments:

  1. Full-time trades (plumbers, electricians, HVAC) — need P_IVA, insurance, albo for legal compliance
  2. Occasional earners (music lessons, tutoring, small home repairs) — prestazione occasionale regime (<€5k/year, no P_IVA required)

MVP-0 must serve both. Airbnb-style unified entry (everyone signs up as consumer first, “Diventa un professionista” CTA switches role) avoids two-track sign-up complexity.

  • Background check / criminal record verification (Phase D+ — see “Background Check” section below)
  • SPID / CIE integration (Year 2+ — requires AgID accreditation)
  • Automated P_IVA validation against Agenzia Entrate (Phase D — manual admin review MVP-0)
  • Albo cross-check against provincial registries (Phase D — manual MVP-0)
  • Pro-to-pro referrals / ambassador program (post-MVP)
  • Multi-language beyond IT + EN (post-MVP)

Entry Pattern — Airbnb-style unified signup

Section titled “Entry Pattern — Airbnb-style unified signup”
First app open
├─ Welcome tour (3 slides) → Auth (unified Clerk identifier-first)
├─ Signup completes → Consumer home by default (role=CONSUMER)
└─ "Diventa un professionista" CTA (persistent in profile menu + home empty state)
└─ Pro onboarding wizard (this spec)
└─ On complete → role=PROFESSIONAL, pro tabs visible

Rationale — matches Airbnb host flow, Deliveroo rider flow, Uber driver flow. Single auth means:

  • Same user can be both consumer + pro (drum teacher who also books plumbers)
  • No duplicate accounts / merge issues
  • Lower friction: people who “aren’t sure they want to be a pro” can explore consumer side first

Role switching — implemented via User.roles: Role[] (array, not single enum). Tabs switch in-app via profile menu toggle when user has both roles.

┌─────────────────────────────────────────────────────────────────┐
│ Welcome → "Start" → persist ProfessionalProfile.onboardingStep │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────┐
│ 1. Profile basics │ display name, bio, categories (multi-select)
│ │ required: name, ≥1 category
└──────┬──────────────┘
│ save → onboardingStep=AREA
┌─────────────────────┐
│ 2. Service area │ Mapbox map, draggable pin + radius slider
│ │ required: center + radius (5-50km)
└──────┬──────────────┘
│ save → onboardingStep=STRIPE
┌─────────────────────┐
│ 3. Stripe Connect │ redirect to Stripe Express onboarding
│ Express │ returnUrl → /(pro)/onboarding/identity
└──────┬──────────────┘
│ webhook: account.updated → onboardingStep=IDENTITY
┌─────────────────────┐
│ 4. ID verification │ Stripe Identity session (native SDK or redirect)
│ (Stripe Identity)│ ID doc + selfie liveness
└──────┬──────────────┘
│ webhook: identity.verification_session.verified → idVerified=true
│ GATE: can now accept bookings (minimum viable operational state)
┌─────────────────────┐
│ 5. Credentials │ optional — P_IVA, insurance, albo, F-gas
│ (optional) │ admin-queue review (see credentials spec)
└──────┬──────────────┘
│ skip-able → onboardingStep=PRICING
┌─────────────────────┐
│ 6. Pricing │ per-category: fixed rate OR hourly + min callout fee
│ │ required to go live
└──────┬──────────────┘
│ save → onboardingStep=PORTFOLIO
┌─────────────────────┐
│ 7. Portfolio │ min 3 photos of past work (or profile photo + work samples)
│ │ required to go live
└──────┬──────────────┘
│ save → onboardingStep=COMPLETE
┌─────────────────────┐
│ LIVE — visible in │ appear in consumer search results
│ search, can │ accept SOS + booking requests
│ accept jobs │ trust tier = BASIC (0-30) until credentials verified
└─────────────────────┘

Two gates, both enforced server-side in ProfessionalsService:

Gate 1 — “Can accept bookings” (after Step 4)

Section titled “Gate 1 — “Can accept bookings” (after Step 4)”
idVerified === true
AND stripeAccountStatus === 'active' (can receive payouts)
AND onboardingStep ∈ {PRICING, PORTFOLIO, COMPLETE}

Gate 2 — “Visible in search” (after Step 7)

Section titled “Gate 2 — “Visible in search” (after Step 7)”
Gate 1 passes
AND hasPricing === true (at least 1 category with price set)
AND portfolioImageCount >= 3
AND profilePublished === true (pro explicit toggle; default false → true on Step 7 complete)

Pros in Steps 1-3 don’t exist in search, don’t receive notifications, can’t view other pro profiles (to prevent window-shopping before committing).

Decision (2026-04-19): Stripe Identity over Onfido / Veriff / Jumio / Persona.

Why:

  • Already in stack (Stripe Connect Express) — same customer object, single webhook handler, unified dashboard
  • EU-native, supports Italian carta d’identità + passaporto + patente di guida + selfie liveness
  • €1.50/verification, no monthly minimum (Onfido: €4/verif + €300/mo min; Veriff: €3.50/verif + min; Persona: 100 free/mo then per-verif)
  • SOTA liveness (Stripe upgraded 2024 via Knot acquisition)
  • Test mode is fully functional — all MVP dev happens free, production launch €1.50/pro one-time

Integration path:

  1. POST /api/professionals/me/identity/start → creates Stripe::Identity::VerificationSession, returns clientSecret
  2. Mobile: @stripe/stripe-react-nativepresentIdentityVerificationSheet(clientSecret) OR web redirect for Expo Web
  3. Webhook identity.verification_session.verified → update ProfessionalProfile.idVerified = true, idVerifiedAt = now, trust score +20
  4. Webhook identity.verification_session.requires_input (e.g., blurry photo) → push notification “Riprova la verifica”
  5. Rate limit: 3 attempts / 24hr per pro, then manual admin unlock

Failure modes:

  • User uploads blurry doc → Stripe auto-retry, webhook fires requires_input → app re-opens sheet with guidance
  • User’s country ID not supported → (rare for Italian residents but possible for non-EU) → fallback: admin manual review with uploaded passport scan
  • Stripe outage → show “Verifica temporaneamente non disponibile, riprova tra poco” + log Sentry

Occasional Earners — P_IVA Optional Path

Section titled “Occasional Earners — P_IVA Optional Path”

Italian legal regime prestazione occasionale (Codice Civile art. 2222) allows natural persons to earn up to €5.000/year across all clients without opening P_IVA. Tax: 20% withholding at source (ritenuta d’acconto).

Implications for Ideony:

  • P_IVA upload = optional during onboarding (marked “Richiesto solo se superi €5.000/anno”)
  • Stripe receipts auto-mark “ricevuta prestazione occasionale” with 20% withholding line item
  • ProfessionalProfile.annualGrossEarnings (Decimal) tracked server-side from completed bookings (EUR, calendar year)
  • Dashboard banner at €3.000 threshold: “⚠️ Ti avvicini al limite €5.000 — apri P_IVA per continuare a guadagnare oltre soglia”
  • Dashboard banner at €4.500 threshold: ”🛑 Apri P_IVA ora — ti mancano €500 al limite”
  • At €5.000 exactly: block new bookings until P_IVA uploaded + VERIFIED. Email + push notification “Hai raggiunto il limite — carica P_IVA per continuare”
  • Annual reset: January 1st cron job zeros annualGrossEarnings

Opens Ideony to segments like:

  • Drum lessons, music tutoring
  • Homework tutors
  • Weekend handyman work (hobbyists)
  • Babysitting, pet sitting
  • Side-hustle gardeners / cleaners

These users need ID verify + Stripe Connect but NOT P_IVA. Lowers signup friction significantly. Trust tier stays BASIC (0-30) without P_IVA but remains fully operational.

Context: Criminal record (certificato del casellario giudiziale) checks if pro has convictions. US platforms (Uber, DoorDash, TaskRabbit) mandatory because pros have unsupervised access (homes, vehicles). Italian trades same risk profile.

Why MVP-0 skips:

  • Italian certificate requires pro’s manual trip to Procura della Repubblica — €20 + 1-2 weeks wait
  • Would block 80%+ of pros from signup (friction too high)
  • MVP-0 goal = validate PMF, not achieve full trust
  • Insurance (RC professionale) partially mitigates civil liability already

Phase D+ addition:

  • Optional “Casellario verificato” badge (trust score +15)
  • Pro uploads official PDF certificate → admin review → badge unlocks
  • Separate from main onboarding wizard (credential #8 in CredentialType enum)
  • API check possible via eIDAS node (future, complex)

Data Model — Additions to Existing Schema

Section titled “Data Model — Additions to Existing Schema”
enum OnboardingStep {
NOT_STARTED
PROFILE
AREA
STRIPE
IDENTITY
CREDENTIALS
PRICING
PORTFOLIO
COMPLETE
}
model ProfessionalProfile {
// existing fields ...
onboardingStep OnboardingStep @default(NOT_STARTED)
onboardingStartedAt DateTime?
onboardingCompletedAt DateTime?
idVerified Boolean @default(false)
idVerifiedAt DateTime?
identitySessionId String? // Stripe Identity session ref
stripeAccountStatus String @default("pending") // "pending" | "active" | "restricted"
stripeAccountUpdatedAt DateTime?
annualGrossEarnings Decimal @default(0) @db.Decimal(10, 2)
annualEarningsResetAt DateTime? // Jan 1 of current tracking year
profilePublished Boolean @default(false)
portfolioImageCount Int @default(0) // denormalized for fast gate check
hasPricing Boolean @default(false)
}
model PortfolioImage {
id String @id @default(cuid())
professionalId String
professional ProfessionalProfile @relation(fields: [professionalId], references: [id], onDelete: Cascade)
fileKey String // R2 key
caption String?
categorySlug String? // optional: tag to a category
sortOrder Int @default(0)
createdAt DateTime @default(now())
}
model ServicePricing {
id String @id @default(cuid())
professionalId String
professional ProfessionalProfile @relation(fields: [professionalId], references: [id], onDelete: Cascade)
categorySlug String
model PricingModel // FIXED | HOURLY | QUOTE_ON_REQUEST
fixedPrice Decimal? @db.Decimal(10, 2)
hourlyRate Decimal? @db.Decimal(10, 2)
minCalloutFee Decimal? @db.Decimal(10, 2)
description String? // "Include sopralluogo + preventivo"
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([professionalId, categorySlug])
}
enum PricingModel {
FIXED
HOURLY
QUOTE_ON_REQUEST
}

All routes under /api/professionals/me/onboarding/* (ProfessionalsController), requires auth + role=PROFESSIONAL (or upgrading from CONSUMER).

POST /onboarding/start // flip role, create empty ProfessionalProfile, set step=PROFILE
PUT /onboarding/profile // name, bio, categories → step=AREA
PUT /onboarding/area // latitude, longitude, radiusKm → step=STRIPE
POST /onboarding/stripe/link // returns Stripe Connect onboarding URL
POST /onboarding/identity/session // returns Stripe Identity clientSecret
GET /onboarding/status // { step, idVerified, stripeStatus, hasPricing, portfolioCount }
POST /onboarding/credentials/skip // mark step=PRICING (credentials deferred)
PUT /onboarding/pricing // [{categorySlug, model, fixedPrice, hourlyRate, minCalloutFee}]
POST /onboarding/portfolio/upload-url // presigned R2 URL (reuses MediaModule)
POST /onboarding/portfolio/confirm // { fileKey, caption?, categorySlug? }
DELETE /onboarding/portfolio/:id // remove image
POST /onboarding/publish // final gate check → profilePublished=true, step=COMPLETE
GET /onboarding/resume // returns current step for deep-link resume

New handlers in StripeWebhookController:

account.updated → update stripeAccountStatus
identity.verification_session.verified → idVerified=true, idVerifiedAt=now, trust score +20
identity.verification_session.requires_input → push notification "Riprova la verifica"
identity.verification_session.canceled → log, allow retry
identity.verification_session.processing → no-op, transient state

All webhook events verified via svix signature (existing pattern — uses req.rawBody).

app/(professional)/onboarding/
_layout.tsx // stack navigator, progress bar header (1/7, 2/7, ...)
index.tsx // welcome + resume-or-start
profile.tsx // Step 1
area.tsx // Step 2 (Mapbox)
stripe.tsx // Step 3 (redirect wrapper)
stripe-return.tsx // Step 3 return URL — verifies account.updated fired, advances to Step 4
identity.tsx // Step 4 (Stripe Identity sheet or redirect)
credentials.tsx // Step 5 (optional, reuses Phase A CredentialsForm component)
pricing.tsx // Step 6
portfolio.tsx // Step 7
review.tsx // Summary + "Pubblica profilo" CTA
complete.tsx // Confetti + "Inizia a ricevere richieste"

Deep-link resume: opening app after partial onboarding routes to /onboarding/resume → reads ProfessionalProfile.onboardingStep → navigates to correct screen.

Welcome screen:

“Guadagna condividendo le tue competenze” “Bastano 15 minuti per iniziare. Ti guideremo passo passo.”

Step 1 — Profile:

“Chi sei?” “Nome pubblico · Breve descrizione · Cosa sai fare”

Step 3 — Stripe:

“Collega il tuo conto per ricevere i pagamenti” “Ti portiamo su Stripe, il nostro partner di pagamento. Inserisci IBAN e qualche dato fiscale, torni qui in 2 minuti.”

Step 4 — Identity:

“Verifica la tua identità” “Scatta una foto del tuo documento e un selfie. Serve per proteggere clienti e professionisti — lo fai una volta sola.”

Step 5 — Credentials (P_IVA optional notice):

“Partita IVA (opzionale)” “Richiesto solo se prevedi di guadagnare più di €5.000/anno (regime prestazione occasionale)”

Completion:

“Il tuo profilo è online 🎉” “Riceverai richieste dai clienti della tua zona. Apri l’app per accettarle.”

Approaching €5k threshold (push + dashboard banner):

“Ti avvicini al limite di €5.000/anno” “Apri la P_IVA per continuare a ricevere richieste oltre soglia. Ti aiutiamo a farlo nel modo più semplice.”

IDENTITY_VERIFIED = 20 (new — auto via Stripe Identity)
P_IVA = 30
INSURANCE = 25
ALBO = 20
F_GAS = 15
MANUFACTURER_CERT = 10
TRAINING_DIPLOMA = 10
CASELLARIO_VERIFIED = 15 (Phase D+, deferred)
OTHER = 5

Max score without background check: 20 + 30 + 25 + 20 + 15 + 10 + 10 + 5 = 135.

Tiers (unchanged):

  • BASIC: 0-30 (minimum: just ID + 1 low-value cert)
  • VERIFIED: 31-70 (ID + P_IVA, or ID + P_IVA + one of insurance/albo/F_GAS)
  • ELITE: 71+ (ID + P_IVA + insurance + at least one of albo/F_GAS/training)

ID_DOCUMENT credential type deprecated — superseded by idVerified boolean from Stripe Identity. Existing records migrate to status=EXPIRED, no score contribution (already covered by +20).

  • ProfessionalProfile row created at Step 1 start — all subsequent state persists server-side
  • User closes app mid-flow → onboardingStep remembers position
  • Reopens app → Consumer tab default. Profile menu shows “Continua registrazione professionista” banner linking to /onboarding/resume
  • Nudge push notifications:
    • +24hr after last activity: “Hai iniziato la registrazione — completala per iniziare a ricevere richieste”
    • +7 days: final reminder
    • +30 days: auto-archive (role reverts to CONSUMER, ProfessionalProfile soft-deleted, data retained 90 days for privacy compliance)

Phase A (MVP 0) — this spec:

  1. Schema migrations (onboardingStep enum, new fields, PortfolioImage, ServicePricing models)
  2. API routes (11 endpoints above)
  3. Webhook handlers (Stripe Identity events)
  4. Mobile routes (11 screens)
  5. Test mode Stripe Identity (free)
  6. P_IVA threshold logic (€5k gate, dashboard banners)

Phase D — polish:

  • OCR auto-extract for P_IVA / albo numbers from uploaded docs
  • Admin auto-approve if OCR matches registry
  • SPID service provider accreditation (Year 2+)
  • Casellario giudiziale badge
  • Annual earnings dashboard widget for pros

Post-MVP:

  • Direct integration with Agenzia Entrate API for P_IVA validation (requires AgID accreditation)
  • Provincial albo cross-check via CCIAA APIs
  • Background check automation (eIDAS node)
  • Stripe Connect onboarding fails → “Riprova” button, log Sentry, support email link
  • Stripe Identity fails 3x in 24h → manual admin unlock required, clear CTA
  • R2 upload fails → retry with exponential backoff (3 attempts), fallback to in-app support
  • Webhook delivery fails → Stripe auto-retries, our handlers idempotent (check identitySessionId before flipping idVerified)
  • Partial Stripe account (missing bank details) → gate stays closed, dashboard CTA “Completa i dati bancari”
  • Unit: onboarding gate logic (Gate 1 + Gate 2 combinations), trust score calc, €5k threshold
  • Integration: full Stripe Identity webhook → idVerified flow (use Stripe CLI fixtures)
  • E2E: resume-from-partial-state (Step 3 → close app → reopen → resume at Step 4)
  • E2E: €5k threshold — seed pro with €4.999 earnings, complete booking for €2 → expect block on next booking request
  • Manual: Stripe test cards for Connect + Stripe Identity test doc for Identity
RiskImpactMitigation
Stripe Identity not enabled for Italian accounts on testblocks MVP devpre-flight: verify Stripe IT supports Identity API (docs confirm yes, 2025)
User closes app during Stripe Identity sheetpartial state, unclear UXwebhook processing marker + dashboard “Riprova verifica” CTA
€5k threshold edge cases (Stripe rounding, currency drift)wrong block timinguse annualGrossEarnings as single source of truth, computed from our Bookings only (not Stripe Balance)
Pros abandon after Step 3 (Stripe redirect friction)low conversionmeasure drop-off rate; if >30%, move Stripe to Step 5 (after ID)
Admin queue backlog on credentialsMVP-0 manual review doesn’t scalePhase D: OCR + auto-approve for matched docs
  • @stripe/stripe-react-native (already in mobile stack)
  • stripe Node SDK (already in BE — check version supports Identity API)
  • @rnmapbox/maps (Service area Step 2 — locked 2026-04-19)
  • @ideony/design-tokens (Sole palette — locked 2026-04-19)
  • Existing MediaModule (R2 presigned URLs — reuse for portfolio)
  • Existing CredentialsModule (Step 5 upload flow — reuse)
  • SPID-based verification (Year 2+)
  • Background check automation
  • Multi-business-entity support (pro with 2 P_IVAs for different services)
  • Sub-contractor / team pro accounts (master pro + helpers)
  • Verified business accreditation (albo artigiani, specialized trade boards)
  • Referral program (pro brings pro → bonus)
  1. Push notification copy for Stripe Identity retry — needs UX review before launch
  2. €5k threshold currency year — calendar year (Jan-Dec) or rolling 12 months? Italian tax law uses calendar year — match that
  3. Profile photo mandatory? Step 1 or Step 7? Current design has it implicit in portfolio — consider extracting
  4. Trust tier display during onboarding — show preview “Sarai BASIC tier — puoi salire uploadando P_IVA” ?
  5. Re-verification cadence for ID (every 2 years? 5?) — Stripe Identity doesn’t auto-expire, we could enforce
  • 2026-04-19 — Spec created. Brainstorm locked: Airbnb-style unified signup, Stripe Identity for ID verify (€1.50/prod, test mode free), P_IVA optional under €5k/year threshold, background check deferred to Phase D+, trust score formula +20 for IDENTITY_VERIFIED.