diff --git a/cloud/apps/billing/scripts/stripe-test-setup.ts b/cloud/apps/billing/scripts/stripe-test-setup.ts index 96b4ddecc70..628b43cd992 100644 --- a/cloud/apps/billing/scripts/stripe-test-setup.ts +++ b/cloud/apps/billing/scripts/stripe-test-setup.ts @@ -35,19 +35,23 @@ async function post(path: string, params: Record, idempotenc return json } +// Monthly price in cents per plan (Team is per seat) and the overage price per standard hour. +// Draft numbers; override with STRIPE_SETUP_PRICES='{"pro":2000,"max":10000,"team":5000,"overagePerHour":45}'. +const prices = { pro: 2500, max: 10000, team: 6000, overagePerHour: 45, ...JSON.parse(process.env.STRIPE_SETUP_PRICES ?? '{}') } const plans = [ - { id: 'pro', name: 'Orca Pro', cents: 2500 }, - { id: 'max', name: 'Orca Max', cents: 10000 }, - { id: 'team', name: 'Orca Team (per seat)', cents: 6000 } + { id: 'pro', name: 'Orca Pro', cents: prices.pro }, + { id: 'max', name: 'Orca Max', cents: prices.max }, + { id: 'team', name: 'Orca Team (per seat)', cents: prices.team } ] as const +const suffix = `${prices.pro}-${prices.max}-${prices.team}-${prices.overagePerHour}` const out: Record = {} for (const plan of plans) { const product = await post<{ id: string }>('/v1/products', { name: plan.name, metadata: { orca_plan: plan.id } }, `product:${plan.id}`) const price = await post<{ id: string }>( '/v1/prices', - { product: product.id, currency: 'usd', unit_amount: plan.cents, recurring: { interval: 'month' }, lookup_key: `orca_${plan.id}_monthly` }, - `price:${plan.id}` + { product: product.id, currency: 'usd', unit_amount: plan.cents, recurring: { interval: 'month' }, lookup_key: `orca_${plan.id}_monthly`, transfer_lookup_key: true }, + `price:${plan.id}:${suffix}` ) out[`STRIPE_PRICE_${plan.id.toUpperCase()}`] = price.id } @@ -64,18 +68,19 @@ const meter = await post<{ id: string; event_name: string }>( 'meter' ) const overageProduct = await post<{ id: string }>('/v1/products', { name: 'Orca sandbox overage', unit_label: 'hour' }, 'product:overage') -// $0.45 per standard hour; the meter counts seconds and Stripe divides by 3600 (rounding up) on the invoice. +// Per standard hour; the meter counts seconds and Stripe divides by 3600 (rounding up) on the invoice. const overage = await post<{ id: string }>( '/v1/prices', { product: overageProduct.id, currency: 'usd', - unit_amount: 45, + unit_amount: prices.overagePerHour, recurring: { interval: 'month', usage_type: 'metered', meter: meter.id }, transform_quantity: { divide_by: 3600, round: 'up' }, - lookup_key: 'orca_sandbox_overage_hourly' + lookup_key: 'orca_sandbox_overage_hourly', + transfer_lookup_key: true }, - 'price:overage' + `price:overage:${suffix}` ) out.STRIPE_PRICE_OVERAGE = overage.id out.STRIPE_METER_EVENT_NAME = meter.event_name diff --git a/cloud/apps/billing/src/checkout.ts b/cloud/apps/billing/src/checkout.ts index 9ee96b31b2a..06a6d457c1f 100644 --- a/cloud/apps/billing/src/checkout.ts +++ b/cloud/apps/billing/src/checkout.ts @@ -70,6 +70,9 @@ export async function createCheckout( success_url: returnUrl(env, { checkout: 'success', session_id: 'CHECKOUT_SESSION_ID_PLACEHOLDER' }), cancel_url: returnUrl(env, { checkout: 'cancel' }), metadata: { account_id: caller.accountId, plan: request.plan }, + ...(env.STRIPE_AUTOMATIC_TAX === 'true' + ? { automatic_tax: { enabled: true }, tax_id_collection: { enabled: true } } + : {}), subscription_data: { metadata: { account_id: caller.accountId, plan: request.plan }, trial_period_days: Number.isInteger(trialDays) && trialDays > 0 ? trialDays : undefined diff --git a/cloud/apps/billing/src/entitlements.ts b/cloud/apps/billing/src/entitlements.ts index a807a9ef146..83ec8364dfa 100644 --- a/cloud/apps/billing/src/entitlements.ts +++ b/cloud/apps/billing/src/entitlements.ts @@ -1,6 +1,7 @@ // "Can this account start a sandbox, and how many hours are left?" One computation shared by the // desktop endpoint and the control plane's start check, so the two can never disagree. -import { MACHINE_SIZES, PLANS, includedSeconds, overageCapSeconds, type MachineSize, type PlanId } from './plans' +import { MACHINE_SIZES, includedSeconds, overageCapSeconds, planCatalog, type MachineSize, type PlanId } from './plans' +import type { Env } from './env' import { currentSubscription, periodUsage } from './store' export type DenyReason = @@ -33,7 +34,9 @@ export type Entitlement = { const LIVE_STATUSES = new Set(['active', 'trialing']) -export async function computeEntitlement(db: D1Database, accountId: string): Promise { +export async function computeEntitlement(env: Pick, accountId: string): Promise { + const db = env.BILLING_DB + const catalog = planCatalog(env) const subscription = await currentSubscription(db, accountId) if (!subscription) { return { @@ -55,9 +58,9 @@ export async function computeEntitlement(db: D1Database, accountId: string): Pro reason: 'no_subscription' } } - const plan = PLANS[subscription.plan] - const included = includedSeconds(subscription.plan, subscription.seats) - const cap = overageCapSeconds(subscription.plan, subscription.seats) + const plan = catalog[subscription.plan] + const included = includedSeconds(plan, subscription.seats) + const cap = overageCapSeconds(plan, subscription.seats) const usage = await periodUsage(db, accountId, subscription.current_period_start) const remaining = Math.max(0, included - usage.usedSeconds) const overageEnabled = subscription.overage_enabled === 1 diff --git a/cloud/apps/billing/src/env.ts b/cloud/apps/billing/src/env.ts index 98736a016a3..27364aba50b 100644 --- a/cloud/apps/billing/src/env.ts +++ b/cloud/apps/billing/src/env.ts @@ -25,6 +25,13 @@ export type Env = { STRIPE_METER_EVENT_NAME: string // Optional card-required trial length for new subscriptions. STRIPE_TRIAL_DAYS?: string + // 'true' turns on Stripe Tax in Checkout (automatic_tax + tax id collection). Requires Stripe Tax + // to be activated with an origin address and registrations in the dashboard. Default off. + STRIPE_AUTOMATIC_TAX?: string + + // Optional JSON overriding any field of the default plan catalog (src/plans.ts), e.g. + // {"pro":{"priceLabel":"$20/mo","includedHoursPerSeat":40}}. Malformed JSON fails closed (503). + BILLING_PLANS?: string // Where Checkout and the portal send the browser back (a page that deep-links into Orca). BILLING_RETURN_URL: string diff --git a/cloud/apps/billing/src/index.ts b/cloud/apps/billing/src/index.ts index d8303c63b8f..05f1aa8160a 100644 --- a/cloud/apps/billing/src/index.ts +++ b/cloud/apps/billing/src/index.ts @@ -14,7 +14,7 @@ import { authenticateControlPlane, authenticateDesktop } from './auth' import { CheckoutRequestSchema, createCheckout, createPortal } from './checkout' import { checkStart, computeEntitlement } from './entitlements' import type { Env } from './env' -import { MACHINE_SIZES } from './plans' +import { MACHINE_SIZES, PlanConfigError, planCatalog, publicPlans } from './plans' import { LiveModeRefusedError, StripeError, createStripeClient, type StripeClient } from './stripe' import { UsageReportSchema, flushMeterReports, recordUsage, validateSegment } from './usage' import { handleStripeWebhook } from './webhook' @@ -58,8 +58,8 @@ export async function handleRequest(request: Request, env: Env, ctx: ExecutionCo if (!caller) { return json({ error: 'unauthorized' }, 401) } - const entitlement = await computeEntitlement(env.BILLING_DB, caller.accountId) - return json({ ...entitlement, canManageBilling: caller.canManage }) + const entitlement = await computeEntitlement(env, caller.accountId) + return json({ ...entitlement, canManageBilling: caller.canManage, plans: publicPlans(planCatalog(env)) }) } case 'POST /v1/billing/checkout': { @@ -92,7 +92,7 @@ export async function handleRequest(request: Request, env: Env, ctx: ExecutionCo if (!body.success) { return json({ error: 'invalid_request' }, 400) } - const entitlement = await computeEntitlement(env.BILLING_DB, body.data.accountId) + const entitlement = await computeEntitlement(env, body.data.accountId) return json({ ...checkStart(entitlement, body.data), entitlement }) } @@ -113,7 +113,7 @@ export async function handleRequest(request: Request, env: Env, ctx: ExecutionCo // Best effort inline; the cron trigger retries whatever is still pending. ctx.waitUntil(flushMeterReports(env, stripe, now, { usageId: body.data.usageId }).catch(() => undefined)) } - const entitlement = await computeEntitlement(env.BILLING_DB, body.data.accountId) + const entitlement = await computeEntitlement(env, body.data.accountId) return json({ ...result, canStartSandbox: entitlement.canStartSandbox, reason: entitlement.reason }) } @@ -124,7 +124,7 @@ export async function handleRequest(request: Request, env: Env, ctx: ExecutionCo if (error instanceof SyntaxError) { return json({ error: 'invalid_json' }, 400) } - if (error instanceof LiveModeRefusedError) { + if (error instanceof LiveModeRefusedError || error instanceof PlanConfigError) { return json({ error: 'billing_misconfigured' }, 503) } if (error instanceof StripeError) { diff --git a/cloud/apps/billing/src/plans.ts b/cloud/apps/billing/src/plans.ts index 1c60388e402..47630686497 100644 --- a/cloud/apps/billing/src/plans.ts +++ b/cloud/apps/billing/src/plans.ts @@ -1,6 +1,9 @@ -// Plan catalog. Prices live in Stripe; this file owns what each plan entitles. +// Plan catalog. Prices live in Stripe (price ids are Worker vars); what each plan entitles, and +// the price label shown before Checkout, are config too: BILLING_PLANS (JSON) overrides any field +// of the defaults below, so pricing changes need no deploy of code. // Usage is metered in "standard seconds": wall-clock seconds x the machine size weight, so one // included hour is one hour on the standard 4 vCPU / 8 GB box. +import { z } from 'zod' import type { Env } from './env' export const PLAN_IDS = ['pro', 'max', 'team'] as const @@ -11,21 +14,29 @@ export type MachineSize = (typeof MACHINE_SIZES)[number] export const MACHINE_SIZE_WEIGHT: Record = { standard: 1, large: 2, xlarge: 4 } -export type PlanEntitlement = { - plan: PlanId - label: string - // Included standard hours per billing period; Team multiplies by seats (pooled). - includedHoursPerSeat: number - maxConcurrentSandboxes: number - machineSizes: readonly MachineSize[] - // Overage hours allowed per period before new starts are refused (spend cap). - overageCapHours: number -} +const PlanEntitlementSchema = z + .object({ + label: z.string().min(1).max(40), + // Display only (e.g. "$25/mo"); Stripe's price is what is charged. + priceLabel: z.string().max(40), + // Included standard hours per billing period; Team multiplies by seats (pooled). + includedHoursPerSeat: z.number().nonnegative(), + maxConcurrentSandboxes: z.number().int().nonnegative(), + machineSizes: z.array(z.enum(MACHINE_SIZES)), + // Overage hours allowed per period before new starts are refused (spend cap). + overageCapHours: z.number().nonnegative() + }) + .strict() -export const PLANS: Record = { +export type PlanEntitlement = z.infer & { plan: PlanId } +export type PlanCatalog = Record + +// Draft numbers from the cloud tracker; override with BILLING_PLANS until pricing is final. +export const DEFAULT_PLANS: PlanCatalog = { pro: { plan: 'pro', label: 'Pro', + priceLabel: '$25/mo', includedHoursPerSeat: 50, maxConcurrentSandboxes: 2, machineSizes: ['standard'], @@ -34,6 +45,7 @@ export const PLANS: Record = { max: { plan: 'max', label: 'Max', + priceLabel: '$100/mo', includedHoursPerSeat: 300, maxConcurrentSandboxes: 5, machineSizes: ['standard', 'large', 'xlarge'], @@ -42,6 +54,7 @@ export const PLANS: Record = { team: { plan: 'team', label: 'Team', + priceLabel: '$60/seat/mo', includedHoursPerSeat: 150, maxConcurrentSandboxes: 3, machineSizes: ['standard', 'large'], @@ -49,6 +62,57 @@ export const PLANS: Record = { } } +const OverridesSchema = z + .object({ + pro: PlanEntitlementSchema.partial().optional(), + max: PlanEntitlementSchema.partial().optional(), + team: PlanEntitlementSchema.partial().optional() + }) + .strict() + +export class PlanConfigError extends Error { + constructor() { + super('billing_plans_invalid') + this.name = 'PlanConfigError' + } +} + +const catalogs = new Map() + +/** The effective catalog. Throws PlanConfigError (-> 503) on malformed BILLING_PLANS. */ +export function planCatalog(env: Pick): PlanCatalog { + const raw = env.BILLING_PLANS?.trim() ?? '' + const cached = catalogs.get(raw) + if (cached) { + return cached + } + let overrides: z.infer = {} + if (raw) { + try { + overrides = OverridesSchema.parse(JSON.parse(raw)) + } catch { + throw new PlanConfigError() + } + } + const catalog = {} as PlanCatalog + for (const plan of PLAN_IDS) { + catalog[plan] = { ...DEFAULT_PLANS[plan], ...overrides[plan], plan } + } + catalogs.set(raw, catalog) + return catalog +} + +/** What the desktop shows before Checkout. */ +export function publicPlans(catalog: PlanCatalog) { + return PLAN_IDS.map((plan) => ({ + plan, + label: catalog[plan].label, + priceLabel: catalog[plan].priceLabel, + includedHours: catalog[plan].includedHoursPerSeat, + perSeat: plan === 'team' + })) +} + export function isPlanId(value: unknown): value is PlanId { return typeof value === 'string' && (PLAN_IDS as readonly string[]).includes(value) } @@ -67,12 +131,12 @@ export function planForPrice(env: Env, priceId: string): PlanId | null { return null } -export function includedSeconds(plan: PlanId, seats: number): number { - const perSeat = PLANS[plan].includedHoursPerSeat * 3600 - return plan === 'team' ? perSeat * Math.max(1, seats) : perSeat +export function includedSeconds(entry: PlanEntitlement, seats: number): number { + const perSeat = Math.round(entry.includedHoursPerSeat * 3600) + return entry.plan === 'team' ? perSeat * Math.max(1, seats) : perSeat } -export function overageCapSeconds(plan: PlanId, seats: number): number { - const cap = PLANS[plan].overageCapHours * 3600 - return plan === 'team' ? cap * Math.max(1, seats) : cap +export function overageCapSeconds(entry: PlanEntitlement, seats: number): number { + const cap = Math.round(entry.overageCapHours * 3600) + return entry.plan === 'team' ? cap * Math.max(1, seats) : cap } diff --git a/cloud/apps/billing/src/usage.ts b/cloud/apps/billing/src/usage.ts index 074449ff38f..adbfb4ba681 100644 --- a/cloud/apps/billing/src/usage.ts +++ b/cloud/apps/billing/src/usage.ts @@ -4,7 +4,7 @@ // price on a "standard seconds" meter with transform_quantity divide_by=3600. import { z } from 'zod' import type { Env } from './env' -import { MACHINE_SIZE_WEIGHT, MACHINE_SIZES, includedSeconds } from './plans' +import { MACHINE_SIZE_WEIGHT, MACHINE_SIZES, includedSeconds, planCatalog } from './plans' import type { StripeClient } from './stripe' import { currentSubscription, customerFor, insertUsage, markMeterReport, pendingMeterReports, usageOverage } from './store' @@ -66,7 +66,7 @@ export async function recordUsage( endedAt: report.endedAt, weightedSeconds, periodStart: subscription?.current_period_start ?? null, - includedSeconds: subscription ? includedSeconds(subscription.plan, subscription.seats) : 0, + includedSeconds: subscription ? includedSeconds(planCatalog(env)[subscription.plan], subscription.seats) : 0, now, meterCustomer: customer }) diff --git a/cloud/apps/billing/test/billing-config.test.ts b/cloud/apps/billing/test/billing-config.test.ts new file mode 100644 index 00000000000..80fefefdfbf --- /dev/null +++ b/cloud/apps/billing/test/billing-config.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import { createHarness } from './harness' +import { stripeEvent } from './stripe-fake' + +const HOUR = 3600 +const PERIOD_START = Math.floor(Date.now() / 1000) - 5 * 24 * HOUR +const PERIOD_END = PERIOD_START + 30 * 24 * HOUR + +describe('config: tax and plan catalog', () => { + it('leaves Stripe Tax off by default and turns it on with STRIPE_AUTOMATIC_TAX=true', async () => { + for (const [flag, expected] of [ + [undefined, null], + ['true', 'true'] + ] as const) { + const h = await createHarness(flag ? { STRIPE_AUTOMATIC_TAX: flag } : {}) + const token = await h.desktopToken({ sub: 'u1' }) + await h.call('POST', '/v1/billing/checkout', { body: { plan: 'pro' }, headers: { authorization: `Bearer ${token}` } }) + const session = h.stripe.calls.find((call) => call.path === '/v1/checkout/sessions')! + expect(session.form.get('automatic_tax[enabled]')).toBe(expected) + expect(session.form.get('tax_id_collection[enabled]')).toBe(expected) + // Stripe Tax needs the customer's address; Checkout saves it back to the customer. + expect(session.form.get('customer_update[address]')).toBe('auto') + } + }) + + it('takes plan entitlements and price labels from BILLING_PLANS', async () => { + const h = await createHarness({ + BILLING_PLANS: JSON.stringify({ pro: { includedHoursPerSeat: 40, priceLabel: '$20/mo' } }) + }) + const token = await h.desktopToken({ sub: 'u1', org: 'o1', orgRole: 'owner' }) + await h.call('POST', '/v1/billing/checkout', { body: { plan: 'pro' }, headers: { authorization: `Bearer ${token}` } }) + const customer = [...h.stripe.customers.keys()][0]! + const sub = h.stripe.putSubscription({ + customer, + accountId: 'org:o1', + basePrice: 'price_pro', + overagePrice: 'price_overage', + periodStart: PERIOD_START, + periodEnd: PERIOD_END + }) + await h.webhook(stripeEvent('customer.subscription.created', sub)) + const entitlement = await h.call('GET', '/v1/billing/entitlements', { headers: { authorization: `Bearer ${token}` } }) + expect(entitlement.body).toMatchObject({ includedSeconds: 40 * HOUR }) + expect(entitlement.body.plans).toEqual([ + { plan: 'pro', label: 'Pro', priceLabel: '$20/mo', includedHours: 40, perSeat: false }, + { plan: 'max', label: 'Max', priceLabel: '$100/mo', includedHours: 300, perSeat: false }, + { plan: 'team', label: 'Team', priceLabel: '$60/seat/mo', includedHours: 150, perSeat: true } + ]) + }) + + it('fails closed on a malformed catalog', async () => { + for (const bad of ['{"pro":{"includedHoursPerSeat":"lots"}}', '{"free":{}}', 'not json']) { + const h = await createHarness({ BILLING_PLANS: bad }) + const token = await h.desktopToken({ sub: 'u1' }) + expect(await h.call('GET', '/v1/billing/entitlements', { headers: { authorization: `Bearer ${token}` } })).toEqual({ + status: 503, + body: { error: 'billing_misconfigured' } + }) + } + }) +}) diff --git a/cloud/docs/accounts-billing-design.md b/cloud/docs/accounts-billing-design.md index f2f9b91b451..83508bcaaf2 100644 --- a/cloud/docs/accounts-billing-design.md +++ b/cloud/docs/accounts-billing-design.md @@ -34,7 +34,9 @@ Code: `cloud/apps/billing` (Worker), desktop `src/main/billing`, `src/renderer/s | `usage_events` | sandbox usage segments keyed by caller's `usageId`; weighted seconds; overage seconds fixed at insert | | `meter_reports` | outbox of overage → Stripe meter events (pending/sent/failed, attempts) | -Plan entitlements live in code (`src/plans.ts`): Pro 50 h / 2 concurrent / standard; Max 300 h / 5 / all sizes; +Plans are config, not code: Stripe price ids are Worker vars, and `BILLING_PLANS` (JSON) overrides any default in +`src/plans.ts` (label, display price, included hours, concurrency, sizes, overage cap); malformed config fails closed +(503). The desktop renders plan cards from the `plans` list the entitlements endpoint returns. Draft defaults: Pro 50 h / 2 concurrent / standard; Max 300 h / 5 / all sizes; Team 150 h × seats pooled / 3 / standard+large. Overage spend cap per plan (Pro 100 h, Max 500 h, Team 200 h/seat). Usage is metered in "standard seconds" (wall seconds × size weight 1/2/4). Enterprise/BYOC: out of scope (sales-led). @@ -44,6 +46,9 @@ Usage is metered in "standard seconds" (wall seconds × size weight 1/2/4). Ente (qty=seats) + metered overage price; `payment_method_collection=always` (no free plan; optional card-required trial via `STRIPE_TRIAL_DAYS`); `subscription_data.metadata.account_id`. One Stripe customer per account (idempotency key `customer-create:`). 409 if a subscription is already open — plan/seat changes go through the Portal. +- **Tax**: `STRIPE_AUTOMATIC_TAX=true` adds `automatic_tax` + `tax_id_collection` to Checkout (default off). Before + enabling: activate Stripe Tax, set the origin address (California), product tax code for SaaS, and add + registrations as thresholds are crossed (dashboard work, no code). - **Customer Portal** (`POST /v1/billing/portal`): upgrade/downgrade/cancel/payment methods/invoices. Portal config (allowed plan switches) is set in the Stripe dashboard. - **Webhooks** (`POST /v1/stripe/webhook`): HMAC-SHA256 verification of `Stripe-Signature` over the raw body @@ -56,7 +61,7 @@ Usage is metered in "standard seconds" (wall seconds × size weight 1/2/4). Ente Stripe prices). Overage is computed inside the INSERT from the running period total, so concurrent reports can't double-claim included time. Meter `orca_sandbox_seconds` (sum, by customer id); overage price $0.45/h via `transform_quantity.divide_by=3600, round=up`. `identifier=usageId` + Idempotency-Key dedupe retries. - Setup: `cloud/apps/billing/scripts/stripe-test-setup.ts` (refuses non-test keys). + Setup: `cloud/apps/billing/scripts/stripe-test-setup.ts` (refuses non-test keys; prices via `STRIPE_SETUP_PRICES`). ## Entitlement checks @@ -92,6 +97,6 @@ Usage is metered in "standard seconds" (wall seconds × size weight 1/2/4). Ente ## Open decisions -1. Final prices/included hours/overage caps; trial length. 2. past_due grace for running sandboxes. 3. Team seat ↔ -org member count sync (manual seats in Portal for now). 4. Tax (Stripe Tax) and invoicing for Team. 5. Billing URL +1. Final prices/included hours/overage caps (config only); trial length. 2. past_due grace for running sandboxes. 3. Team seat ↔ +org member count sync (manual seats in Portal for now). 4. When to turn on Stripe Tax; invoicing for Team. 5. Billing URL host (e.g. `billing.onorca.dev`) and return page that deep-links back to Orca. diff --git a/config/scripts/ci/unit-selection/check-test-file-runners.mjs b/config/scripts/ci/unit-selection/check-test-file-runners.mjs index 24993494bd6..984f6e9c91e 100644 --- a/config/scripts/ci/unit-selection/check-test-file-runners.mjs +++ b/config/scripts/ci/unit-selection/check-test-file-runners.mjs @@ -62,6 +62,12 @@ const NON_UNIT_TEST_RUNNERS = [ sources: ['cloud/apps/relay-edge/vitest.config.ts'], mirrors: ["include: ['test/**/*.test.ts']"] }, + { + name: 'cloud billing', + include: ['cloud/apps/billing/test/**/*.test.ts'], + sources: ['cloud/apps/billing/vitest.config.ts'], + mirrors: ["include: ['test/**/*.test.ts']"] + }, ...[ 'cloud/apps/relay-fence-broker', 'cloud/packages/cloud-database', diff --git a/src/main/billing/billing-client.test.ts b/src/main/billing/billing-client.test.ts index a0a7d5e56b3..0bf6360a36d 100644 --- a/src/main/billing/billing-client.test.ts +++ b/src/main/billing/billing-client.test.ts @@ -147,7 +147,17 @@ describe('billing client', () => { canStartSandbox: true, reason: 'something_new', canManageBilling: true, - accountId: 'org:o1' + accountId: 'org:o1', + plans: [ + { plan: 'pro', label: 'Pro', priceLabel: '$25/mo', includedHours: 50, perSeat: false }, + { + plan: 'enterprise', + label: 'Enterprise', + priceLabel: 'Talk to us', + includedHours: 0, + perSeat: true + } + ] }) }) ) @@ -158,5 +168,8 @@ describe('billing client', () => { canManageBilling: true }) expect(entitlement).not.toHaveProperty('accountId') + expect(entitlement.plans).toEqual([ + { plan: 'pro', label: 'Pro', priceLabel: '$25/mo', includedHours: 50, perSeat: false } + ]) }) }) diff --git a/src/main/billing/billing-client.ts b/src/main/billing/billing-client.ts index c5a1f12d0bf..1e44dcce269 100644 --- a/src/main/billing/billing-client.ts +++ b/src/main/billing/billing-client.ts @@ -93,6 +93,14 @@ async function billingRequest( const seconds = z.number().catch(0) +const PlanOptionSchema = z.object({ + plan: z.enum(ORCA_BILLING_PLAN_IDS), + label: z.string().max(40), + priceLabel: z.string().max(40), + includedHours: z.number().nonnegative(), + perSeat: z.boolean() +}) + // Lenient by design: an unknown plan/reason or a missing field degrades to "none"/0, never an error, // so a newer billing service cannot break an older desktop. const EntitlementSchema = z.object({ @@ -119,7 +127,17 @@ const EntitlementSchema = z.object({ ]) .nullable() .catch(null), - canManageBilling: z.boolean().catch(false) + canManageBilling: z.boolean().catch(false), + // Unknown plans (a newer service) are dropped rather than failing the whole response. + plans: z + .array(z.unknown()) + .catch([]) + .transform((items) => + items.flatMap((item) => { + const parsed = PlanOptionSchema.safeParse(item) + return parsed.success ? [parsed.data] : [] + }) + ) }) export function normalizeEntitlement(value: Record): OrcaBillingEntitlement { diff --git a/src/renderer/src/components/billing/PlanBillingSection.fixture.tsx b/src/renderer/src/components/billing/PlanBillingSection.fixture.tsx index cdc9ca26088..8f0ca28bf56 100644 --- a/src/renderer/src/components/billing/PlanBillingSection.fixture.tsx +++ b/src/renderer/src/components/billing/PlanBillingSection.fixture.tsx @@ -20,7 +20,12 @@ const pro: OrcaBillingEntitlement = { overageCapSeconds: 100 * HOUR, canStartSandbox: true, reason: null, - canManageBilling: true + canManageBilling: true, + plans: [ + { plan: 'pro', label: 'Pro', priceLabel: '$25/mo', includedHours: 50, perSeat: false }, + { plan: 'max', label: 'Max', priceLabel: '$100/mo', includedHours: 300, perSeat: false }, + { plan: 'team', label: 'Team', priceLabel: '$60/seat/mo', includedHours: 150, perSeat: true } + ] } const handlers = { diff --git a/src/renderer/src/components/billing/PlanBillingSection.tsx b/src/renderer/src/components/billing/PlanBillingSection.tsx index 3a32bd029ae..595e8fac00b 100644 --- a/src/renderer/src/components/billing/PlanBillingSection.tsx +++ b/src/renderer/src/components/billing/PlanBillingSection.tsx @@ -15,32 +15,6 @@ import type { import { formatHours, includedUsagePercent, planBillingTone } from './plan-billing-format' import { useOrcaBillingStatus } from './use-orca-billing-status' -// Display only: the billing service and Stripe own the real catalog and prices. -function planOptions( - t: Translate -): { plan: OrcaBillingPlanId; label: string; price: string; hours: string }[] { - return [ - { - plan: 'pro', - label: t('auto.components.billing.planPro', 'Pro'), - price: t('auto.components.billing.pricePro', '$25/mo'), - hours: '50' - }, - { - plan: 'max', - label: t('auto.components.billing.planMax', 'Max'), - price: t('auto.components.billing.priceMax', '$100/mo'), - hours: '300' - }, - { - plan: 'team', - label: t('auto.components.billing.planTeam', 'Team'), - price: t('auto.components.billing.priceTeam', '$60/seat/mo'), - hours: '150' - } - ] -} - function denyReasonCopy(reason: OrcaBillingDenyReason, t: Translate): string { switch (reason) { case 'no_subscription': @@ -281,26 +255,26 @@ export function PlanBillingView({

{denyReasonCopy(entitlement.reason, t)}

) : null} - {!hasPlan && entitlement.canManageBilling ? ( + {!hasPlan && entitlement.canManageBilling && entitlement.plans.length > 0 ? (
- {planOptions(t).map((option) => ( + {entitlement.plans.map((option) => (

{option.label}

-

{option.price}

+

{option.priceLabel}

- {option.plan === 'team' + {option.perSeat ? t( 'auto.components.billing.teamHours', '{{value0}} sandbox hours per seat, pooled', - { value0: option.hours } + { value0: String(option.includedHours) } ) : t('auto.components.billing.planHours', '{{value0}} sandbox hours per month', { - value0: option.hours + value0: String(option.includedHours) })}