mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user