Skip to content

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):

  1. Company basics — name, country, vertical pick, headcount (drives tier recommendation), website URL. Submitting step 1 creates the Company row with onboardingStatus = INTAKE_IN_PROGRESS and a wizardDraft JSON blob holding partial state. The wizard supports back-and-forth navigation; later steps re-read wizardDraft to repopulate.
  2. 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.
  3. Product picker — vertical-scoped Sku list. The shopper picks 3-10 starter Skus from Joy’s curated catalog for their vertical. Drives IntakeSubmission.selectedSkuIds.
  4. Brand details — Brandfetch results (auto-populated from website URL) are shown; customer can override logo / accent / fonts. Headline + tagline.
  5. 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.onboardingStatus flips to INTAKE_COMPLETE + Company.activatedAt is 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 404SCRAPING_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

apps/api/src/modules/onboarding/onboarding.service.ts
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):

FromToReason
INTAKE_IN_PROGRESSINTAKE_COMPLETEwizard step 5 submitted + payment cleared
INTAKE_COMPLETESCRAPINGscraper kicked off
SCRAPINGSCRAPING_FAILEDBrandfetch / Claude failed
SCRAPINGLOGO_DESIGN_PENDINGlogo add-on purchased; pauses for YCM design
SCRAPINGPRINT_FILES_PENDINGno logo add-on; print files needed
LOGO_DESIGN_PENDINGPRINT_FILES_PENDINGlogo design complete
PRINT_FILES_PENDINGLIVEall print files APPROVED
LIVEPAUSEDadmin or YCM disabled the store
PAUSEDLIVEadmin or YCM re-enabled the store
LIVE / PAUSEDCHURNEDsubscription cancelled
SCRAPING_FAILEDSCRAPINGmanual 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:

TransitionSide effects
→ INTAKE_COMPLETEActivation Stripe PI fires (setup fee for ONE_TIME_SETUP + MEMBER_PAYS); scraper kicks off automatically on PI success
→ SCRAPINGBrandfetch + Claude synthesizer job dispatched; cron sweeper retries on transient failure
→ PRINT_FILES_PENDINGDesignTicket rows created for every (Sku, placement) pair on every enabled Product; INTERNAL_DESIGNER notified
→ LIVEStorefront opens for orders; Postmark “store live” email to admin + public-facing list update if applicable; activation cron flips Company.goLiveAt
→ PAUSEDStorefront returns 503-style “store paused” page; existing orders keep fulfilling (Gelato doesn’t know about MerchOS pause states)
→ CHURNEDStripe 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:

  1. Company.onboardingStatus === "LIVE" — all print files approved.
  2. 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

Triggers for update

Update this chapter if you:

  • Add or remove an OnboardingStatus enum 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).