diff --git a/cloud/apps/billing/src/entitlements.ts b/cloud/apps/billing/src/entitlements.ts index be4d89b4d8e..257823564bc 100644 --- a/cloud/apps/billing/src/entitlements.ts +++ b/cloud/apps/billing/src/entitlements.ts @@ -129,7 +129,7 @@ type AccountState = { export { entitlementsMode, type EntitlementsMode } from './entitlements-mode' -const LIVE_STATUSES = new Set(['active', 'trialing']) +export const LIVE_STATUSES = new Set(['active', 'trialing']) export function subscriptionWindow(subscription: SubscriptionRow, at: number): { start: number; end: number } { return allowanceWindow({ start: subscription.current_period_start, end: subscription.current_period_end }, at) diff --git a/cloud/apps/billing/src/features.ts b/cloud/apps/billing/src/features.ts index 5b85e515088..1448724f718 100644 --- a/cloud/apps/billing/src/features.ts +++ b/cloud/apps/billing/src/features.ts @@ -5,13 +5,19 @@ // live grants in every mode but 'off' (where nothing projects grants, so nothing is on). The // legacy computation has no features, so there is nothing for shadow mode to compare. // -// POST /v1/internal/entitlements/features {accountId} -> { features: { audit_log, sso, scim, mfa_enforcement } } +// POST /v1/internal/entitlements/features {accountId} +// -> { features: { audit_log, sso, scim, mfa_enforcement }, subscribed } +// `subscribed` (hasPaidSubscription) is what auth counts as Orca Cloud access for the account, so a +// paying team is never sent to "Choose a Team plan" because it isn't on the per-account rollout. // Callers: the control plane (CONTROL_PLANE_TOKEN) and auth (MEMBERSHIP_SYNC_TOKEN). Both fail closed. import { z } from 'zod' import { FEATURE_KEYS, type FeatureFlags } from './entitlement-keys' import { entitlementsMode } from './entitlements-mode' import type { Env } from './env' +import { LIVE_STATUSES } from './entitlements' import { grantsForSubject, liveAt } from './grants' +import { currentSubscription } from './store' +import { PAYMENT_FAILED_STATUSES } from './webhook' export const FeaturesRequestSchema = z.object({ accountId: z.string().min(1).max(160).regex(/^[A-Za-z0-9_.:-]+$/) }).strict() @@ -35,3 +41,15 @@ export async function featuresFor(env: Pick { + const subscription = await currentSubscription(db, accountId) + return subscription !== null && (LIVE_STATUSES.has(subscription.status) || PAYMENT_FAILED_STATUSES.has(subscription.status)) +} diff --git a/cloud/apps/billing/src/index.ts b/cloud/apps/billing/src/index.ts index 419c7a6265e..ebc71c6162b 100644 --- a/cloud/apps/billing/src/index.ts +++ b/cloud/apps/billing/src/index.ts @@ -15,7 +15,7 @@ // POST /v1/stripe/webhook signed events // Sandbox control plane (CONTROL_PLANE_TOKEN): // POST /v1/internal/entitlements/check {accountId, machineSize, runningSandboxes} -// POST /v1/internal/entitlements/features {accountId} -> {features: {audit_log, sso, scim, mfa_enforcement}} plan feature flags (control plane or auth) +// POST /v1/internal/entitlements/features {accountId} -> {features: {audit_log, sso, scim, mfa_enforcement}, subscribed} plan feature flags and paid-subscription access (control plane or auth) // POST /v1/internal/usage {usageId, accountId, sandboxId, machineSize, startedAt, endedAt} // GET /v1/internal/suspensions accounts whose payment grace expired (suspend running) // POST /v1/internal/trial/github {accountId, githubUserId?, githubOwnerId?} GitHub binding -> trial abuse keys @@ -58,7 +58,7 @@ import { MACHINE_SIZES, PlanConfigError, planCatalog, publicOffers } from './pla import { OverageRequestSchema, updateOverage } from './overage' import { LiveModeRefusedError, StripeError, createStripeClient, type StripeClient } from './stripe' import { UsageReportSchema, flushMeterReports, recordUsage, validateSegment } from './usage' -import { FeaturesRequestSchema, featuresFor } from './features' +import { FeaturesRequestSchema, featuresFor, hasPaidSubscription } from './features' import { handleStripeWebhook } from './webhook' import { UsageHistoryQuerySchema, usageHistory } from './usage-history' import { renderReturnPage, returnOutcome } from './return-page' @@ -526,7 +526,11 @@ export async function handleRequest(request: Request, env: Env, ctx: ExecutionCo if (!body.success) { return json({ error: 'invalid_request' }, 400) } - return json({ features: await featuresFor(env, body.data.accountId, now) }) + const [features, subscribed] = await Promise.all([ + featuresFor(env, body.data.accountId, now), + hasPaidSubscription(env.BILLING_DB, body.data.accountId) + ]) + return json({ features, subscribed }) } case 'POST /v1/internal/usage': { diff --git a/cloud/apps/billing/test/features.test.ts b/cloud/apps/billing/test/features.test.ts index 60e760d6bc2..7fe7cca7c41 100644 --- a/cloud/apps/billing/test/features.test.ts +++ b/cloud/apps/billing/test/features.test.ts @@ -7,6 +7,7 @@ import { featuresFor } from '../src/features' import { grantContactSalesPlan, grantsForSource, revokeManualGrant } from '../src/grants' import { repoCatalog } from './catalog-fixture' import { createHarness } from './harness' +import { checkoutCompleted } from './stripe-fake' const ALL_OFF = { audit_log: false, sso: false, scim: false, mfa_enforcement: false } const ALL_ON = { audit_log: true, sso: true, scim: true, mfa_enforcement: true } @@ -50,11 +51,11 @@ describe('catalog', () => { describe.each(['shadow', 'on'])('features (ENTITLEMENTS_MODE=%s)', (mode) => { it('are off for a team without Enterprise and on for one granted the plan', async () => { const h = await createHarness({ ENTITLEMENTS_MODE: mode }) - expect((await features(h)).body).toEqual({ features: ALL_OFF }) + expect((await features(h)).body).toEqual({ features: ALL_OFF, subscribed: false }) expect(await enterprise(h)).toEqual({ changed: true, planVersion: 2 }) - expect((await features(h)).body).toEqual({ features: ALL_ON }) + expect((await features(h)).body).toEqual({ features: ALL_ON, subscribed: false }) // Another team is unaffected. - expect((await features(h, 'org:o2')).body).toEqual({ features: ALL_OFF }) + expect((await features(h, 'org:o2')).body).toEqual({ features: ALL_OFF, subscribed: false }) }) it('the grant names the real plan and version, and stops when it ends or is revoked', async () => { @@ -76,7 +77,7 @@ describe('features', () => { const h = await createHarness({ ENTITLEMENTS_MODE: 'shadow' }) await enterprise(h) h.env.ENTITLEMENTS_MODE = 'off' - expect((await features(h)).body).toEqual({ features: ALL_OFF }) + expect((await features(h)).body).toEqual({ features: ALL_OFF, subscribed: false }) }) it('only a contact-sales plan can be granted this way', async () => { @@ -121,3 +122,43 @@ describe('features', () => { expect(body.grants.find((grant: { key: string }) => grant.key === 'audit_log')).toMatchObject({ value: true, source: 'manual', plan: { id: 'enterprise', version: 2 }, state: 'active' }) }) }) + +describe('subscribed', () => { + const PERIOD_START = Math.floor(Date.now() / 1000) - 5 * 24 * 3600 + const PERIOD_END = PERIOD_START + 30 * 24 * 3600 + + // A paid Team subscription for org:o1, through checkout and Stripe's webhook as in production. + async function paidTeam(mode = 'shadow') { + const h = await createHarness({ ENTITLEMENTS_MODE: mode }) + const token = await h.desktopToken({ sub: 'u1', org: 'o1', orgRole: 'owner', email: 'a@example.com' }) + const checkout = await h.call('POST', '/v1/billing/checkout', { body: { plan: 'team', seats: 2 }, headers: { authorization: `Bearer ${token}` } }) + expect(checkout.status).toBe(200) + const customer = [...h.stripe.customers.keys()][0]! + const subscription = h.stripe.putSubscription({ customer, accountId: 'org:o1', basePrice: 'price_team_month', quantity: 2, periodStart: PERIOD_START, periodEnd: PERIOD_END }) + await h.webhook(checkoutCompleted({ customer, subscription: subscription.id, accountId: 'org:o1' })) + return h + } + const setStatus = (h: Awaited>, status: string) => h.raw.prepare('UPDATE subscriptions SET status = ? WHERE account_id = ?').run(status, 'org:o1') + + it('is true for a team paying for a plan, whatever its hours, and false for everyone else', async () => { + for (const mode of ['off', 'shadow', 'on']) { + const h = await paidTeam(mode) + expect((await features(h)).body).toMatchObject({ subscribed: true }) + // Another team, and the owner's personal account, have no subscription. + expect((await features(h, 'org:o2')).body).toMatchObject({ subscribed: false }) + expect((await features(h, 'user:u1')).body).toMatchObject({ subscribed: false }) + } + }) + + it('stays true while a payment is being retried, and ends with the subscription', async () => { + const h = await paidTeam() + for (const status of ['trialing', 'past_due', 'unpaid']) { + setStatus(h, status) + expect((await features(h)).body, status).toMatchObject({ subscribed: true }) + } + for (const status of ['canceled', 'incomplete', 'incomplete_expired', 'paused']) { + setStatus(h, status) + expect((await features(h)).body, status).toMatchObject({ subscribed: false }) + } + }) +})