fix(billing): grant the trial to the desktop's active team when setup starts on it

Live m4air run: a signed-in owner whose active team never had a trial was sent
to T3/Checkout. Only teams bound in the browser got the trial. Session creation
now runs the same grant (N11 limits) for the active team, so the chain skips
the plan page.
This commit is contained in:
Neil
2026-11-10 08:49:41 -08:00
parent 977a17e0a2
commit 062c05d2fe
3 changed files with 64 additions and 13 deletions
+42 -12
View File
@@ -14,7 +14,7 @@
import { z } from 'zod'
import type { BillingCaller } from './auth'
import { createCheckout, OPEN_STATUSES } from './checkout'
import type { TrialPort } from './cloud-setup-trial'
import type { TrialGrantInput, TrialPort } from './cloud-setup-trial'
import { computeEntitlement } from './entitlements'
import type { Env } from './env'
import type { MembershipSource } from './membership'
@@ -147,9 +147,11 @@ export type CreateSetupSessionResult =
export async function createSetupSession(
env: Env,
trial: TrialPort,
caller: BillingCaller,
body: z.infer<typeof CreateSetupSessionSchema>,
now: number
now: number,
ip?: string
): Promise<CreateSetupSessionResult> {
const recent = await env.BILLING_DB.prepare(
'SELECT COUNT(*) AS n FROM cloud_setup_sessions WHERE user_id = ?1 AND created_at > ?2'
@@ -195,6 +197,17 @@ export async function createSetupSession(
expiresAt
)
.run()
if (keepTeam && orgAccount && needs.has('plan')) {
// The desktop's active team is the session's team: like a team setup picks, it gets its
// one-time trial now if it never had a plan or a trial, so the chain skips T3 and Checkout.
await grantTrialIfEligible(
env,
trial,
orgAccount,
{ userId: caller.userId, ...(caller.email ? { email: caller.email } : {}), ...(ip ? { ip } : {}) },
now
)
}
return { ok: true, id, url: setupPageUrl(env, '/start', { session: id, ticket }), expiresAt }
}
@@ -461,28 +474,45 @@ export async function bindSetupTeam(
},
now
)
const state = await planState(env, trial, accountId, now)
if (state.plan !== null || !parseNeeds(row).has('plan')) {
if (!parseNeeds(row).has('plan')) {
return { ok: true, trial: 'not_needed' }
}
return { ok: true, trial: await grantTrialIfEligible(env, trial, accountId, body, now) }
}
/**
* The team's plan step: a live plan or trial needs nothing; a team that never had either gets its
* one-time trial now (N11 limits apply); one whose trial is used up goes to T3 and Checkout.
*/
async function grantTrialIfEligible(
env: Env,
trial: TrialPort,
accountId: string,
who: Omit<TrialGrantInput, 'accountId'>,
now: number
): Promise<'granted' | 'unavailable' | 'not_needed'> {
const state = await planState(env, trial, accountId, now)
if (state.plan !== null) {
return 'not_needed'
}
if (state.trial) {
// Had a trial and used it up: Checkout.
return { ok: true, trial: 'unavailable' }
return 'unavailable'
}
const granted = await trial.grant(
env,
{
accountId,
userId: body.userId,
...(body.email ? { email: body.email } : {}),
...(body.emailVerified !== undefined ? { emailVerified: body.emailVerified } : {}),
...(body.githubUserId ? { githubUserId: body.githubUserId } : {}),
...(body.githubOwnerId ? { githubOwnerId: body.githubOwnerId } : {}),
...(body.ip ? { ip: body.ip } : {})
userId: who.userId,
...(who.email ? { email: who.email } : {}),
...(who.emailVerified !== undefined ? { emailVerified: who.emailVerified } : {}),
...(who.githubUserId ? { githubUserId: who.githubUserId } : {}),
...(who.githubOwnerId ? { githubOwnerId: who.githubOwnerId } : {}),
...(who.ip ? { ip: who.ip } : {})
},
now
)
return { ok: true, trial: granted.ok ? 'granted' : 'unavailable' }
return granted.ok ? 'granted' : 'unavailable'
}
export const SetupErrorSchema = z
+8 -1
View File
@@ -165,7 +165,14 @@ async function handleCloudSetup(
if (!body.success) {
return json({ error: 'invalid_request' }, 400)
}
const created = await createSetupSession(env, caller, body.data, now)
const created = await createSetupSession(
env,
deps.trial,
caller,
body.data,
now,
request.headers.get('cf-connecting-ip') ?? undefined
)
return created.ok
? json({ id: created.id, url: created.url, expiresAt: created.expiresAt }, 201)
: json({ error: created.error }, created.status)
@@ -275,4 +275,18 @@ describe('setup session with the real trial module', () => {
})
expect(again.body).toEqual({ ok: true, trial: 'unavailable' })
})
it('grants the trial to the desktop’s active team when setup starts on it (no Checkout)', async () => {
const h = await createHarness({ MEMBERSHIP_SYNC_TOKEN: SYNC, CLOUD_SETUP_PAGES_URL: 'https://auth.test' })
const owner = { authorization: `Bearer ${await h.desktopToken({ sub: 'u7', org: 'o_live', orgRole: 'owner', email: 'o@acme.dev' })}` }
const id = (await h.call('POST', '/v1/cloud-setup/sessions', { body: { needs: ['plan'] }, headers: owner })).body.id
const view = (await h.call('GET', `/v1/cloud-setup/sessions/${id}`, { headers: owner })).body
expect(view.plan).toBe('trial')
expect(view.steps).toEqual([
{ id: 'account', state: 'done' },
{ id: 'team', state: 'done' },
{ id: 'plan', state: 'done', detail: 'trial' }
])
expect(view.finished).toBe(true)
})
})