Onboarding flow
The journey
A new customer’s first ~30 minutes inside MerchOS:
Marketing site CTA ─→ /onboard wizard (5 steps) │ ▼ INTAKE_IN_PROGRESS (Company row created at step 1) │ ▼ INTAKE_COMPLETE (step 5 submitted) │ ▼ SCRAPING (Brandfetch + Claude synthesizer) │ ▼ PRINT_FILES_PENDING (designer queue runs) │ ▼ LIVE (storefront opens; first orders accepted)The customer-visible promise: intake takes 5 minutes, the rest is YCM doing work. If anything in this chain breaks, the chain stalls and Joy or Jess gets a notification. Branches are recoverable, not terminal.
The intake wizard
Source of truth: ADR-0091 — State-machine wizard intake + activation. Five steps, all rendered by apps/storefront/src/app/onboard/ (the public-facing intake is on the storefront app, branded Coral Fire — not theme-able per-company because the customer doesn’t have a theme yet):
- Company basics — name, country, vertical pick, headcount (drives tier recommendation), website URL. Submitting step 1 creates the
Companyrow withonboardingStatus = INTAKE_IN_PROGRESSand awizardDraftJSON blob holding partial state. The wizard supports back-and-forth navigation; later steps re-readwizardDraftto repopulate. - Brand kit teaser — preview of what Brandfetch will pull (logo, colors, fonts). Customer can pre-skip the scrape if they want to upload their own assets at step 4.
- Product picker — vertical-scoped Sku list. The shopper picks 3-10 starter Skus from Joy’s curated catalog for their vertical. Drives
IntakeSubmission.selectedSkuIds. - Brand details — Brandfetch results (auto-populated from website URL) are shown; customer can override logo / accent / fonts. Headline + tagline.
- Confirm + pay — tier preview ($49 / $99 / $199 / $399 CAD per tier, set by the headcount picker), $49 setup fee for ONE_TIME_SETUP + MEMBER_PAYS, Stripe Elements modal for payment. On payment success,
Company.onboardingStatusflips toINTAKE_COMPLETE+Company.activatedAtis set (gates real-money orders per CLAUDE.md §6 / ADR-0091).
Drafts that sit at INTAKE_IN_PROGRESS for >24h without progress are swept (deleted) by a cron — see OnboardingService.cleanupStaleDrafts. Real intakes don’t sit; an abandoned draft signals confusion or churn before commit.
Public-facing. The intake form is the only customer-facing surface that runs without auth. Cloudflare Turnstile gates it against bots (turnstile module). The form posts to POST /v1/onboarding/wizard/* endpoints — separate from the legacy POST /v1/intake (still kept for back-compat with the old single-form intake path).
The brand-scrape pipeline
Phase 0006 / ADR-0014 — Brand scraper v1. On INTAKE_IN_PROGRESS → INTAKE_COMPLETE transition (or on operator-triggered re-scrape), the brand-scraper runs:
Brandfetch (logo + brand colors + brand fonts + brand description) │ ▼Claude API (synthesizes a complete 9-field BrandKit, WCAG-validates contrast, picks a safe accent color when vibrancy is too low) │ ▼BrandScrapeResult row (raw + synthesized) ──→ Company.brandKit (live theme)Failure modes:
- Brandfetch 404 →
SCRAPING_FAILED. Common for very new companies (no public website crawl history yet). Joy intervenes manually. - Claude API timeout / quota → retry once with backoff; on second failure →
SCRAPING_FAILED. - Vibrancy gate (ADR-0061) — if the scraped brand accent has HSL saturation < 0.25, the company-admin portal substitutes YCM’s Coral default for its own UI (storefront still uses the brand accent verbatim).
The BrandKit is live-themed in the storefront via CSS custom properties: --color-primary, --color-accent, --font-heading, etc. Joy and Jess can edit each field via the company-admin portal’s BrandKit editor.
The print-files state — the gate
PRINT_FILES_PENDING is the longest-living state for most companies — typically 1-3 business days while YCM’s design queue produces print-ready files per enabled Sku × placement combination.
This is the gate on go-live (CLAUDE.md §6 rule 1):
Print files gate orders. Hard block in
OrdersService, not a soft warning in the UI.
The hard block lives at order submission time: OrdersService.validateAndPrepareOrder checks each Product.printFiles has at least one APPROVED revision. Without it, the order is rejected.
The designer queue lives in apps/internal-admin/src/app/design-queue/. Each DesignTicket row represents one (Sku, placement) pair. INTERNAL_DESIGNER users work the queue; SUPER_ADMIN can override.
ADR-0134 — Logo & print-file queue redesign is the current spec for the queue.
Activation race-condition: the company can be PRINT_FILES_PENDING but have activatedAt = null (setup fee not yet cleared). Both gates fire independently at order submit; the company waits on whichever one finishes last. Phase 0091 / ADR-0091 separated these out so they don’t deadlock each other.
The state machine itself
async transitionTo( companyId: string, newStatus: OnboardingStatus, ...): Promise<void>The single chokepoint. No direct prisma.company.update({ data: { onboardingStatus: ... } }) from outside OnboardingService. The chokepoint is what makes the state machine trustworthy — it validates the from→to edge against an allow-list, writes an OnboardingRecord audit row, and fires the corresponding side effects (email notifications, BrandKit re-scrape, designer queue intake) atomically.
Allowed edges (today, per the live code):
| From | To | Reason |
|---|---|---|
INTAKE_IN_PROGRESS | INTAKE_COMPLETE | wizard step 5 submitted + payment cleared |
INTAKE_COMPLETE | SCRAPING | scraper kicked off |
SCRAPING | SCRAPING_FAILED | Brandfetch / Claude failed |
SCRAPING | LOGO_DESIGN_PENDING | logo add-on purchased; pauses for YCM design |
SCRAPING | PRINT_FILES_PENDING | no logo add-on; print files needed |
LOGO_DESIGN_PENDING | PRINT_FILES_PENDING | logo design complete |
PRINT_FILES_PENDING | LIVE | all print files APPROVED |
LIVE | PAUSED | admin or YCM disabled the store |
PAUSED | LIVE | admin or YCM re-enabled the store |
LIVE / PAUSED | CHURNED | subscription cancelled |
SCRAPING_FAILED | SCRAPING | manual retry by YCM |
Every transition writes an OnboardingRecord row. Use OnboardingService.getHistory(companyId) for the audit trail.
Side effects per transition
What happens “automatically” as a result of transitionTo firing:
| Transition | Side effects |
|---|---|
→ INTAKE_COMPLETE | Activation Stripe PI fires (setup fee for ONE_TIME_SETUP + MEMBER_PAYS); scraper kicks off automatically on PI success |
→ SCRAPING | Brandfetch + Claude synthesizer job dispatched; cron sweeper retries on transient failure |
→ PRINT_FILES_PENDING | DesignTicket rows created for every (Sku, placement) pair on every enabled Product; INTERNAL_DESIGNER notified |
→ LIVE | Storefront opens for orders; Postmark “store live” email to admin + public-facing list update if applicable; activation cron flips Company.goLiveAt |
→ PAUSED | Storefront returns 503-style “store paused” page; existing orders keep fulfilling (Gelato doesn’t know about MerchOS pause states) |
→ CHURNED | Stripe subscription cancelled (no proration unless YCM intervenes); data retained for analytics + potential re-activation; storefront shut down |
Activation gates (the two-key system)
Two independent gates control whether a Company can accept real-money orders:
Company.onboardingStatus === "LIVE"— all print files approved.Company.activatedAt !== null— setup fee (and first month for MONTHLY_SUBSCRIPTION) has cleared Stripe.
Both check at order-submit time in OrdersService.validateAndPrepareOrder. Either gate failing returns a clear, customer-readable error:
- Status not LIVE → “Store isn’t open for orders right now”
- activatedAt null → “Your store isn’t open for orders yet — billing needs to be activated.”
The order of gate checks matters: status check first (gives Joy / Jess the most useful info) → activation check second (only if status passes). ADR-0091 §Decision invariant 1.
What’s next
- Catalog pipeline — how Joy turns supplier catalogs into shoppable Skus.
- Internal admin (Chapter 9) — Joy + Jess’s daily workflow inside the system.
Canonical sources
prisma/schema.prisma—OnboardingStatusenum +Company.onboardingStatus+Company.activatedAt+OnboardingRecordmodel.apps/api/src/modules/onboarding/onboarding.service.ts—transitionTo()+submitIntake()+getHistory().apps/api/src/modules/onboarding/wizard.service.ts— wizard step state machine.- ADR-0091 — the wizard-intake architecture.
- ADR-0014 — brand-scraper pipeline.
- ADR-0061 — vibrancy gate for the company-admin portal.
- ADR-0066 — headcount → tier recommendation logic.
- ADR-0134 — print-file queue spec.
docs/services/onboarding.md— service-level deep read.docs/services/brand-scraper.md— scraper pipeline internals.
Triggers for update
Update this chapter if you:
- Add or remove an
OnboardingStatusenum value (and update CLAUDE.md §5 in the same commit). - Add or remove an allowed edge in
transitionTo’s state-machine allow-list. - Change which side effects fire on a given transition.
- Change the wizard step shape (add or remove a step).
- Change the activation-gate logic in
validateAndPrepareOrder. - Replace Brandfetch or Claude in the brand-scrape pipeline.
- Replace the designer queue’s data model (
DesignTicket).