From 062c05d2fe69bb2d8a62ab0cf503cc86223ff7a7 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 08:49:41 -0800 Subject: [PATCH] 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. --- cloud/apps/billing/src/cloud-setup.ts | 54 ++++++++++++++++----- cloud/apps/billing/src/index.ts | 9 +++- cloud/apps/billing/test/cloud-setup.test.ts | 14 ++++++ 3 files changed, 64 insertions(+), 13 deletions(-) diff --git a/cloud/apps/billing/src/cloud-setup.ts b/cloud/apps/billing/src/cloud-setup.ts index b84bd4cd62c..aaf31c73cae 100644 --- a/cloud/apps/billing/src/cloud-setup.ts +++ b/cloud/apps/billing/src/cloud-setup.ts @@ -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, - now: number + now: number, + ip?: string ): Promise { 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, + 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 diff --git a/cloud/apps/billing/src/index.ts b/cloud/apps/billing/src/index.ts index 91296cd57ba..7a4c658d575 100644 --- a/cloud/apps/billing/src/index.ts +++ b/cloud/apps/billing/src/index.ts @@ -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) diff --git a/cloud/apps/billing/test/cloud-setup.test.ts b/cloud/apps/billing/test/cloud-setup.test.ts index 3c473afe8e8..6656ab863cf 100644 --- a/cloud/apps/billing/test/cloud-setup.test.ts +++ b/cloud/apps/billing/test/cloud-setup.test.ts @@ -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) + }) })