mirror of
https://github.com/hanzoai/id.git
synced 2026-10-16 18:38:15 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -26,6 +26,7 @@ export {
|
||||
export {
|
||||
frontier,
|
||||
move,
|
||||
paidReturn,
|
||||
reachable,
|
||||
resume,
|
||||
start,
|
||||
|
||||
@@ -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'), [])
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user