feat(billing): plans and prices from config, opt-in Stripe Tax, real account menu

- Worker: BILLING_PLANS JSON overrides the plan catalog (labels, display price,
  hours, limits); entitlements return the plan list so the desktop shows no
  hardcoded prices. STRIPE_AUTOMATIC_TAX=true adds automatic_tax and tax id
  collection to Checkout (default off). Setup script takes prices from env.
- Desktop: register Plan & billing through the sidebar account menu
  (merged nwparker/sidebar-account-menu) and drop the local adapter.
- Register the billing Worker's tests with the test-runner check.
This commit is contained in:
Neil
2026-11-08 22:09:50 -08:00
parent 48f15db6aa
commit e1ee97797a
20 changed files with 277 additions and 124 deletions
@@ -35,19 +35,23 @@ async function post<T>(path: string, params: Record<string, unknown>, 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<string, string> = {}
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
+3
View File
@@ -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
+8 -5
View File
@@ -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<Entitlement> {
export async function computeEntitlement(env: Pick<Env, 'BILLING_DB' | 'BILLING_PLANS'>, accountId: string): Promise<Entitlement> {
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
+7
View File
@@ -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
+6 -6
View File
@@ -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) {
+82 -18
View File
@@ -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<MachineSize, number> = { 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<PlanId, PlanEntitlement> = {
export type PlanEntitlement = z.infer<typeof PlanEntitlementSchema> & { plan: PlanId }
export type PlanCatalog = Record<PlanId, PlanEntitlement>
// 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<PlanId, PlanEntitlement> = {
max: {
plan: 'max',
label: 'Max',
priceLabel: '$100/mo',
includedHoursPerSeat: 300,
maxConcurrentSandboxes: 5,
machineSizes: ['standard', 'large', 'xlarge'],
@@ -42,6 +54,7 @@ export const PLANS: Record<PlanId, PlanEntitlement> = {
team: {
plan: 'team',
label: 'Team',
priceLabel: '$60/seat/mo',
includedHoursPerSeat: 150,
maxConcurrentSandboxes: 3,
machineSizes: ['standard', 'large'],
@@ -49,6 +62,57 @@ export const PLANS: Record<PlanId, PlanEntitlement> = {
}
}
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<string, PlanCatalog>()
/** The effective catalog. Throws PlanConfigError (-> 503) on malformed BILLING_PLANS. */
export function planCatalog(env: Pick<Env, 'BILLING_PLANS'>): PlanCatalog {
const raw = env.BILLING_PLANS?.trim() ?? ''
const cached = catalogs.get(raw)
if (cached) {
return cached
}
let overrides: z.infer<typeof OverridesSchema> = {}
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
}
+2 -2
View File
@@ -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
})
@@ -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' }
})
}
})
})
+9 -4
View File
@@ -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:<account>`). 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.
@@ -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',
+14 -1
View File
@@ -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 }
])
})
})
+19 -1
View File
@@ -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<string, unknown>): OrcaBillingEntitlement {
@@ -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 = {
@@ -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({
<p className="text-xs text-muted-foreground">{denyReasonCopy(entitlement.reason, t)}</p>
) : null}
{!hasPlan && entitlement.canManageBilling ? (
{!hasPlan && entitlement.canManageBilling && entitlement.plans.length > 0 ? (
<div className="grid gap-3 md:grid-cols-3">
{planOptions(t).map((option) => (
{entitlement.plans.map((option) => (
<div
key={option.plan}
className="flex flex-col gap-2 rounded-md border border-border/60 p-3"
>
<div className="flex items-baseline justify-between gap-2">
<p className="text-sm font-medium">{option.label}</p>
<p className="text-xs text-muted-foreground">{option.price}</p>
<p className="text-xs text-muted-foreground">{option.priceLabel}</p>
</div>
<p className="flex-1 text-xs text-muted-foreground">
{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)
})}
</p>
<Button
@@ -1,32 +0,0 @@
// Local stand-in for the sidebar account menu's item registry, which is landing upstream at
// src/renderer/src/components/sidebar/account-menu/account-menu-items.ts with this exact shape.
// When it lands, delete this file and import registerAccountMenuItem from there instead.
import type { ComponentType } from 'react'
export type AccountMenuItem = {
id: string
/** Called at render so the label follows the current language. */
label: () => string
icon?: ComponentType<{ className?: string }>
/** Lower sorts first. */
order?: number
/** Shown only when the connected account's capabilities.flags[cap] === true. */
requiredCapability?: string
onSelect: () => void
}
const items = new Map<string, AccountMenuItem>()
/** Same id replaces; the disposer removes exactly the entry it registered. */
export function registerAccountMenuItem(item: AccountMenuItem): () => void {
items.set(item.id, item)
return () => {
if (items.get(item.id) === item) {
items.delete(item.id)
}
}
}
export function registeredAccountMenuItemsForTest(): AccountMenuItem[] {
return [...items.values()]
}
@@ -17,7 +17,8 @@ const base: OrcaBillingEntitlement = {
overageCapSeconds: 100 * 3600,
canStartSandbox: true,
reason: null,
canManageBilling: true
canManageBilling: true,
plans: []
}
describe('plan billing format', () => {
@@ -6,15 +6,16 @@ vi.mock('@/store/index', () => ({
useAppStore: { getState: () => ({ openSettingsTarget, openSettingsPage }) }
}))
import { registeredAccountMenuItemsForTest } from './account-menu-adapter'
import {
getRegisteredAccountMenuItems,
selectVisibleAccountMenuItems
} from '../sidebar/account-menu/account-menu-items'
import { BILLING_CAPABILITY, registerPlanBillingMenuItem } from './register-plan-billing-menu-item'
describe('Plan & billing account menu item', () => {
it('registers behind the billing capability and opens Orca account settings', () => {
const dispose = registerPlanBillingMenuItem()
const item = registeredAccountMenuItemsForTest().find(
(entry) => entry.id === 'orca.plan-billing'
)
const item = getRegisteredAccountMenuItems().find((entry) => entry.id === 'orca.plan-billing')
expect(item?.requiredCapability).toBe(BILLING_CAPABILITY)
expect(item?.label()).toBe('Plan & billing')
item?.onSelect()
@@ -23,7 +24,20 @@ describe('Plan & billing account menu item', () => {
repoId: null
})
expect(openSettingsPage).toHaveBeenCalled()
const visible = (flags: Record<string, boolean>): string[] =>
selectVisibleAccountMenuItems(getRegisteredAccountMenuItems(), {
activeProfileId: 'p1',
configured: true,
state: 'connected',
persistence: 'encrypted',
capabilities: { flags, refreshedAt: 0 }
}).map((entry) => entry.id)
expect(visible({})).not.toContain('orca.plan-billing')
expect(visible({ [BILLING_CAPABILITY]: true })).toContain('orca.plan-billing')
expect(selectVisibleAccountMenuItems(getRegisteredAccountMenuItems(), null)).toHaveLength(0)
dispose()
expect(registeredAccountMenuItemsForTest()).toHaveLength(0)
expect(getRegisteredAccountMenuItems()).toHaveLength(0)
})
})
@@ -1,7 +1,7 @@
import { CreditCard } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store/index'
import { registerAccountMenuItem } from './account-menu-adapter'
import { registerAccountMenuItem } from '../sidebar/account-menu/account-menu-items'
/** Cloud capability flag that turns billing on per account (server-controlled rollout). */
export const BILLING_CAPABILITY = 'billing'
-6
View File
@@ -19514,12 +19514,6 @@
"manage": "Manage billing",
"askAdmin": "Ask an org owner or admin to change the plan.",
"menuItem": "Plan & billing",
"planPro": "Pro",
"pricePro": "$25/mo",
"planMax": "Max",
"priceMax": "$100/mo",
"planTeam": "Team",
"priceTeam": "$60/seat/mo",
"statusActive": "Active",
"statusTrial": "Trial",
"statusPastDue": "Payment due",
+12
View File
@@ -28,6 +28,18 @@ export type OrcaBillingEntitlement = {
canStartSandbox: boolean
reason: OrcaBillingDenyReason | null
canManageBilling: boolean
/** Plans on offer, from the billing service's catalog (prices are config there, not here). */
plans: OrcaBillingPlanOption[]
}
export type OrcaBillingPlanOption = {
plan: OrcaBillingPlanId
label: string
/** Display only, e.g. "$25/mo"; Stripe charges the configured price. */
priceLabel: string
includedHours: number
/** Team: hours are per seat and pooled. */
perSeat: boolean
}
export type OrcaBillingStatusResult =