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