Onboarding completes when checkout returns paid

The plan step records the choice and sends the buyer to checkout with the
org they founded as ?org=, so pay charges that org's ledger rather than the
signup org. Completion records only when pay returns to /onboarding with
checkout=success; leaving checkout unpaid reopens the plan step. Plans show
the monthly price checkout sells, and a per-seat plan states its seat
minimum and first charge from the catalog's perSeat and limits.minSeats.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
zooqueen
2026-09-23 13:17:34 -07:00
co-authored by Hanzo Dev
parent 80929e5a67
commit 1261a5ab88
9 changed files with 148 additions and 67 deletions
+26 -10
View File
@@ -4,6 +4,7 @@ import { createIam, loginWithWalletChain, type AuthClient } from '@hanzo/id-auth
import {
OnboardingFlow,
createOnboardingService,
paidReturn,
resume,
type OnboardingState,
type StepId,
@@ -28,11 +29,11 @@ const DEFAULT_PAY_URL = 'https://pay.hanzo.ai'
* the wallet step is skip-only.
*
* NEVER REPEATS: completion is recorded on the USER (Properties, via
* saveOnboarding) — so before mounting the flow this page reads it back and,
* if the user already finished onboarding on ANY browser, goes straight to
* the portal. The read failing open (network blip → run the flow again) is
* deliberate: repeating is annoying, silently skipping a required step is
* worse.
* saveOnboarding) when checkout returns here paid — so before mounting the flow
* this page reads it back and, if the user already finished onboarding on ANY
* browser, goes straight to the portal. The read failing open (network blip →
* run the flow again) is deliberate: repeating is annoying, silently skipping a
* required step is worse.
*
* NEVER RESTARTS EITHER: the same read says where to RESUME. Every answer the
* account already holds retires its step, so a refresh, a closed tab, a second
@@ -103,6 +104,15 @@ export function Onboarding({
window.location.replace('/?signed_in=1')
return
}
// Checkout came back paid for the recorded choice: THIS is where
// completion records. A failed write still lands the paying person on
// the portal; the flow reopens at the plan step on their next visit.
if (paidReturn(window.location.search, answers.plan)) {
setEntry('done')
await service.saveOnboarding({ completedAt: new Date().toISOString() })
window.location.replace('/?signed_in=1')
return
}
// Not finished — so open on whatever the account leaves outstanding.
setEntry(resume(answers))
return
@@ -121,8 +131,8 @@ export function Onboarding({
// - a plan slug → the pay cart, seats + payment there (price is the
// catalog's — commerce recomputes server-side, the slug is enough)
// - pay as you go → the top-up flow ($5 minimum, all methods)
// The plan choice is already persisted on the user, so bouncing off the
// payment page never re-enters onboarding.
// Only the choice is recorded so far. Completion records when checkout
// returns to this page paid, so leaving checkout unpaid reopens the plan step.
const choice = state.planChoice
// Carry where to come BACK to. Checkout is a different origin, and with no
// returnUrl it has nothing to name: its header logo and Cancel link stood in the
@@ -131,11 +141,17 @@ export function Onboarding({
// offered an exit into whichever product sorted first. pay validates this against
// that same allowlist and ignores anything not on it, so sending it can only
// narrow where a buyer lands, never widen it.
const back = encodeURIComponent(`${window.location.origin}/?signed_in=1`)
//
// `org` names the ledger to charge: the org this person founded or works in,
// not the signup org every account is enrolled in. pay honours it only while
// the buyer's token lists that org.
const q = new URLSearchParams({ returnUrl: `${window.location.origin}/onboarding` })
if (state.orgName) q.set('org', state.orgName)
if (choice === 'payg') {
window.location.replace(`${payUrl}/onboard?returnUrl=${back}`)
window.location.replace(`${payUrl}/onboard?${q}`)
} else if (choice) {
window.location.replace(`${payUrl}/cart?plan=${encodeURIComponent(choice)}&returnUrl=${back}`)
q.set('plan', choice)
window.location.replace(`${payUrl}/cart?${q}`)
} else {
// No recorded choice (should not happen — the plan step requires one):
// land on the authenticated portal rather than a dead end.
+25 -8
View File
@@ -119,10 +119,13 @@ export function move(flow: Flow, m: Move): Flow {
* a second org exactly when the caller already admins one — so the client and
* the server agree by construction instead of by a client guess.
*
* `project` and `wallet` are never pre-answered: nothing records them on the
* account, and both are optional and additive (a second project and a second
* wallet are both legal), so offering them again costs nothing and claiming they
* were answered would be a guess.
* `project` and `wallet` are pre-answered only by a recorded plan choice, which
* is made after both. Nothing else records them, and both are optional and
* additive (a second project and a second wallet are both legal), so offering
* them again costs nothing and claiming they were answered would be a guess.
*
* `plan` is never pre-answered: it is answered by paying, and a paid account has
* `completedAt` and never mounts the flow.
*/
export function resume(a: Answers): { answered: StepId[]; data: OnboardingState } {
const answered: StepId[] = []
@@ -135,9 +138,23 @@ export function resume(a: Answers): { answered: StepId[]; data: OnboardingState
answered.push('consent')
data.dataSharingConsent = a.consent
}
if (a.plan !== null) {
answered.push('plan')
data.planChoice = a.plan
}
// A recorded plan is a choice made on the way to checkout, not a payment, so
// it never answers the plan step. It does show the person got past project and
// wallet, so they reopen on the plan step.
if (a.plan !== null) answered.push('project', 'wallet')
return { answered, data: data as OnboardingState }
}
/**
* Did checkout come back paid, for an account whose plan step recorded a choice?
*
* pay's success page sends the buyer to `returnUrl` with `checkout=success` once
* the payment settles; a rail that settles later (crypto, wire) returns its own
* status instead. Completion is recorded on this return and never on the click,
* so leaving checkout unpaid leaves onboarding open. The signal comes through the
* browser: completion only retires this flow, and billing decides what an org
* may use.
*/
export function paidReturn(search: string, plan: string | null): boolean {
return plan !== null && new URLSearchParams(search).get('checkout') === 'success'
}
+11 -13
View File
@@ -10,8 +10,8 @@
* 3. wallet — prove a wallet and bind it to the account. Optional.
* 4. consent — answer the data-sharing question. Required: the answer is what
* must exist, and both answers are valid ones.
* 5. plan — choose how you pay. Required, and LAST: choosing records
* completion and hands off to checkout.
* 5. plan — choose how you pay. Required, and LAST: choosing hands off to
* checkout, and completion records when checkout returns paid.
*
* The flow is declared as data here so the UI layer can render it without
* the domain importing React. `OnboardingService` (the service layer) does
@@ -78,7 +78,7 @@ export const STEPS: readonly StepDesc[] = [
title: 'Choose how you pay',
byline: 'Pick a plan, or pay as you go with a prepaid balance.',
// The LAST page, and the only way out of onboarding: choosing a plan or a
// prepaid balance records completion and sends the person to checkout.
// prepaid balance sends the person to checkout, and paying completes it.
skippable: false,
},
] as const
@@ -128,8 +128,8 @@ export interface OnboardingState {
*/
export interface Answers {
/**
* RFC3339 stamp written by the LAST step. Set means onboarding is finished and
* the host must not mount the flow at all.
* RFC3339 stamp written when checkout returns paid (see `paidReturn`). Set
* means onboarding is finished and the host must not mount the flow at all.
*/
readonly completedAt: string | null
/**
@@ -138,7 +138,7 @@ export interface Answers {
* from "not asked", and a screen has to know whether to ask.
*/
readonly consent: boolean | null
/** The recorded payment choice: a plan slug, or 'payg'. */
/** The plan step's recorded choice, a plan slug or 'payg'; not proof of payment. */
readonly plan: string | null
/** The org the account is filed under. */
readonly org: string | null
@@ -158,14 +158,12 @@ export interface PlanInfo {
readonly slug: string
readonly name: string
readonly description?: string
/** Monthly price in CENTS (the catalog's `price` — 900 = $9/mo). */
/** Monthly price in CENTS (the catalog's `price` — 900 = $9/mo), per seat when `perSeat`. */
readonly priceCents: number
/**
* Monthly-equivalent price in CENTS when billed annually (the catalog's
* `priceAnnual` — 825 = $8.25/mo ≈ $99/yr). Absent when the plan has no
* annual rate.
*/
readonly priceAnnualCents?: number
/** The price is per seat (the catalog's `perSeat`). */
readonly perSeat: boolean
/** Fewest seats checkout sells (the catalog's `limits.minSeats`), 1 when unstated. */
readonly minSeats: number
readonly popular?: boolean
}
+29 -8
View File
@@ -10,7 +10,7 @@
*/
import { test } from 'vitest'
import assert from 'node:assert/strict'
import { frontier, move, reachable, resume, start } from './domain/flow.ts'
import { frontier, move, paidReturn, reachable, resume, start } from './domain/flow.ts'
import type { Answers, OnboardingState, StepId } from './domain/types.ts'
/** A brand-new account: nothing answered anywhere. */
@@ -176,20 +176,41 @@ test('a stored consent answer retires the consent step and carries its value', (
})
test('an account with an org and an answered consent lands on what is left', () => {
const { answered, data } = resume({ ...fresh, org: 'acme', admin: true, consent: true, plan: 'pro' })
assert.deepEqual(answered.slice().sort(), ['consent', 'org', 'plan'])
const { answered, data } = resume({ ...fresh, org: 'acme', admin: true, consent: true })
assert.deepEqual(answered.slice().sort(), ['consent', 'org'])
const flow = start(answered, data)
assert.equal(flow.step, 'project', 'the first thing genuinely outstanding')
// Answering it moves to the next OUTSTANDING step, skipping the answered ones,
// so a re-entry converges instead of re-walking the whole flow.
assert.equal(move(flow, { kind: 'answer', patch: {} }).step, 'wallet')
assert.equal(move(move(flow, { kind: 'answer', patch: {} }), { kind: 'answer', patch: {} }).step, 'done')
assert.equal(move(move(flow, { kind: 'answer', patch: {} }), { kind: 'answer', patch: {} }).step, 'plan')
})
test('project and wallet are never pre-answered — nothing on the account records them', () => {
const { answered } = resume({ ...fresh, org: 'acme', admin: true, consent: true, plan: 'pro' })
assert.equal(answered.includes('project'), false)
assert.equal(answered.includes('wallet'), false)
test('project and wallet are pre-answered only by a recorded plan choice', () => {
assert.equal(resume({ ...fresh, org: 'acme', admin: true, consent: true }).answered.includes('project'), false)
assert.equal(resume({ ...fresh, org: 'acme', admin: true, consent: true }).answered.includes('wallet'), false)
})
// The plan step used to record completion on the click, before any payment, so
// leaving checkout unpaid still finished onboarding. A recorded choice is now only
// a choice: the account reopens on the plan step until checkout returns paid.
test('a recorded but unpaid plan choice reopens the plan step', () => {
const { answered, data } = resume({ ...fresh, org: 'acme', admin: true, consent: false, plan: 'pro' })
assert.equal(answered.includes('plan'), false)
assert.equal(data.planChoice, undefined)
const flow = start(answered, data)
assert.equal(flow.step, 'plan')
assert.equal(move(flow, { kind: 'goTo', step: 'done' }).step, 'plan')
})
test('completion waits for checkout to return paid for a recorded choice', () => {
assert.equal(paidReturn('?checkout=success&plan=pro', 'pro'), true)
assert.equal(paidReturn('?checkout=success', 'payg'), true)
// The click alone, an unpaid or pending return, and a return with no choice recorded.
assert.equal(paidReturn('', 'pro'), false)
assert.equal(paidReturn('?checkout=pending&plan=pro', 'pro'), false)
assert.equal(paidReturn('?checkout=unknown', 'payg'), false)
assert.equal(paidReturn('?checkout=success&plan=pro', null), false)
})
test('a fresh account resumes to nothing answered', () => {
+1
View File
@@ -26,6 +26,7 @@ export {
export {
frontier,
move,
paidReturn,
reachable,
resume,
start,
+8 -5
View File
@@ -281,15 +281,15 @@ test('a non-JSON 2xx is a failed write, not a silent success', async () => {
assert.match(res.ok === false ? res.error : '', /non-JSON/)
})
// The live catalog prices in CENTS (go=900 means $9/mo, priceAnnual=825 means
// $8.25/mo billed annually) and carries other product lines (dns-*) in the
// same list. This test pins both facts with production-shaped rows.
// The live catalog prices in CENTS (go=900 means $9/mo), marks per-seat plans
// with `perSeat` and their floor with `limits.minSeats`, and carries other
// product lines (dns-*) in the same list. Production-shaped rows pin all three.
test('listPlans keeps cents unscaled, keeps personal+team only; [] on failure', async () => {
const { service, calls } = harness(() => ({
json: [
{ slug: 'pro', name: 'Pro', category: 'personal', price: 4900, priceAnnual: 4150, popular: true },
{ slug: 'go', name: 'Go', category: 'personal', price: 900, priceAnnual: 825 },
{ slug: 'team', name: 'Team', category: 'team', price: 2500, priceAnnual: 2000 },
{ slug: 'team', name: 'Team', category: 'team', price: 2400, priceAnnual: 1968, perSeat: true, limits: { minSeats: 2 } },
{ slug: 'dns-pro', name: 'DNS Pro', category: 'dns', price: 500 }, // other product line → dropped
{ slug: 'enterprise', name: 'Enterprise', category: 'enterprise', price: 0 }, // not self-serve → dropped
{ slug: '', name: 'broken', category: 'personal', price: 500 }, // no slug → dropped
@@ -302,8 +302,11 @@ test('listPlans keeps cents unscaled, keeps personal+team only; [] on failure',
['pro', 'go', 'team'],
)
assert.equal(plans[0]!.priceCents, 4900)
assert.equal(plans[0]!.priceAnnualCents, 4150)
assert.equal(plans[0]!.popular, true)
// Seats come from the catalog; an annual rate is not read, because checkout sells monthly only.
assert.deepEqual([plans[0]!.perSeat, plans[0]!.minSeats], [false, 1])
assert.deepEqual([plans[2]!.perSeat, plans[2]!.minSeats], [true, 2])
assert.equal('priceAnnualCents' in plans[0]!, false)
const down = harness(() => ({ status: 503, json: { error: 'nope' } }))
assert.deepEqual(await down.service.listPlans('https://pay.hanzo.ai'), [])
+15 -9
View File
@@ -329,15 +329,21 @@ export function createOnboardingService(opts: OnboardingServiceOptions): Onboard
// Onboarding offers the account plans; other product lines in the same
// catalog (dns-*, enterprise) have their own surfaces.
.filter((r) => r.category === 'personal' || r.category === 'team')
.map((r) => ({
slug: typeof r.slug === 'string' ? r.slug : '',
name: typeof r.name === 'string' ? r.name : '',
description: typeof r.description === 'string' ? r.description : undefined,
// Catalog prices are CENTS (900 = $9/mo); passed through unscaled.
priceCents: typeof r.price === 'number' ? r.price : NaN,
priceAnnualCents: typeof r.priceAnnual === 'number' && r.priceAnnual > 0 ? r.priceAnnual : undefined,
popular: r.popular === true,
}))
// `priceAnnual` is not read: checkout sells monthly only.
.map((r) => {
const limits = (typeof r.limits === 'object' && r.limits !== null ? r.limits : {}) as Record<string, unknown>
const min = limits.minSeats
return {
slug: typeof r.slug === 'string' ? r.slug : '',
name: typeof r.name === 'string' ? r.name : '',
description: typeof r.description === 'string' ? r.description : undefined,
// Catalog prices are CENTS (900 = $9/mo); passed through unscaled.
priceCents: typeof r.price === 'number' ? r.price : NaN,
perSeat: r.perSeat === true,
minSeats: typeof min === 'number' && Number.isInteger(min) && min > 1 ? min : 1,
popular: r.popular === true,
}
})
.filter((p) => p.slug && p.name && Number.isFinite(p.priceCents) && p.priceCents > 0)
} catch {
return []
+14 -5
View File
@@ -11,7 +11,8 @@ import { OnboardingFlow } from './OnboardingFlow'
afterEach(cleanup)
const pro: PlanInfo = { slug: 'pro', name: 'Pro', priceCents: 1900 }
const pro: PlanInfo = { slug: 'pro', name: 'Pro', priceCents: 1900, perSeat: false, minSeats: 1 }
const team: PlanInfo = { slug: 'team', name: 'Team', priceCents: 2400, perSeat: true, minSeats: 2 }
/** A service whose catalog answers from `catalogs` in turn, recording every save. */
function service(catalogs: PlanInfo[][]) {
@@ -70,14 +71,22 @@ test('a failed catalog offers Retry and no choice, then the plans once it answer
assert.deepEqual(saves, [])
})
test('choosing a plan records it with completion and finishes the flow', async () => {
test('choosing a plan records the choice, not completion, and hands off to checkout', async () => {
const { svc, saves } = service([[pro]])
const done: OnboardingState[] = []
mount(svc, done)
fireEvent.click(await screen.findByText('Pro'))
await waitFor(() => assert.equal(done.length, 1))
assert.equal(done[0]!.planChoice, 'pro')
const save = saves[0] as { plan: string; completedAt: string }
assert.equal(save.plan, 'pro')
assert.ok(save.completedAt)
assert.equal(done[0]!.orgName, 'acme')
assert.deepEqual(saves, [{ plan: 'pro' }])
})
test('prices are monthly, and a per-seat plan states its seat floor and first charge', async () => {
const { svc } = service([[pro, team]])
mount(svc)
await screen.findByText('Team')
assert.ok(screen.getByText('$19/mo'))
assert.ok(screen.getByText('$24 per seat/mo · minimum 2 seats · first charge $48'))
assert.equal(screen.queryByText(/annual|\/yr/), null)
})
+19 -9
View File
@@ -534,6 +534,18 @@ function usd(cents: number): string {
return Number.isInteger(dollars) ? `$${dollars}` : `$${dollars.toFixed(2)}`
}
/**
* What checkout will charge, from the catalog's own fields: "$19/mo", or for a
* per-seat plan with a seat minimum "$24 per seat/mo · minimum 2 seats · first
* charge $48". Monthly only, because that is the only term checkout sells.
*/
function price(p: PlanInfo): string {
if (!p.perSeat) return `${usd(p.priceCents)}/mo`
const each = `${usd(p.priceCents)} per seat/mo`
if (p.minSeats <= 1) return each
return `${each} · minimum ${p.minSeats} seats · first charge ${usd(p.priceCents * p.minSeats)}`
}
function PlanStep({
service,
payUrl,
@@ -561,14 +573,15 @@ function PlanStep({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [attempt])
// The choice is persisted with completion BEFORE the flow advances to
// checkout, so a person who leaves the payment page lands on the portal next
// time rather than back in onboarding. Choosing is the only way to answer this
// step: it has no Skip, and the machine refuses an answer without a plan.
// The choice is recorded before the flow hands off to checkout; completion is
// not. It records when checkout returns paid (`paidReturn`), so a person who
// leaves the payment page unpaid reopens on this step. Choosing is the only
// way to answer it: it has no Skip, and the machine refuses an answer without
// a plan.
async function choose(choice: string) {
setBusy(choice)
setError(null)
const res = await service.saveOnboarding({ plan: choice, completedAt: new Date().toISOString() })
const res = await service.saveOnboarding({ plan: choice })
setBusy(null)
if (!res.ok) {
setError(res.error)
@@ -613,10 +626,7 @@ function PlanStep({
>
{p.popular ? <span className="hanzo-id-plan-badge">Popular</span> : null}
<span className="hanzo-id-plan-name">{p.name}</span>
<span className="hanzo-id-plan-price">
{usd(p.priceCents)}/mo
{p.priceAnnualCents ? <em> · {usd(p.priceAnnualCents * 12)}/yr billed annually</em> : null}
</span>
<span className="hanzo-id-plan-price">{price(p)}</span>
{p.description ? <span className="hanzo-id-plan-desc">{p.description}</span> : null}
</button>
))}