mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
Merge pull request #628 from nwparker/cloud/billing-subscribed-flag
feat(billing): features answer says whether the account pays for a plan (PAID-TEAM-CHOOSE-PLAN)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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<Env, 'BILLING_DB' | 'ENTITLEMENTS_MO
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the account holds a paid subscription that is live (active, trialing) or in payment
|
||||
* recovery (past_due, unpaid): a paying customer, whatever its hours or payment state, whose
|
||||
* problems the entitlement itself reports. Canceled, incomplete and expired subscriptions, the
|
||||
* no-card trial and no subscription at all are false. Independent of ENTITLEMENTS_MODE: it reads
|
||||
* the subscription row Stripe's webhooks keep, as the legacy entitlement does.
|
||||
*/
|
||||
export async function hasPaidSubscription(db: D1Database, accountId: string): Promise<boolean> {
|
||||
const subscription = await currentSubscription(db, accountId)
|
||||
return subscription !== null && (LIVE_STATUSES.has(subscription.status) || PAYMENT_FAILED_STATUSES.has(subscription.status))
|
||||
}
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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<ReturnType<typeof createHarness>>, 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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user