mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
Merge remote-tracking branch 'private/nwparker/accounts-billing' into cloud/integration
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.wrangler/
|
||||
.dev.vars
|
||||
@@ -0,0 +1,69 @@
|
||||
-- Billing ledger (D1). Identity (users, orgs, membership, roles) stays in Orca Cloud auth;
|
||||
-- billing only stores the account id the auth token names. Times are epoch milliseconds.
|
||||
|
||||
-- One row per billable account: an org ('org:<orgId>') or a signed-in user without an org
|
||||
-- ('user:<userId>').
|
||||
CREATE TABLE billing_accounts (
|
||||
account_id TEXT PRIMARY KEY,
|
||||
stripe_customer_id TEXT UNIQUE,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Mirror of the Stripe subscription, rewritten from a fresh API read on every webhook so
|
||||
-- out-of-order deliveries cannot regress it.
|
||||
CREATE TABLE subscriptions (
|
||||
stripe_subscription_id TEXT PRIMARY KEY,
|
||||
account_id TEXT NOT NULL REFERENCES billing_accounts(account_id),
|
||||
stripe_customer_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
plan TEXT NOT NULL,
|
||||
seats INTEGER NOT NULL,
|
||||
overage_enabled INTEGER NOT NULL,
|
||||
current_period_start INTEGER NOT NULL,
|
||||
current_period_end INTEGER NOT NULL,
|
||||
cancel_at_period_end INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX subscriptions_account ON subscriptions(account_id, current_period_end);
|
||||
|
||||
-- Webhook idempotency: an event id is processed at most once (processed_at set on success).
|
||||
CREATE TABLE stripe_events (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
received_at INTEGER NOT NULL,
|
||||
processed_at INTEGER,
|
||||
last_error TEXT
|
||||
);
|
||||
|
||||
-- Sandbox usage segments reported by the control plane. usage_id is the caller's idempotency
|
||||
-- key. weighted_seconds = wall seconds x machine size weight. overage_seconds is the part of
|
||||
-- this segment past the period's included allowance, fixed when the row is inserted.
|
||||
CREATE TABLE usage_events (
|
||||
usage_id TEXT PRIMARY KEY,
|
||||
account_id TEXT NOT NULL,
|
||||
sandbox_id TEXT NOT NULL,
|
||||
machine_size TEXT NOT NULL,
|
||||
started_at INTEGER NOT NULL,
|
||||
ended_at INTEGER NOT NULL,
|
||||
weighted_seconds INTEGER NOT NULL,
|
||||
period_start INTEGER,
|
||||
overage_seconds INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX usage_events_period ON usage_events(account_id, period_start);
|
||||
|
||||
-- Outbox for Stripe meter events (overage only). Sent inline, retried by the cron trigger.
|
||||
-- usage_id doubles as the Stripe meter event identifier, so a retry is deduplicated by Stripe.
|
||||
CREATE TABLE meter_reports (
|
||||
usage_id TEXT PRIMARY KEY REFERENCES usage_events(usage_id),
|
||||
stripe_customer_id TEXT NOT NULL,
|
||||
seconds INTEGER NOT NULL,
|
||||
event_timestamp INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
sent_at INTEGER
|
||||
);
|
||||
CREATE INDEX meter_reports_pending ON meter_reports(status, created_at);
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Team pricing: overage is opt-in per account with an admin-set monthly spend cap, and annual
|
||||
-- plans keep a monthly allowance window (usage_events.period_start is that window's start).
|
||||
|
||||
-- 1 once an admin opts in to overage; spend cap in cents (NULL = the plan default).
|
||||
ALTER TABLE billing_accounts ADD COLUMN overage_opt_in INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE billing_accounts ADD COLUMN spend_cap_cents INTEGER;
|
||||
|
||||
-- 'month' | 'year' (the seat price's interval) and the metered overage item, once added.
|
||||
ALTER TABLE subscriptions ADD COLUMN billing_interval TEXT NOT NULL DEFAULT 'month';
|
||||
ALTER TABLE subscriptions ADD COLUMN overage_item_id TEXT;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Payment failure grace: when billing first saw the subscription past_due/unpaid (epoch ms).
|
||||
-- Cleared when it is paid again. New starts stop immediately; running sandboxes are suspended
|
||||
-- once the plan's grace period has passed (entitlements.ts).
|
||||
ALTER TABLE subscriptions ADD COLUMN past_due_since INTEGER;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Seat fairness: the latest org member count (seats may stay above it until renewal), and the
|
||||
-- member who started each sandbox (usage attribution, per-member caps, abuse review).
|
||||
ALTER TABLE subscriptions ADD COLUMN member_count INTEGER;
|
||||
ALTER TABLE usage_events ADD COLUMN user_id TEXT;
|
||||
CREATE INDEX usage_events_member ON usage_events(account_id, period_start, user_id);
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@orca-cloud/billing",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wrangler dev --port 8790 --ip 127.0.0.1",
|
||||
"db:migrate:local": "wrangler d1 migrations apply BILLING_DB --local",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"jose": "^6.2.13",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^5.20261103.1",
|
||||
"@types/node": "^24.19.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^5.0.5",
|
||||
"wrangler": "^4.157.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
// End-to-end billing run against a running Worker (local `wrangler dev` or a dev deploy) and
|
||||
// Stripe TEST MODE, with real webhooks (via `stripe listen` locally, or a test webhook endpoint).
|
||||
//
|
||||
// tsx scripts/e2e-test-mode.ts keys <dir> # local-only ES256 key + JWKS (stands in for auth)
|
||||
// tsx scripts/e2e-test-mode.ts run # drive the flows below
|
||||
//
|
||||
// Env for `run`: BILLING_URL, STRIPE_SECRET_KEY (sk_test_/rk_test_ only), CONTROL_PLANE_TOKEN,
|
||||
// E2E_JWK_PRIVATE (path), E2E_CATALOG (path to the stripe-test-setup.ts JSON), E2E_ISSUER,
|
||||
// E2E_D1_ARGS (wrangler d1 execute target, e.g. "BILLING_DB --local").
|
||||
//
|
||||
// Flows: Checkout (2 seats, test card, headless browser) -> overage opt-in -> usage past the
|
||||
// included hours -> meter events in Stripe -> spend cap blocks starts -> annual plan + overage ->
|
||||
// test-clock renewal -> invoice.payment_failed -> cancel. Prints PASS/FAIL per check and the ids.
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { SignJWT, exportJWK, generateKeyPair, importJWK } from 'jose'
|
||||
import { encodeForm } from '../src/stripe'
|
||||
|
||||
const HOUR = 3600
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
if (process.argv[2] === 'keys') {
|
||||
const dir = process.argv[3]!
|
||||
const { privateKey, publicKey } = await generateKeyPair('ES256', { extractable: true })
|
||||
writeFileSync(`${dir}/jwk-private.json`, JSON.stringify({ ...(await exportJWK(privateKey)), kid: 'e2e', alg: 'ES256' }), {
|
||||
mode: 0o600
|
||||
})
|
||||
writeFileSync(`${dir}/jwks.json`, JSON.stringify({ keys: [{ ...(await exportJWK(publicKey)), kid: 'e2e', alg: 'ES256', use: 'sig' }] }))
|
||||
console.log(`wrote ${dir}/jwk-private.json and ${dir}/jwks.json`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const env = (name: string): string => {
|
||||
const value = process.env[name]
|
||||
if (!value) {
|
||||
throw new Error(`missing ${name}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
const BILLING_URL = env('BILLING_URL')
|
||||
const STRIPE_KEY = env('STRIPE_SECRET_KEY')
|
||||
if (!STRIPE_KEY.startsWith('sk_test_') && !STRIPE_KEY.startsWith('rk_test_')) {
|
||||
throw new Error('Refusing: test-mode Stripe key required')
|
||||
}
|
||||
const CONTROL = env('CONTROL_PLANE_TOKEN')
|
||||
const catalog = JSON.parse(readFileSync(env('E2E_CATALOG'), 'utf8')) as Record<string, string> & { meter: string }
|
||||
const jwk = JSON.parse(readFileSync(env('E2E_JWK_PRIVATE'), 'utf8')) as { kid: string }
|
||||
const signingKey = await importJWK(jwk, 'ES256')
|
||||
const issuer = env('E2E_ISSUER')
|
||||
const d1Args = env('E2E_D1_ARGS').split(' ')
|
||||
const runId = Date.now().toString(36)
|
||||
|
||||
const results: Array<{ ok: boolean; name: string; detail: string }> = []
|
||||
function check(name: string, ok: boolean, detail: unknown = ''): boolean {
|
||||
results.push({ ok, name, detail: typeof detail === 'string' ? detail : JSON.stringify(detail) })
|
||||
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${ok ? '' : ` :: ${typeof detail === 'string' ? detail : JSON.stringify(detail)}`}`)
|
||||
return ok
|
||||
}
|
||||
const ids: Record<string, string> = {}
|
||||
|
||||
async function token(org: string): Promise<string> {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
return new SignJWT({ sub: `user_${org}`, org, orgRole: 'owner', email: `e2e+${org}@example.com`, purpose: 'billing' })
|
||||
.setProtectedHeader({ alg: 'ES256', kid: jwk.kid })
|
||||
.setIssuer(issuer)
|
||||
.setAudience('orca-billing')
|
||||
.setIssuedAt(now)
|
||||
.setExpirationTime(now + 300)
|
||||
.sign(signingKey)
|
||||
}
|
||||
|
||||
async function billing(method: string, path: string, org: string | null, body?: unknown) {
|
||||
const headers: Record<string, string> = { 'content-type': 'application/json' }
|
||||
headers.authorization = `Bearer ${org ? await token(org) : CONTROL}`
|
||||
const response = await fetch(`${BILLING_URL}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) })
|
||||
return { status: response.status, body: (await response.json().catch(() => ({}))) as Record<string, any> }
|
||||
}
|
||||
|
||||
async function stripe<T = Record<string, any>>(method: 'GET' | 'POST' | 'DELETE', path: string, params?: Record<string, unknown>): Promise<T> {
|
||||
const url = new URL(`https://api.stripe.com${path}`)
|
||||
if (method !== 'POST' && params) {
|
||||
url.search = encodeForm(params as never)
|
||||
}
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { authorization: `Bearer ${STRIPE_KEY}`, 'content-type': 'application/x-www-form-urlencoded', 'stripe-version': '2025-09-30.clover' },
|
||||
body: method === 'POST' ? encodeForm((params ?? {}) as never) : undefined
|
||||
})
|
||||
const json = (await response.json()) as T & { livemode?: boolean; error?: { message: string } }
|
||||
if (!response.ok) {
|
||||
throw new Error(`stripe ${method} ${path}: ${json.error?.message}`)
|
||||
}
|
||||
if (json.livemode) {
|
||||
throw new Error('live-mode object returned; aborting')
|
||||
}
|
||||
return json
|
||||
}
|
||||
|
||||
async function until<T>(what: string, read: () => Promise<T>, done: (value: T) => boolean, timeoutMs = 90_000): Promise<T> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
let last = await read()
|
||||
while (!done(last) && Date.now() < deadline) {
|
||||
await sleep(2000)
|
||||
last = await read()
|
||||
}
|
||||
if (!done(last)) {
|
||||
console.log(` (timed out waiting for ${what})`)
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
const entitlement = (org: string) => billing('GET', '/v1/billing/entitlements', org).then((r) => r.body)
|
||||
const startCheck = (accountId: string) =>
|
||||
billing('POST', '/v1/internal/entitlements/check', null, { accountId, machineSize: 'standard', runningSandboxes: 0 }).then((r) => r.body)
|
||||
let usageCounter = 0
|
||||
async function reportUsage(accountId: string, hours: number) {
|
||||
const endedAt = Date.now() - 60_000 - usageCounter * 1000
|
||||
usageCounter++
|
||||
return billing('POST', '/v1/internal/usage', null, {
|
||||
usageId: `e2e-${runId}-${usageCounter}`,
|
||||
accountId,
|
||||
sandboxId: `sbx-${runId}`,
|
||||
machineSize: 'standard',
|
||||
startedAt: endedAt - hours * HOUR * 1000,
|
||||
endedAt
|
||||
}).then((r) => r.body)
|
||||
}
|
||||
|
||||
function linkAccount(accountId: string, customer: string) {
|
||||
if (!/^[A-Za-z0-9_:.-]+$/.test(accountId + customer)) {
|
||||
throw new Error('unsafe id')
|
||||
}
|
||||
const now = Date.now()
|
||||
execFileSync(
|
||||
'npx',
|
||||
['wrangler', 'd1', 'execute', ...d1Args, '--command',
|
||||
`INSERT INTO billing_accounts (account_id, stripe_customer_id, created_at, updated_at) VALUES ('${accountId}', '${customer}', ${now}, ${now})`],
|
||||
{ stdio: 'ignore', env: { ...process.env, CI: '1' } }
|
||||
)
|
||||
}
|
||||
|
||||
async function completeCheckout(url: string) {
|
||||
const { chromium } = await import('playwright-core')
|
||||
const browser = await chromium.launch({ headless: true })
|
||||
try {
|
||||
const page = await browser.newPage()
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' })
|
||||
await page.locator('#cardNumber').waitFor({ timeout: 60_000 })
|
||||
await page.fill('#cardNumber', '4242424242424242')
|
||||
await page.fill('#cardExpiry', '12/34')
|
||||
await page.fill('#cardCvc', '123')
|
||||
if (await page.locator('#billingName').count()) {
|
||||
await page.fill('#billingName', 'Orca E2E')
|
||||
}
|
||||
if (await page.locator('#billingPostalCode').count()) {
|
||||
await page.fill('#billingPostalCode', '94107')
|
||||
}
|
||||
// Opting out of "Save my information with Link" (it would require a phone number).
|
||||
const link = page.locator('#enableStripePass')
|
||||
if ((await link.count()) && (await link.isChecked())) {
|
||||
await link.uncheck({ force: true })
|
||||
}
|
||||
await page.locator('[data-testid="hosted-payment-submit-button"], .SubmitButton').first().click()
|
||||
try {
|
||||
await page.waitForURL((current) => current.searchParams.get('checkout') === 'success', { timeout: 90_000 })
|
||||
} catch (error) {
|
||||
if (process.env.E2E_SCREENSHOT_DIR) {
|
||||
await page.screenshot({ path: `${process.env.E2E_SCREENSHOT_DIR}/checkout-failure.png`, fullPage: true })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
} finally {
|
||||
await browser.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function advanceClock(clock: string, to: number) {
|
||||
await stripe('POST', `/v1/test_helpers/test_clocks/${clock}/advance`, { frozen_time: to })
|
||||
await until('test clock ready', () => stripe('GET', `/v1/test_helpers/test_clocks/${clock}`), (c) => c.status === 'ready', 180_000)
|
||||
}
|
||||
|
||||
// ---- 1. Checkout, 2 seats, test card ----------------------------------------------------------
|
||||
const orgA = `e2e-${runId}-a`
|
||||
const accountA = `org:${orgA}`
|
||||
const checkout = await billing('POST', '/v1/billing/checkout', orgA, { plan: 'team', seats: 2, interval: 'month' })
|
||||
check('checkout session created (hosted Stripe URL)', checkout.status === 200 && /^https:\/\/checkout\.stripe\.com\//.test(checkout.body.url), checkout)
|
||||
await completeCheckout(checkout.body.url)
|
||||
const afterCheckout = await until('active subscription', () => entitlement(orgA), (e) => e.status === 'active')
|
||||
check('webhook -> 2-seat Team active, 120 h included, 6 concurrent', afterCheckout.status === 'active' && afterCheckout.seats === 2 &&
|
||||
afterCheckout.includedSeconds === 120 * HOUR && afterCheckout.limits?.maxConcurrentSandboxes === 6, afterCheckout)
|
||||
// List-by-email is consistent (search is not); the email is unique to this run.
|
||||
const customerA = (await stripe<{ data: Array<{ id: string; metadata: Record<string, string> }> }>('GET', '/v1/customers', {
|
||||
email: `e2e+${orgA}@example.com`
|
||||
})).data.find((customer) => customer.metadata.account_id === accountA)?.id ?? ''
|
||||
const subsA = await stripe<{ data: Array<{ id: string; items: { data: Array<{ price: { id: string } }> } }> }>('GET', '/v1/subscriptions', { customer: customerA })
|
||||
ids.checkoutCustomer = customerA
|
||||
ids.checkoutSubscription = subsA.data[0]?.id ?? ''
|
||||
|
||||
// ---- 2. Opt in to overage --------------------------------------------------------------------
|
||||
const optIn = await billing('POST', '/v1/billing/overage', orgA, { enabled: true })
|
||||
const subA = await stripe<{ items: { data: Array<{ price: { id: string } }> } }>('GET', `/v1/subscriptions/${ids.checkoutSubscription}`)
|
||||
check('overage opt-in adds the metered item ($25 default cap)', optIn.status === 200 && optIn.body.overage?.active === true &&
|
||||
optIn.body.overage?.spendCapCents === 2500 && subA.items.data.some((item) => item.price.id === catalog.STRIPE_PRICE_OVERAGE), optIn.body)
|
||||
|
||||
// ---- 3. Usage past included hours -> meter events --------------------------------------------
|
||||
const meterStart = Math.floor(Date.now() / 1000 / 60) * 60 - 2 * 24 * HOUR
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await reportUsage(accountA, 24)
|
||||
}
|
||||
const crossing = await reportUsage(accountA, 4)
|
||||
check('usage past 120 h: 4 h overage recorded, starts still allowed', crossing.overageSeconds === 4 * HOUR && crossing.canStartSandbox === true, crossing)
|
||||
const warned = await entitlement(orgA)
|
||||
check('100% included-hours warning', warned.usageWarningPercent === 100, warned.usageWarningPercent)
|
||||
|
||||
// ---- 4. Spend cap ----------------------------------------------------------------------------
|
||||
await reportUsage(accountA, 20)
|
||||
await reportUsage(accountA, 20)
|
||||
const capped = await startCheck(accountA)
|
||||
check('spend cap ($25 = 33.3 overage h) reached -> new starts blocked', capped.allow === false && capped.reason === 'overage_cap_reached', capped)
|
||||
const spent = await entitlement(orgA)
|
||||
check('metered spend stops at the cap', spent.overage?.spentCents <= 2500 && spent.overage?.spentCents >= 2490, spent.overage)
|
||||
|
||||
const meterEnd = Math.ceil(Date.now() / 1000 / 60) * 60 + 60
|
||||
const summaries = await until(
|
||||
'meter event summaries',
|
||||
() => stripe<{ data: Array<{ aggregated_value: number }> }>('GET', `/v1/billing/meters/${catalog.meter}/event_summaries`, {
|
||||
customer: customerA, start_time: meterStart, end_time: meterEnd
|
||||
}),
|
||||
(s) => s.data.reduce((sum, row) => sum + row.aggregated_value, 0) >= Math.floor((2500 / 75) * HOUR),
|
||||
240_000
|
||||
)
|
||||
const meteredTotal = summaries.data.reduce((sum, row) => sum + row.aggregated_value, 0)
|
||||
check('Stripe meter received exactly the capped overage seconds', meteredTotal === Math.floor((2500 / 75) * HOUR), { meteredTotal })
|
||||
|
||||
// ---- 5. Portal ------------------------------------------------------------------------------
|
||||
// ---- 5a. Seats follow membership (auth pushes member counts) ------------------------------------
|
||||
const membershipToken = process.env.E2E_MEMBERSHIP_TOKEN
|
||||
if (membershipToken) {
|
||||
const push = async (members: number) => {
|
||||
const response = await fetch(`${BILLING_URL}/v1/internal/seats`, {
|
||||
method: 'POST',
|
||||
headers: { authorization: `Bearer ${membershipToken}`, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ accountId: accountA, members })
|
||||
})
|
||||
return (await response.json()) as Record<string, any>
|
||||
}
|
||||
const seatItem = async () =>
|
||||
(await stripe<{ items: { data: Array<{ quantity: number; price: { id: string } }> } }>('GET', `/v1/subscriptions/${ids.checkoutSubscription}`))
|
||||
.items.data.find((item) => item.price.id === catalog.STRIPE_PRICE_TEAM_MONTHLY)?.quantity
|
||||
const added = await push(3)
|
||||
const invoices = await stripe<{ data: Array<{ billing_reason: string; status: string; amount_due: number }> }>('GET', '/v1/invoices', {
|
||||
subscription: ids.checkoutSubscription, limit: 1
|
||||
})
|
||||
check('member added -> 3 seats, prorated invoice charged now', added.status === 'updated' && (await seatItem()) === 3 &&
|
||||
invoices.data[0]?.billing_reason === 'subscription_update' && invoices.data[0]?.status === 'paid', { added, invoice: invoices.data[0] })
|
||||
const removed = await push(2)
|
||||
const afterRemove = await entitlement(orgA)
|
||||
check('member removed -> 2 seats (credit on next invoice), 120 h included', removed.status === 'updated' && (await seatItem()) === 2 &&
|
||||
afterRemove.seats === 2 && afterRemove.includedSeconds === 120 * HOUR, { removed, seats: afterRemove.seats })
|
||||
check('pending invites do not change seats (same member count is a no-op)', (await push(2)).status === 'unchanged')
|
||||
}
|
||||
|
||||
const portal = await billing('POST', '/v1/billing/portal', orgA)
|
||||
check('customer portal session (seats, monthly<->annual, cancel config)', /^https:\/\/billing\.stripe\.com\//.test(portal.body.url ?? ''), portal.body)
|
||||
|
||||
// ---- 6. Annual plan + overage (mixed intervals) -----------------------------------------------
|
||||
const orgB = `e2e-${runId}-b`
|
||||
const customerB = await stripe<{ id: string }>('POST', '/v1/customers', { email: `e2e+${orgB}@example.com`, metadata: { account_id: `org:${orgB}` } })
|
||||
ids.annualCustomer = customerB.id
|
||||
linkAccount(`org:${orgB}`, customerB.id)
|
||||
await stripe('POST', '/v1/payment_methods/pm_card_visa/attach', { customer: customerB.id })
|
||||
.then((pm: any) => stripe('POST', `/v1/customers/${customerB.id}`, { invoice_settings: { default_payment_method: pm.id } }))
|
||||
const annualSub = await stripe<{ id: string }>('POST', '/v1/subscriptions', {
|
||||
customer: customerB.id, items: [{ price: catalog.STRIPE_PRICE_TEAM_ANNUAL, quantity: 3 }], metadata: { account_id: `org:${orgB}` }
|
||||
})
|
||||
ids.annualSubscription = annualSub.id
|
||||
const annualEnt = await until('annual active', () => entitlement(orgB), (e) => e.status === 'active')
|
||||
check('annual Team: 3 seats, 180 h monthly allowance window', annualEnt.interval === 'year' && annualEnt.includedSeconds === 180 * HOUR &&
|
||||
annualEnt.usageWindow && annualEnt.usageWindow.end - annualEnt.usageWindow.start <= 31 * 24 * HOUR * 1000, annualEnt)
|
||||
const annualOptIn = await billing('POST', '/v1/billing/overage', orgB, { enabled: true })
|
||||
check('annual plan accepts the monthly metered overage item', annualOptIn.status === 200 && annualOptIn.body.overage?.active === true, annualOptIn)
|
||||
|
||||
// ---- 7. Test clock: renewal, then invoice.payment_failed --------------------------------------
|
||||
const orgC = `e2e-${runId}-c`
|
||||
const start = Math.floor(Date.now() / 1000)
|
||||
const clock = await stripe<{ id: string }>('POST', '/v1/test_helpers/test_clocks', { frozen_time: start, name: `orca-e2e-${runId}` })
|
||||
ids.testClock = clock.id
|
||||
const customerC = await stripe<{ id: string }>('POST', '/v1/customers', { email: `e2e+${orgC}@example.com`, test_clock: clock.id, metadata: { account_id: `org:${orgC}` } })
|
||||
ids.clockCustomer = customerC.id
|
||||
linkAccount(`org:${orgC}`, customerC.id)
|
||||
const visa = await stripe<{ id: string }>('POST', '/v1/payment_methods/pm_card_visa/attach', { customer: customerC.id })
|
||||
await stripe('POST', `/v1/customers/${customerC.id}`, { invoice_settings: { default_payment_method: visa.id } })
|
||||
const clockSub = await stripe<{ id: string }>('POST', '/v1/subscriptions', {
|
||||
customer: customerC.id, items: [{ price: catalog.STRIPE_PRICE_TEAM_MONTHLY, quantity: 2 }], metadata: { account_id: `org:${orgC}` }
|
||||
})
|
||||
ids.clockSubscription = clockSub.id
|
||||
const before = await until('clock sub active', () => entitlement(orgC), (e) => e.status === 'active')
|
||||
await advanceClock(clock.id, before.period.end / 1000 + HOUR)
|
||||
const renewed = await until('renewal synced', () => entitlement(orgC), (e) => e.period?.start === before.period.end, 120_000)
|
||||
check('test-clock renewal rolls the period (invoice.paid -> new window)', renewed.period?.start === before.period.end && renewed.status === 'active', {
|
||||
before: before.period, after: renewed.period
|
||||
})
|
||||
const failing = await stripe<{ id: string }>('POST', '/v1/payment_methods/pm_card_chargeCustomerFail/attach', { customer: customerC.id })
|
||||
await stripe('POST', `/v1/customers/${customerC.id}`, { invoice_settings: { default_payment_method: failing.id } })
|
||||
await advanceClock(clock.id, renewed.period.end / 1000 + HOUR)
|
||||
const pastDue = await until('past_due synced', () => entitlement(orgC), (e) => e.status === 'past_due', 120_000)
|
||||
check('invoice.payment_failed -> past_due -> starts blocked', pastDue.status === 'past_due' && pastDue.reason === 'payment_past_due', pastDue.status)
|
||||
const graceSeconds = Number(process.env.E2E_GRACE_SECONDS ?? 4 * 24 * HOUR)
|
||||
check('grace window recorded (suspend running after the grace)', pastDue.payment?.suspendRunning === false &&
|
||||
pastDue.payment?.graceEndsAt - pastDue.payment?.pastDueSince === graceSeconds * 1000, pastDue.payment)
|
||||
if (graceSeconds <= 120) {
|
||||
// Short grace configured for this run (BILLING_PLANS team.paymentGraceHours): watch it expire.
|
||||
const suspended = await until('grace expiry', () => startCheck(`org:${orgC}`), (c) => c.suspendRunning === true, (graceSeconds + 60) * 1000)
|
||||
check('grace expired -> suspendRunning, reason payment_grace_expired', suspended.suspendRunning === true && suspended.reason === 'payment_grace_expired', suspended)
|
||||
const listed = await billing('GET', '/v1/internal/suspensions', null).then((r) => r.body)
|
||||
check('/v1/internal/suspensions lists the account', listed.accounts?.some((a: any) => a.accountId === `org:${orgC}`), listed)
|
||||
// Pay the open invoice with a good card -> active -> resume allowed.
|
||||
const goodCard = await stripe<{ id: string }>('POST', '/v1/payment_methods/pm_card_visa/attach', { customer: customerC.id })
|
||||
await stripe('POST', `/v1/customers/${customerC.id}`, { invoice_settings: { default_payment_method: goodCard.id } })
|
||||
const live = await stripe<{ latest_invoice: string }>('GET', `/v1/subscriptions/${clockSub.id}`)
|
||||
await stripe('POST', `/v1/invoices/${live.latest_invoice}/pay`, { payment_method: goodCard.id })
|
||||
const resumed = await until('paid', () => startCheck(`org:${orgC}`), (c) => c.allow === true, 120_000)
|
||||
check('paid -> resume allowed, suspendRunning cleared', resumed.allow === true && resumed.suspendRunning === false, resumed)
|
||||
}
|
||||
|
||||
// ---- 8. Cancel -------------------------------------------------------------------------------
|
||||
await stripe('DELETE', `/v1/subscriptions/${ids.checkoutSubscription}`)
|
||||
const canceled = await until('cancel synced', () => entitlement(orgA), (e) => e.status === 'canceled')
|
||||
check('cancel -> subscription_inactive, starts blocked', canceled.status === 'canceled' && canceled.canStartSandbox === false, canceled.status)
|
||||
|
||||
console.log(JSON.stringify({ runId, ids }, null, 2))
|
||||
const failed = results.filter((r) => !r.ok)
|
||||
console.log(`${results.length - failed.length}/${results.length} checks passed`)
|
||||
process.exit(failed.length === 0 ? 0 : 1)
|
||||
@@ -0,0 +1,136 @@
|
||||
// One-time TEST MODE catalog setup: the Team product with monthly + annual seat prices, the
|
||||
// sandbox-seconds meter with its metered overage price, and a Customer Portal configuration
|
||||
// (monthly <-> annual switch, cancel; seats follow membership). Refuses any key that is not
|
||||
// sk_test_/rk_test_ and stops if Stripe ever reports a live-mode object. Prints the Worker vars.
|
||||
//
|
||||
// STRIPE_SECRET_KEY=sk_test_... BILLING_RETURN_URL=https://... tsx scripts/stripe-test-setup.ts
|
||||
//
|
||||
// Prices (cents) default to the agreed Team pricing; override with
|
||||
// STRIPE_SETUP_PRICES='{"teamMonthly":5000,"teamAnnual":48000,"overagePerHour":75}'
|
||||
// Idempotency keys include the prices, so a re-run within 24h returns the same objects.
|
||||
import { encodeForm } from '../src/stripe'
|
||||
|
||||
const key = process.env.STRIPE_SECRET_KEY ?? ''
|
||||
if (!key.startsWith('sk_test_') && !key.startsWith('rk_test_')) {
|
||||
console.error('Refusing: STRIPE_SECRET_KEY must be a test-mode key (sk_test_ or rk_test_).')
|
||||
process.exit(1)
|
||||
}
|
||||
const returnUrl = process.env.BILLING_RETURN_URL ?? 'https://onorca.dev/billing/return'
|
||||
|
||||
type StripeObject = { id: string; livemode?: boolean; error?: { message: string } }
|
||||
|
||||
async function post<T extends StripeObject>(path: string, params: Record<string, unknown>, idempotencyKey: string): Promise<T> {
|
||||
const response = await fetch(`https://api.stripe.com${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${key}`,
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
'stripe-version': '2025-09-30.clover',
|
||||
'idempotency-key': `orca-billing-setup:${idempotencyKey}`
|
||||
},
|
||||
body: encodeForm(params as never)
|
||||
})
|
||||
const json = (await response.json()) as T
|
||||
if (!response.ok) {
|
||||
throw new Error(`${path}: ${json.error?.message}`)
|
||||
}
|
||||
if (json.livemode) {
|
||||
throw new Error('Stripe reported a live-mode object; stopping.')
|
||||
}
|
||||
return json
|
||||
}
|
||||
|
||||
const prices = { teamMonthly: 5000, teamAnnual: 48000, overagePerHour: 75, ...JSON.parse(process.env.STRIPE_SETUP_PRICES ?? '{}') }
|
||||
const suffix = `${prices.teamMonthly}-${prices.teamAnnual}-${prices.overagePerHour}`
|
||||
|
||||
const team = await post('/v1/products', { name: 'Orca Team', unit_label: 'seat', metadata: { orca_plan: 'team' } }, 'product:team')
|
||||
const monthly = await post(
|
||||
'/v1/prices',
|
||||
{
|
||||
product: team.id,
|
||||
currency: 'usd',
|
||||
unit_amount: prices.teamMonthly,
|
||||
recurring: { interval: 'month' },
|
||||
lookup_key: 'orca_team_seat_monthly',
|
||||
transfer_lookup_key: true
|
||||
},
|
||||
`price:team:month:${suffix}`
|
||||
)
|
||||
const annual = await post(
|
||||
'/v1/prices',
|
||||
{
|
||||
product: team.id,
|
||||
currency: 'usd',
|
||||
unit_amount: prices.teamAnnual,
|
||||
recurring: { interval: 'year' },
|
||||
lookup_key: 'orca_team_seat_annual',
|
||||
transfer_lookup_key: true
|
||||
},
|
||||
`price:team:year:${suffix}`
|
||||
)
|
||||
|
||||
const meter = await post<StripeObject & { event_name: string }>(
|
||||
'/v1/billing/meters',
|
||||
{
|
||||
display_name: 'Orca sandbox overage (standard seconds)',
|
||||
event_name: 'orca_sandbox_seconds',
|
||||
default_aggregation: { formula: 'sum' },
|
||||
customer_mapping: { type: 'by_id', event_payload_key: 'stripe_customer_id' },
|
||||
value_settings: { event_payload_key: 'value' }
|
||||
},
|
||||
'meter'
|
||||
)
|
||||
const overageProduct = await post('/v1/products', { name: 'Orca sandbox overage', unit_label: 'std hour' }, 'product:overage')
|
||||
// Per Standard hour (Large burns 2x through the meter weight); the meter counts seconds and the
|
||||
// invoice divides by 3600, rounding up.
|
||||
const overage = await post(
|
||||
'/v1/prices',
|
||||
{
|
||||
product: overageProduct.id,
|
||||
currency: 'usd',
|
||||
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',
|
||||
transfer_lookup_key: true
|
||||
},
|
||||
`price:overage:${suffix}`
|
||||
)
|
||||
|
||||
const portal = await post(
|
||||
'/v1/billing_portal/configurations',
|
||||
{
|
||||
business_profile: { headline: 'Orca Team billing' },
|
||||
default_return_url: returnUrl,
|
||||
features: {
|
||||
customer_update: { enabled: true, allowed_updates: ['email', 'address', 'tax_id', 'name'] },
|
||||
invoice_history: { enabled: true },
|
||||
payment_method_update: { enabled: true },
|
||||
subscription_cancel: { enabled: true, mode: 'at_period_end', cancellation_reason: { enabled: true, options: ['too_expensive', 'missing_features', 'switched_service', 'unused', 'other'] } },
|
||||
subscription_update: {
|
||||
enabled: true,
|
||||
// Seats follow org membership (seats.ts); the portal only switches monthly <-> annual.
|
||||
default_allowed_updates: ['price'],
|
||||
proration_behavior: 'create_prorations',
|
||||
products: [{ product: team.id, prices: [monthly.id, annual.id] }]
|
||||
}
|
||||
}
|
||||
},
|
||||
`portal:${team.id}:${monthly.id}:${annual.id}`
|
||||
)
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
STRIPE_PRICE_TEAM_MONTHLY: monthly.id,
|
||||
STRIPE_PRICE_TEAM_ANNUAL: annual.id,
|
||||
STRIPE_PRICE_OVERAGE: overage.id,
|
||||
STRIPE_METER_EVENT_NAME: meter.event_name,
|
||||
STRIPE_PORTAL_CONFIGURATION: portal.id,
|
||||
products: { team: team.id, overage: overageProduct.id },
|
||||
meter: meter.id
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,146 @@
|
||||
// Caller authentication.
|
||||
// - Desktop: a short-lived ES256 JWT minted by Orca Cloud auth for the signed-in user
|
||||
// (aud = BILLING_JWT_AUDIENCE, purpose = 'billing'), verified against the auth JWKS — the same
|
||||
// pattern the relay uses, so billing never sees the user's refresh or access token.
|
||||
// - Control plane: a static bearer secret (rotation: comma-separated), compared in constant time.
|
||||
import { createLocalJWKSet, createRemoteJWKSet, jwtVerify, type JSONWebKeySet, type JWTVerifyGetKey } from 'jose'
|
||||
import { z } from 'zod'
|
||||
import type { Env } from './env'
|
||||
import { constantTimeEqual } from './webhook-signature'
|
||||
|
||||
const ID = z.string().min(1).max(128).regex(/^[A-Za-z0-9_.:-]+$/)
|
||||
|
||||
const ClaimsSchema = z.object({
|
||||
sub: ID,
|
||||
org: ID.optional(),
|
||||
// Role in `org`. Only owners and admins may buy or manage the org's plan.
|
||||
orgRole: z.enum(['owner', 'admin', 'member']).optional(),
|
||||
email: z.string().email().max(320).optional(),
|
||||
purpose: z.literal('billing')
|
||||
})
|
||||
|
||||
export type BillingCaller = {
|
||||
userId: string
|
||||
accountId: string
|
||||
canManage: boolean
|
||||
email?: string
|
||||
}
|
||||
|
||||
const keySets = new Map<string, JWTVerifyGetKey>()
|
||||
|
||||
function keysFor(env: Env): JWTVerifyGetKey {
|
||||
const source = env.BILLING_JWKS_URL ?? `inline:${env.BILLING_JWKS ?? ''}`
|
||||
let keys = keySets.get(source)
|
||||
if (!keys) {
|
||||
keys = env.BILLING_JWKS_URL
|
||||
? createRemoteJWKSet(new URL(env.BILLING_JWKS_URL))
|
||||
: createLocalJWKSet(JSON.parse(env.BILLING_JWKS ?? '{"keys":[]}') as JSONWebKeySet)
|
||||
keySets.set(source, keys)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
export const DEV_ISSUER = 'orca-billing-dev-test'
|
||||
let devKeys: { source: string; keys: JWTVerifyGetKey } | null = null
|
||||
|
||||
/** Dev operator keys; never honored when BILLING_ENV is 'prod'. */
|
||||
function devKeysFor(env: Env): JWTVerifyGetKey | null {
|
||||
if (env.BILLING_ENV === 'prod' || !env.BILLING_DEV_JWKS) {
|
||||
return null
|
||||
}
|
||||
if (devKeys?.source !== env.BILLING_DEV_JWKS) {
|
||||
devKeys = {
|
||||
source: env.BILLING_DEV_JWKS,
|
||||
keys: createLocalJWKSet(JSON.parse(env.BILLING_DEV_JWKS) as JSONWebKeySet)
|
||||
}
|
||||
}
|
||||
return devKeys.keys
|
||||
}
|
||||
|
||||
export function readBearer(value: string | null): string | null {
|
||||
return /^Bearer ([^\s]+)$/.exec(value ?? '')?.[1] ?? null
|
||||
}
|
||||
|
||||
/** An org pays for its members; a user without an org is their own account. */
|
||||
export function accountIdFor(claims: { sub: string; org?: string }): string {
|
||||
return claims.org ? `org:${claims.org}` : `user:${claims.sub}`
|
||||
}
|
||||
|
||||
// Billing tokens re-resolve org and role from auth's org_memberships rows when minted and live 5
|
||||
// minutes. Actions that spend money or change billing take only tokens minted in the last few
|
||||
// minutes, so a demotion or removal stops them within that window (auth backdates iat by 30 s).
|
||||
export const ADMIN_ACTION_MAX_TOKEN_AGE = '6m'
|
||||
|
||||
export async function authenticateDesktop(
|
||||
env: Env,
|
||||
request: Request,
|
||||
options: { maxTokenAge?: string } = {}
|
||||
): Promise<BillingCaller | null> {
|
||||
const token = readBearer(request.headers.get('authorization'))
|
||||
if (!token) {
|
||||
return null
|
||||
}
|
||||
const verify = (keys: JWTVerifyGetKey, issuer: string | undefined) =>
|
||||
jwtVerify(token, keys, {
|
||||
audience: env.BILLING_JWT_AUDIENCE,
|
||||
algorithms: ['ES256'],
|
||||
// Billing tokens are minted per call; refuse anything long-lived.
|
||||
maxTokenAge: options.maxTokenAge ?? '15m',
|
||||
requiredClaims: ['exp', 'iat'],
|
||||
...(issuer ? { issuer } : {})
|
||||
})
|
||||
try {
|
||||
let verified
|
||||
try {
|
||||
verified = await verify(keysFor(env), env.BILLING_AUTH_ISSUER)
|
||||
} catch (error) {
|
||||
// Dev only: an operator key set (its own issuer) until auth mints billing tokens.
|
||||
const dev = devKeysFor(env)
|
||||
if (!dev) {
|
||||
throw error
|
||||
}
|
||||
verified = await verify(dev, DEV_ISSUER)
|
||||
}
|
||||
const claims = ClaimsSchema.parse(verified.payload)
|
||||
return {
|
||||
userId: claims.sub,
|
||||
accountId: accountIdFor(claims),
|
||||
canManage: !claims.org || claims.orgRole === 'owner' || claims.orgRole === 'admin',
|
||||
email: claims.email
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function digest(value: string): Promise<string> {
|
||||
const bytes = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)))
|
||||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
export function authenticateControlPlane(env: Env, request: Request): Promise<boolean> {
|
||||
return authenticateSharedSecret(env.CONTROL_PLANE_TOKEN, request)
|
||||
}
|
||||
|
||||
/** Orca Cloud auth -> billing membership pushes; a separate secret from the control plane's. */
|
||||
export function authenticateMembershipSync(env: Env, request: Request): Promise<boolean> {
|
||||
return authenticateSharedSecret(env.MEMBERSHIP_SYNC_TOKEN, request)
|
||||
}
|
||||
|
||||
async function authenticateSharedSecret(secret: string | undefined, request: Request): Promise<boolean> {
|
||||
const token = readBearer(request.headers.get('authorization'))
|
||||
const accepted = (secret ?? '')
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length >= 32)
|
||||
if (!token || accepted.length === 0) {
|
||||
return false
|
||||
}
|
||||
// Hash both sides so the comparison is fixed-length and constant time.
|
||||
const presented = await digest(token)
|
||||
let matched = false
|
||||
for (const candidate of accepted) {
|
||||
matched = constantTimeEqual(presented, await digest(candidate)) || matched
|
||||
}
|
||||
return matched
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// Hosted Checkout and Customer Portal sessions. Card data only ever touches Stripe's pages.
|
||||
import { z } from 'zod'
|
||||
import type { BillingCaller } from './auth'
|
||||
import type { Env } from './env'
|
||||
import { BILLING_INTERVALS, PLAN_IDS, priceForPlan, type BillingInterval, type PlanId } from './plans'
|
||||
import type { StripeClient } from './stripe'
|
||||
import { currentSubscription, customerFor, ensureAccount, linkCustomer } from './store'
|
||||
import { orgIdOf, type MembershipSource } from './membership'
|
||||
|
||||
export const MAX_TEAM_SEATS = 500
|
||||
|
||||
export const CheckoutRequestSchema = z
|
||||
.object({
|
||||
plan: z.enum(PLAN_IDS),
|
||||
interval: z.enum(BILLING_INTERVALS).default('month'),
|
||||
seats: z.number().int().min(1).max(MAX_TEAM_SEATS).default(1)
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const OPEN_STATUSES = new Set(['active', 'trialing', 'past_due', 'incomplete', 'unpaid', 'paused'])
|
||||
|
||||
export type CheckoutOutcome = { ok: true; url: string } | { ok: false; status: number; error: string }
|
||||
|
||||
function returnUrl(env: Env, params: Record<string, string>): string {
|
||||
const url = new URL(env.BILLING_RETURN_URL)
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
url.searchParams.set(key, value)
|
||||
}
|
||||
// Stripe substitutes this literal placeholder; URLSearchParams would percent-encode the braces.
|
||||
return url.toString().replace('CHECKOUT_SESSION_ID_PLACEHOLDER', '{CHECKOUT_SESSION_ID}')
|
||||
}
|
||||
|
||||
async function ensureCustomer(env: Env, stripe: StripeClient, caller: BillingCaller, now: number): Promise<string> {
|
||||
await ensureAccount(env.BILLING_DB, caller.accountId, now)
|
||||
const existing = await customerFor(env.BILLING_DB, caller.accountId)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const created = await stripe.createCustomer({ accountId: caller.accountId, email: caller.email })
|
||||
return linkCustomer(env.BILLING_DB, caller.accountId, created.id, now)
|
||||
}
|
||||
|
||||
export async function createCheckout(
|
||||
env: Env,
|
||||
stripe: StripeClient,
|
||||
caller: BillingCaller,
|
||||
request: { plan: PlanId; interval: BillingInterval; seats: number },
|
||||
now: number,
|
||||
membership: MembershipSource
|
||||
): Promise<CheckoutOutcome> {
|
||||
if (!caller.canManage) {
|
||||
return { ok: false, status: 403, error: 'billing_admin_required' }
|
||||
}
|
||||
const existing = await currentSubscription(env.BILLING_DB, caller.accountId)
|
||||
if (existing && OPEN_STATUSES.has(existing.status)) {
|
||||
// Seat and interval changes go through the portal, which prorates; a second subscription
|
||||
// would double-bill.
|
||||
return { ok: false, status: 409, error: 'already_subscribed' }
|
||||
}
|
||||
// Seats = org members from auth when it is reachable; never fewer than 1. Throws (-> 503) if
|
||||
// auth is configured but unavailable rather than guessing a seat count.
|
||||
const orgId = orgIdOf(caller.accountId)
|
||||
const live = orgId ? await membership(orgId) : null
|
||||
const seats = Math.max(1, live ?? request.seats)
|
||||
const customer = await ensureCustomer(env, stripe, caller, now)
|
||||
const metadata = { account_id: caller.accountId, plan: request.plan, interval: request.interval }
|
||||
const session = await stripe.createCheckoutSession(
|
||||
{
|
||||
mode: 'subscription',
|
||||
customer,
|
||||
client_reference_id: caller.accountId,
|
||||
// Seats only. Overage is opt-in later; its metered item is added then (overage.ts).
|
||||
line_items: [
|
||||
// Seats then follow org membership automatically (seats.ts); not editable on the page.
|
||||
{ price: priceForPlan(env, request.interval), quantity: seats }
|
||||
],
|
||||
payment_method_collection: 'always',
|
||||
allow_promotion_codes: true,
|
||||
customer_update: { address: 'auto', name: 'auto' },
|
||||
success_url: returnUrl(env, { checkout: 'success', session_id: 'CHECKOUT_SESSION_ID_PLACEHOLDER' }),
|
||||
cancel_url: returnUrl(env, { checkout: 'cancel' }),
|
||||
metadata,
|
||||
...(env.STRIPE_AUTOMATIC_TAX === 'true'
|
||||
? { automatic_tax: { enabled: true }, tax_id_collection: { enabled: true } }
|
||||
: {}),
|
||||
subscription_data: { metadata }
|
||||
},
|
||||
`checkout:${caller.accountId}:${crypto.randomUUID()}`
|
||||
)
|
||||
return { ok: true, url: session.url }
|
||||
}
|
||||
|
||||
export async function createPortal(env: Env, stripe: StripeClient, caller: BillingCaller): Promise<CheckoutOutcome> {
|
||||
if (!caller.canManage) {
|
||||
return { ok: false, status: 403, error: 'billing_admin_required' }
|
||||
}
|
||||
const customer = await customerFor(env.BILLING_DB, caller.accountId)
|
||||
if (!customer) {
|
||||
return { ok: false, status: 404, error: 'no_billing_account' }
|
||||
}
|
||||
const session = await stripe.createPortalSession(
|
||||
{ customer, returnUrl: returnUrl(env, { portal: 'return' }), configuration: env.STRIPE_PORTAL_CONFIGURATION },
|
||||
`portal:${caller.accountId}:${crypto.randomUUID()}`
|
||||
)
|
||||
return { ok: true, url: session.url }
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
// "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 type { Env } from './env'
|
||||
import {
|
||||
MACHINE_SIZES,
|
||||
allowanceWindow,
|
||||
capSeconds,
|
||||
includedSeconds,
|
||||
planCatalog,
|
||||
type BillingInterval,
|
||||
type MachineSize,
|
||||
type PlanId
|
||||
} from './plans'
|
||||
import { accountSettings, currentSubscription, meteredSeconds, periodUsage, type SubscriptionRow } from './store'
|
||||
import { PAYMENT_FAILED_STATUSES } from './webhook'
|
||||
|
||||
export type DenyReason =
|
||||
| 'no_subscription'
|
||||
| 'subscription_inactive'
|
||||
| 'payment_past_due'
|
||||
| 'payment_grace_expired'
|
||||
| 'included_hours_exhausted'
|
||||
| 'overage_cap_reached'
|
||||
| 'machine_size_not_in_plan'
|
||||
| 'concurrency_limit'
|
||||
| 'member_concurrency_limit'
|
||||
|
||||
export type Entitlement = {
|
||||
accountId: string
|
||||
plan: PlanId | null
|
||||
planLabel: string | null
|
||||
status: string
|
||||
interval: BillingInterval | null
|
||||
seats: number
|
||||
/** Latest org member count from auth; seats stay at the period's peak until renewal. */
|
||||
members: number | null
|
||||
/** When seats will come down to the member count at renewal. */
|
||||
seatChange: { seatsAtRenewal: number; effectiveAt: number } | null
|
||||
/** Billing period (a year for annual plans). */
|
||||
period: { start: number; end: number } | null
|
||||
/** Monthly allowance window the hours below count against. */
|
||||
usageWindow: { start: number; end: number } | null
|
||||
cancelAtPeriodEnd: boolean
|
||||
includedSeconds: number
|
||||
usedSeconds: number
|
||||
remainingIncludedSeconds: number
|
||||
/** Highest configured warning level reached (e.g. 80, 100), or null. */
|
||||
usageWarningPercent: number | null
|
||||
overageSeconds: number
|
||||
overage: {
|
||||
optedIn: boolean
|
||||
/** Opted in and the metered item is on the subscription: overage is being billed. */
|
||||
active: boolean
|
||||
spendCapCents: number
|
||||
spentCents: number
|
||||
centsPerHour: number
|
||||
}
|
||||
limits: {
|
||||
maxConcurrentSandboxes: number
|
||||
/** Per member, so one seat cannot run the whole team's sandboxes. */
|
||||
maxConcurrentSandboxesPerMember: number
|
||||
machineSizes: readonly MachineSize[]
|
||||
idleSleepMinutes: number
|
||||
}
|
||||
/** Failed payment: new starts stop at once; running sandboxes must be suspended after the grace. */
|
||||
payment: { pastDueSince: number | null; graceEndsAt: number | null; suspendRunning: boolean }
|
||||
canStartSandbox: boolean
|
||||
reason: DenyReason | null
|
||||
}
|
||||
|
||||
const LIVE_STATUSES = new Set(['active', 'trialing'])
|
||||
|
||||
export function subscriptionWindow(subscription: SubscriptionRow, at: number): { start: number; end: number } {
|
||||
return allowanceWindow({ start: subscription.current_period_start, end: subscription.current_period_end }, at)
|
||||
}
|
||||
|
||||
export async function computeEntitlement(
|
||||
env: Pick<Env, 'BILLING_DB' | 'BILLING_PLANS'>,
|
||||
accountId: string,
|
||||
now: number = Date.now()
|
||||
): Promise<Entitlement> {
|
||||
const db = env.BILLING_DB
|
||||
const team = planCatalog(env).team
|
||||
const [subscription, settings] = await Promise.all([currentSubscription(db, accountId), accountSettings(db, accountId)])
|
||||
const spendCapCents = settings.spend_cap_cents ?? team.defaultSpendCapCents
|
||||
const overageBase = { optedIn: settings.overage_opt_in === 1, spendCapCents, centsPerHour: team.overageCentsPerHour }
|
||||
if (!subscription) {
|
||||
return {
|
||||
accountId,
|
||||
plan: null,
|
||||
planLabel: null,
|
||||
status: 'none',
|
||||
interval: null,
|
||||
seats: 0,
|
||||
members: null,
|
||||
seatChange: null,
|
||||
period: null,
|
||||
usageWindow: null,
|
||||
cancelAtPeriodEnd: false,
|
||||
includedSeconds: 0,
|
||||
usedSeconds: 0,
|
||||
remainingIncludedSeconds: 0,
|
||||
usageWarningPercent: null,
|
||||
overageSeconds: 0,
|
||||
overage: { ...overageBase, active: false, spentCents: 0 },
|
||||
limits: {
|
||||
maxConcurrentSandboxes: 0,
|
||||
maxConcurrentSandboxesPerMember: 0,
|
||||
machineSizes: [],
|
||||
idleSleepMinutes: team.idleSleepMinutes
|
||||
},
|
||||
payment: { pastDueSince: null, graceEndsAt: null, suspendRunning: false },
|
||||
canStartSandbox: false,
|
||||
reason: 'no_subscription'
|
||||
}
|
||||
}
|
||||
const window = subscriptionWindow(subscription, now)
|
||||
const included = includedSeconds(team, subscription.seats)
|
||||
const [usage, metered] = await Promise.all([
|
||||
periodUsage(db, accountId, window.start),
|
||||
meteredSeconds(db, accountId, window.start)
|
||||
])
|
||||
const remaining = Math.max(0, included - usage.usedSeconds)
|
||||
const overageActive = overageBase.optedIn && subscription.overage_item_id !== null
|
||||
const usedPercent = included > 0 ? (usage.usedSeconds / included) * 100 : 0
|
||||
const warnings = team.warnAtPercent.filter((level) => usedPercent >= level)
|
||||
|
||||
const pastDueSince = PAYMENT_FAILED_STATUSES.has(subscription.status) ? (subscription.past_due_since ?? now) : null
|
||||
const graceEndsAt = pastDueSince === null ? null : pastDueSince + team.paymentGraceHours * 3600_000
|
||||
const graceExpired = graceEndsAt !== null && now >= graceEndsAt
|
||||
|
||||
let reason: DenyReason | null = null
|
||||
if (graceExpired) {
|
||||
reason = 'payment_grace_expired'
|
||||
} else if (pastDueSince !== null) {
|
||||
// Stripe is retrying the card (Smart Retries + dunning emails): no new starts, running ones keep going.
|
||||
reason = 'payment_past_due'
|
||||
} else if (!LIVE_STATUSES.has(subscription.status)) {
|
||||
reason = 'subscription_inactive'
|
||||
} else if (remaining === 0 && !overageActive) {
|
||||
reason = 'included_hours_exhausted'
|
||||
} else if (remaining === 0 && metered >= capSeconds(team, spendCapCents)) {
|
||||
reason = 'overage_cap_reached'
|
||||
}
|
||||
|
||||
return {
|
||||
accountId,
|
||||
plan: subscription.plan,
|
||||
planLabel: team.label,
|
||||
status: subscription.status,
|
||||
interval: subscription.billing_interval,
|
||||
seats: subscription.seats,
|
||||
members: subscription.member_count,
|
||||
seatChange:
|
||||
subscription.member_count !== null && Math.max(1, subscription.member_count) < subscription.seats
|
||||
? { seatsAtRenewal: Math.max(1, subscription.member_count), effectiveAt: subscription.current_period_end }
|
||||
: null,
|
||||
period: { start: subscription.current_period_start, end: subscription.current_period_end },
|
||||
usageWindow: window,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end === 1,
|
||||
includedSeconds: included,
|
||||
usedSeconds: usage.usedSeconds,
|
||||
remainingIncludedSeconds: remaining,
|
||||
usageWarningPercent: warnings.length > 0 ? Math.max(...warnings) : null,
|
||||
overageSeconds: usage.overageSeconds,
|
||||
overage: {
|
||||
...overageBase,
|
||||
active: overageActive,
|
||||
spentCents: Math.round((metered / 3600) * team.overageCentsPerHour)
|
||||
},
|
||||
limits: {
|
||||
maxConcurrentSandboxes: team.concurrentSandboxesPerSeat * Math.max(1, subscription.seats),
|
||||
maxConcurrentSandboxesPerMember: team.concurrentSandboxesPerSeat,
|
||||
machineSizes: team.machineSizes,
|
||||
idleSleepMinutes: team.idleSleepMinutes
|
||||
},
|
||||
payment: { pastDueSince, graceEndsAt, suspendRunning: graceExpired },
|
||||
canStartSandbox: reason === null,
|
||||
reason
|
||||
}
|
||||
}
|
||||
|
||||
export function isMachineSize(value: unknown): value is MachineSize {
|
||||
return typeof value === 'string' && (MACHINE_SIZES as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
/** The control plane's start gate: the account-level answer plus size and concurrency. */
|
||||
export function checkStart(
|
||||
entitlement: Entitlement,
|
||||
request: { machineSize: MachineSize; runningSandboxes: number; runningSandboxesForUser?: number }
|
||||
): { allow: boolean; reason: DenyReason | null; suspendRunning: boolean } {
|
||||
const suspendRunning = entitlement.payment.suspendRunning
|
||||
if (!entitlement.canStartSandbox) {
|
||||
return { allow: false, reason: entitlement.reason, suspendRunning }
|
||||
}
|
||||
if (!entitlement.limits.machineSizes.includes(request.machineSize)) {
|
||||
return { allow: false, reason: 'machine_size_not_in_plan', suspendRunning }
|
||||
}
|
||||
if (request.runningSandboxes >= entitlement.limits.maxConcurrentSandboxes) {
|
||||
return { allow: false, reason: 'concurrency_limit', suspendRunning }
|
||||
}
|
||||
if (
|
||||
request.runningSandboxesForUser !== undefined &&
|
||||
request.runningSandboxesForUser >= entitlement.limits.maxConcurrentSandboxesPerMember
|
||||
) {
|
||||
return { allow: false, reason: 'member_concurrency_limit', suspendRunning }
|
||||
}
|
||||
return { allow: true, reason: null, suspendRunning }
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Billing Worker bindings. Secrets are set with `wrangler secret put` (stdin), never in
|
||||
// wrangler.jsonc or git. Deployed environments live in the private deploy config.
|
||||
export type Env = {
|
||||
BILLING_DB: D1Database
|
||||
|
||||
// Stripe. Use a RESTRICTED key (rk_test_...) scoped to: Customers write, Checkout Sessions
|
||||
// write, Customer portal write, Subscriptions read, Billing meter events write. Nothing else.
|
||||
STRIPE_SECRET_KEY: string
|
||||
// Webhook endpoint signing secret(s), comma-separated during rotation (whsec_...).
|
||||
STRIPE_WEBHOOK_SECRET: string
|
||||
// Pinned so a dashboard default-version bump cannot change payload shapes under us.
|
||||
STRIPE_API_VERSION?: string
|
||||
// Must be exactly 'true' to accept live-mode keys or events. Unset everywhere until launch.
|
||||
STRIPE_ALLOW_LIVEMODE?: string
|
||||
// Overrides the Stripe API origin (tests and stripe-mock only).
|
||||
STRIPE_API_BASE?: string
|
||||
|
||||
// Team seat prices (price_...), quantity = seats.
|
||||
STRIPE_PRICE_TEAM_MONTHLY: string
|
||||
STRIPE_PRICE_TEAM_ANNUAL: string
|
||||
// Monthly metered price on the sandbox-seconds meter, added to a subscription when an admin
|
||||
// opts in to overage (see overage.ts, usage.ts).
|
||||
STRIPE_PRICE_OVERAGE: string
|
||||
// Billing meter event_name the overage price aggregates.
|
||||
STRIPE_METER_EVENT_NAME: string
|
||||
// Customer portal configuration (bpc_...) allowing seat changes, monthly<->annual and cancel.
|
||||
STRIPE_PORTAL_CONFIGURATION?: 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
|
||||
|
||||
// Desktop tokens: short-lived ES256 JWTs minted by Orca Cloud auth with aud=BILLING_JWT_AUDIENCE.
|
||||
// BILLING_JWKS (inline set) is for local dev and tests only.
|
||||
BILLING_JWKS_URL?: string
|
||||
BILLING_JWKS?: string
|
||||
BILLING_AUTH_ISSUER?: string
|
||||
BILLING_JWT_AUDIENCE: string
|
||||
// 'dev' | 'prod'. Dev may also accept BILLING_DEV_JWKS (issuer 'orca-billing-dev-test', a secret
|
||||
// operator key set for e2e runs before auth mints billing tokens); prod never does.
|
||||
BILLING_ENV?: string
|
||||
BILLING_DEV_JWKS?: string
|
||||
|
||||
// Sandbox control plane -> billing (usage reports, start checks). Secret; >= 32 chars.
|
||||
// Comma-separated during rotation. Replace with a service binding once the control plane is a Worker.
|
||||
CONTROL_PLANE_TOKEN: string
|
||||
// Shared secret between Orca Cloud auth and billing for membership: auth pushes member counts to
|
||||
// POST /v1/internal/seats, billing pulls them from AUTH_INTERNAL_URL. >= 32 chars; comma-separated
|
||||
// during rotation (billing sends the first).
|
||||
MEMBERSHIP_SYNC_TOKEN?: string
|
||||
// Auth origin serving POST /v1/internal/billing/org-members. Unset: seats come from pushes and the
|
||||
// Checkout request only (local dev).
|
||||
AUTH_INTERNAL_URL?: string
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
// Orca billing Worker: Stripe Checkout/Portal, Stripe webhooks, entitlements and usage.
|
||||
//
|
||||
// Desktop (Orca Cloud billing JWT):
|
||||
// GET /v1/billing/entitlements current plan, hours used/remaining, can-start
|
||||
// POST /v1/billing/checkout {plan,interval,seats} -> { url } hosted Stripe Checkout
|
||||
// POST /v1/billing/overage {enabled, spendCapCents?} opt in/out of overage, set the cap
|
||||
// POST /v1/billing/portal -> { url } Stripe Customer Portal
|
||||
// Stripe:
|
||||
// POST /v1/stripe/webhook signed events
|
||||
// Sandbox control plane (CONTROL_PLANE_TOKEN):
|
||||
// POST /v1/internal/entitlements/check {accountId, machineSize, runningSandboxes}
|
||||
// POST /v1/internal/usage {usageId, accountId, sandboxId, machineSize, startedAt, endedAt}
|
||||
// GET /v1/internal/suspensions accounts whose payment grace expired (suspend running)
|
||||
// Orca Cloud auth (MEMBERSHIP_SYNC_TOKEN):
|
||||
// POST /v1/internal/seats {accountId, members}
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
ADMIN_ACTION_MAX_TOKEN_AGE,
|
||||
authenticateControlPlane,
|
||||
authenticateDesktop,
|
||||
authenticateMembershipSync
|
||||
} from './auth'
|
||||
import { SeatSyncSchema, reconcileSeats, syncSeats } from './seats'
|
||||
import { MembershipUnavailableError, authMembershipSource, type MembershipSource } from './membership'
|
||||
import { graceExpiredAccounts } from './store'
|
||||
import { CheckoutRequestSchema, createCheckout, createPortal } from './checkout'
|
||||
import { checkStart, computeEntitlement } from './entitlements'
|
||||
import type { Env } from './env'
|
||||
import { MACHINE_SIZES, PlanConfigError, planCatalog, publicOffers } from './plans'
|
||||
import { OverageRequestSchema, updateOverage } from './overage'
|
||||
import { LiveModeRefusedError, StripeError, createStripeClient, type StripeClient } from './stripe'
|
||||
import { UsageReportSchema, flushMeterReports, recordUsage, validateSegment } from './usage'
|
||||
import { handleStripeWebhook } from './webhook'
|
||||
|
||||
const MAX_JSON_BYTES = 16 * 1024
|
||||
|
||||
const StartCheckSchema = z
|
||||
.object({
|
||||
accountId: z.string().min(1).max(128),
|
||||
machineSize: z.enum(MACHINE_SIZES),
|
||||
runningSandboxes: z.number().int().min(0).max(10_000),
|
||||
// The member starting it and how many they already run (per-member cap; attribution).
|
||||
userId: z.string().min(1).max(128).regex(/^[A-Za-z0-9_.:-]+$/).optional(),
|
||||
runningSandboxesForUser: z.number().int().min(0).max(10_000).optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
function json(body: unknown, status = 200): Response {
|
||||
return Response.json(body, { status, headers: { 'cache-control': 'no-store' } })
|
||||
}
|
||||
|
||||
async function readJson(request: Request): Promise<unknown> {
|
||||
const text = await request.text()
|
||||
if (text.length > MAX_JSON_BYTES) {
|
||||
throw new SyntaxError('too_large')
|
||||
}
|
||||
return text ? JSON.parse(text) : {}
|
||||
}
|
||||
|
||||
export type Deps = { stripe?: StripeClient; now?: () => number; membership?: MembershipSource }
|
||||
|
||||
export async function handleRequest(request: Request, env: Env, ctx: ExecutionContext, deps: Deps = {}): Promise<Response> {
|
||||
const url = new URL(request.url)
|
||||
const now = deps.now?.() ?? Date.now()
|
||||
const stripe = deps.stripe ?? createStripeClient(env)
|
||||
const membership = deps.membership ?? authMembershipSource(env)
|
||||
const route = `${request.method} ${url.pathname}`
|
||||
try {
|
||||
switch (route) {
|
||||
case 'GET /return':
|
||||
// Where Checkout and the Portal send the browser back. Static; reveals nothing.
|
||||
return new Response(
|
||||
'<!doctype html><meta charset="utf-8"><title>Orca</title>' +
|
||||
'<body style="font:15px system-ui;margin:4rem auto;max-width:28rem;text-align:center">' +
|
||||
'<p>You can close this tab and return to Orca.</p></body>',
|
||||
{ headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } }
|
||||
)
|
||||
|
||||
case 'POST /v1/stripe/webhook':
|
||||
return await handleStripeWebhook(env, stripe, request, now)
|
||||
|
||||
case 'GET /v1/billing/entitlements': {
|
||||
const caller = await authenticateDesktop(env, request)
|
||||
if (!caller) {
|
||||
return json({ error: 'unauthorized' }, 401)
|
||||
}
|
||||
const entitlement = await computeEntitlement(env, caller.accountId, now)
|
||||
return json({ ...entitlement, canManageBilling: caller.canManage, offers: publicOffers(planCatalog(env)) })
|
||||
}
|
||||
|
||||
case 'POST /v1/billing/checkout': {
|
||||
const caller = await authenticateDesktop(env, request, { maxTokenAge: ADMIN_ACTION_MAX_TOKEN_AGE })
|
||||
if (!caller) {
|
||||
return json({ error: 'unauthorized' }, 401)
|
||||
}
|
||||
const body = CheckoutRequestSchema.safeParse(await readJson(request))
|
||||
if (!body.success) {
|
||||
return json({ error: 'invalid_request' }, 400)
|
||||
}
|
||||
const outcome = await createCheckout(env, stripe, caller, body.data, now, membership)
|
||||
return outcome.ok ? json({ url: outcome.url }) : json({ error: outcome.error }, outcome.status)
|
||||
}
|
||||
|
||||
case 'POST /v1/billing/overage': {
|
||||
const caller = await authenticateDesktop(env, request, { maxTokenAge: ADMIN_ACTION_MAX_TOKEN_AGE })
|
||||
if (!caller) {
|
||||
return json({ error: 'unauthorized' }, 401)
|
||||
}
|
||||
const body = OverageRequestSchema.safeParse(await readJson(request))
|
||||
if (!body.success) {
|
||||
return json({ error: 'invalid_request' }, 400)
|
||||
}
|
||||
const outcome = await updateOverage(env, stripe, caller, body.data, now)
|
||||
if (!outcome.ok) {
|
||||
return json({ error: outcome.error }, outcome.status)
|
||||
}
|
||||
const entitlement = await computeEntitlement(env, caller.accountId, now)
|
||||
return json({ ...entitlement, canManageBilling: caller.canManage, offers: publicOffers(planCatalog(env)) })
|
||||
}
|
||||
|
||||
case 'POST /v1/internal/seats': {
|
||||
if (!(await authenticateMembershipSync(env, request))) {
|
||||
return json({ error: 'unauthorized' }, 401)
|
||||
}
|
||||
const body = SeatSyncSchema.safeParse(await readJson(request))
|
||||
if (!body.success) {
|
||||
return json({ error: 'invalid_request' }, 400)
|
||||
}
|
||||
return json(await syncSeats(env, stripe, body.data.accountId, body.data.members, now))
|
||||
}
|
||||
|
||||
case 'GET /v1/internal/suspensions': {
|
||||
// Accounts whose payment grace ran out: suspend their running sandboxes (poll from a cron).
|
||||
if (!(await authenticateControlPlane(env, request))) {
|
||||
return json({ error: 'unauthorized' }, 401)
|
||||
}
|
||||
const graceMs = planCatalog(env).team.paymentGraceHours * 3600_000
|
||||
const accounts = await graceExpiredAccounts(env.BILLING_DB, now - graceMs)
|
||||
return json({
|
||||
accounts: accounts.map((row) => ({
|
||||
accountId: row.account_id,
|
||||
pastDueSince: row.past_due_since,
|
||||
graceEndsAt: row.past_due_since + graceMs
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
case 'POST /v1/billing/portal': {
|
||||
const caller = await authenticateDesktop(env, request, { maxTokenAge: ADMIN_ACTION_MAX_TOKEN_AGE })
|
||||
if (!caller) {
|
||||
return json({ error: 'unauthorized' }, 401)
|
||||
}
|
||||
const outcome = await createPortal(env, stripe, caller)
|
||||
return outcome.ok ? json({ url: outcome.url }) : json({ error: outcome.error }, outcome.status)
|
||||
}
|
||||
|
||||
case 'POST /v1/internal/entitlements/check': {
|
||||
if (!(await authenticateControlPlane(env, request))) {
|
||||
return json({ error: 'unauthorized' }, 401)
|
||||
}
|
||||
const body = StartCheckSchema.safeParse(await readJson(request))
|
||||
if (!body.success) {
|
||||
return json({ error: 'invalid_request' }, 400)
|
||||
}
|
||||
const entitlement = await computeEntitlement(env, body.data.accountId, now)
|
||||
return json({
|
||||
...checkStart(entitlement, body.data),
|
||||
// Billed seats: the period's peak (never raw membership), so caps can't be inflated by
|
||||
// adding members the org hasn't paid for, nor shrunk by mid-period removals.
|
||||
seats: entitlement.seats,
|
||||
maxConcurrentSandboxes: entitlement.limits.maxConcurrentSandboxes,
|
||||
maxConcurrentSandboxesPerMember: entitlement.limits.maxConcurrentSandboxesPerMember,
|
||||
entitlement
|
||||
})
|
||||
}
|
||||
|
||||
case 'POST /v1/internal/usage': {
|
||||
if (!(await authenticateControlPlane(env, request))) {
|
||||
return json({ error: 'unauthorized' }, 401)
|
||||
}
|
||||
const body = UsageReportSchema.safeParse(await readJson(request))
|
||||
if (!body.success) {
|
||||
return json({ error: 'invalid_request' }, 400)
|
||||
}
|
||||
const invalid = validateSegment(body.data, now)
|
||||
if (invalid) {
|
||||
return json({ error: invalid }, 400)
|
||||
}
|
||||
const result = await recordUsage(env, body.data, now)
|
||||
if (result.overageSeconds > 0) {
|
||||
// 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, body.data.accountId, now)
|
||||
return json({
|
||||
...result,
|
||||
canStartSandbox: entitlement.canStartSandbox,
|
||||
reason: entitlement.reason,
|
||||
suspendRunning: entitlement.payment.suspendRunning
|
||||
})
|
||||
}
|
||||
|
||||
default:
|
||||
return json({ error: 'not_found' }, 404)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
return json({ error: 'invalid_json' }, 400)
|
||||
}
|
||||
if (error instanceof LiveModeRefusedError || error instanceof PlanConfigError) {
|
||||
return json({ error: 'billing_misconfigured' }, 503)
|
||||
}
|
||||
if (error instanceof MembershipUnavailableError) {
|
||||
// Never guess a seat count: fail the action until auth answers.
|
||||
console.error(JSON.stringify({ route, error: 'membership', message: error.message }))
|
||||
return json({ error: 'membership_unavailable' }, 503)
|
||||
}
|
||||
if (error instanceof StripeError) {
|
||||
// Stripe's message never contains the key or card data.
|
||||
console.error(JSON.stringify({ route, error: 'stripe', status: error.status, code: error.code, message: error.message }))
|
||||
return json({ error: 'stripe_unavailable' }, 502)
|
||||
}
|
||||
console.error(JSON.stringify({ route, error: error instanceof Error ? error.name : 'unknown', message: error instanceof Error ? error.message.slice(0, 200) : '' }))
|
||||
return json({ error: 'internal_error' }, 500)
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
return handleRequest(request, env, ctx)
|
||||
},
|
||||
async scheduled(_controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
|
||||
const stripe = createStripeClient(env)
|
||||
ctx.waitUntil(flushMeterReports(env, stripe, Date.now()).then(() => undefined))
|
||||
// Seat drift from a missed membership push heals here (needs AUTH_INTERNAL_URL).
|
||||
ctx.waitUntil(reconcileSeats(env, stripe, authMembershipSource(env), Date.now()).then(() => undefined))
|
||||
}
|
||||
} satisfies ExportedHandler<Env>
|
||||
@@ -0,0 +1,52 @@
|
||||
// Seat count source of truth: Orca Cloud auth's org membership (org_memberships rows; pending
|
||||
// invites excluded). Auth pushes changes to POST /v1/internal/seats; billing also pulls the live
|
||||
// count when it needs one (Checkout, the reconcile cron) so a missed push self-heals.
|
||||
import { z } from 'zod'
|
||||
import type { Env } from './env'
|
||||
|
||||
const MemberCountResponse = z.object({ orgId: z.string(), members: z.number().int().min(0) })
|
||||
|
||||
export class MembershipUnavailableError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'MembershipUnavailableError'
|
||||
}
|
||||
}
|
||||
|
||||
/** Live member count from auth, or null when no auth URL is configured (local dev and tests). */
|
||||
export type MembershipSource = (orgId: string) => Promise<number | null>
|
||||
|
||||
export function authMembershipSource(env: Env, fetchImpl: typeof fetch = fetch): MembershipSource {
|
||||
return async (orgId) => {
|
||||
if (!env.AUTH_INTERNAL_URL) {
|
||||
return null
|
||||
}
|
||||
if (!env.MEMBERSHIP_SYNC_TOKEN) {
|
||||
throw new MembershipUnavailableError('membership token not configured')
|
||||
}
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetchImpl(new URL('/v1/internal/billing/org-members', env.AUTH_INTERNAL_URL), {
|
||||
method: 'POST',
|
||||
headers: { authorization: `Bearer ${env.MEMBERSHIP_SYNC_TOKEN.split(',')[0]!.trim()}`, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ orgId }),
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(10_000)
|
||||
})
|
||||
} catch {
|
||||
throw new MembershipUnavailableError('auth unreachable')
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new MembershipUnavailableError(`auth answered ${response.status}`)
|
||||
}
|
||||
const parsed = MemberCountResponse.safeParse(await response.json().catch(() => null))
|
||||
if (!parsed.success || parsed.data.orgId !== orgId) {
|
||||
throw new MembershipUnavailableError('auth answered an unexpected body')
|
||||
}
|
||||
return parsed.data.members
|
||||
}
|
||||
}
|
||||
|
||||
export function orgIdOf(accountId: string): string | null {
|
||||
return accountId.startsWith('org:') ? accountId.slice(4) : null
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Overage is opt-in per account (off by default) with a monthly spend cap admins can raise.
|
||||
// Opting in adds the metered overage price to the Stripe subscription (once); opting out stops
|
||||
// metering immediately but leaves the item, so usage already reported is still invoiced.
|
||||
import { z } from 'zod'
|
||||
import type { BillingCaller } from './auth'
|
||||
import { OPEN_STATUSES } from './checkout'
|
||||
import type { Env } from './env'
|
||||
import { planCatalog } from './plans'
|
||||
import type { StripeClient } from './stripe'
|
||||
import { accountSettings, currentSubscription, setOverageSettings, upsertSubscription } from './store'
|
||||
|
||||
export const OverageRequestSchema = z
|
||||
.object({
|
||||
enabled: z.boolean(),
|
||||
// Whole cents; null restores the plan default.
|
||||
spendCapCents: z.number().int().min(0).nullable().optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type OverageOutcome = { ok: true } | { ok: false; status: number; error: string }
|
||||
|
||||
export async function updateOverage(
|
||||
env: Env,
|
||||
stripe: StripeClient,
|
||||
caller: BillingCaller,
|
||||
request: z.infer<typeof OverageRequestSchema>,
|
||||
now: number
|
||||
): Promise<OverageOutcome> {
|
||||
if (!caller.canManage) {
|
||||
return { ok: false, status: 403, error: 'billing_admin_required' }
|
||||
}
|
||||
const team = planCatalog(env).team
|
||||
if (request.spendCapCents != null && request.spendCapCents > team.maxSpendCapCents) {
|
||||
return { ok: false, status: 400, error: 'spend_cap_too_high' }
|
||||
}
|
||||
const subscription = await currentSubscription(env.BILLING_DB, caller.accountId)
|
||||
if (!subscription || !OPEN_STATUSES.has(subscription.status)) {
|
||||
return { ok: false, status: 409, error: 'no_subscription' }
|
||||
}
|
||||
if (request.enabled && !subscription.overage_item_id) {
|
||||
// Idempotent per subscription+price, so a retried opt-in cannot add a second item.
|
||||
const item = await stripe.addSubscriptionItem({ subscription: subscription.stripe_subscription_id, price: env.STRIPE_PRICE_OVERAGE })
|
||||
await upsertSubscription(env.BILLING_DB, { ...subscription, overage_enabled: 1, overage_item_id: item.id, updated_at: now })
|
||||
}
|
||||
// An omitted cap keeps the current one; null restores the plan default.
|
||||
const spendCapCents =
|
||||
request.spendCapCents === undefined
|
||||
? (await accountSettings(env.BILLING_DB, caller.accountId)).spend_cap_cents
|
||||
: request.spendCapCents
|
||||
await setOverageSettings(env.BILLING_DB, caller.accountId, { optIn: request.enabled, spendCapCents }, now)
|
||||
return { ok: true }
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// Plan catalog. One self-serve plan (Team, per seat, monthly or annual) plus Enterprise (contact
|
||||
// sales, no Stripe price). Stripe price ids are Worker vars; every number and label below is a
|
||||
// default that BILLING_PLANS (JSON) can override, so pricing changes need no code change.
|
||||
//
|
||||
// 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 and a Large box burns 2x.
|
||||
// Included hours are a MONTHLY per-seat allowance pooled across the org, for annual plans too.
|
||||
import { z } from 'zod'
|
||||
import type { Env } from './env'
|
||||
|
||||
export const PLAN_IDS = ['team'] as const
|
||||
export type PlanId = (typeof PLAN_IDS)[number]
|
||||
export const BILLING_INTERVALS = ['month', 'year'] as const
|
||||
export type BillingInterval = (typeof BILLING_INTERVALS)[number]
|
||||
|
||||
export const MACHINE_SIZES = ['standard', 'large'] as const
|
||||
export type MachineSize = (typeof MACHINE_SIZES)[number]
|
||||
export const MACHINE_SIZE_WEIGHT: Record<MachineSize, number> = { standard: 1, large: 2 }
|
||||
|
||||
const TeamSchema = z
|
||||
.object({
|
||||
label: z.string().min(1).max(40),
|
||||
// Display only; Stripe's prices are what is charged.
|
||||
monthlyPriceLabel: z.string().max(40),
|
||||
annualPriceLabel: z.string().max(40),
|
||||
// Display only, for totals and the annual saving on the plans page; must match Stripe's prices.
|
||||
monthlyPriceCentsPerSeat: z.number().int().nonnegative(),
|
||||
annualPriceCentsPerSeat: z.number().int().nonnegative(),
|
||||
includedHoursPerSeat: z.number().nonnegative(),
|
||||
concurrentSandboxesPerSeat: z.number().int().nonnegative(),
|
||||
machineSizes: z.array(z.enum(MACHINE_SIZES)).min(1),
|
||||
idleSleepMinutes: z.number().int().positive(),
|
||||
// Overage: opt-in per account. The Stripe overage price must match overageCentsPerHour.
|
||||
overageCentsPerHour: z.number().int().positive(),
|
||||
defaultSpendCapCents: z.number().int().nonnegative(),
|
||||
maxSpendCapCents: z.number().int().positive(),
|
||||
// Included-hours usage levels (percent) the desktop warns at.
|
||||
warnAtPercent: z.array(z.number().int().min(1).max(100)).max(4),
|
||||
// After a failed payment: new starts stop at once; running sandboxes are suspended after this.
|
||||
paymentGraceHours: z.number().nonnegative(),
|
||||
// Seats only come down to the member count this close to renewal (never mid-period).
|
||||
seatTrueDownHours: z.number().positive()
|
||||
})
|
||||
.strict()
|
||||
|
||||
const EnterpriseSchema = z
|
||||
.object({
|
||||
label: z.string().min(1).max(40),
|
||||
priceLabel: z.string().max(40),
|
||||
contactUrl: z.string().url().startsWith('https://')
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type TeamPlan = z.infer<typeof TeamSchema>
|
||||
export type EnterpriseOffer = z.infer<typeof EnterpriseSchema>
|
||||
export type PlanCatalog = { team: TeamPlan; enterprise: EnterpriseOffer }
|
||||
|
||||
export const DEFAULT_PLANS: PlanCatalog = {
|
||||
team: {
|
||||
label: 'Team',
|
||||
monthlyPriceLabel: '$50/seat/mo',
|
||||
annualPriceLabel: '$480/seat/yr',
|
||||
monthlyPriceCentsPerSeat: 5000,
|
||||
annualPriceCentsPerSeat: 48000,
|
||||
includedHoursPerSeat: 60,
|
||||
concurrentSandboxesPerSeat: 3,
|
||||
machineSizes: ['standard', 'large'],
|
||||
idleSleepMinutes: 15,
|
||||
overageCentsPerHour: 75,
|
||||
defaultSpendCapCents: 2500,
|
||||
maxSpendCapCents: 500_000,
|
||||
warnAtPercent: [80, 100],
|
||||
paymentGraceHours: 96,
|
||||
seatTrueDownHours: 6
|
||||
},
|
||||
enterprise: {
|
||||
label: 'Enterprise',
|
||||
priceLabel: 'Custom',
|
||||
contactUrl: 'https://onorca.dev/contact-sales'
|
||||
}
|
||||
}
|
||||
|
||||
const OverridesSchema = z
|
||||
.object({ team: TeamSchema.partial().optional(), enterprise: EnterpriseSchema.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 catalog: PlanCatalog
|
||||
try {
|
||||
const overrides = raw ? OverridesSchema.parse(JSON.parse(raw)) : {}
|
||||
catalog = {
|
||||
team: TeamSchema.parse({ ...DEFAULT_PLANS.team, ...overrides.team }),
|
||||
enterprise: EnterpriseSchema.parse({ ...DEFAULT_PLANS.enterprise, ...overrides.enterprise })
|
||||
}
|
||||
} catch {
|
||||
throw new PlanConfigError()
|
||||
}
|
||||
catalogs.set(raw, catalog)
|
||||
return catalog
|
||||
}
|
||||
|
||||
/** What the desktop shows before Checkout. */
|
||||
export function publicOffers(catalog: PlanCatalog) {
|
||||
const team = catalog.team
|
||||
return {
|
||||
team: {
|
||||
label: team.label,
|
||||
monthlyPriceLabel: team.monthlyPriceLabel,
|
||||
annualPriceLabel: team.annualPriceLabel,
|
||||
monthlyPriceCentsPerSeat: team.monthlyPriceCentsPerSeat,
|
||||
annualPriceCentsPerSeat: team.annualPriceCentsPerSeat,
|
||||
largeMachineWeight: MACHINE_SIZE_WEIGHT.large,
|
||||
includedHoursPerSeat: team.includedHoursPerSeat,
|
||||
concurrentSandboxesPerSeat: team.concurrentSandboxesPerSeat,
|
||||
idleSleepMinutes: team.idleSleepMinutes,
|
||||
overageCentsPerHour: team.overageCentsPerHour,
|
||||
defaultSpendCapCents: team.defaultSpendCapCents
|
||||
},
|
||||
enterprise: catalog.enterprise
|
||||
}
|
||||
}
|
||||
|
||||
export function priceForPlan(env: Env, interval: BillingInterval): string {
|
||||
return interval === 'year' ? env.STRIPE_PRICE_TEAM_ANNUAL : env.STRIPE_PRICE_TEAM_MONTHLY
|
||||
}
|
||||
|
||||
/** The plan and interval a subscription's seat price maps to, or null for a price we did not configure. */
|
||||
export function planForPrice(env: Env, priceId: string): { plan: PlanId; interval: BillingInterval } | null {
|
||||
if (priceId === env.STRIPE_PRICE_TEAM_MONTHLY) {
|
||||
return { plan: 'team', interval: 'month' }
|
||||
}
|
||||
if (priceId === env.STRIPE_PRICE_TEAM_ANNUAL) {
|
||||
return { plan: 'team', interval: 'year' }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function includedSeconds(team: TeamPlan, seats: number): number {
|
||||
return Math.round(team.includedHoursPerSeat * 3600) * Math.max(1, seats)
|
||||
}
|
||||
|
||||
/** The spend cap expressed as overage seconds (what the meter counts). */
|
||||
export function capSeconds(team: TeamPlan, spendCapCents: number): number {
|
||||
return Math.floor((spendCapCents / team.overageCentsPerHour) * 3600)
|
||||
}
|
||||
|
||||
function addUtcMonths(ms: number, months: number): number {
|
||||
const date = new Date(ms)
|
||||
const day = date.getUTCDate()
|
||||
const target = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + months, 1, date.getUTCHours(),
|
||||
date.getUTCMinutes(), date.getUTCSeconds(), date.getUTCMilliseconds()))
|
||||
// Clamp to the target month's last day (Jan 31 + 1 month = Feb 28/29), like Stripe's anchors.
|
||||
const lastDay = new Date(Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0)).getUTCDate()
|
||||
target.setUTCDate(Math.min(day, lastDay))
|
||||
return target.getTime()
|
||||
}
|
||||
|
||||
/**
|
||||
* The monthly allowance window containing `at`: the billing period itself for monthly plans,
|
||||
* one calendar-month slice of the year (anchored on the period start) for annual plans.
|
||||
*/
|
||||
export function allowanceWindow(period: { start: number; end: number }, at: number): { start: number; end: number } {
|
||||
let start = period.start
|
||||
for (let month = 1; month <= 12; month++) {
|
||||
const next = Math.min(addUtcMonths(period.start, month), period.end)
|
||||
if (at < next || next >= period.end) {
|
||||
return { start, end: next }
|
||||
}
|
||||
start = next
|
||||
}
|
||||
return { start, end: period.end }
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// Team seats follow org membership, billed by the period's PEAK (the GitHub model, with peak
|
||||
// protection against remove/re-add churn):
|
||||
//
|
||||
// - Adding members above the seats already paid this period: the extra seats are invoiced and
|
||||
// charged now, prorated (`always_invoice`).
|
||||
// - Removing members: nothing changes mid-period (no credit). Seats come down to the current
|
||||
// member count only at renewal: inside `seatTrueDownHours` of the period end, billing lowers the
|
||||
// quantity with `proration_behavior: none`, so the renewal invoice bills the smaller team.
|
||||
// - Re-adding someone removed earlier in the same period costs nothing: the period's seats are
|
||||
// never lowered mid-period, so membership up to the peak is already paid.
|
||||
//
|
||||
// Membership is owned by Orca Cloud auth (pull: /v1/internal/billing/org-members; push:
|
||||
// /v1/internal/seats). Seats are never below 1. See cloud/docs/accounts-billing-design.md.
|
||||
import { z } from 'zod'
|
||||
import { MAX_TEAM_SEATS, OPEN_STATUSES } from './checkout'
|
||||
import type { Env } from './env'
|
||||
import { orgIdOf, type MembershipSource } from './membership'
|
||||
import { planCatalog, planForPrice } from './plans'
|
||||
import type { StripeClient } from './stripe'
|
||||
import { currentSubscription, openOrgAccounts, setMemberCount } from './store'
|
||||
import { syncSubscription } from './webhook'
|
||||
|
||||
export const SeatSyncSchema = z
|
||||
.object({
|
||||
accountId: z.string().min(1).max(128).regex(/^org:[A-Za-z0-9_.:-]+$/),
|
||||
members: z.number().int().min(0).max(100_000)
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type SeatSyncOutcome =
|
||||
| { status: 'unchanged'; seats: number }
|
||||
| { status: 'increased'; seats: number; previous: number }
|
||||
/** Fewer members than paid seats: the paid seats stay until renewal. */
|
||||
| { status: 'deferred'; seats: number; members: number; effectiveAt: number }
|
||||
/** Lowered for the renewal invoice (no credit for the current period). */
|
||||
| { status: 'reduced_at_renewal'; seats: number; previous: number; effectiveAt: number }
|
||||
| { status: 'no_subscription' }
|
||||
| { status: 'too_many_seats' }
|
||||
|
||||
export async function syncSeats(
|
||||
env: Env,
|
||||
stripe: StripeClient,
|
||||
accountId: string,
|
||||
members: number,
|
||||
now: number
|
||||
): Promise<SeatSyncOutcome> {
|
||||
const db = env.BILLING_DB
|
||||
const subscription = await currentSubscription(db, accountId)
|
||||
if (!subscription || !OPEN_STATUSES.has(subscription.status)) {
|
||||
return { status: 'no_subscription' }
|
||||
}
|
||||
const desired = Math.max(1, members)
|
||||
if (desired > MAX_TEAM_SEATS) {
|
||||
return { status: 'too_many_seats' }
|
||||
}
|
||||
// Remembered so the reconcile cron can apply a deferred decrease without a fresh push.
|
||||
await setMemberCount(db, subscription.stripe_subscription_id, members, now)
|
||||
if (desired === subscription.seats) {
|
||||
return { status: 'unchanged', seats: desired }
|
||||
}
|
||||
const periodEnd = subscription.current_period_end
|
||||
const trueDownFrom = periodEnd - planCatalog(env).team.seatTrueDownHours * 3600_000
|
||||
if (desired < subscription.seats && now < trueDownFrom) {
|
||||
return { status: 'deferred', seats: subscription.seats, members: desired, effectiveAt: periodEnd }
|
||||
}
|
||||
|
||||
// Read the live item: the mirror can lag a webhook, and the item id is not stored.
|
||||
const live = await stripe.retrieveSubscription(subscription.stripe_subscription_id)
|
||||
const seatItem = live.items.data.find((item) => planForPrice(env, item.price.id))
|
||||
if (!seatItem) {
|
||||
return { status: 'no_subscription' }
|
||||
}
|
||||
const current = seatItem.quantity ?? 1
|
||||
if (current !== desired) {
|
||||
await stripe.setSubscriptionItemQuantity({
|
||||
item: seatItem.id,
|
||||
quantity: desired,
|
||||
prorationBehavior: desired > current ? 'always_invoice' : 'none',
|
||||
// Same change retried within Stripe's idempotency window is applied once.
|
||||
idempotencyKey: `seats:${seatItem.id}:${periodEnd}:${current}->${desired}`
|
||||
})
|
||||
}
|
||||
await syncSubscription(env, stripe, subscription.stripe_subscription_id, now)
|
||||
return desired > current
|
||||
? { status: 'increased', seats: desired, previous: current }
|
||||
: { status: 'reduced_at_renewal', seats: desired, previous: current, effectiveAt: periodEnd }
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron: brings every open org's seats in line with membership. Re-reads the count from auth when
|
||||
* configured (catching invites accepted at the provider); otherwise uses the last pushed count.
|
||||
* Also where deferred decreases land, once a subscription is inside its true-down window.
|
||||
*/
|
||||
export async function reconcileSeats(
|
||||
env: Env,
|
||||
stripe: StripeClient,
|
||||
membership: MembershipSource,
|
||||
now: number
|
||||
): Promise<{ checked: number; changed: number; failed: number }> {
|
||||
let checked = 0
|
||||
let changed = 0
|
||||
let failed = 0
|
||||
for (const { account_id: accountId, member_count: stored } of await openOrgAccounts(env.BILLING_DB, 200)) {
|
||||
const orgId = orgIdOf(accountId)
|
||||
if (!orgId) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const members = (await membership(orgId)) ?? stored
|
||||
if (members === null) {
|
||||
continue
|
||||
}
|
||||
checked++
|
||||
const outcome = await syncSeats(env, stripe, accountId, members, now)
|
||||
if (outcome.status === 'increased' || outcome.status === 'reduced_at_renewal') {
|
||||
changed++
|
||||
}
|
||||
} catch {
|
||||
failed++
|
||||
}
|
||||
}
|
||||
return { checked, changed, failed }
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
// D1 access. Every multi-statement write goes through db.batch() (one transaction), and every
|
||||
// insert that can be retried is INSERT OR IGNORE on a caller-supplied key.
|
||||
import type { BillingInterval, PlanId } from './plans'
|
||||
|
||||
export type SubscriptionRow = {
|
||||
stripe_subscription_id: string
|
||||
account_id: string
|
||||
stripe_customer_id: string
|
||||
status: string
|
||||
plan: PlanId
|
||||
seats: number
|
||||
overage_enabled: number
|
||||
current_period_start: number
|
||||
current_period_end: number
|
||||
cancel_at_period_end: number
|
||||
updated_at: number
|
||||
billing_interval: BillingInterval
|
||||
overage_item_id: string | null
|
||||
past_due_since: number | null
|
||||
member_count: number | null
|
||||
}
|
||||
|
||||
export type AccountSettings = { overage_opt_in: number; spend_cap_cents: number | null }
|
||||
|
||||
export type MeterReportRow = {
|
||||
usage_id: string
|
||||
stripe_customer_id: string
|
||||
seconds: number
|
||||
event_timestamp: number
|
||||
attempts: number
|
||||
}
|
||||
|
||||
export async function ensureAccount(db: D1Database, accountId: string, now: number): Promise<void> {
|
||||
await db
|
||||
.prepare('INSERT OR IGNORE INTO billing_accounts (account_id, created_at, updated_at) VALUES (?1, ?2, ?2)')
|
||||
.bind(accountId, now)
|
||||
.run()
|
||||
}
|
||||
|
||||
export async function customerFor(db: D1Database, accountId: string): Promise<string | null> {
|
||||
const row = await db
|
||||
.prepare('SELECT stripe_customer_id FROM billing_accounts WHERE account_id = ?1')
|
||||
.bind(accountId)
|
||||
.first<{ stripe_customer_id: string | null }>()
|
||||
return row?.stripe_customer_id ?? null
|
||||
}
|
||||
|
||||
export async function accountForCustomer(db: D1Database, customerId: string): Promise<string | null> {
|
||||
const row = await db
|
||||
.prepare('SELECT account_id FROM billing_accounts WHERE stripe_customer_id = ?1')
|
||||
.bind(customerId)
|
||||
.first<{ account_id: string }>()
|
||||
return row?.account_id ?? null
|
||||
}
|
||||
|
||||
/** Links only an unlinked account, so a race between two first checkouts keeps the first. */
|
||||
export async function linkCustomer(db: D1Database, accountId: string, customerId: string, now: number): Promise<string> {
|
||||
await db
|
||||
.prepare(
|
||||
'UPDATE billing_accounts SET stripe_customer_id = ?2, updated_at = ?3 WHERE account_id = ?1 AND stripe_customer_id IS NULL'
|
||||
)
|
||||
.bind(accountId, customerId, now)
|
||||
.run()
|
||||
return (await customerFor(db, accountId)) ?? customerId
|
||||
}
|
||||
|
||||
// Live statuses sort first, then the latest period: the row entitlements are computed from.
|
||||
export async function currentSubscription(db: D1Database, accountId: string): Promise<SubscriptionRow | null> {
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT * FROM subscriptions WHERE account_id = ?1
|
||||
ORDER BY CASE status WHEN 'active' THEN 0 WHEN 'trialing' THEN 0 WHEN 'past_due' THEN 1 ELSE 2 END,
|
||||
current_period_end DESC
|
||||
LIMIT 1`
|
||||
)
|
||||
.bind(accountId)
|
||||
.first<SubscriptionRow>()
|
||||
}
|
||||
|
||||
export async function upsertSubscription(db: D1Database, row: SubscriptionRow): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO subscriptions (stripe_subscription_id, account_id, stripe_customer_id, status, plan, seats,
|
||||
overage_enabled, current_period_start, current_period_end, cancel_at_period_end, updated_at,
|
||||
billing_interval, overage_item_id, past_due_since, member_count)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)
|
||||
ON CONFLICT (stripe_subscription_id) DO UPDATE SET
|
||||
status = excluded.status, plan = excluded.plan, seats = excluded.seats,
|
||||
overage_enabled = excluded.overage_enabled, current_period_start = excluded.current_period_start,
|
||||
current_period_end = excluded.current_period_end, cancel_at_period_end = excluded.cancel_at_period_end,
|
||||
updated_at = excluded.updated_at, billing_interval = excluded.billing_interval,
|
||||
overage_item_id = excluded.overage_item_id,
|
||||
-- Keep the first time it went past due; clear it once paid.
|
||||
past_due_since = CASE WHEN excluded.past_due_since IS NULL THEN NULL
|
||||
ELSE COALESCE(subscriptions.past_due_since, excluded.past_due_since) END,
|
||||
member_count = COALESCE(excluded.member_count, subscriptions.member_count)`
|
||||
)
|
||||
.bind(
|
||||
row.stripe_subscription_id,
|
||||
row.account_id,
|
||||
row.stripe_customer_id,
|
||||
row.status,
|
||||
row.plan,
|
||||
row.seats,
|
||||
row.overage_enabled,
|
||||
row.current_period_start,
|
||||
row.current_period_end,
|
||||
row.cancel_at_period_end,
|
||||
row.updated_at,
|
||||
row.billing_interval,
|
||||
row.overage_item_id,
|
||||
row.past_due_since,
|
||||
row.member_count
|
||||
)
|
||||
.run()
|
||||
}
|
||||
|
||||
export async function accountSettings(db: D1Database, accountId: string): Promise<AccountSettings> {
|
||||
const row = await db
|
||||
.prepare('SELECT overage_opt_in, spend_cap_cents FROM billing_accounts WHERE account_id = ?1')
|
||||
.bind(accountId)
|
||||
.first<AccountSettings>()
|
||||
return row ?? { overage_opt_in: 0, spend_cap_cents: null }
|
||||
}
|
||||
|
||||
export async function setOverageSettings(
|
||||
db: D1Database,
|
||||
accountId: string,
|
||||
settings: { optIn: boolean; spendCapCents: number | null },
|
||||
now: number
|
||||
): Promise<void> {
|
||||
await db
|
||||
.prepare('UPDATE billing_accounts SET overage_opt_in = ?2, spend_cap_cents = ?3, updated_at = ?4 WHERE account_id = ?1')
|
||||
.bind(accountId, settings.optIn ? 1 : 0, settings.spendCapCents, now)
|
||||
.run()
|
||||
}
|
||||
|
||||
export async function periodUsage(
|
||||
db: D1Database,
|
||||
accountId: string,
|
||||
periodStart: number
|
||||
): Promise<{ usedSeconds: number; overageSeconds: number }> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
`SELECT COALESCE(SUM(weighted_seconds), 0) AS used, COALESCE(SUM(overage_seconds), 0) AS overage
|
||||
FROM usage_events WHERE account_id = ?1 AND period_start = ?2`
|
||||
)
|
||||
.bind(accountId, periodStart)
|
||||
.first<{ used: number; overage: number }>()
|
||||
return { usedSeconds: row?.used ?? 0, overageSeconds: row?.overage ?? 0 }
|
||||
}
|
||||
|
||||
export type UsageInsert = {
|
||||
usageId: string
|
||||
accountId: string
|
||||
sandboxId: string
|
||||
machineSize: string
|
||||
startedAt: number
|
||||
endedAt: number
|
||||
weightedSeconds: number
|
||||
periodStart: number | null
|
||||
includedSeconds: number
|
||||
now: number
|
||||
// The member who started the sandbox, when the control plane reports it.
|
||||
userId: string | null
|
||||
// Set when overage is billable: queues a meter report in the same transaction, for at most
|
||||
// what is left of the window's spend cap (meterCapSeconds).
|
||||
meterCustomer: string | null
|
||||
meterCapSeconds: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Records one usage segment and, atomically, the overage it adds. The overage is computed inside
|
||||
* the INSERT from the period's running total, so concurrent reports cannot both claim the same
|
||||
* included seconds. Returns false when usageId was already recorded (a retry).
|
||||
*/
|
||||
export async function insertUsage(db: D1Database, usage: UsageInsert): Promise<boolean> {
|
||||
const insert = db
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO usage_events (usage_id, account_id, sandbox_id, machine_size, started_at, ended_at,
|
||||
weighted_seconds, period_start, overage_seconds, created_at, user_id)
|
||||
SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8,
|
||||
CASE WHEN ?8 IS NULL THEN 0
|
||||
ELSE MAX(0, MIN(?7, COALESCE(SUM(weighted_seconds), 0) + ?7 - ?9)) END,
|
||||
?10, ?11
|
||||
FROM usage_events WHERE account_id = ?2 AND period_start IS ?8`
|
||||
)
|
||||
.bind(
|
||||
usage.usageId,
|
||||
usage.accountId,
|
||||
usage.sandboxId,
|
||||
usage.machineSize,
|
||||
usage.startedAt,
|
||||
usage.endedAt,
|
||||
usage.weightedSeconds,
|
||||
usage.periodStart,
|
||||
usage.includedSeconds,
|
||||
usage.now,
|
||||
usage.userId
|
||||
)
|
||||
const statements = [insert]
|
||||
if (usage.meterCustomer) {
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO meter_reports (usage_id, stripe_customer_id, seconds, event_timestamp, status, created_at)
|
||||
SELECT usage_id, ?2, billable, ended_at, 'pending', ?3 FROM (
|
||||
SELECT u.usage_id, u.ended_at,
|
||||
MIN(u.overage_seconds, MAX(0, ?4 - (
|
||||
SELECT COALESCE(SUM(m.seconds), 0) FROM meter_reports m
|
||||
JOIN usage_events p ON p.usage_id = m.usage_id
|
||||
WHERE p.account_id = u.account_id AND p.period_start IS u.period_start))) AS billable
|
||||
FROM usage_events u WHERE u.usage_id = ?1 AND u.overage_seconds > 0
|
||||
) WHERE billable > 0`
|
||||
)
|
||||
.bind(usage.usageId, usage.meterCustomer, usage.now, usage.meterCapSeconds)
|
||||
)
|
||||
}
|
||||
const [result] = await db.batch(statements)
|
||||
return (result?.meta.changes ?? 0) > 0
|
||||
}
|
||||
|
||||
/** Overage seconds already metered (billed) in an allowance window. */
|
||||
export async function meteredSeconds(db: D1Database, accountId: string, windowStart: number): Promise<number> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
`SELECT COALESCE(SUM(m.seconds), 0) AS metered FROM meter_reports m
|
||||
JOIN usage_events p ON p.usage_id = m.usage_id
|
||||
WHERE p.account_id = ?1 AND p.period_start = ?2`
|
||||
)
|
||||
.bind(accountId, windowStart)
|
||||
.first<{ metered: number }>()
|
||||
return row?.metered ?? 0
|
||||
}
|
||||
|
||||
/** Accounts whose payment grace has run out: the control plane suspends their running sandboxes. */
|
||||
export async function graceExpiredAccounts(db: D1Database, cutoff: number): Promise<Array<{ account_id: string; past_due_since: number }>> {
|
||||
return (
|
||||
await db
|
||||
.prepare(
|
||||
`SELECT account_id, MIN(past_due_since) AS past_due_since FROM subscriptions
|
||||
WHERE status IN ('past_due', 'unpaid') AND past_due_since IS NOT NULL AND past_due_since <= ?1
|
||||
GROUP BY account_id ORDER BY past_due_since LIMIT 1000`
|
||||
)
|
||||
.bind(cutoff)
|
||||
.all<{ account_id: string; past_due_since: number }>()
|
||||
).results
|
||||
}
|
||||
|
||||
/** Org accounts with an open subscription (and the last member count seen), for the seat cron. */
|
||||
export async function openOrgAccounts(
|
||||
db: D1Database,
|
||||
limit: number
|
||||
): Promise<Array<{ account_id: string; member_count: number | null }>> {
|
||||
return (
|
||||
await db
|
||||
.prepare(
|
||||
`SELECT account_id, MAX(member_count) AS member_count FROM subscriptions
|
||||
WHERE account_id LIKE 'org:%' AND status IN ('active', 'trialing', 'past_due', 'unpaid')
|
||||
GROUP BY account_id ORDER BY MIN(updated_at) LIMIT ?1`
|
||||
)
|
||||
.bind(limit)
|
||||
.all<{ account_id: string; member_count: number | null }>()
|
||||
).results
|
||||
}
|
||||
|
||||
export async function setMemberCount(
|
||||
db: D1Database,
|
||||
subscriptionId: string,
|
||||
members: number,
|
||||
now: number
|
||||
): Promise<void> {
|
||||
await db
|
||||
.prepare('UPDATE subscriptions SET member_count = ?2, updated_at = ?3 WHERE stripe_subscription_id = ?1')
|
||||
.bind(subscriptionId, members, now)
|
||||
.run()
|
||||
}
|
||||
|
||||
export async function usageOverage(db: D1Database, usageId: string): Promise<number> {
|
||||
const row = await db
|
||||
.prepare('SELECT overage_seconds FROM usage_events WHERE usage_id = ?1')
|
||||
.bind(usageId)
|
||||
.first<{ overage_seconds: number }>()
|
||||
return row?.overage_seconds ?? 0
|
||||
}
|
||||
|
||||
export async function pendingMeterReports(db: D1Database, limit: number, usageId?: string): Promise<MeterReportRow[]> {
|
||||
const query = usageId
|
||||
? db
|
||||
.prepare(
|
||||
"SELECT usage_id, stripe_customer_id, seconds, event_timestamp, attempts FROM meter_reports WHERE usage_id = ?1 AND status = 'pending'"
|
||||
)
|
||||
.bind(usageId)
|
||||
: db
|
||||
.prepare(
|
||||
"SELECT usage_id, stripe_customer_id, seconds, event_timestamp, attempts FROM meter_reports WHERE status = 'pending' ORDER BY created_at LIMIT ?1"
|
||||
)
|
||||
.bind(limit)
|
||||
return (await query.all<MeterReportRow>()).results
|
||||
}
|
||||
|
||||
export async function markMeterReport(
|
||||
db: D1Database,
|
||||
usageId: string,
|
||||
outcome: { sent: true; now: number } | { sent: false; error: string; giveUp: boolean }
|
||||
): Promise<void> {
|
||||
if (outcome.sent) {
|
||||
await db
|
||||
.prepare("UPDATE meter_reports SET status = 'sent', sent_at = ?2, attempts = attempts + 1 WHERE usage_id = ?1")
|
||||
.bind(usageId, outcome.now)
|
||||
.run()
|
||||
return
|
||||
}
|
||||
await db
|
||||
.prepare('UPDATE meter_reports SET status = ?2, last_error = ?3, attempts = attempts + 1 WHERE usage_id = ?1')
|
||||
.bind(usageId, outcome.giveUp ? 'failed' : 'pending', outcome.error.slice(0, 200))
|
||||
.run()
|
||||
}
|
||||
|
||||
/** Claims an event id. Returns 'duplicate' when it was already processed successfully. */
|
||||
export async function claimEvent(db: D1Database, eventId: string, type: string, now: number): Promise<'new' | 'duplicate'> {
|
||||
await db
|
||||
.prepare('INSERT OR IGNORE INTO stripe_events (event_id, type, received_at) VALUES (?1, ?2, ?3)')
|
||||
.bind(eventId, type, now)
|
||||
.run()
|
||||
const row = await db
|
||||
.prepare('SELECT processed_at FROM stripe_events WHERE event_id = ?1')
|
||||
.bind(eventId)
|
||||
.first<{ processed_at: number | null }>()
|
||||
return row?.processed_at ? 'duplicate' : 'new'
|
||||
}
|
||||
|
||||
/** `now` marks the event done (with an optional note, e.g. why it was ignored); `error` leaves it retryable. */
|
||||
export async function finishEvent(
|
||||
db: D1Database,
|
||||
eventId: string,
|
||||
outcome: { now: number; note?: string } | { error: string }
|
||||
): Promise<void> {
|
||||
if ('now' in outcome) {
|
||||
await db
|
||||
.prepare('UPDATE stripe_events SET processed_at = ?2, last_error = ?3 WHERE event_id = ?1')
|
||||
.bind(eventId, outcome.now, outcome.note ?? null)
|
||||
.run()
|
||||
return
|
||||
}
|
||||
await db
|
||||
.prepare('UPDATE stripe_events SET last_error = ?2 WHERE event_id = ?1')
|
||||
.bind(eventId, outcome.error.slice(0, 200))
|
||||
.run()
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
// Minimal Stripe REST client over fetch: only the calls billing makes. Form-encoded bodies,
|
||||
// pinned API version, an Idempotency-Key on every POST, and a hard refusal of live-mode keys
|
||||
// unless STRIPE_ALLOW_LIVEMODE is exactly 'true'.
|
||||
import type { Env } from './env'
|
||||
|
||||
export const DEFAULT_STRIPE_API_VERSION = '2025-09-30.clover'
|
||||
const STRIPE_TIMEOUT_MS = 15_000
|
||||
|
||||
export class StripeError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
public readonly code: string | undefined,
|
||||
message: string
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'StripeError'
|
||||
}
|
||||
}
|
||||
|
||||
export class LiveModeRefusedError extends Error {
|
||||
constructor() {
|
||||
super('stripe_livemode_refused')
|
||||
this.name = 'LiveModeRefusedError'
|
||||
}
|
||||
}
|
||||
|
||||
export function allowsLiveMode(env: Env): boolean {
|
||||
return env.STRIPE_ALLOW_LIVEMODE === 'true'
|
||||
}
|
||||
|
||||
/** Throws unless the key is a test-mode key (or live mode was explicitly enabled). */
|
||||
export function assertKeyMode(env: Env): void {
|
||||
const key = env.STRIPE_SECRET_KEY ?? ''
|
||||
const isTest = key.startsWith('sk_test_') || key.startsWith('rk_test_')
|
||||
const isLive = key.startsWith('sk_live_') || key.startsWith('rk_live_')
|
||||
if (isLive && !allowsLiveMode(env)) {
|
||||
throw new LiveModeRefusedError()
|
||||
}
|
||||
if (!isTest && !isLive) {
|
||||
throw new Error('stripe_key_missing')
|
||||
}
|
||||
}
|
||||
|
||||
type FormValue = string | number | boolean | null | undefined | FormRecord | FormValue[]
|
||||
type FormRecord = { [key: string]: FormValue }
|
||||
|
||||
/** Stripe's bracket encoding: a[b][0][c]=v. Undefined/null values are omitted. */
|
||||
export function encodeForm(params: FormRecord): string {
|
||||
const pairs: string[] = []
|
||||
const walk = (prefix: string, value: FormValue): void => {
|
||||
if (value === undefined || value === null) {
|
||||
return
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, index) => walk(`${prefix}[${index}]`, item))
|
||||
return
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
for (const [key, inner] of Object.entries(value)) {
|
||||
walk(prefix ? `${prefix}[${key}]` : key, inner)
|
||||
}
|
||||
return
|
||||
}
|
||||
pairs.push(`${encodeURIComponent(prefix)}=${encodeURIComponent(String(value))}`)
|
||||
}
|
||||
walk('', params)
|
||||
return pairs.join('&')
|
||||
}
|
||||
|
||||
export type StripeClient = ReturnType<typeof createStripeClient>
|
||||
|
||||
export function createStripeClient(env: Env, fetchImpl: typeof fetch = fetch) {
|
||||
const base = env.STRIPE_API_BASE ?? 'https://api.stripe.com'
|
||||
const version = env.STRIPE_API_VERSION ?? DEFAULT_STRIPE_API_VERSION
|
||||
|
||||
async function request<T>(method: 'GET' | 'POST', path: string, params?: FormRecord, idempotencyKey?: string) {
|
||||
assertKeyMode(env)
|
||||
const headers: Record<string, string> = {
|
||||
authorization: `Bearer ${env.STRIPE_SECRET_KEY}`,
|
||||
'stripe-version': version
|
||||
}
|
||||
let url = `${base}${path}`
|
||||
let body: string | undefined
|
||||
if (method === 'POST') {
|
||||
headers['content-type'] = 'application/x-www-form-urlencoded'
|
||||
body = encodeForm(params ?? {})
|
||||
if (idempotencyKey) {
|
||||
headers['idempotency-key'] = idempotencyKey
|
||||
}
|
||||
} else if (params) {
|
||||
url += `?${encodeForm(params)}`
|
||||
}
|
||||
const response = await fetchImpl(url, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
// Never follow a redirect with the key attached. Workers reject redirect: 'error', so take
|
||||
// the 3xx manually and fail it below (a 3xx is never ok).
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(STRIPE_TIMEOUT_MS)
|
||||
})
|
||||
const json = (await response.json().catch(() => ({}))) as { error?: { code?: string; message?: string } }
|
||||
if (!response.ok) {
|
||||
// Stripe's message never contains the key; still keep it short for logs.
|
||||
throw new StripeError(response.status, json.error?.code, (json.error?.message ?? 'stripe_error').slice(0, 200))
|
||||
}
|
||||
return json as T
|
||||
}
|
||||
|
||||
return {
|
||||
createCustomer(args: { accountId: string; email?: string; name?: string }) {
|
||||
return request<{ id: string }>(
|
||||
'POST',
|
||||
'/v1/customers',
|
||||
{ email: args.email, name: args.name, metadata: { account_id: args.accountId } },
|
||||
// Stable per account: a retried first checkout cannot create a second customer.
|
||||
`customer-create:${args.accountId}`
|
||||
)
|
||||
},
|
||||
createCheckoutSession(params: FormRecord, idempotencyKey: string) {
|
||||
return request<{ id: string; url: string }>('POST', '/v1/checkout/sessions', params, idempotencyKey)
|
||||
},
|
||||
createPortalSession(args: { customer: string; returnUrl: string; configuration?: string }, idempotencyKey: string) {
|
||||
return request<{ id: string; url: string }>(
|
||||
'POST',
|
||||
'/v1/billing_portal/sessions',
|
||||
{ customer: args.customer, return_url: args.returnUrl, configuration: args.configuration },
|
||||
idempotencyKey
|
||||
)
|
||||
},
|
||||
addSubscriptionItem(args: { subscription: string; price: string }) {
|
||||
return request<{ id: string }>(
|
||||
'POST',
|
||||
'/v1/subscription_items',
|
||||
// Metered: nothing to prorate; usage is billed in arrears.
|
||||
{ subscription: args.subscription, price: args.price, proration_behavior: 'none' },
|
||||
`overage-item:${args.subscription}:${args.price}`
|
||||
)
|
||||
},
|
||||
setSubscriptionItemQuantity(args: {
|
||||
item: string
|
||||
quantity: number
|
||||
prorationBehavior: 'always_invoice' | 'none'
|
||||
idempotencyKey: string
|
||||
}) {
|
||||
return request<{ id: string; quantity: number }>(
|
||||
'POST',
|
||||
`/v1/subscription_items/${encodeURIComponent(args.item)}`,
|
||||
{ quantity: args.quantity, proration_behavior: args.prorationBehavior },
|
||||
args.idempotencyKey
|
||||
)
|
||||
},
|
||||
retrieveSubscription(id: string) {
|
||||
return request<StripeSubscription>('GET', `/v1/subscriptions/${encodeURIComponent(id)}`)
|
||||
},
|
||||
createMeterEvent(args: { eventName: string; customer: string; value: number; identifier: string; timestamp: number }) {
|
||||
return request<{ identifier: string }>(
|
||||
'POST',
|
||||
'/v1/billing/meter_events',
|
||||
{
|
||||
event_name: args.eventName,
|
||||
identifier: args.identifier,
|
||||
timestamp: Math.floor(args.timestamp / 1000),
|
||||
payload: { stripe_customer_id: args.customer, value: String(args.value) }
|
||||
},
|
||||
`meter:${args.identifier}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fields billing reads. Since API 2025-03-31 the period lives on each item; older payloads keep it
|
||||
// on the subscription, so both are read.
|
||||
export type StripeSubscription = {
|
||||
id: string
|
||||
object: 'subscription'
|
||||
customer: string | { id: string }
|
||||
status: string
|
||||
cancel_at_period_end: boolean
|
||||
livemode: boolean
|
||||
metadata?: Record<string, string>
|
||||
current_period_start?: number
|
||||
current_period_end?: number
|
||||
items: {
|
||||
data: Array<{
|
||||
id: string
|
||||
quantity?: number
|
||||
current_period_start?: number
|
||||
current_period_end?: number
|
||||
price: { id: string }
|
||||
}>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Sandbox usage ingestion (control plane -> billing) and the Stripe meter outbox.
|
||||
// Only overage reaches Stripe, and only for accounts that opted in, up to their monthly spend cap:
|
||||
// included hours are tracked here (pooled across seats, monthly even on annual plans, which tiered
|
||||
// Stripe prices cannot express); the overage price is a flat per-hour metered 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, capSeconds, includedSeconds, planCatalog } from './plans'
|
||||
import { subscriptionWindow } from './entitlements'
|
||||
import type { StripeClient } from './stripe'
|
||||
import {
|
||||
accountSettings,
|
||||
currentSubscription,
|
||||
customerFor,
|
||||
insertUsage,
|
||||
markMeterReport,
|
||||
pendingMeterReports,
|
||||
usageOverage
|
||||
} from './store'
|
||||
|
||||
// One segment is at most a day: the control plane settles running sandboxes periodically.
|
||||
const MAX_SEGMENT_MS = 24 * 3600 * 1000
|
||||
// Stripe accepts meter events up to 35 days old and 5 minutes in the future.
|
||||
const MAX_AGE_MS = 30 * 24 * 3600 * 1000
|
||||
const FUTURE_SKEW_MS = 5 * 60 * 1000
|
||||
export const MAX_METER_ATTEMPTS = 10
|
||||
|
||||
const ID = z.string().min(1).max(128).regex(/^[A-Za-z0-9_.:-]+$/)
|
||||
|
||||
export const UsageReportSchema = z
|
||||
.object({
|
||||
usageId: ID,
|
||||
accountId: ID,
|
||||
sandboxId: ID,
|
||||
machineSize: z.enum(MACHINE_SIZES),
|
||||
startedAt: z.number().int().positive(),
|
||||
endedAt: z.number().int().positive(),
|
||||
// The member who started the sandbox (attribution and per-member caps).
|
||||
userId: ID.optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type UsageReport = z.infer<typeof UsageReportSchema>
|
||||
|
||||
export function validateSegment(report: UsageReport, now: number): string | null {
|
||||
if (report.endedAt <= report.startedAt) {
|
||||
return 'empty_segment'
|
||||
}
|
||||
if (report.endedAt - report.startedAt > MAX_SEGMENT_MS) {
|
||||
return 'segment_too_long'
|
||||
}
|
||||
if (report.endedAt > now + FUTURE_SKEW_MS) {
|
||||
return 'segment_in_future'
|
||||
}
|
||||
if (report.endedAt < now - MAX_AGE_MS) {
|
||||
return 'segment_too_old'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function recordUsage(
|
||||
env: Env,
|
||||
report: UsageReport,
|
||||
now: number
|
||||
): Promise<{ recorded: boolean; weightedSeconds: number; overageSeconds: number }> {
|
||||
const db = env.BILLING_DB
|
||||
const team = planCatalog(env).team
|
||||
const [subscription, settings] = await Promise.all([
|
||||
currentSubscription(db, report.accountId),
|
||||
accountSettings(db, report.accountId)
|
||||
])
|
||||
const wallSeconds = Math.ceil((report.endedAt - report.startedAt) / 1000)
|
||||
const weightedSeconds = wallSeconds * MACHINE_SIZE_WEIGHT[report.machineSize]
|
||||
// A segment counts in the allowance window it ended in.
|
||||
const window = subscription ? subscriptionWindow(subscription, report.endedAt) : null
|
||||
const billable = subscription !== null && settings.overage_opt_in === 1 && subscription.overage_item_id !== null
|
||||
const customer = billable ? await customerFor(db, report.accountId) : null
|
||||
const recorded = await insertUsage(db, {
|
||||
usageId: report.usageId,
|
||||
accountId: report.accountId,
|
||||
sandboxId: report.sandboxId,
|
||||
machineSize: report.machineSize,
|
||||
startedAt: report.startedAt,
|
||||
endedAt: report.endedAt,
|
||||
weightedSeconds,
|
||||
periodStart: window?.start ?? null,
|
||||
includedSeconds: subscription ? includedSeconds(team, subscription.seats) : 0,
|
||||
now,
|
||||
userId: report.userId ?? null,
|
||||
meterCustomer: customer,
|
||||
meterCapSeconds: capSeconds(team, settings.spend_cap_cents ?? team.defaultSpendCapCents)
|
||||
})
|
||||
return { recorded, weightedSeconds, overageSeconds: await usageOverage(db, report.usageId) }
|
||||
}
|
||||
|
||||
/** Sends pending meter reports (one usage id, or the oldest batch for the cron). */
|
||||
export async function flushMeterReports(
|
||||
env: Env,
|
||||
stripe: StripeClient,
|
||||
now: number,
|
||||
options: { usageId?: string; limit?: number } = {}
|
||||
): Promise<{ sent: number; failed: number }> {
|
||||
const pending = await pendingMeterReports(env.BILLING_DB, options.limit ?? 100, options.usageId)
|
||||
let sent = 0
|
||||
let failed = 0
|
||||
for (const report of pending) {
|
||||
try {
|
||||
await stripe.createMeterEvent({
|
||||
eventName: env.STRIPE_METER_EVENT_NAME,
|
||||
customer: report.stripe_customer_id,
|
||||
value: report.seconds,
|
||||
identifier: report.usage_id,
|
||||
timestamp: report.event_timestamp
|
||||
})
|
||||
await markMeterReport(env.BILLING_DB, report.usage_id, { sent: true, now })
|
||||
sent++
|
||||
} catch (error) {
|
||||
failed++
|
||||
await markMeterReport(env.BILLING_DB, report.usage_id, {
|
||||
sent: false,
|
||||
error: error instanceof Error ? error.message : 'unknown',
|
||||
giveUp: report.attempts + 1 >= MAX_METER_ATTEMPTS
|
||||
})
|
||||
}
|
||||
}
|
||||
return { sent, failed }
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Stripe webhook signature verification (https://docs.stripe.com/webhooks#verify-manually):
|
||||
// header `Stripe-Signature: t=<unix>,v1=<hex hmac>[,v1=...]`, HMAC-SHA256 over `${t}.${rawBody}`
|
||||
// keyed by the endpoint secret. Implemented on WebCrypto so the Worker carries no Stripe SDK.
|
||||
export const DEFAULT_TOLERANCE_SECONDS = 300
|
||||
|
||||
export type SignatureResult = { ok: true } | { ok: false; reason: 'malformed' | 'stale' | 'mismatch' }
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
async function hmacHex(secret: string, payload: string): Promise<string> {
|
||||
const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, [
|
||||
'sign'
|
||||
])
|
||||
const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, encoder.encode(payload)))
|
||||
return Array.from(mac, (byte) => byte.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
/** Constant-time over equal-length strings; length itself is not secret (always 64 hex chars). */
|
||||
export function constantTimeEqual(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) {
|
||||
return false
|
||||
}
|
||||
let diff = 0
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
|
||||
}
|
||||
return diff === 0
|
||||
}
|
||||
|
||||
function parseHeader(header: string): { timestamp: number; signatures: string[] } | null {
|
||||
let timestamp: number | null = null
|
||||
const signatures: string[] = []
|
||||
for (const part of header.split(',')) {
|
||||
const index = part.indexOf('=')
|
||||
if (index <= 0) {
|
||||
continue
|
||||
}
|
||||
const key = part.slice(0, index).trim()
|
||||
const value = part.slice(index + 1).trim()
|
||||
if (key === 't' && /^\d{1,12}$/.test(value)) {
|
||||
timestamp = Number(value)
|
||||
} else if (key === 'v1' && /^[0-9a-f]{64}$/.test(value)) {
|
||||
signatures.push(value)
|
||||
}
|
||||
}
|
||||
return timestamp === null || signatures.length === 0 ? null : { timestamp, signatures }
|
||||
}
|
||||
|
||||
export async function verifyStripeSignature(
|
||||
rawBody: string,
|
||||
header: string | null,
|
||||
secrets: readonly string[],
|
||||
nowMs: number = Date.now(),
|
||||
toleranceSeconds: number = DEFAULT_TOLERANCE_SECONDS
|
||||
): Promise<SignatureResult> {
|
||||
const parsed = header ? parseHeader(header) : null
|
||||
const usable = secrets.filter((secret) => secret.length > 0)
|
||||
if (!parsed || usable.length === 0) {
|
||||
return { ok: false, reason: 'malformed' }
|
||||
}
|
||||
if (Math.abs(nowMs / 1000 - parsed.timestamp) > toleranceSeconds) {
|
||||
return { ok: false, reason: 'stale' }
|
||||
}
|
||||
const signedPayload = `${parsed.timestamp}.${rawBody}`
|
||||
for (const secret of usable) {
|
||||
const expected = await hmacHex(secret, signedPayload)
|
||||
// Check every candidate so timing does not reveal which one matched.
|
||||
let matched = false
|
||||
for (const signature of parsed.signatures) {
|
||||
matched = constantTimeEqual(expected, signature) || matched
|
||||
}
|
||||
if (matched) {
|
||||
return { ok: true }
|
||||
}
|
||||
}
|
||||
return { ok: false, reason: 'mismatch' }
|
||||
}
|
||||
|
||||
export function splitSecrets(value: string | undefined): string[] {
|
||||
return (value ?? '')
|
||||
.split(',')
|
||||
.map((secret) => secret.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// Stripe webhook: verify the signature over the raw body, dedupe by event id, then re-read the
|
||||
// subscription from the API instead of trusting the (possibly stale, possibly out-of-order) payload.
|
||||
import type { Env } from './env'
|
||||
import { planForPrice } from './plans'
|
||||
import { allowsLiveMode, type StripeClient, type StripeSubscription } from './stripe'
|
||||
import { accountForCustomer, claimEvent, customerFor, finishEvent, upsertSubscription } from './store'
|
||||
import { splitSecrets, verifyStripeSignature } from './webhook-signature'
|
||||
|
||||
const MAX_BODY_BYTES = 512 * 1024
|
||||
|
||||
type StripeEvent = {
|
||||
id: string
|
||||
type: string
|
||||
livemode: boolean
|
||||
data: { object: Record<string, unknown> }
|
||||
}
|
||||
|
||||
const SUBSCRIPTION_EVENTS = new Set([
|
||||
'customer.subscription.created',
|
||||
'customer.subscription.updated',
|
||||
'customer.subscription.deleted',
|
||||
'customer.subscription.paused',
|
||||
'customer.subscription.resumed'
|
||||
])
|
||||
const INVOICE_EVENTS = new Set(['invoice.paid', 'invoice.payment_failed'])
|
||||
|
||||
function idOf(value: unknown): string | null {
|
||||
if (typeof value === 'string' && value) {
|
||||
return value
|
||||
}
|
||||
if (value && typeof value === 'object' && typeof (value as { id?: unknown }).id === 'string') {
|
||||
return (value as { id: string }).id
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** The subscription an event concerns, across the payload shapes of old and new API versions. */
|
||||
export function subscriptionIdFor(event: StripeEvent): string | null {
|
||||
const object = event.data.object
|
||||
if (SUBSCRIPTION_EVENTS.has(event.type)) {
|
||||
return idOf(object.id)
|
||||
}
|
||||
if (event.type === 'checkout.session.completed') {
|
||||
return object.mode === 'subscription' ? idOf(object.subscription) : null
|
||||
}
|
||||
if (INVOICE_EVENTS.has(event.type)) {
|
||||
const parent = object.parent as { subscription_details?: { subscription?: unknown } } | undefined
|
||||
return idOf(object.subscription) ?? idOf(parent?.subscription_details?.subscription)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
class IgnoredSubscription extends Error {}
|
||||
|
||||
/** Stripe statuses after a failed renewal: past_due while retrying, unpaid once retries are exhausted. */
|
||||
export const PAYMENT_FAILED_STATUSES = new Set(['past_due', 'unpaid'])
|
||||
|
||||
export async function syncSubscription(env: Env, stripe: StripeClient, subscriptionId: string, now: number) {
|
||||
const subscription: StripeSubscription = await stripe.retrieveSubscription(subscriptionId)
|
||||
if (subscription.livemode && !allowsLiveMode(env)) {
|
||||
throw new IgnoredSubscription('livemode_subscription')
|
||||
}
|
||||
const customer = idOf(subscription.customer)
|
||||
if (!customer) {
|
||||
throw new IgnoredSubscription('subscription_without_customer')
|
||||
}
|
||||
const db = env.BILLING_DB
|
||||
// Metadata is ours (customers cannot edit it), but the customer link is the authority: an
|
||||
// account is only credited with a subscription billed to the customer we created for it.
|
||||
const claimed = subscription.metadata?.account_id
|
||||
const accountId = claimed && (await customerFor(db, claimed)) === customer ? claimed : await accountForCustomer(db, customer)
|
||||
if (!accountId) {
|
||||
throw new IgnoredSubscription('unknown_customer')
|
||||
}
|
||||
let match: ReturnType<typeof planForPrice> = null
|
||||
let seats = 1
|
||||
let overageItemId: string | null = null
|
||||
let periodStart = subscription.current_period_start
|
||||
let periodEnd = subscription.current_period_end
|
||||
for (const item of subscription.items.data) {
|
||||
if (item.price.id === env.STRIPE_PRICE_OVERAGE) {
|
||||
overageItemId = item.id
|
||||
continue
|
||||
}
|
||||
const itemPlan = planForPrice(env, item.price.id)
|
||||
if (itemPlan) {
|
||||
match = itemPlan
|
||||
seats = Math.max(1, item.quantity ?? 1)
|
||||
periodStart = item.current_period_start ?? periodStart
|
||||
periodEnd = item.current_period_end ?? periodEnd
|
||||
}
|
||||
}
|
||||
if (!match || !periodStart || !periodEnd) {
|
||||
throw new IgnoredSubscription('unrecognized_subscription_items')
|
||||
}
|
||||
await upsertSubscription(db, {
|
||||
stripe_subscription_id: subscription.id,
|
||||
account_id: accountId,
|
||||
stripe_customer_id: customer,
|
||||
status: subscription.status,
|
||||
plan: match.plan,
|
||||
seats,
|
||||
overage_enabled: overageItemId ? 1 : 0,
|
||||
current_period_start: periodStart * 1000,
|
||||
current_period_end: periodEnd * 1000,
|
||||
cancel_at_period_end: subscription.cancel_at_period_end ? 1 : 0,
|
||||
updated_at: now,
|
||||
billing_interval: match.interval,
|
||||
overage_item_id: overageItemId,
|
||||
// The upsert keeps the earliest value while it stays unpaid and clears it once paid.
|
||||
past_due_since: PAYMENT_FAILED_STATUSES.has(subscription.status) ? now : null,
|
||||
// Membership comes from auth, not Stripe: keep the stored count.
|
||||
member_count: null
|
||||
})
|
||||
}
|
||||
|
||||
export async function handleStripeWebhook(env: Env, stripe: StripeClient, request: Request, now: number): Promise<Response> {
|
||||
const length = Number(request.headers.get('content-length') ?? '0')
|
||||
if (length > MAX_BODY_BYTES) {
|
||||
return new Response(null, { status: 413 })
|
||||
}
|
||||
const rawBody = await request.text()
|
||||
if (rawBody.length > MAX_BODY_BYTES) {
|
||||
return new Response(null, { status: 413 })
|
||||
}
|
||||
const verified = await verifyStripeSignature(
|
||||
rawBody,
|
||||
request.headers.get('stripe-signature'),
|
||||
splitSecrets(env.STRIPE_WEBHOOK_SECRET),
|
||||
now
|
||||
)
|
||||
if (!verified.ok) {
|
||||
return Response.json({ error: `signature_${verified.reason}` }, { status: 400 })
|
||||
}
|
||||
let event: StripeEvent
|
||||
try {
|
||||
event = JSON.parse(rawBody) as StripeEvent
|
||||
} catch {
|
||||
return Response.json({ error: 'invalid_json' }, { status: 400 })
|
||||
}
|
||||
if (typeof event.id !== 'string' || typeof event.type !== 'string' || !event.data?.object) {
|
||||
return Response.json({ error: 'invalid_event' }, { status: 400 })
|
||||
}
|
||||
// Acknowledged (so Stripe stops retrying) but never applied.
|
||||
if (event.livemode && !allowsLiveMode(env)) {
|
||||
return Response.json({ ignored: 'livemode' })
|
||||
}
|
||||
const subscriptionId = subscriptionIdFor(event)
|
||||
if (!subscriptionId) {
|
||||
return Response.json({ ignored: 'event_type' })
|
||||
}
|
||||
if ((await claimEvent(env.BILLING_DB, event.id, event.type, now)) === 'duplicate') {
|
||||
return Response.json({ duplicate: true })
|
||||
}
|
||||
try {
|
||||
await syncSubscription(env, stripe, subscriptionId, now)
|
||||
await finishEvent(env.BILLING_DB, event.id, { now })
|
||||
return Response.json({ received: true })
|
||||
} catch (error) {
|
||||
if (error instanceof IgnoredSubscription) {
|
||||
// Not ours or not recognizable: retrying will not help. Recorded for the audit script.
|
||||
await finishEvent(env.BILLING_DB, event.id, { now, note: error.message })
|
||||
return Response.json({ ignored: error.message })
|
||||
}
|
||||
await finishEvent(env.BILLING_DB, event.id, { error: error instanceof Error ? error.message : 'unknown' })
|
||||
// Non-2xx: Stripe retries with backoff for up to three days.
|
||||
return Response.json({ error: 'processing_failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { SignJWT, exportJWK, generateKeyPair } from 'jose'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { DEV_ISSUER } from '../src/auth'
|
||||
import { createHarness } from './harness'
|
||||
|
||||
async function devKeyPair() {
|
||||
const { privateKey, publicKey } = await generateKeyPair('ES256')
|
||||
const jwks = JSON.stringify({ keys: [{ ...(await exportJWK(publicKey)), kid: 'dev', alg: 'ES256' }] })
|
||||
const sign = (issuer: string) => {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
return new SignJWT({ sub: 'u1', purpose: 'billing' })
|
||||
.setProtectedHeader({ alg: 'ES256', kid: 'dev' })
|
||||
.setIssuer(issuer)
|
||||
.setAudience('orca-billing')
|
||||
.setIssuedAt(now)
|
||||
.setExpirationTime(now + 300)
|
||||
.sign(privateKey)
|
||||
}
|
||||
return { jwks, sign }
|
||||
}
|
||||
|
||||
describe('dev operator keys', () => {
|
||||
it('accepts dev-issuer tokens outside prod only, and only with the dev issuer', async () => {
|
||||
const dev = await devKeyPair()
|
||||
const call = async (env: Record<string, string>, token: string) => {
|
||||
const h = await createHarness(env)
|
||||
return (await h.call('GET', '/v1/billing/entitlements', { headers: { authorization: `Bearer ${token}` } })).status
|
||||
}
|
||||
expect(await call({ BILLING_ENV: 'dev', BILLING_DEV_JWKS: dev.jwks }, await dev.sign(DEV_ISSUER))).toBe(200)
|
||||
expect(await call({ BILLING_ENV: 'prod', BILLING_DEV_JWKS: dev.jwks }, await dev.sign(DEV_ISSUER))).toBe(401)
|
||||
// A dev key cannot impersonate the real issuer.
|
||||
expect(await call({ BILLING_ENV: 'dev', BILLING_DEV_JWKS: dev.jwks }, await dev.sign('https://login.onorca.dev'))).toBe(401)
|
||||
expect(await call({ BILLING_ENV: 'dev' }, await dev.sign(DEV_ISSUER))).toBe(401)
|
||||
})
|
||||
|
||||
it('serves a static return page for Checkout and the Portal', async () => {
|
||||
const h = await createHarness()
|
||||
const { handleRequest } = await import('../src/index')
|
||||
const response = await handleRequest(new Request('https://billing.test/return?checkout=success'), h.env, {
|
||||
waitUntil() {},
|
||||
passThroughOnException() {}
|
||||
} as unknown as ExecutionContext)
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.text()).toContain('return to Orca')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { allowanceWindow, capSeconds, DEFAULT_PLANS } from '../src/plans'
|
||||
import { createHarness } from './harness'
|
||||
|
||||
const HOUR = 3600
|
||||
|
||||
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: 'team' }, 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 Team numbers and labels and the Enterprise contact from BILLING_PLANS', async () => {
|
||||
const h = await createHarness({
|
||||
BILLING_PLANS: JSON.stringify({
|
||||
team: { includedHoursPerSeat: 40, monthlyPriceLabel: '$45/seat/mo', defaultSpendCapCents: 5000 },
|
||||
enterprise: { contactUrl: 'https://example.test/sales' }
|
||||
})
|
||||
})
|
||||
const token = await h.desktopToken({ sub: 'u1' })
|
||||
const entitlement = await h.call('GET', '/v1/billing/entitlements', { headers: { authorization: `Bearer ${token}` } })
|
||||
expect(entitlement.body.offers).toEqual({
|
||||
team: {
|
||||
label: 'Team',
|
||||
monthlyPriceLabel: '$45/seat/mo',
|
||||
annualPriceLabel: '$480/seat/yr',
|
||||
monthlyPriceCentsPerSeat: 5000,
|
||||
annualPriceCentsPerSeat: 48000,
|
||||
largeMachineWeight: 2,
|
||||
includedHoursPerSeat: 40,
|
||||
concurrentSandboxesPerSeat: 3,
|
||||
idleSleepMinutes: 15,
|
||||
overageCentsPerHour: 75,
|
||||
defaultSpendCapCents: 5000
|
||||
},
|
||||
enterprise: { label: 'Enterprise', priceLabel: 'Custom', contactUrl: 'https://example.test/sales' }
|
||||
})
|
||||
expect(entitlement.body.overage.spendCapCents).toBe(5000)
|
||||
})
|
||||
|
||||
it('fails closed on a malformed catalog', async () => {
|
||||
for (const bad of [
|
||||
'{"team":{"includedHoursPerSeat":"lots"}}',
|
||||
'{"pro":{}}',
|
||||
'{"enterprise":{"contactUrl":"http://insecure.test"}}',
|
||||
'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' }
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('allowance windows and caps', () => {
|
||||
const day = 24 * HOUR * 1000
|
||||
it('uses the billing period for monthly plans', () => {
|
||||
const period = { start: Date.UTC(2026, 0, 15), end: Date.UTC(2026, 1, 15) }
|
||||
expect(allowanceWindow(period, Date.UTC(2026, 1, 1))).toEqual(period)
|
||||
})
|
||||
|
||||
it('slices annual plans into monthly windows anchored on the start, clamping short months', () => {
|
||||
const period = { start: Date.UTC(2026, 0, 31), end: Date.UTC(2027, 0, 31) }
|
||||
expect(allowanceWindow(period, Date.UTC(2026, 1, 10))).toEqual({ start: Date.UTC(2026, 0, 31), end: Date.UTC(2026, 1, 28) })
|
||||
expect(allowanceWindow(period, Date.UTC(2026, 2, 1))).toEqual({ start: Date.UTC(2026, 1, 28), end: Date.UTC(2026, 2, 31) })
|
||||
expect(allowanceWindow(period, period.end - day)).toEqual({ start: Date.UTC(2026, 11, 31), end: period.end })
|
||||
})
|
||||
|
||||
it('converts the $25 default cap to 33.3 overage hours at $0.75', () => {
|
||||
expect(capSeconds(DEFAULT_PLANS.team, 2500)).toBe(Math.floor((2500 / 75) * HOUR))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,356 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { checkoutCompleted, invoiceEvent, stripeEvent } from './stripe-fake'
|
||||
import { createHarness } from './harness'
|
||||
|
||||
const HOUR = 3600
|
||||
const PERIOD_START = Math.floor(Date.now() / 1000) - 5 * 24 * HOUR
|
||||
const PERIOD_END = PERIOD_START + 30 * 24 * HOUR
|
||||
|
||||
/** A 2-seat monthly Team org (120 included hours, 6 concurrent), optionally opted in to overage. */
|
||||
async function subscribedTeam(options: { seats?: number; overage?: boolean; spendCapCents?: number } = {}) {
|
||||
const h = await createHarness()
|
||||
const token = await h.desktopToken({ sub: 'u1', org: 'o1', orgRole: 'owner', email: 'a@example.com' })
|
||||
const auth = { authorization: `Bearer ${token}` }
|
||||
const seats = options.seats ?? 2
|
||||
const checkout = await h.call('POST', '/v1/billing/checkout', { body: { plan: 'team', seats }, headers: auth })
|
||||
expect(checkout.status).toBe(200)
|
||||
const customer = [...h.stripe.customers.keys()][0]!
|
||||
const subscription = h.stripe.putSubscription({
|
||||
customer,
|
||||
accountId: 'org:o1',
|
||||
basePrice: 'price_team_month',
|
||||
quantity: seats,
|
||||
periodStart: PERIOD_START,
|
||||
periodEnd: PERIOD_END
|
||||
})
|
||||
expect((await h.webhook(checkoutCompleted({ customer, subscription: subscription.id, accountId: 'org:o1' }))).body).toEqual({
|
||||
received: true
|
||||
})
|
||||
if (options.overage) {
|
||||
const body = options.spendCapCents === undefined ? { enabled: true } : { enabled: true, spendCapCents: options.spendCapCents }
|
||||
expect((await h.call('POST', '/v1/billing/overage', { body, headers: auth })).status).toBe(200)
|
||||
}
|
||||
return { h, token, auth, customer, subscription }
|
||||
}
|
||||
|
||||
function usage(id: string, hours: number, extra: Record<string, unknown> = {}) {
|
||||
const endedAt = Date.now() - 60_000
|
||||
return { usageId: id, accountId: 'org:o1', sandboxId: 'sbx_1', machineSize: 'standard', startedAt: endedAt - hours * HOUR * 1000, endedAt, ...extra }
|
||||
}
|
||||
|
||||
describe('checkout and portal', () => {
|
||||
it('creates one customer per account and a seat-quantity Checkout without overage', async () => {
|
||||
const h = await createHarness()
|
||||
const token = await h.desktopToken({ sub: 'u1', org: 'o1', orgRole: 'admin', email: 'a@example.com' })
|
||||
const auth = { authorization: `Bearer ${token}` }
|
||||
const first = await h.call('POST', '/v1/billing/checkout', { body: { plan: 'team', seats: 4, interval: 'year' }, headers: auth })
|
||||
expect(first.status).toBe(200)
|
||||
expect(first.body.url).toMatch(/^https:\/\/checkout\.stripe\.com\//)
|
||||
await h.call('POST', '/v1/billing/checkout', { body: { plan: 'team', seats: 4 }, headers: auth })
|
||||
expect(h.stripe.customers.size).toBe(1)
|
||||
|
||||
const [annual, monthly] = h.stripe.calls.filter((call) => call.path === '/v1/checkout/sessions')
|
||||
expect(annual!.form.get('mode')).toBe('subscription')
|
||||
expect(annual!.form.get('line_items[0][price]')).toBe('price_team_year')
|
||||
expect(monthly!.form.get('line_items[0][price]')).toBe('price_team_month')
|
||||
expect(annual!.form.get('line_items[0][quantity]')).toBe('4')
|
||||
expect(annual!.form.has('line_items[0][adjustable_quantity][enabled]')).toBe(false)
|
||||
expect(annual!.form.has('line_items[1][price]')).toBe(false)
|
||||
expect(annual!.form.get('subscription_data[metadata][account_id]')).toBe('org:o1')
|
||||
expect(annual!.form.has('subscription_data[trial_period_days]')).toBe(false)
|
||||
expect(annual!.form.get('payment_method_collection')).toBe('always')
|
||||
expect(annual!.form.get('success_url')).toContain('session_id={CHECKOUT_SESSION_ID}')
|
||||
expect(annual!.headers.get('idempotency-key')).toMatch(/^checkout:org:o1:/)
|
||||
})
|
||||
|
||||
it('lets only org owners and admins buy or manage the plan; personal accounts manage themselves', async () => {
|
||||
const h = await createHarness()
|
||||
const member = await h.desktopToken({ sub: 'u2', org: 'o1', orgRole: 'member' })
|
||||
const auth = { authorization: `Bearer ${member}` }
|
||||
expect(await h.call('POST', '/v1/billing/checkout', { body: { plan: 'team' }, headers: auth })).toEqual({
|
||||
status: 403,
|
||||
body: { error: 'billing_admin_required' }
|
||||
})
|
||||
expect((await h.call('POST', '/v1/billing/portal', { headers: auth })).status).toBe(403)
|
||||
expect((await h.call('POST', '/v1/billing/overage', { body: { enabled: true }, headers: auth })).status).toBe(403)
|
||||
|
||||
const solo = await h.desktopToken({ sub: 'u3' })
|
||||
expect((await h.call('POST', '/v1/billing/checkout', { body: { plan: 'team' }, headers: { authorization: `Bearer ${solo}` } })).status).toBe(200)
|
||||
expect(h.stripe.customers.values().next().value?.metadata.account_id).toBe('user:u3')
|
||||
})
|
||||
|
||||
it('rejects bad tokens, unknown plans and a second subscription', async () => {
|
||||
const { h, auth } = await subscribedTeam()
|
||||
const wrongAudience = await h.desktopToken({ sub: 'u1' }, { audience: 'orca-relay' })
|
||||
const wrongPurpose = await h.desktopToken({ sub: 'u1' }, { purpose: 'host-control' })
|
||||
const tooOld = await h.desktopToken({ sub: 'u1' }, { ageSeconds: 20 * 60 })
|
||||
for (const bad of [wrongAudience, wrongPurpose, tooOld, 'garbage']) {
|
||||
expect((await h.call('GET', '/v1/billing/entitlements', { headers: { authorization: `Bearer ${bad}` } })).status).toBe(401)
|
||||
}
|
||||
expect((await h.call('GET', '/v1/billing/entitlements')).status).toBe(401)
|
||||
for (const body of [{ plan: 'pro' }, { plan: 'enterprise' }, { plan: 'team', interval: 'week' }, { plan: 'team', seats: 0 }]) {
|
||||
expect((await h.call('POST', '/v1/billing/checkout', { body, headers: auth })).status).toBe(400)
|
||||
}
|
||||
expect(await h.call('POST', '/v1/billing/checkout', { body: { plan: 'team' }, headers: auth })).toEqual({
|
||||
status: 409,
|
||||
body: { error: 'already_subscribed' }
|
||||
})
|
||||
})
|
||||
|
||||
it('opens the portal (with the configured portal configuration) for the linked customer only', async () => {
|
||||
const { h, auth, customer } = await subscribedTeam()
|
||||
h.env.STRIPE_PORTAL_CONFIGURATION = 'bpc_test_1'
|
||||
const portal = await h.call('POST', '/v1/billing/portal', { headers: auth })
|
||||
expect(portal.body.url).toMatch(/^https:\/\/billing\.stripe\.com\//)
|
||||
const call = h.stripe.calls.find((entry) => entry.path === '/v1/billing_portal/sessions')!
|
||||
expect(call.form.get('customer')).toBe(customer)
|
||||
expect(call.form.get('configuration')).toBe('bpc_test_1')
|
||||
const stranger = await h.desktopToken({ sub: 'u9' })
|
||||
expect(await h.call('POST', '/v1/billing/portal', { headers: { authorization: `Bearer ${stranger}` } })).toEqual({
|
||||
status: 404,
|
||||
body: { error: 'no_billing_account' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('webhooks', () => {
|
||||
it('rejects unsigned or mis-signed deliveries without touching state', async () => {
|
||||
const { h, subscription, customer } = await subscribedTeam()
|
||||
const event = invoiceEvent('invoice.paid', subscription.id, customer)
|
||||
expect((await h.call('POST', '/v1/stripe/webhook', { rawBody: JSON.stringify(event) })).status).toBe(400)
|
||||
expect((await h.webhook(event, 'whsec_wrong')).body).toEqual({ error: 'signature_mismatch' })
|
||||
expect(h.raw.prepare('SELECT COUNT(*) AS n FROM stripe_events WHERE event_id = ?').get(event.id)).toEqual({ n: 0 })
|
||||
})
|
||||
|
||||
it('processes an event id once', async () => {
|
||||
const { h, subscription, customer } = await subscribedTeam()
|
||||
const event = invoiceEvent('invoice.paid', subscription.id, customer)
|
||||
expect((await h.webhook(event)).body).toEqual({ received: true })
|
||||
const reads = h.stripe.calls.filter((call) => call.method === 'GET').length
|
||||
expect((await h.webhook(event)).body).toEqual({ duplicate: true })
|
||||
expect(h.stripe.calls.filter((call) => call.method === 'GET').length).toBe(reads)
|
||||
})
|
||||
|
||||
it('applies Stripe current state, so out-of-order deliveries cannot resurrect a canceled plan', async () => {
|
||||
const { h, auth, subscription } = await subscribedTeam()
|
||||
const staleUpdate = stripeEvent('customer.subscription.updated', { ...subscription, status: 'active' })
|
||||
subscription.status = 'canceled'
|
||||
await h.webhook(stripeEvent('customer.subscription.deleted', { ...subscription }))
|
||||
await h.webhook(staleUpdate)
|
||||
const entitlement = await h.call('GET', '/v1/billing/entitlements', { headers: auth })
|
||||
expect(entitlement.body).toMatchObject({ status: 'canceled', canStartSandbox: false, reason: 'subscription_inactive' })
|
||||
})
|
||||
|
||||
it('tracks seat and interval changes made in the portal', async () => {
|
||||
const { h, auth, subscription } = await subscribedTeam()
|
||||
subscription.items.data[0]!.quantity = 5
|
||||
subscription.items.data[0]!.price = { id: 'price_team_year' }
|
||||
subscription.items.data[0]!.current_period_end = PERIOD_START + 365 * 24 * HOUR
|
||||
await h.webhook(stripeEvent('customer.subscription.updated', subscription))
|
||||
const entitlement = await h.call('GET', '/v1/billing/entitlements', { headers: auth })
|
||||
expect(entitlement.body).toMatchObject({ seats: 5, interval: 'year', includedSeconds: 300 * HOUR })
|
||||
expect(entitlement.body.limits).toMatchObject({ maxConcurrentSandboxes: 15, idleSleepMinutes: 15 })
|
||||
// Annual plans still get a monthly allowance window.
|
||||
const window = entitlement.body.usageWindow
|
||||
expect(window.end - window.start).toBeLessThanOrEqual(31 * 24 * HOUR * 1000)
|
||||
})
|
||||
|
||||
it('marks past_due from invoice.payment_failed (new invoice.parent shape)', async () => {
|
||||
const { h, auth, subscription, customer } = await subscribedTeam()
|
||||
subscription.status = 'past_due'
|
||||
expect((await h.webhook(invoiceEvent('invoice.payment_failed', subscription.id, customer))).body).toEqual({ received: true })
|
||||
const entitlement = await h.call('GET', '/v1/billing/entitlements', { headers: auth })
|
||||
expect(entitlement.body).toMatchObject({ status: 'past_due', canStartSandbox: false, reason: 'payment_past_due' })
|
||||
})
|
||||
|
||||
it('acknowledges but ignores live-mode events and subscriptions on customers it did not create', async () => {
|
||||
const { h, subscription } = await subscribedTeam()
|
||||
expect((await h.webhook(stripeEvent('customer.subscription.updated', subscription, { livemode: true }))).body).toEqual({
|
||||
ignored: 'livemode'
|
||||
})
|
||||
const foreign = h.stripe.putSubscription({
|
||||
customer: 'cus_someone_else',
|
||||
accountId: 'org:o1',
|
||||
basePrice: 'price_team_year',
|
||||
quantity: 50,
|
||||
periodStart: PERIOD_START,
|
||||
periodEnd: PERIOD_END
|
||||
})
|
||||
// Metadata naming org:o1 is not enough: the customer must be the one billing created for it.
|
||||
expect((await h.webhook(stripeEvent('customer.subscription.created', foreign))).body).toEqual({ ignored: 'unknown_customer' })
|
||||
expect(h.raw.prepare('SELECT seats FROM subscriptions WHERE account_id = ?').all('org:o1')).toEqual([{ seats: 2 }])
|
||||
})
|
||||
|
||||
it('returns 500 (so Stripe retries) when Stripe cannot be read, and succeeds on redelivery', async () => {
|
||||
const { h, subscription, customer } = await subscribedTeam()
|
||||
const event = invoiceEvent('invoice.paid', 'sub_missing', customer)
|
||||
expect((await h.webhook(event)).status).toBe(500)
|
||||
h.stripe.subscriptions.set('sub_missing', { ...subscription, id: 'sub_missing' })
|
||||
expect((await h.webhook(event)).body).toEqual({ received: true })
|
||||
})
|
||||
|
||||
it('ignores event types it does not handle', async () => {
|
||||
const h = await createHarness()
|
||||
expect((await h.webhook(stripeEvent('customer.created', { id: 'cus_1' }))).body).toEqual({ ignored: 'event_type' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('entitlements, overage and usage', () => {
|
||||
it('reports pooled hours, warnings and limits for the desktop', async () => {
|
||||
const { h, auth } = await subscribedTeam()
|
||||
expect((await h.controlPlane('/v1/internal/usage', usage('u-1', 10))).status).toBe(200)
|
||||
const entitlement = await h.call('GET', '/v1/billing/entitlements', { headers: auth })
|
||||
expect(entitlement.body).toMatchObject({
|
||||
accountId: 'org:o1',
|
||||
plan: 'team',
|
||||
planLabel: 'Team',
|
||||
status: 'active',
|
||||
interval: 'month',
|
||||
seats: 2,
|
||||
includedSeconds: 120 * HOUR,
|
||||
usedSeconds: 10 * HOUR,
|
||||
remainingIncludedSeconds: 110 * HOUR,
|
||||
usageWarningPercent: null,
|
||||
overage: { optedIn: false, active: false, spendCapCents: 2500, spentCents: 0, centsPerHour: 75 },
|
||||
limits: { maxConcurrentSandboxes: 6, machineSizes: ['standard', 'large'], idleSleepMinutes: 15 },
|
||||
canStartSandbox: true,
|
||||
canManageBilling: true,
|
||||
period: { start: PERIOD_START * 1000, end: PERIOD_END * 1000 }
|
||||
})
|
||||
expect(entitlement.body.offers.team).toMatchObject({ monthlyPriceLabel: '$50/seat/mo', annualPriceLabel: '$480/seat/yr' })
|
||||
expect(entitlement.body.offers.enterprise).toMatchObject({ label: 'Enterprise', priceLabel: 'Custom' })
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await h.controlPlane('/v1/internal/usage', usage(`w-${i}`, 22))
|
||||
}
|
||||
expect((await h.call('GET', '/v1/billing/entitlements', { headers: auth })).body.usageWarningPercent).toBe(80)
|
||||
})
|
||||
|
||||
it('is idempotent per usageId and burns Large at 2x', async () => {
|
||||
const { h } = await subscribedTeam()
|
||||
const first = await h.controlPlane('/v1/internal/usage', usage('u-1', 1, { machineSize: 'large' }))
|
||||
const again = await h.controlPlane('/v1/internal/usage', usage('u-1', 1, { machineSize: 'large' }))
|
||||
expect(first.body).toMatchObject({ recorded: true, weightedSeconds: 2 * HOUR })
|
||||
expect(again.body).toMatchObject({ recorded: false })
|
||||
expect(h.raw.prepare('SELECT SUM(weighted_seconds) AS s FROM usage_events').get()).toEqual({ s: 2 * HOUR })
|
||||
})
|
||||
|
||||
it('without opt-in, stops new starts at the included hours and meters nothing', async () => {
|
||||
const { h, auth } = await subscribedTeam()
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await h.controlPlane('/v1/internal/usage', usage(`u-${i}`, 24))
|
||||
}
|
||||
const entitlement = await h.call('GET', '/v1/billing/entitlements', { headers: auth })
|
||||
expect(entitlement.body).toMatchObject({ usageWarningPercent: 100, canStartSandbox: false, reason: 'included_hours_exhausted' })
|
||||
expect(h.stripe.meterEvents).toHaveLength(0)
|
||||
expect(h.stripe.calls.some((call) => call.path === '/v1/subscription_items')).toBe(false)
|
||||
})
|
||||
|
||||
it('opting in adds the metered item once, then meters only the overage past included hours', async () => {
|
||||
const { h, auth, customer, subscription } = await subscribedTeam({ overage: true })
|
||||
expect(subscription.items.data.map((item) => item.price.id)).toEqual(['price_team_month', 'price_overage'])
|
||||
await h.call('POST', '/v1/billing/overage', { body: { enabled: true }, headers: auth })
|
||||
expect(h.stripe.calls.filter((call) => call.path === '/v1/subscription_items')).toHaveLength(1)
|
||||
// The item's webhook must not drop the item id.
|
||||
await h.webhook(stripeEvent('customer.subscription.updated', subscription))
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await h.controlPlane('/v1/internal/usage', usage(`u-${i}`, 24))
|
||||
}
|
||||
// Exactly the 120 included hours: nothing billable yet.
|
||||
expect(h.stripe.meterEvents).toHaveLength(0)
|
||||
const crossing = await h.controlPlane('/v1/internal/usage', usage('u-5', 4))
|
||||
expect(crossing.body).toMatchObject({ overageSeconds: 4 * HOUR, canStartSandbox: true })
|
||||
await h.controlPlane('/v1/internal/usage', usage('u-5', 4))
|
||||
expect(h.stripe.meterEvents).toEqual([
|
||||
expect.objectContaining({ identifier: 'u-5', 'payload[stripe_customer_id]': customer, 'payload[value]': String(4 * HOUR) })
|
||||
])
|
||||
const entitlement = await h.call('GET', '/v1/billing/entitlements', { headers: auth })
|
||||
expect(entitlement.body.overage).toMatchObject({ optedIn: true, active: true, spentCents: 300 })
|
||||
})
|
||||
|
||||
it('stops metering and new starts at the spend cap; admins can raise it', async () => {
|
||||
// $3 cap = 4 overage hours at $0.75.
|
||||
const { h, auth } = await subscribedTeam({ overage: true, spendCapCents: 300 })
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await h.controlPlane('/v1/internal/usage', usage(`u-${i}`, 24))
|
||||
}
|
||||
await h.controlPlane('/v1/internal/usage', usage('o-1', 6))
|
||||
expect(h.stripe.meterEvents.map((event) => event['payload[value]'])).toEqual([String(4 * HOUR)])
|
||||
const check = () => h.controlPlane('/v1/internal/entitlements/check', { accountId: 'org:o1', machineSize: 'standard', runningSandboxes: 0 })
|
||||
expect((await check()).body).toMatchObject({ allow: false, reason: 'overage_cap_reached' })
|
||||
|
||||
expect((await h.call('POST', '/v1/billing/overage', { body: { enabled: true, spendCapCents: 10_000_000 }, headers: auth })).body).toEqual({
|
||||
error: 'spend_cap_too_high'
|
||||
})
|
||||
await h.call('POST', '/v1/billing/overage', { body: { enabled: true, spendCapCents: 2500 }, headers: auth })
|
||||
expect((await check()).body).toMatchObject({ allow: true })
|
||||
})
|
||||
|
||||
it('opting out stops metering immediately and blocks starts past included hours', async () => {
|
||||
const { h, auth } = await subscribedTeam({ overage: true })
|
||||
await h.call('POST', '/v1/billing/overage', { body: { enabled: false }, headers: auth })
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await h.controlPlane('/v1/internal/usage', usage(`u-${i}`, 24))
|
||||
}
|
||||
expect(h.stripe.meterEvents).toHaveLength(0)
|
||||
expect((await h.call('GET', '/v1/billing/entitlements', { headers: auth })).body).toMatchObject({ reason: 'included_hours_exhausted' })
|
||||
})
|
||||
|
||||
it('retries failed meter reports from the outbox', async () => {
|
||||
const { h } = await subscribedTeam({ overage: true })
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await h.controlPlane('/v1/internal/usage', usage(`u-${i}`, 24))
|
||||
}
|
||||
h.stripe.failNextMeterEvents(1)
|
||||
await h.controlPlane('/v1/internal/usage', usage('u-9', 2))
|
||||
expect(h.stripe.meterEvents).toHaveLength(0)
|
||||
expect(h.raw.prepare('SELECT status, attempts FROM meter_reports').get()).toEqual({ status: 'pending', attempts: 1 })
|
||||
const { flushMeterReports } = await import('../src/usage')
|
||||
const { createStripeClient } = await import('../src/stripe')
|
||||
expect(await flushMeterReports(h.env, createStripeClient(h.env, h.stripe.fetchImpl), Date.now())).toEqual({ sent: 1, failed: 0 })
|
||||
expect(h.stripe.meterEvents[0]?.['payload[value]']).toBe(String(2 * HOUR))
|
||||
})
|
||||
|
||||
it('gates starts by machine size and per-seat concurrency for the control plane', async () => {
|
||||
const { h } = await subscribedTeam()
|
||||
const check = (body: Record<string, unknown>) => h.controlPlane('/v1/internal/entitlements/check', { accountId: 'org:o1', ...body })
|
||||
expect((await check({ machineSize: 'large', runningSandboxes: 5 })).body).toMatchObject({ allow: true, reason: null, seats: 2, maxConcurrentSandboxes: 6, maxConcurrentSandboxesPerMember: 3 })
|
||||
expect((await check({ machineSize: 'standard', runningSandboxes: 6 })).body).toMatchObject({ allow: false, reason: 'concurrency_limit' })
|
||||
expect((await check({ machineSize: 'xlarge', runningSandboxes: 0 })).status).toBe(400)
|
||||
const unknown = await h.controlPlane('/v1/internal/entitlements/check', { accountId: 'org:nobody', machineSize: 'standard', runningSandboxes: 0 })
|
||||
expect(unknown.body).toMatchObject({ allow: false, reason: 'no_subscription' })
|
||||
})
|
||||
|
||||
it('refuses overage opt-in without an open subscription', async () => {
|
||||
const h = await createHarness()
|
||||
const token = await h.desktopToken({ sub: 'u1' })
|
||||
expect(await h.call('POST', '/v1/billing/overage', { body: { enabled: true }, headers: { authorization: `Bearer ${token}` } })).toEqual({
|
||||
status: 409,
|
||||
body: { error: 'no_subscription' }
|
||||
})
|
||||
})
|
||||
|
||||
it('authenticates the control plane and validates segments', async () => {
|
||||
const { h } = await subscribedTeam()
|
||||
expect((await h.controlPlane('/v1/internal/usage', usage('u-1', 1), 'cp_test_wrong_but_long_enough_000000000')).status).toBe(401)
|
||||
const desktop = await h.desktopToken({ sub: 'u1', org: 'o1', orgRole: 'owner' })
|
||||
expect((await h.controlPlane('/v1/internal/usage', usage('u-1', 1), desktop)).status).toBe(401)
|
||||
expect((await h.controlPlane('/v1/internal/usage', usage('u-1', 25))).body).toEqual({ error: 'segment_too_long' })
|
||||
expect((await h.controlPlane('/v1/internal/usage', usage('u-1', 0))).body).toEqual({ error: 'empty_segment' })
|
||||
const future = Date.now() + 3600_000
|
||||
expect((await h.controlPlane('/v1/internal/usage', usage('u-1', 1, { startedAt: future - 1000, endedAt: future }))).body).toEqual({
|
||||
error: 'segment_in_future'
|
||||
})
|
||||
expect((await h.controlPlane('/v1/internal/usage', { ...usage('u-1', 1), accountId: 'org:o1; DROP' })).status).toBe(400)
|
||||
})
|
||||
|
||||
it('refuses to run with a live Stripe key', async () => {
|
||||
const h = await createHarness({ STRIPE_SECRET_KEY: 'sk_live_nope' })
|
||||
const token = await h.desktopToken({ sub: 'u1' })
|
||||
expect(await h.call('POST', '/v1/billing/checkout', { body: { plan: 'team' }, headers: { authorization: `Bearer ${token}` } })).toEqual({
|
||||
status: 503,
|
||||
body: { error: 'billing_misconfigured' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
// A D1Database over node:sqlite for tests: same SQL dialect (SQLite), same prepare/bind/first/
|
||||
// all/run/batch surface, batch() in one transaction like D1.
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
type Param = string | number | null
|
||||
|
||||
class FakeStatement {
|
||||
constructor(
|
||||
private readonly db: DatabaseSync,
|
||||
readonly sql: string,
|
||||
readonly params: Param[] = []
|
||||
) {}
|
||||
|
||||
bind(...params: unknown[]): FakeStatement {
|
||||
for (const param of params) {
|
||||
if (param === undefined || typeof param === 'boolean' || typeof param === 'object' && param !== null) {
|
||||
throw new Error(`D1 cannot bind ${typeof param}`)
|
||||
}
|
||||
}
|
||||
return new FakeStatement(this.db, this.sql, params as Param[])
|
||||
}
|
||||
|
||||
async first<T>(): Promise<T | null> {
|
||||
return (this.db.prepare(this.sql).get(...this.params) as T | undefined) ?? null
|
||||
}
|
||||
|
||||
async all<T>(): Promise<{ results: T[]; success: true; meta: { changes: number } }> {
|
||||
return { results: this.db.prepare(this.sql).all(...this.params) as T[], success: true, meta: { changes: 0 } }
|
||||
}
|
||||
|
||||
runSync(): { success: true; meta: { changes: number } } {
|
||||
const result = this.db.prepare(this.sql).run(...this.params)
|
||||
return { success: true, meta: { changes: Number(result.changes) } }
|
||||
}
|
||||
|
||||
async run() {
|
||||
return this.runSync()
|
||||
}
|
||||
}
|
||||
|
||||
export function createFakeD1(): { db: D1Database; raw: DatabaseSync } {
|
||||
const raw = new DatabaseSync(':memory:')
|
||||
const dir = fileURLToPath(new URL('../migrations/', import.meta.url).href)
|
||||
for (const file of readdirSync(dir).filter((name) => name.endsWith('.sql')).sort()) {
|
||||
raw.exec(readFileSync(`${dir}${file}`, 'utf8'))
|
||||
}
|
||||
const db = {
|
||||
prepare: (sql: string) => new FakeStatement(raw, sql),
|
||||
async batch(statements: FakeStatement[]) {
|
||||
raw.exec('BEGIN')
|
||||
try {
|
||||
const results = statements.map((statement) => statement.runSync())
|
||||
raw.exec('COMMIT')
|
||||
return results
|
||||
} catch (error) {
|
||||
raw.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
return { db: db as unknown as D1Database, raw }
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { SignJWT, exportJWK, generateKeyPair } from 'jose'
|
||||
import type { Env } from '../src/env'
|
||||
import { handleRequest } from '../src/index'
|
||||
import { createStripeClient } from '../src/stripe'
|
||||
import { createFakeD1 } from './d1-fake'
|
||||
import { createFakeStripe, signStripePayload } from './stripe-fake'
|
||||
|
||||
export const WEBHOOK_SECRET = 'whsec_test_local_fixture_secret'
|
||||
export const CONTROL_TOKEN = 'cp_test_0123456789abcdef0123456789abcdef'
|
||||
|
||||
export async function createHarness(overrides: Partial<Env> = {}) {
|
||||
const { privateKey, publicKey } = await generateKeyPair('ES256')
|
||||
const jwk = { ...(await exportJWK(publicKey)), kid: `k${Math.random()}`, alg: 'ES256', use: 'sig' }
|
||||
const { db, raw } = createFakeD1()
|
||||
const stripe = createFakeStripe()
|
||||
const env: Env = {
|
||||
BILLING_DB: db,
|
||||
STRIPE_SECRET_KEY: 'rk_test_fixture',
|
||||
STRIPE_WEBHOOK_SECRET: WEBHOOK_SECRET,
|
||||
STRIPE_API_BASE: 'https://stripe.test',
|
||||
STRIPE_PRICE_TEAM_MONTHLY: 'price_team_month',
|
||||
STRIPE_PRICE_TEAM_ANNUAL: 'price_team_year',
|
||||
STRIPE_PRICE_OVERAGE: 'price_overage',
|
||||
STRIPE_METER_EVENT_NAME: 'orca_sandbox_seconds',
|
||||
BILLING_RETURN_URL: 'https://onorca.dev/billing/return',
|
||||
BILLING_JWKS: JSON.stringify({ keys: [jwk] }),
|
||||
BILLING_JWT_AUDIENCE: 'orca-billing',
|
||||
BILLING_AUTH_ISSUER: 'https://login.onorca.dev',
|
||||
CONTROL_PLANE_TOKEN: CONTROL_TOKEN,
|
||||
...overrides
|
||||
}
|
||||
let now = Date.now()
|
||||
// Live org member counts from auth; null = not configured (the default).
|
||||
let membership: (orgId: string) => Promise<number | null> = async () => null
|
||||
const waits: Promise<unknown>[] = []
|
||||
const ctx = { waitUntil: (promise: Promise<unknown>) => waits.push(promise), passThroughOnException() {} } as unknown as ExecutionContext
|
||||
|
||||
async function call(method: string, path: string, init: { body?: unknown; rawBody?: string; headers?: Record<string, string> } = {}) {
|
||||
const request = new Request(`https://billing.test${path}`, {
|
||||
method,
|
||||
headers: { 'content-type': 'application/json', ...init.headers },
|
||||
body: init.rawBody ?? (init.body === undefined ? undefined : JSON.stringify(init.body))
|
||||
})
|
||||
const response = await handleRequest(request, env, ctx, {
|
||||
stripe: createStripeClient(env, stripe.fetchImpl),
|
||||
now: () => now,
|
||||
membership: (orgId) => membership(orgId)
|
||||
})
|
||||
await Promise.all(waits.splice(0))
|
||||
return { status: response.status, body: (await response.json()) as Record<string, any> }
|
||||
}
|
||||
|
||||
return {
|
||||
env,
|
||||
raw,
|
||||
stripe,
|
||||
call,
|
||||
setNow(value: number) {
|
||||
now = value
|
||||
},
|
||||
setMembership(source: (orgId: string) => Promise<number | null>) {
|
||||
membership = source
|
||||
},
|
||||
async desktopToken(claims: { sub: string; org?: string; orgRole?: string; email?: string; orgMembers?: number }, options: { audience?: string; purpose?: string; ageSeconds?: number } = {}) {
|
||||
const iat = Math.floor(now / 1000) - (options.ageSeconds ?? 0)
|
||||
return new SignJWT({ ...claims, purpose: options.purpose ?? 'billing' })
|
||||
.setProtectedHeader({ alg: 'ES256', kid: jwk.kid })
|
||||
.setIssuer('https://login.onorca.dev')
|
||||
.setAudience(options.audience ?? 'orca-billing')
|
||||
.setIssuedAt(iat)
|
||||
.setExpirationTime(iat + 900)
|
||||
.sign(privateKey)
|
||||
},
|
||||
async webhook(event: unknown, secret = WEBHOOK_SECRET) {
|
||||
const rawBody = JSON.stringify(event)
|
||||
return call('POST', '/v1/stripe/webhook', {
|
||||
rawBody,
|
||||
headers: { 'stripe-signature': signStripePayload(rawBody, secret, Math.floor(now / 1000)) }
|
||||
})
|
||||
},
|
||||
controlPlane(path: string, body: unknown, token = CONTROL_TOKEN) {
|
||||
return call('POST', path, { body, headers: { authorization: `Bearer ${token}` } })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { checkoutCompleted, invoiceEvent, stripeEvent } from './stripe-fake'
|
||||
import { createHarness, CONTROL_TOKEN } from './harness'
|
||||
|
||||
const HOUR = 3600
|
||||
const PERIOD_START = Math.floor(Date.now() / 1000) - 5 * 24 * HOUR
|
||||
const PERIOD_END = PERIOD_START + 30 * 24 * HOUR
|
||||
const MEMBERSHIP_TOKEN = 'ms_test_0123456789abcdef0123456789abcdef'
|
||||
|
||||
async function team(seats = 2) {
|
||||
const h = await createHarness({ MEMBERSHIP_SYNC_TOKEN: MEMBERSHIP_TOKEN })
|
||||
const owner = await h.desktopToken({ sub: 'u1', org: 'o1', orgRole: 'owner' })
|
||||
await h.call('POST', '/v1/billing/checkout', { body: { plan: 'team', seats }, headers: { authorization: `Bearer ${owner}` } })
|
||||
const customer = [...h.stripe.customers.keys()][0]!
|
||||
const subscription = h.stripe.putSubscription({
|
||||
customer,
|
||||
accountId: 'org:o1',
|
||||
basePrice: 'price_team_month',
|
||||
quantity: seats,
|
||||
periodStart: PERIOD_START,
|
||||
periodEnd: PERIOD_END
|
||||
})
|
||||
await h.webhook(checkoutCompleted({ customer, subscription: subscription.id, accountId: 'org:o1' }))
|
||||
return { h, customer, subscription }
|
||||
}
|
||||
|
||||
const seatUpdates = (h: Awaited<ReturnType<typeof team>>['h']) =>
|
||||
h.stripe.calls
|
||||
.filter((call) => call.method === 'POST' && call.path.startsWith('/v1/subscription_items/'))
|
||||
.map((call) => ({ quantity: call.form.get('quantity'), proration: call.form.get('proration_behavior') }))
|
||||
|
||||
describe('seats follow membership', () => {
|
||||
it('adds are charged now; removals wait for renewal; re-adding within the period is free', async () => {
|
||||
const { h } = await team(2)
|
||||
const push = (members: number, token = MEMBERSHIP_TOKEN) =>
|
||||
h.call('POST', '/v1/internal/seats', { body: { accountId: 'org:o1', members }, headers: { authorization: `Bearer ${token}` } })
|
||||
const owner = await h.desktopToken({ sub: 'u1', org: 'o1', orgRole: 'owner' })
|
||||
const entitlement = async () =>
|
||||
(await h.call('GET', '/v1/billing/entitlements', { headers: { authorization: `Bearer ${owner}` } })).body
|
||||
|
||||
expect((await push(3, CONTROL_TOKEN)).status).toBe(401)
|
||||
expect((await push(3)).body).toEqual({ status: 'increased', seats: 3, previous: 2 })
|
||||
expect((await push(3)).body).toEqual({ status: 'unchanged', seats: 3 })
|
||||
// Two members leave mid-period: still billed (and entitled) for the 3 seats paid this period.
|
||||
expect((await push(1)).body).toEqual({ status: 'deferred', seats: 3, members: 1, effectiveAt: PERIOD_END * 1000 })
|
||||
expect(await entitlement()).toMatchObject({
|
||||
seats: 3,
|
||||
members: 1,
|
||||
seatChange: { seatsAtRenewal: 1, effectiveAt: PERIOD_END * 1000 },
|
||||
includedSeconds: 180 * HOUR
|
||||
})
|
||||
// Churn: they come back the same period. Already paid for, so no new charge.
|
||||
expect((await push(3)).body).toEqual({ status: 'unchanged', seats: 3 })
|
||||
expect(seatUpdates(h)).toEqual([{ quantity: '3', proration: 'always_invoice' }])
|
||||
|
||||
// Near renewal, seats come down to the team (no credit for the current period). Min 1 seat.
|
||||
h.setNow(PERIOD_END * 1000 - 3600_000)
|
||||
expect((await push(0)).body).toEqual({ status: 'reduced_at_renewal', seats: 1, previous: 3, effectiveAt: PERIOD_END * 1000 })
|
||||
expect(seatUpdates(h)).toEqual([
|
||||
{ quantity: '3', proration: 'always_invoice' },
|
||||
{ quantity: '1', proration: 'none' }
|
||||
])
|
||||
expect(await entitlement()).toMatchObject({ seats: 1, includedSeconds: 60 * HOUR, limits: { maxConcurrentSandboxes: 3 } })
|
||||
})
|
||||
|
||||
it('Checkout bills the live member count from auth, never fewer than 1 seat', async () => {
|
||||
const h = await createHarness()
|
||||
const owner = await h.desktopToken({ sub: 'u1', org: 'o1', orgRole: 'owner' })
|
||||
const seen: string[] = []
|
||||
h.setMembership(async (orgId) => {
|
||||
seen.push(orgId)
|
||||
return 0
|
||||
})
|
||||
await h.call('POST', '/v1/billing/checkout', { body: { plan: 'team', seats: 9 }, headers: { authorization: `Bearer ${owner}` } })
|
||||
expect(seen).toEqual(['o1'])
|
||||
expect(h.stripe.calls.find((call) => call.path === '/v1/checkout/sessions')?.form.get('line_items[0][quantity]')).toBe('1')
|
||||
})
|
||||
|
||||
it('fails closed when auth is configured but cannot answer', async () => {
|
||||
const h = await createHarness()
|
||||
const owner = await h.desktopToken({ sub: 'u1', org: 'o1', orgRole: 'owner' })
|
||||
const { MembershipUnavailableError } = await import('../src/membership')
|
||||
h.setMembership(async () => {
|
||||
throw new MembershipUnavailableError('auth answered 500')
|
||||
})
|
||||
expect(await h.call('POST', '/v1/billing/checkout', { body: { plan: 'team' }, headers: { authorization: `Bearer ${owner}` } })).toEqual({
|
||||
status: 503,
|
||||
body: { error: 'membership_unavailable' }
|
||||
})
|
||||
expect(h.stripe.calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('the reconcile cron heals a missed membership push', async () => {
|
||||
const { h } = await team(2)
|
||||
const { reconcileSeats } = await import('../src/seats')
|
||||
const { createStripeClient } = await import('../src/stripe')
|
||||
const stripe = createStripeClient(h.env, h.stripe.fetchImpl)
|
||||
expect(await reconcileSeats(h.env, stripe, async () => 5, Date.now())).toEqual({ checked: 1, changed: 1, failed: 0 })
|
||||
expect(await reconcileSeats(h.env, stripe, async () => 5, Date.now())).toEqual({ checked: 1, changed: 0, failed: 0 })
|
||||
expect(seatUpdates(h)).toEqual([{ quantity: '5', proration: 'always_invoice' }])
|
||||
})
|
||||
|
||||
it('the cron applies a deferred decrease in the true-down window from the last pushed count', async () => {
|
||||
const { h } = await team(4)
|
||||
await h.call('POST', '/v1/internal/seats', {
|
||||
body: { accountId: 'org:o1', members: 2 },
|
||||
headers: { authorization: `Bearer ${MEMBERSHIP_TOKEN}` }
|
||||
})
|
||||
const { reconcileSeats } = await import('../src/seats')
|
||||
const { createStripeClient } = await import('../src/stripe')
|
||||
const stripe = createStripeClient(h.env, h.stripe.fetchImpl)
|
||||
// Mid-period: nothing changes. Without an auth pull, the stored count is used.
|
||||
expect(await reconcileSeats(h.env, stripe, async () => null, Date.now())).toEqual({ checked: 1, changed: 0, failed: 0 })
|
||||
expect(await reconcileSeats(h.env, stripe, async () => null, PERIOD_END * 1000 - 60_000)).toEqual({
|
||||
checked: 1,
|
||||
changed: 1,
|
||||
failed: 0
|
||||
})
|
||||
expect(seatUpdates(h)).toEqual([{ quantity: '2', proration: 'none' }])
|
||||
})
|
||||
|
||||
it('billing admin actions need a freshly minted token (role re-read by auth at mint)', async () => {
|
||||
const { h } = await team(2)
|
||||
const stale = await h.desktopToken({ sub: 'u1', org: 'o1', orgRole: 'owner' }, { ageSeconds: 7 * 60 })
|
||||
const auth = { authorization: `Bearer ${stale}` }
|
||||
expect((await h.call('GET', '/v1/billing/entitlements', { headers: auth })).status).toBe(200)
|
||||
expect((await h.call('POST', '/v1/billing/portal', { headers: auth })).status).toBe(401)
|
||||
expect((await h.call('POST', '/v1/billing/overage', { body: { enabled: true }, headers: auth })).status).toBe(401)
|
||||
expect((await h.call('POST', '/v1/billing/checkout', { body: { plan: 'team' }, headers: auth })).status).toBe(401)
|
||||
})
|
||||
|
||||
it('rejects malformed or missing member counts instead of billing 0 seats', async () => {
|
||||
const { h } = await team(2)
|
||||
for (const body of [{ accountId: 'org:o1' }, { accountId: 'org:o1', members: -1 }, { accountId: 'org:o1', members: 'many' }, { accountId: 'user:u1', members: 3 }]) {
|
||||
const response = await h.call('POST', '/v1/internal/seats', { body, headers: { authorization: `Bearer ${MEMBERSHIP_TOKEN}` } })
|
||||
expect(response.status).toBe(400)
|
||||
}
|
||||
expect(seatUpdates(h)).toEqual([])
|
||||
})
|
||||
|
||||
it('does nothing without an open subscription', async () => {
|
||||
const h = await createHarness({ MEMBERSHIP_SYNC_TOKEN: MEMBERSHIP_TOKEN })
|
||||
const pushed = await h.call('POST', '/v1/internal/seats', {
|
||||
body: { accountId: 'org:nobody', members: 5 },
|
||||
headers: { authorization: `Bearer ${MEMBERSHIP_TOKEN}` }
|
||||
})
|
||||
expect(pushed.body).toEqual({ status: 'no_subscription' })
|
||||
expect(h.stripe.calls.filter((call) => call.method === 'POST')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('payment failure grace', () => {
|
||||
it('blocks new starts at once, suspends running after 4 days, resumes when paid', async () => {
|
||||
const { h, customer, subscription } = await team(2)
|
||||
const failedAt = Date.now()
|
||||
h.setNow(failedAt)
|
||||
subscription.status = 'past_due'
|
||||
await h.webhook(invoiceEvent('invoice.payment_failed', subscription.id, customer))
|
||||
const check = () =>
|
||||
h.controlPlane('/v1/internal/entitlements/check', { accountId: 'org:o1', machineSize: 'standard', runningSandboxes: 0 })
|
||||
const suspensions = () => h.call('GET', '/v1/internal/suspensions', { headers: { authorization: `Bearer ${CONTROL_TOKEN}` } })
|
||||
|
||||
expect((await check()).body).toMatchObject({ allow: false, reason: 'payment_past_due', suspendRunning: false })
|
||||
expect((await check()).body.entitlement.payment).toEqual({
|
||||
pastDueSince: failedAt,
|
||||
graceEndsAt: failedAt + 96 * HOUR * 1000,
|
||||
suspendRunning: false
|
||||
})
|
||||
expect((await suspensions()).body).toEqual({ accounts: [] })
|
||||
|
||||
// A retry failing again later must not restart the grace clock.
|
||||
h.setNow(failedAt + 50 * HOUR * 1000)
|
||||
await h.webhook(invoiceEvent('invoice.payment_failed', subscription.id, customer))
|
||||
h.setNow(failedAt + 97 * HOUR * 1000)
|
||||
expect((await check()).body).toMatchObject({ allow: false, reason: 'payment_grace_expired', suspendRunning: true })
|
||||
expect((await suspensions()).body.accounts).toEqual([
|
||||
{ accountId: 'org:o1', pastDueSince: failedAt, graceEndsAt: failedAt + 96 * HOUR * 1000 }
|
||||
])
|
||||
const usage = await h.controlPlane('/v1/internal/usage', {
|
||||
usageId: 'u-1',
|
||||
accountId: 'org:o1',
|
||||
sandboxId: 's',
|
||||
machineSize: 'standard',
|
||||
startedAt: failedAt + 96 * HOUR * 1000 - 60_000,
|
||||
endedAt: failedAt + 96 * HOUR * 1000
|
||||
})
|
||||
expect(usage.body).toMatchObject({ canStartSandbox: false, suspendRunning: true })
|
||||
|
||||
// Retries exhausted (unpaid) stays suspended; paying restores everything.
|
||||
subscription.status = 'unpaid'
|
||||
await h.webhook(stripeEvent('customer.subscription.updated', subscription))
|
||||
expect((await check()).body).toMatchObject({ reason: 'payment_grace_expired' })
|
||||
subscription.status = 'active'
|
||||
await h.webhook(invoiceEvent('invoice.paid', subscription.id, customer))
|
||||
expect((await check()).body).toMatchObject({ allow: true, suspendRunning: false })
|
||||
expect((await suspensions()).body).toEqual({ accounts: [] })
|
||||
})
|
||||
|
||||
it('takes the grace length from config', async () => {
|
||||
const h = await createHarness({ BILLING_PLANS: JSON.stringify({ team: { paymentGraceHours: 0 } }) })
|
||||
const owner = await h.desktopToken({ sub: 'u1', org: 'o1', orgRole: 'owner' })
|
||||
await h.call('POST', '/v1/billing/checkout', { body: { plan: 'team' }, headers: { authorization: `Bearer ${owner}` } })
|
||||
const customer = [...h.stripe.customers.keys()][0]!
|
||||
const subscription = h.stripe.putSubscription({
|
||||
customer,
|
||||
accountId: 'org:o1',
|
||||
basePrice: 'price_team_month',
|
||||
status: 'past_due',
|
||||
periodStart: PERIOD_START,
|
||||
periodEnd: PERIOD_END
|
||||
})
|
||||
await h.webhook(stripeEvent('customer.subscription.updated', subscription))
|
||||
const entitlement = await h.call('GET', '/v1/billing/entitlements', { headers: { authorization: `Bearer ${owner}` } })
|
||||
expect(entitlement.body).toMatchObject({ reason: 'payment_grace_expired', payment: { suspendRunning: true } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('per-member limits and attribution', () => {
|
||||
it('caps concurrent sandboxes per member so one seat cannot run the team', async () => {
|
||||
const { h } = await team(3)
|
||||
const check = (runningSandboxes: number, runningSandboxesForUser?: number) =>
|
||||
h.controlPlane('/v1/internal/entitlements/check', {
|
||||
accountId: 'org:o1',
|
||||
machineSize: 'standard',
|
||||
runningSandboxes,
|
||||
userId: 'u2',
|
||||
...(runningSandboxesForUser === undefined ? {} : { runningSandboxesForUser })
|
||||
})
|
||||
expect((await check(2, 2)).body).toMatchObject({ allow: true })
|
||||
expect((await check(3, 3)).body).toMatchObject({ allow: false, reason: 'member_concurrency_limit' })
|
||||
// The org-wide cap (3 per seat x 3 seats) still applies.
|
||||
expect((await check(9, 0)).body).toMatchObject({ allow: false, reason: 'concurrency_limit' })
|
||||
})
|
||||
|
||||
it('records which member used the hours', async () => {
|
||||
const { h } = await team(2)
|
||||
const endedAt = Date.now() - 60_000
|
||||
await h.controlPlane('/v1/internal/usage', {
|
||||
usageId: 'u-attr',
|
||||
accountId: 'org:o1',
|
||||
sandboxId: 's1',
|
||||
machineSize: 'standard',
|
||||
startedAt: endedAt - 3600_000,
|
||||
endedAt,
|
||||
userId: 'usr_alice'
|
||||
})
|
||||
expect(h.raw.prepare('SELECT user_id FROM usage_events WHERE usage_id = ?').get('u-attr')).toEqual({ user_id: 'usr_alice' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Env } from '../src/env'
|
||||
import { LiveModeRefusedError, assertKeyMode, createStripeClient, encodeForm } from '../src/stripe'
|
||||
|
||||
const env = (key: string, extra: Partial<Env> = {}) => ({ STRIPE_SECRET_KEY: key, ...extra }) as Env
|
||||
|
||||
describe('stripe client', () => {
|
||||
it('encodes nested params with Stripe bracket syntax and drops undefined', () => {
|
||||
expect(
|
||||
decodeURIComponent(
|
||||
encodeForm({ mode: 'subscription', line_items: [{ price: 'p', quantity: 2 }, { price: 'o' }], metadata: { a: 'b' }, x: undefined })
|
||||
)
|
||||
).toBe('mode=subscription&line_items[0][price]=p&line_items[0][quantity]=2&line_items[1][price]=o&metadata[a]=b')
|
||||
})
|
||||
|
||||
it('refuses live-mode keys unless live mode is explicitly enabled', () => {
|
||||
expect(() => assertKeyMode(env('sk_live_x'))).toThrow(LiveModeRefusedError)
|
||||
expect(() => assertKeyMode(env('rk_live_x'))).toThrow(LiveModeRefusedError)
|
||||
expect(() => assertKeyMode(env('rk_live_x', { STRIPE_ALLOW_LIVEMODE: '1' }))).toThrow(LiveModeRefusedError)
|
||||
expect(() => assertKeyMode(env('rk_live_x', { STRIPE_ALLOW_LIVEMODE: 'true' }))).not.toThrow()
|
||||
expect(() => assertKeyMode(env('rk_test_x'))).not.toThrow()
|
||||
expect(() => assertKeyMode(env(''))).toThrow('stripe_key_missing')
|
||||
})
|
||||
|
||||
it('never sends a request with a live key', async () => {
|
||||
let called = false
|
||||
const client = createStripeClient(env('sk_live_x'), async () => {
|
||||
called = true
|
||||
return Response.json({})
|
||||
})
|
||||
await expect(client.retrieveSubscription('sub_1')).rejects.toThrow(LiveModeRefusedError)
|
||||
expect(called).toBe(false)
|
||||
})
|
||||
|
||||
it('pins the API version, sends an idempotency key and refuses redirects', async () => {
|
||||
let seen: RequestInit | undefined
|
||||
const client = createStripeClient(env('rk_test_x'), async (_url, init) => {
|
||||
seen = init
|
||||
return Response.json({ id: 'cus_1' })
|
||||
})
|
||||
await client.createCustomer({ accountId: 'org:o1' })
|
||||
const headers = new Headers(seen?.headers)
|
||||
expect(headers.get('stripe-version')).toBe('2025-09-30.clover')
|
||||
expect(headers.get('idempotency-key')).toBe('customer-create:org:o1')
|
||||
expect(seen?.redirect).toBe('manual')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,192 @@
|
||||
// In-memory Stripe API behind a fetch function, plus event fixtures shaped like Stripe's
|
||||
// (API version 2025-09-30.clover: periods on subscription items, invoice.parent.subscription_details).
|
||||
// The real client in src/stripe.ts talks to it, so form encoding and headers are exercised too.
|
||||
import { createHmac } from 'node:crypto'
|
||||
|
||||
export type FakeSubscription = {
|
||||
id: string
|
||||
object: 'subscription'
|
||||
customer: string
|
||||
status: string
|
||||
cancel_at_period_end: boolean
|
||||
livemode: boolean
|
||||
metadata: Record<string, string>
|
||||
items: { object: 'list'; data: Array<{ id: string; quantity?: number; current_period_start: number; current_period_end: number; price: { id: string } }> }
|
||||
}
|
||||
|
||||
export type RecordedCall = { method: string; path: string; form: URLSearchParams; headers: Headers }
|
||||
|
||||
export function createFakeStripe() {
|
||||
const calls: RecordedCall[] = []
|
||||
const customers = new Map<string, { id: string; metadata: Record<string, string> }>()
|
||||
const subscriptions = new Map<string, FakeSubscription>()
|
||||
const idempotent = new Map<string, unknown>()
|
||||
const meterEvents: Array<Record<string, string>> = []
|
||||
let nextId = 1
|
||||
let failMeterEvents = 0
|
||||
|
||||
const fetchImpl: typeof fetch = async (input, init) => {
|
||||
const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url)
|
||||
const headers = new Headers(init?.headers)
|
||||
const form = new URLSearchParams(typeof init?.body === 'string' ? init.body : '')
|
||||
const method = init?.method ?? 'GET'
|
||||
calls.push({ method, path: url.pathname, form, headers })
|
||||
if (!headers.get('authorization')?.startsWith('Bearer rk_test_')) {
|
||||
return Response.json({ error: { message: 'Invalid API Key provided' } }, { status: 401 })
|
||||
}
|
||||
const key = headers.get('idempotency-key')
|
||||
if (method === 'POST' && key && idempotent.has(key)) {
|
||||
return Response.json(idempotent.get(key))
|
||||
}
|
||||
const respond = (body: unknown) => {
|
||||
if (method === 'POST' && key) {
|
||||
idempotent.set(key, body)
|
||||
}
|
||||
return Response.json(body)
|
||||
}
|
||||
if (method === 'POST' && url.pathname === '/v1/customers') {
|
||||
const id = `cus_test_${nextId++}`
|
||||
const customer = { id, metadata: { account_id: form.get('metadata[account_id]') ?? '' } }
|
||||
customers.set(id, customer)
|
||||
return respond(customer)
|
||||
}
|
||||
if (method === 'POST' && url.pathname === '/v1/checkout/sessions') {
|
||||
const id = `cs_test_${nextId++}`
|
||||
return respond({ id, object: 'checkout.session', url: `https://checkout.stripe.com/c/pay/${id}` })
|
||||
}
|
||||
if (method === 'POST' && url.pathname === '/v1/billing_portal/sessions') {
|
||||
const id = `bps_test_${nextId++}`
|
||||
return respond({ id, url: `https://billing.stripe.com/p/session/${id}` })
|
||||
}
|
||||
if (method === 'GET' && url.pathname.startsWith('/v1/subscriptions/')) {
|
||||
const subscription = subscriptions.get(decodeURIComponent(url.pathname.slice('/v1/subscriptions/'.length)))
|
||||
return subscription
|
||||
? Response.json(subscription)
|
||||
: Response.json({ error: { code: 'resource_missing', message: 'No such subscription' } }, { status: 404 })
|
||||
}
|
||||
if (method === 'POST' && url.pathname.startsWith('/v1/subscription_items/')) {
|
||||
const itemId = decodeURIComponent(url.pathname.slice('/v1/subscription_items/'.length))
|
||||
for (const subscription of subscriptions.values()) {
|
||||
const item = subscription.items.data.find((entry) => entry.id === itemId)
|
||||
if (item) {
|
||||
item.quantity = Number(form.get('quantity'))
|
||||
return respond({ id: item.id, quantity: item.quantity })
|
||||
}
|
||||
}
|
||||
return Response.json({ error: { code: 'resource_missing', message: 'No such item' } }, { status: 404 })
|
||||
}
|
||||
if (method === 'POST' && url.pathname === '/v1/subscription_items') {
|
||||
const subscription = subscriptions.get(form.get('subscription') ?? '')
|
||||
if (!subscription) {
|
||||
return Response.json({ error: { code: 'resource_missing', message: 'No such subscription' } }, { status: 404 })
|
||||
}
|
||||
const base = subscription.items.data[0]!
|
||||
const item = {
|
||||
id: `si_test_${nextId++}`,
|
||||
price: { id: form.get('price') ?? '' },
|
||||
current_period_start: base.current_period_start,
|
||||
current_period_end: base.current_period_end
|
||||
}
|
||||
subscription.items.data.push(item)
|
||||
return respond(item)
|
||||
}
|
||||
if (method === 'POST' && url.pathname === '/v1/billing/meter_events') {
|
||||
if (failMeterEvents > 0) {
|
||||
failMeterEvents--
|
||||
return Response.json({ error: { message: 'temporarily unavailable' } }, { status: 503 })
|
||||
}
|
||||
meterEvents.push(Object.fromEntries(form))
|
||||
return respond({ object: 'billing.meter_event', identifier: form.get('identifier') })
|
||||
}
|
||||
return Response.json({ error: { message: `unhandled ${method} ${url.pathname}` } }, { status: 404 })
|
||||
}
|
||||
|
||||
return {
|
||||
fetchImpl,
|
||||
calls,
|
||||
customers,
|
||||
subscriptions,
|
||||
meterEvents,
|
||||
failNextMeterEvents(count: number) {
|
||||
failMeterEvents = count
|
||||
},
|
||||
/** What Stripe holds after Checkout completes for `customer` (the test plays the browser). */
|
||||
putSubscription(args: {
|
||||
customer: string
|
||||
accountId: string
|
||||
basePrice: string
|
||||
overagePrice?: string
|
||||
quantity?: number
|
||||
status?: string
|
||||
periodStart: number
|
||||
periodEnd: number
|
||||
livemode?: boolean
|
||||
}): FakeSubscription {
|
||||
const id = `sub_test_${nextId++}`
|
||||
const period = { current_period_start: args.periodStart, current_period_end: args.periodEnd }
|
||||
const data: FakeSubscription['items']['data'] = [
|
||||
{ id: `si_test_${nextId++}`, quantity: args.quantity ?? 1, price: { id: args.basePrice }, ...period }
|
||||
]
|
||||
if (args.overagePrice) {
|
||||
data.push({ id: `si_test_${nextId++}`, price: { id: args.overagePrice }, ...period })
|
||||
}
|
||||
const subscription: FakeSubscription = {
|
||||
id,
|
||||
object: 'subscription',
|
||||
customer: args.customer,
|
||||
status: args.status ?? 'active',
|
||||
cancel_at_period_end: false,
|
||||
livemode: args.livemode ?? false,
|
||||
metadata: { account_id: args.accountId },
|
||||
items: { object: 'list', data }
|
||||
}
|
||||
subscriptions.set(id, subscription)
|
||||
return subscription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let eventCounter = 1
|
||||
|
||||
export function stripeEvent(type: string, object: Record<string, unknown>, options: { livemode?: boolean; id?: string } = {}) {
|
||||
return {
|
||||
id: options.id ?? `evt_test_${eventCounter++}`,
|
||||
object: 'event',
|
||||
api_version: '2025-09-30.clover',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
livemode: options.livemode ?? false,
|
||||
pending_webhooks: 1,
|
||||
request: { id: null, idempotency_key: null },
|
||||
type,
|
||||
data: { object }
|
||||
}
|
||||
}
|
||||
|
||||
export function checkoutCompleted(args: { customer: string; subscription: string; accountId: string }) {
|
||||
return stripeEvent('checkout.session.completed', {
|
||||
id: `cs_test_${eventCounter++}`,
|
||||
object: 'checkout.session',
|
||||
mode: 'subscription',
|
||||
status: 'complete',
|
||||
payment_status: 'paid',
|
||||
customer: args.customer,
|
||||
subscription: args.subscription,
|
||||
client_reference_id: args.accountId,
|
||||
metadata: { account_id: args.accountId }
|
||||
})
|
||||
}
|
||||
|
||||
export function invoiceEvent(type: 'invoice.paid' | 'invoice.payment_failed', subscription: string, customer: string) {
|
||||
return stripeEvent(type, {
|
||||
id: `in_test_${eventCounter++}`,
|
||||
object: 'invoice',
|
||||
customer,
|
||||
parent: { type: 'subscription_details', subscription_details: { subscription } }
|
||||
})
|
||||
}
|
||||
|
||||
/** Signs exactly like Stripe (and stripe-node's generateTestHeaderString), via node:crypto. */
|
||||
export function signStripePayload(payload: string, secret: string, timestampSeconds = Math.floor(Date.now() / 1000)): string {
|
||||
const signature = createHmac('sha256', secret).update(`${timestampSeconds}.${payload}`, 'utf8').digest('hex')
|
||||
return `t=${timestampSeconds},v1=${signature}`
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { constantTimeEqual, splitSecrets, verifyStripeSignature } from '../src/webhook-signature'
|
||||
import { signStripePayload } from './stripe-fake'
|
||||
|
||||
const SECRET = 'whsec_test_signature'
|
||||
const BODY = '{"id":"evt_test_1","object":"event"}'
|
||||
const NOW = 1_762_000_000_000
|
||||
const T = NOW / 1000
|
||||
|
||||
describe('verifyStripeSignature', () => {
|
||||
it('accepts a header signed the way Stripe signs', async () => {
|
||||
expect(await verifyStripeSignature(BODY, signStripePayload(BODY, SECRET, T), [SECRET], NOW)).toEqual({ ok: true })
|
||||
})
|
||||
|
||||
it('accepts any of several v1 signatures and either secret during rotation', async () => {
|
||||
const good = signStripePayload(BODY, 'whsec_new', T).split(',')[1]
|
||||
const header = `t=${T},v1=${'0'.repeat(64)},${good},v0=ignored`
|
||||
expect(await verifyStripeSignature(BODY, header, ['whsec_old', 'whsec_new'], NOW)).toEqual({ ok: true })
|
||||
})
|
||||
|
||||
it('rejects a tampered body, a wrong secret and a replayed old timestamp', async () => {
|
||||
const header = signStripePayload(BODY, SECRET, T)
|
||||
expect(await verifyStripeSignature(`${BODY} `, header, [SECRET], NOW)).toEqual({ ok: false, reason: 'mismatch' })
|
||||
expect(await verifyStripeSignature(BODY, header, ['whsec_other'], NOW)).toEqual({ ok: false, reason: 'mismatch' })
|
||||
expect(await verifyStripeSignature(BODY, header, [SECRET], NOW + 301_000)).toEqual({ ok: false, reason: 'stale' })
|
||||
})
|
||||
|
||||
it('rejects missing or malformed headers and an empty secret list', async () => {
|
||||
expect(await verifyStripeSignature(BODY, null, [SECRET], NOW)).toEqual({ ok: false, reason: 'malformed' })
|
||||
expect(await verifyStripeSignature(BODY, 't=abc,v1=zz', [SECRET], NOW)).toEqual({ ok: false, reason: 'malformed' })
|
||||
expect(await verifyStripeSignature(BODY, signStripePayload(BODY, SECRET, T), [], NOW)).toEqual({
|
||||
ok: false,
|
||||
reason: 'malformed'
|
||||
})
|
||||
})
|
||||
|
||||
it('splits rotation secrets and compares in constant time', () => {
|
||||
expect(splitSecrets(' whsec_a , ,whsec_b')).toEqual(['whsec_a', 'whsec_b'])
|
||||
expect(constantTimeEqual('abc', 'abc')).toBe(true)
|
||||
expect(constantTimeEqual('abc', 'abd')).toBe(false)
|
||||
expect(constantTimeEqual('abc', 'abcd')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2022",
|
||||
"module": "es2022",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["es2022"],
|
||||
"types": ["@cloudflare/workers-types", "@types/node"],
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src", "test", "scripts"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
// A string tsconfig skips the lookup, which otherwise climbs to the desktop app's tsconfig.
|
||||
oxc: { tsconfig: false },
|
||||
test: { include: ['test/**/*.test.ts'] }
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
// DEV deployment only (workers.dev, no custom domain, Stripe TEST MODE). Deploy:
|
||||
// wrangler d1 migrations apply BILLING_DB --remote -c wrangler.dev.jsonc
|
||||
// wrangler deploy -c wrangler.dev.jsonc
|
||||
// Secrets (wrangler secret put <NAME> -c wrangler.dev.jsonc, never here): STRIPE_SECRET_KEY (sk_test_/rk_test_),
|
||||
// STRIPE_WEBHOOK_SECRET (the test-mode endpoint's whsec_), CONTROL_PLANE_TOKEN (shared with
|
||||
// orca-sandbox-control-dev), BILLING_DEV_JWKS (operator key set for e2e until auth mints billing tokens).
|
||||
"name": "orca-billing-dev",
|
||||
"main": "src/index.ts",
|
||||
"compatibility_date": "2026-11-01",
|
||||
"workers_dev": true,
|
||||
"d1_databases": [
|
||||
{
|
||||
"binding": "BILLING_DB",
|
||||
"database_name": "orca-billing-dev",
|
||||
"database_id": "12aa0820-642a-4174-8504-f42d32043959",
|
||||
"migrations_dir": "migrations"
|
||||
}
|
||||
],
|
||||
// Retries Stripe meter reports that failed inline.
|
||||
"triggers": { "crons": ["*/10 * * * *"] },
|
||||
"vars": {
|
||||
"BILLING_ENV": "dev",
|
||||
"STRIPE_API_VERSION": "2025-09-30.clover",
|
||||
"STRIPE_METER_EVENT_NAME": "orca_sandbox_seconds",
|
||||
// Stripe TEST MODE objects in acct_1Or7xeKb73TzRkjk (scripts/stripe-test-setup.ts).
|
||||
"STRIPE_PRICE_TEAM_MONTHLY": "price_1UZmKpKb73TzRkjk33aiB2th",
|
||||
"STRIPE_PRICE_TEAM_ANNUAL": "price_1UZmKpKb73TzRkjkS7qv85NI",
|
||||
"STRIPE_PRICE_OVERAGE": "price_1UZmKvKb73TzRkjkycTvfq0o",
|
||||
"STRIPE_PORTAL_CONFIGURATION": "bpc_1UZmKvKb73TzRkjk59rIuHbx",
|
||||
"BILLING_RETURN_URL": "https://orca-billing-dev.lovecast.workers.dev/return",
|
||||
"BILLING_JWT_AUDIENCE": "orca-billing",
|
||||
"BILLING_JWKS_URL": "https://login.onorca.dev/.well-known/jwks.json",
|
||||
"BILLING_AUTH_ISSUER": "https://login.onorca.dev"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
// Local `wrangler dev` only. No routes and workers.dev is off, so a deploy from this file
|
||||
// publishes nothing reachable. Deployed environments live in the private deploy config.
|
||||
// Secrets (stdin only, never here): STRIPE_SECRET_KEY (rk_test_...), STRIPE_WEBHOOK_SECRET,
|
||||
// CONTROL_PLANE_TOKEN. For local dev put them in an untracked .dev.vars.
|
||||
"name": "orca-billing-local",
|
||||
"main": "src/index.ts",
|
||||
"compatibility_date": "2026-11-01",
|
||||
"workers_dev": false,
|
||||
"d1_databases": [
|
||||
{
|
||||
"binding": "BILLING_DB",
|
||||
"database_name": "orca-billing-local",
|
||||
"database_id": "00000000-0000-0000-0000-000000000000",
|
||||
"migrations_dir": "migrations"
|
||||
}
|
||||
],
|
||||
// Retries Stripe meter reports that failed inline.
|
||||
"triggers": { "crons": ["*/10 * * * *"] },
|
||||
"vars": {
|
||||
"STRIPE_API_VERSION": "2025-09-30.clover",
|
||||
"STRIPE_METER_EVENT_NAME": "orca_sandbox_seconds",
|
||||
"STRIPE_PRICE_TEAM_MONTHLY": "price_local_team_month",
|
||||
"STRIPE_PRICE_TEAM_ANNUAL": "price_local_team_year",
|
||||
"STRIPE_PRICE_OVERAGE": "price_local_overage",
|
||||
"BILLING_RETURN_URL": "http://127.0.0.1:8790/return",
|
||||
"BILLING_JWT_AUDIENCE": "orca-billing"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
# Orca accounts + billing — design (v1)
|
||||
|
||||
Status: built in `nwparker/orca-private` (base branch `nwparker/accounts-billing`). Stripe TEST MODE only.
|
||||
Code: `cloud/apps/billing` (Worker), desktop `src/main/billing`, `src/renderer/src/components/billing`.
|
||||
|
||||
## Pricing (final, all config)
|
||||
|
||||
| Plan | Price | Includes |
|
||||
|---|---|---|
|
||||
| Team | $50/seat/mo or $480/seat/yr | 60 Standard (4 vCPU / 8 GB) sandbox hours per seat per month, pooled across the org; Large (8/16) burns 2x; 3 running sandboxes per seat; 15-min idle sleep |
|
||||
| Overage | opt-in, off by default | $0.75/h Standard ($1.50/h Large); monthly spend cap, default $25, admins can raise (max $5,000); warnings at 80% / 100% of included hours |
|
||||
| Enterprise | Custom | "Contact us" (no Stripe price) |
|
||||
|
||||
No free plan, no trial, no other tiers. Numbers and labels live in `src/plans.ts` defaults and can be overridden
|
||||
without a code change by `BILLING_PLANS` (JSON, validated; malformed config fails closed with 503). Stripe price
|
||||
ids are Worker vars. Annual plans still get a **monthly** allowance window (a calendar-month slice of the year,
|
||||
anchored on the subscription start). Stripe Tax: `STRIPE_AUTOMATIC_TAX=true` adds `automatic_tax` +
|
||||
`tax_id_collection` to Checkout; default off (activate Stripe Tax with the California origin address, SaaS tax code
|
||||
and registrations first).
|
||||
|
||||
## Identity — reuse Orca Cloud sign-in
|
||||
|
||||
- Desktop PKCE login against `login.onorca.dev` already mints audience-scoped short-lived ES256 JWTs for other
|
||||
services (relay, connector). Billing uses the same: `POST /v1/desktop/auth/billing-token` (**to add in the auth
|
||||
service**) returns a ≤15-min JWT, `aud=orca-billing`, `purpose=billing`, `sub`, `org?`, `orgRole?`, `email?`.
|
||||
- Billing account = `org:<orgId>`, else `user:<userId>`. Only owners/admins buy, manage billing, or change overage.
|
||||
- Users/orgs/membership/invites stay owned by auth (provider roster: PropelAuth). Billing stores only account ids.
|
||||
- **Live role:** the billing-token route re-reads the caller's role from the live provider roster
|
||||
(stablyai/orca-cloud#690; before it, the role came from a mirror that rebuilds only at sign-in). Billing also
|
||||
requires checkout/portal/overage to use a token minted in the last 6 minutes, so a demotion or removal stops
|
||||
billing actions within that window.
|
||||
|
||||
## Team seats follow membership
|
||||
|
||||
- **Source of truth:** auth's provider roster. Seats = members; pending invites never count; never fewer than 1.
|
||||
- **Pull:** `POST {AUTH_INTERNAL_URL}/v1/internal/billing/org-members {orgId}` → `{orgId, members}` (shared secret
|
||||
`MEMBERSHIP_SYNC_TOKEN`). Billing pulls at Checkout (the quantity ignores the desktop's request) and on the
|
||||
10-minute cron (`reconcileSeats`), which also picks up invites accepted at the provider.
|
||||
- **Push:** auth calls `POST /v1/internal/seats {accountId, members}` after Orca-side removals (best effort).
|
||||
- **Fail closed:** if auth is configured but unreachable, Checkout answers 503 `membership_unavailable`; malformed or
|
||||
missing counts are 400; nothing ever bills 0 seats. No token claim carries a count (tokens can be stale).
|
||||
- **Proration:** see "Seat fairness" below (peak seats; adds charged now, removals at renewal).
|
||||
- Desktop: Settings → Orca account → Team (also "Team" in the account menu): members with roles, invite by email
|
||||
(accepted at the sign-in provider), change role, remove (with confirm), revoke invites; owners/admins only,
|
||||
mirroring auth's role rules.
|
||||
|
||||
## Seat fairness and abuse resistance (decision)
|
||||
|
||||
How others bill seats: GitHub charges added seats immediately, prorated, and applies removals at the
|
||||
next billing cycle with no refund (docs: "Impact of changing your plan on billing"). Cursor charges
|
||||
adds prorated but treats remove-then-re-add as a brand-new seat, which customers report as
|
||||
double-charging. Slack's Fair Billing Policy credits inactive members automatically. Linear charges
|
||||
everyone not suspended at renewal and prorates later additions.
|
||||
|
||||
**Decision: seats billed = the period's peak membership** (GitHub model plus churn protection):
|
||||
- **Adds** above the seats already paid this period are invoiced and charged now (`always_invoice`).
|
||||
- **Removals** never credit mid-period. Seats drop to the member count only at renewal: within
|
||||
`seatTrueDownHours` (6 h, config) of the period end, billing lowers the quantity with
|
||||
`proration_behavior: none` (cron every 10 min, or the next membership push), so the renewal
|
||||
invoice bills the smaller team. If the cron misses the window, the org renews at the peak and is
|
||||
corrected at the following renewal (errs toward charging what was used).
|
||||
- **Re-adding** someone removed earlier in the period is free. Membership up to the peak is already
|
||||
paid, so remove/re-add churn can neither dodge charges nor double-charge honest teams.
|
||||
- **Included hours and caps derive from billed seats**, not raw membership. Adding members never
|
||||
grants hours or concurrency the org hasn't paid for, and the paid peak keeps its hours until renewal.
|
||||
- **Per-member concurrency:** at most 3 running sandboxes per member (`member_concurrency_limit`), on
|
||||
top of the org cap of 3 × billed seats. One seat can't run the whole team's sandboxes.
|
||||
- **Attribution:** usage segments carry the `userId` that started the sandbox (`usage_events.user_id`)
|
||||
for per-member caps, reporting and abuse review.
|
||||
- **Membership changes are rate-limited** in auth: 10 per actor and 30 per org per minute
|
||||
(stablyai/orca-cloud#692). **The last owner is protected:** owners can't remove or demote
|
||||
themselves and admins can't act on owners, so an org always keeps an owner (pinned by tests there).
|
||||
- **Org switching can't double-dip:** hours belong to the org account the sandbox was started under
|
||||
(the control plane's token names it). A person in two orgs is a seat in both and each pays, and a
|
||||
personal account is its own one-seat plan.
|
||||
- **No free trial or free plan**, and Checkout always collects a card, so throwaway orgs get nothing
|
||||
for free.
|
||||
- **Seat counts never come from the client:** Checkout and reconcile read auth's live count
|
||||
(pull-only in production for now, stablyai/orca-cloud#691). Missing or malformed counts are
|
||||
rejected, and seats are never below 1.
|
||||
|
||||
## Payment failure
|
||||
|
||||
- On `invoice.payment_failed` (status `past_due`, later `unpaid` when retries are exhausted) billing records
|
||||
`past_due_since` (first time seen; a later failed retry does not restart it).
|
||||
- New sandbox starts stop immediately (`payment_past_due`). After `paymentGraceHours` (96 h = 4 days, config)
|
||||
running sandboxes must be suspended: reason `payment_grace_expired`, `suspendRunning: true` on
|
||||
`/v1/internal/entitlements/check` and `/v1/internal/usage`, and `GET /v1/internal/suspensions` lists every
|
||||
account past its grace (for a control-plane cron). Once the invoice is paid the status returns to `active`,
|
||||
`past_due_since` clears, and starts/resume are allowed again.
|
||||
- Desktop shows a banner with the suspension date and an "Update payment method" button (Portal).
|
||||
- Stripe sends the dunning emails and retries (Smart Retries) — dashboard settings, see "What the user sets".
|
||||
|
||||
## Why Cloudflare Worker + D1
|
||||
|
||||
Extends the existing Workers setup (relay-edge); a separate D1 keeps the Stripe key and billing ledger isolated
|
||||
from auth/relay data (least privilege); load is small; Cron Trigger retries the meter outbox. A Durable Object per
|
||||
account is the upgrade path for strict per-account start locking.
|
||||
|
||||
## Data model (D1 migrations 0001, 0002)
|
||||
|
||||
| Table | Purpose |
|
||||
|---|---|
|
||||
| `billing_accounts` | account → Stripe customer; `overage_opt_in`, `spend_cap_cents` |
|
||||
| `subscriptions` | Stripe mirror: status, seats, interval, period, overage item id |
|
||||
| `stripe_events` | webhook idempotency |
|
||||
| `usage_events` | usage segments keyed by caller `usageId`; weighted seconds; allowance-window start; overage fixed at insert |
|
||||
| `meter_reports` | outbox of billable overage → Stripe meter events, capped at the spend cap in SQL |
|
||||
|
||||
## Stripe integration
|
||||
|
||||
- **Checkout** (`POST /v1/billing/checkout {plan:'team', interval, seats}`): hosted, `mode=subscription`, seat price
|
||||
(quantity adjustable on the page), `payment_method_collection=always`, `subscription_data.metadata.account_id`.
|
||||
One customer per account. 409 if a subscription is open (changes go through the Portal).
|
||||
- **Customer Portal**: configuration `STRIPE_PORTAL_CONFIGURATION` allows seat quantity changes, monthly↔annual
|
||||
switch (prorated), cancel at period end, payment method, invoices.
|
||||
- **Overage opt-in** (`POST /v1/billing/overage {enabled, spendCapCents?}`): first opt-in adds the monthly metered
|
||||
overage price to the subscription (idempotent); opting out stops metering immediately (the item stays so
|
||||
already-reported usage still invoices). Annual subscriptions accept the monthly metered item (verified).
|
||||
- **Webhooks**: HMAC-SHA256 over the raw body (5-min tolerance, rotation), event-id dedupe, then re-read the
|
||||
subscription from the API (order/staleness-proof). Handles `checkout.session.completed`, `customer.subscription.*`,
|
||||
`invoice.paid`, `invoice.payment_failed` (old and new payload shapes). Credited only to the customer billing created
|
||||
for that account. Live-mode events are acked and ignored. Failures → 500 so Stripe retries.
|
||||
- **Meter**: `orca_sandbox_seconds` (sum by customer); overage price $0.75/h via `transform_quantity.divide_by=3600`.
|
||||
Only billable overage is sent, at most the remaining spend cap; `identifier=usageId` + Idempotency-Key.
|
||||
|
||||
## Entitlements
|
||||
|
||||
- Desktop `GET /v1/billing/entitlements`: plan, status, interval, seats, period, monthly usage window,
|
||||
included/used/remaining, `usageWarningPercent`, overage {optedIn, active, cap, spent, rate}, limits,
|
||||
`canStartSandbox`, `reason`, `canManageBilling`, and `offers` (Team + Enterprise, for the plan cards).
|
||||
- Control plane (bearer `CONTROL_PLANE_TOKEN`):
|
||||
- `POST /v1/internal/entitlements/check {accountId, machineSize: standard|large, runningSandboxes}` → `{allow, reason}`.
|
||||
- `POST /v1/internal/usage {usageId, accountId, sandboxId, machineSize, startedAt, endedAt}` (segments ≤24 h).
|
||||
- `GET /v1/internal/suspensions` → `{accounts: [{accountId, pastDueSince, graceEndsAt}]}`.
|
||||
- Reasons: `no_subscription | subscription_inactive | payment_past_due | payment_grace_expired |
|
||||
included_hours_exhausted | overage_cap_reached | machine_size_not_in_plan | concurrency_limit`.
|
||||
Stop running sandboxes when `suspendRunning` is true (and for the reasons the control plane already stops on).
|
||||
|
||||
## Security
|
||||
|
||||
No card data touches us (hosted pages; desktop opens only `checkout.stripe.com` / `billing.stripe.com`, and an https
|
||||
Enterprise contact URL). Secrets only via `wrangler secret put` / untracked `.dev.vars`. Worker refuses live keys and
|
||||
live events unless `STRIPE_ALLOW_LIVEMODE=true`. Pinned `Stripe-Version`; strict zod bodies; size limits; redirects
|
||||
never followed with the key; JWT max age 15 min.
|
||||
|
||||
## Tests
|
||||
|
||||
- Unit/flow: `cd cloud && pnpm --filter @orca-cloud/billing test` (D1 fake over node:sqlite with real migrations,
|
||||
in-memory Stripe behind fetch, Stripe-shaped fixtures, Stripe-identical signatures).
|
||||
- Live TEST-mode e2e: `scripts/stripe-test-setup.ts` (catalog + portal) and `scripts/e2e-test-mode.ts run` against
|
||||
`wrangler dev` + `stripe listen --project-name orca`: Checkout with the test card for 2 seats, overage opt-in,
|
||||
usage past included hours → meter events (checked against Stripe's meter summaries), spend cap blocks starts,
|
||||
annual + overage, seat add/remove proration, test-clock renewal, `invoice.payment_failed` → past_due → grace
|
||||
expiry → suspend → pay → resume (36 s grace via `BILLING_PLANS` locally), cancel. 21/21 passed locally.
|
||||
|
||||
## Dev deployment (TEST MODE, for the sandbox control plane)
|
||||
|
||||
- **Billing dev URL:** `https://orca-billing-dev.lovecast.workers.dev` (Cloudflare account "Stably",
|
||||
`wrangler.dev.jsonc`, `BILLING_ENV=dev`). Control plane config: `BILLING_URL=https://orca-billing-dev.lovecast.workers.dev`,
|
||||
`CONTROL_PLANE_TOKEN` = the same secret set on both Workers, and send `machineSize` `standard|large` only.
|
||||
- D1 `orca-billing-dev` (`12aa0820-642a-4174-8504-f42d32043959`), migrations 0001–0002 applied.
|
||||
- Stripe test webhook endpoint `we_1UZmcXKb73TzRkjkJMKg0jso` → `/v1/stripe/webhook` (API `2025-09-30.clover`).
|
||||
- Secrets (stdin only): `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `CONTROL_PLANE_TOKEN`, `BILLING_DEV_JWKS`.
|
||||
- Desktop auth: JWKS `https://login.onorca.dev/.well-known/jwks.json`, issuer `https://login.onorca.dev`. Until auth mints
|
||||
billing tokens, dev also accepts the operator key set `BILLING_DEV_JWKS` (issuer `orca-billing-dev-test`; refused
|
||||
when `BILLING_ENV=prod`).
|
||||
- Same e2e run against dev: 18/18 passed (grace expiry is exercised locally; dev keeps the 96 h grace).
|
||||
|
||||
## What the user sets
|
||||
|
||||
| Where | Name | Value |
|
||||
|---|---|---|
|
||||
| billing Worker (dev/prod) secret | `MEMBERSHIP_SYNC_TOKEN` | random ≥32 chars; same value as auth's `ORCA_CLOUD_BILLING_MEMBERSHIP_TOKEN` |
|
||||
| billing Worker var | `AUTH_INTERNAL_URL` | auth origin, e.g. `https://login.onorca.dev` (after orca-cloud#690 deploys) |
|
||||
| auth (Cloud Run) secret | `ORCA_CLOUD_BILLING_MEMBERSHIP_TOKEN` | same value as above |
|
||||
| auth (Cloud Run) env | `ORCA_CLOUD_BILLING_INTERNAL_URL` | unset for now (pull-only, orca-cloud#691); `https://billing.onorca.dev` once production billing exists |
|
||||
| sandbox-control-dev secret | `CONTROL_PLANE_TOKEN` | same value as billing dev's `CONTROL_PLANE_TOKEN` |
|
||||
| sandbox-control-dev var | `BILLING_URL` | `https://orca-billing-dev.lovecast.workers.dev` (and drop `ENTITLEMENTS_MODE=stub`) |
|
||||
| Stripe dashboard (test, then live) | Billing → Revenue recovery | Smart Retries on; "Send emails when card payments fail" on; after retries → mark subscription unpaid |
|
||||
|
||||
## Open decisions
|
||||
|
||||
1. Real Enterprise contact URL (placeholder `https://onorca.dev/contact-sales`). 2. When to enable Stripe Tax.
|
||||
Generated
+25
@@ -185,6 +185,31 @@ importers:
|
||||
specifier: ^5.0.5
|
||||
version: 5.0.5(@types/node@24.19.2)(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(tsx@4.23.15))
|
||||
|
||||
apps/billing:
|
||||
dependencies:
|
||||
jose:
|
||||
specifier: ^6.2.13
|
||||
version: 6.2.13
|
||||
zod:
|
||||
specifier: ^4.6.7
|
||||
version: 4.6.7
|
||||
devDependencies:
|
||||
'@cloudflare/workers-types':
|
||||
specifier: ^5.20261103.1
|
||||
version: 5.20261104.1
|
||||
'@types/node':
|
||||
specifier: ^24.19.2
|
||||
version: 24.19.2
|
||||
typescript:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^5.0.5
|
||||
version: 5.0.5(@types/node@24.19.2)(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(tsx@4.23.15))
|
||||
wrangler:
|
||||
specifier: ^4.157.0
|
||||
version: 4.157.0(@cloudflare/workers-types@5.20261104.1)(@types/node@24.19.2)
|
||||
|
||||
apps/push:
|
||||
dependencies:
|
||||
'@hono/node-server':
|
||||
|
||||
@@ -100,8 +100,8 @@
|
||||
1 src/renderer/src/components/sidebar/SetupGuideSidebarEntry.tsx
|
||||
1 src/renderer/src/components/sidebar/SetupScriptPromptCard.tsx
|
||||
1 src/renderer/src/components/sidebar/SidebarAgentsList.tsx
|
||||
1 src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx
|
||||
1 src/renderer/src/components/sidebar/SidebarTaskNavButton.tsx
|
||||
1 src/renderer/src/components/sidebar/account-menu/SidebarAccountMenu.tsx
|
||||
1 src/renderer/src/components/sidebar/linear-agent-skill-runtime.ts
|
||||
1 src/renderer/src/components/sidebar/project-admission/drop/sidebar-project-drop.ts
|
||||
2 src/renderer/src/components/sidebar/project-admission/drop/useSidebarProjectDrop.ts
|
||||
|
||||
@@ -55,6 +55,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',
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { OrcaCloudRequestError } from '../orca-profiles/profile-cloud-client'
|
||||
import {
|
||||
BillingServiceError,
|
||||
assertStripeHostedUrl,
|
||||
createCheckoutUrl,
|
||||
createPortalUrl,
|
||||
fetchEntitlement,
|
||||
mintBillingToken
|
||||
} from './billing-client'
|
||||
|
||||
const endpoints = {
|
||||
billingApiUrl: 'https://billing.example.test',
|
||||
billingTokenEndpoint: 'https://login.example.test/v1/desktop/auth/billing-token'
|
||||
}
|
||||
|
||||
type Seen = { url: string; init: RequestInit | undefined }
|
||||
|
||||
function fakeFetch(responses: Record<string, Response>, seen: Seen[] = []): typeof fetch {
|
||||
return async (input, init) => {
|
||||
const url = String(input)
|
||||
seen.push({ url, init })
|
||||
const response = responses[url]
|
||||
if (!response) {
|
||||
throw new Error(`unexpected ${url}`)
|
||||
}
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
describe('billing client', () => {
|
||||
it('mints a billing token with the Orca access token and refuses redirects', async () => {
|
||||
const seen: Seen[] = []
|
||||
const token = await mintBillingToken(
|
||||
endpoints,
|
||||
'access-1',
|
||||
fakeFetch(
|
||||
{
|
||||
[endpoints.billingTokenEndpoint]: Response.json({
|
||||
billingToken: 'bt',
|
||||
expiresAt: 1
|
||||
})
|
||||
},
|
||||
seen
|
||||
)
|
||||
)
|
||||
expect(token).toBe('bt')
|
||||
expect(new Headers(seen[0]?.init?.headers).get('authorization')).toBe('Bearer access-1')
|
||||
expect(seen[0]?.init?.redirect).toBe('error')
|
||||
})
|
||||
|
||||
it('surfaces an auth rejection as an Orca session failure so the session refreshes', async () => {
|
||||
const call = mintBillingToken(
|
||||
endpoints,
|
||||
'stale',
|
||||
fakeFetch({
|
||||
[endpoints.billingTokenEndpoint]: new Response(null, { status: 401 })
|
||||
})
|
||||
)
|
||||
await expect(call).rejects.toBeInstanceOf(OrcaCloudRequestError)
|
||||
})
|
||||
|
||||
it('reports billing refusals as BillingServiceError, never as a session failure', async () => {
|
||||
const call = createCheckoutUrl(
|
||||
endpoints,
|
||||
'bt',
|
||||
{ plan: 'team', interval: 'month', seats: 1 },
|
||||
fakeFetch({
|
||||
'https://billing.example.test/v1/billing/checkout': Response.json(
|
||||
{ error: 'already_subscribed' },
|
||||
{ status: 409 }
|
||||
)
|
||||
})
|
||||
)
|
||||
await expect(call).rejects.toMatchObject({
|
||||
statusCode: 409,
|
||||
errorCode: 'already_subscribed'
|
||||
})
|
||||
await expect(call).rejects.toBeInstanceOf(BillingServiceError)
|
||||
})
|
||||
|
||||
it('sends plan, interval and seats and returns the hosted Checkout URL', async () => {
|
||||
const seen: Seen[] = []
|
||||
const url = await createCheckoutUrl(
|
||||
endpoints,
|
||||
'bt',
|
||||
{ plan: 'team', interval: 'year', seats: 3 },
|
||||
fakeFetch(
|
||||
{
|
||||
'https://billing.example.test/v1/billing/checkout': Response.json({
|
||||
url: 'https://checkout.stripe.com/c/pay/cs_test_1'
|
||||
})
|
||||
},
|
||||
seen
|
||||
)
|
||||
)
|
||||
expect(url).toBe('https://checkout.stripe.com/c/pay/cs_test_1')
|
||||
expect(JSON.parse(String(seen[0]?.init?.body))).toEqual({
|
||||
plan: 'team',
|
||||
interval: 'year',
|
||||
seats: 3
|
||||
})
|
||||
})
|
||||
|
||||
it('only opens Stripe-hosted pages', async () => {
|
||||
expect(assertStripeHostedUrl('https://billing.stripe.com/p/session/x')).toBe(
|
||||
'https://billing.stripe.com/p/session/x'
|
||||
)
|
||||
for (const bad of [
|
||||
'http://checkout.stripe.com/x',
|
||||
'https://checkout.stripe.com.evil.test/x',
|
||||
'https://evil.test/x',
|
||||
'https://user@billing.stripe.com/x',
|
||||
'javascript:alert(1)',
|
||||
42
|
||||
]) {
|
||||
expect(() => assertStripeHostedUrl(bad)).toThrow()
|
||||
}
|
||||
const portal = createPortalUrl(
|
||||
endpoints,
|
||||
'bt',
|
||||
fakeFetch({
|
||||
'https://billing.example.test/v1/billing/portal': Response.json({
|
||||
url: 'https://evil.test/'
|
||||
})
|
||||
})
|
||||
)
|
||||
await expect(portal).rejects.toThrow('invalid_billing_url')
|
||||
})
|
||||
|
||||
it('normalizes the entitlement and drops unknown or unsafe fields', async () => {
|
||||
const offers = {
|
||||
team: {
|
||||
label: 'Team',
|
||||
monthlyPriceLabel: '$50/seat/mo',
|
||||
annualPriceLabel: '$480/seat/yr',
|
||||
includedHoursPerSeat: 60,
|
||||
concurrentSandboxesPerSeat: 3,
|
||||
idleSleepMinutes: 15,
|
||||
overageCentsPerHour: 75,
|
||||
defaultSpendCapCents: 2500
|
||||
},
|
||||
enterprise: {
|
||||
label: 'Enterprise',
|
||||
priceLabel: 'Custom',
|
||||
contactUrl: 'https://onorca.dev/sales'
|
||||
}
|
||||
}
|
||||
const response = (extra: Record<string, unknown>) =>
|
||||
fakeFetch({
|
||||
'https://billing.example.test/v1/billing/entitlements': Response.json({
|
||||
plan: 'team',
|
||||
planLabel: 'Team',
|
||||
status: 'active',
|
||||
interval: 'year',
|
||||
seats: 2,
|
||||
period: { start: 1, end: 2 },
|
||||
usageWindow: { start: 1, end: 2 },
|
||||
includedSeconds: 432000,
|
||||
usedSeconds: 36000,
|
||||
remainingIncludedSeconds: 396000,
|
||||
usageWarningPercent: null,
|
||||
overageSeconds: 0,
|
||||
overage: {
|
||||
optedIn: true,
|
||||
active: true,
|
||||
spendCapCents: 2500,
|
||||
spentCents: 75,
|
||||
centsPerHour: 75
|
||||
},
|
||||
canStartSandbox: true,
|
||||
reason: 'something_new',
|
||||
canManageBilling: true,
|
||||
accountId: 'org:o1',
|
||||
offers,
|
||||
...extra
|
||||
})
|
||||
})
|
||||
const entitlement = await fetchEntitlement(endpoints, 'bt', response({}))
|
||||
expect(entitlement).toMatchObject({
|
||||
plan: 'team',
|
||||
interval: 'year',
|
||||
usedSeconds: 36000,
|
||||
reason: null,
|
||||
canManageBilling: true,
|
||||
overage: { optedIn: true, spentCents: 75 },
|
||||
offers
|
||||
})
|
||||
expect(entitlement).not.toHaveProperty('accountId')
|
||||
// A non-https contact link is never handed to the renderer.
|
||||
const unsafe = await fetchEntitlement(
|
||||
endpoints,
|
||||
'bt',
|
||||
response({
|
||||
offers: {
|
||||
...offers,
|
||||
enterprise: { ...offers.enterprise, contactUrl: 'javascript:alert(1)' }
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(unsafe.offers).toBeNull()
|
||||
expect(
|
||||
(await fetchEntitlement(endpoints, 'bt', response({ plan: 'pro', offers: undefined }))).plan
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,255 @@
|
||||
// HTTP calls behind "Plan & billing": mint a short-lived billing token from Orca Cloud auth,
|
||||
// then call the billing service with it. Pure (fetch injected) so it is testable without Electron.
|
||||
import { z } from 'zod'
|
||||
import type {
|
||||
OrcaBillingCheckoutArgs,
|
||||
OrcaBillingEntitlement,
|
||||
OrcaBillingOverageArgs
|
||||
} from '../../shared/orca-billing'
|
||||
import { ORCA_BILLING_INTERVALS, ORCA_BILLING_PLAN_IDS } from '../../shared/orca-billing'
|
||||
import { OrcaCloudRequestError } from '../orca-profiles/profile-cloud-client'
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 20_000
|
||||
const MAX_TOKEN_LENGTH = 8 * 1024
|
||||
|
||||
export type BillingEndpoints = {
|
||||
billingApiUrl: string
|
||||
billingTokenEndpoint: string
|
||||
}
|
||||
|
||||
/** A billing-service refusal (never an Orca session failure: it must not sign the user out). */
|
||||
export class BillingServiceError extends Error {
|
||||
constructor(
|
||||
public readonly statusCode: number,
|
||||
public readonly errorCode: string | null
|
||||
) {
|
||||
super(errorCode ?? `billing_request_failed_${statusCode}`)
|
||||
this.name = 'BillingServiceError'
|
||||
}
|
||||
}
|
||||
|
||||
const ErrorBody = z.object({ error: z.string().max(64) })
|
||||
const TokenBody = z.object({
|
||||
billingToken: z.string().min(1).max(MAX_TOKEN_LENGTH)
|
||||
})
|
||||
const UrlBody = z.object({ url: z.string() })
|
||||
const JsonObject = z.record(z.string(), z.unknown())
|
||||
|
||||
async function readErrorCode(response: Response): Promise<string | null> {
|
||||
const parsed = ErrorBody.safeParse(await response.json().catch(() => null))
|
||||
return parsed.success ? parsed.data.error : null
|
||||
}
|
||||
|
||||
/** POST to Orca Cloud auth. A 401/403 is surfaced as OrcaCloudRequestError so the caller's
|
||||
* session-refresh wrapper refreshes the Orca session once and retries. */
|
||||
export async function mintBillingToken(
|
||||
endpoints: BillingEndpoints,
|
||||
accessToken: string,
|
||||
fetchImpl: typeof fetch = fetch
|
||||
): Promise<string> {
|
||||
const response = await fetchImpl(endpoints.billingTokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${accessToken}`
|
||||
},
|
||||
body: '{}',
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new OrcaCloudRequestError(response.status, (await readErrorCode(response)) ?? undefined)
|
||||
}
|
||||
const body = TokenBody.safeParse(await response.json())
|
||||
if (!body.success) {
|
||||
throw new Error('invalid_billing_token')
|
||||
}
|
||||
return body.data.billingToken
|
||||
}
|
||||
|
||||
async function billingRequest(
|
||||
endpoints: BillingEndpoints,
|
||||
token: string,
|
||||
method: 'GET' | 'POST',
|
||||
path: string,
|
||||
body: unknown,
|
||||
fetchImpl: typeof fetch
|
||||
): Promise<Record<string, unknown>> {
|
||||
const response = await fetchImpl(new URL(path, `${endpoints.billingApiUrl}/`).toString(), {
|
||||
method,
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
...(method === 'POST' ? { 'content-type': 'application/json' } : {})
|
||||
},
|
||||
body: method === 'POST' ? JSON.stringify(body ?? {}) : undefined,
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new BillingServiceError(response.status, await readErrorCode(response))
|
||||
}
|
||||
const json = JsonObject.safeParse(await response.json())
|
||||
if (!json.success) {
|
||||
throw new Error('invalid_billing_response')
|
||||
}
|
||||
return json.data
|
||||
}
|
||||
|
||||
const seconds = z.number().catch(0)
|
||||
const window = z.object({ start: z.number(), end: z.number() }).nullable().catch(null)
|
||||
|
||||
const OffersSchema = z
|
||||
.object({
|
||||
team: z.object({
|
||||
label: z.string().max(40),
|
||||
monthlyPriceLabel: z.string().max(40),
|
||||
annualPriceLabel: z.string().max(40),
|
||||
monthlyPriceCentsPerSeat: z.number().int().nonnegative().nullable().catch(null),
|
||||
annualPriceCentsPerSeat: z.number().int().nonnegative().nullable().catch(null),
|
||||
largeMachineWeight: z.number().positive().catch(2),
|
||||
includedHoursPerSeat: z.number().nonnegative(),
|
||||
concurrentSandboxesPerSeat: z.number().int().nonnegative(),
|
||||
idleSleepMinutes: z.number().int().positive(),
|
||||
overageCentsPerHour: z.number().int().positive(),
|
||||
defaultSpendCapCents: z.number().int().nonnegative()
|
||||
}),
|
||||
enterprise: z.object({
|
||||
label: z.string().max(40),
|
||||
priceLabel: z.string().max(40),
|
||||
// Opened in the browser: https only.
|
||||
contactUrl: z.string().url().startsWith('https://')
|
||||
})
|
||||
})
|
||||
.nullable()
|
||||
.catch(null)
|
||||
|
||||
// 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({
|
||||
plan: z.enum(ORCA_BILLING_PLAN_IDS).nullable().catch(null),
|
||||
planLabel: z.string().nullable().catch(null),
|
||||
status: z.string().catch('none'),
|
||||
interval: z.enum(ORCA_BILLING_INTERVALS).nullable().catch(null),
|
||||
seats: seconds,
|
||||
seatChange: z
|
||||
.object({ seatsAtRenewal: z.number().int().min(1), effectiveAt: z.number() })
|
||||
.nullable()
|
||||
.catch(null),
|
||||
period: window,
|
||||
usageWindow: window,
|
||||
cancelAtPeriodEnd: z.boolean().catch(false),
|
||||
includedSeconds: seconds,
|
||||
usedSeconds: seconds,
|
||||
remainingIncludedSeconds: seconds,
|
||||
usageWarningPercent: z.number().nullable().catch(null),
|
||||
overageSeconds: seconds,
|
||||
overage: z
|
||||
.object({
|
||||
optedIn: z.boolean().catch(false),
|
||||
active: z.boolean().catch(false),
|
||||
spendCapCents: seconds,
|
||||
spentCents: seconds,
|
||||
centsPerHour: seconds
|
||||
})
|
||||
.catch({ optedIn: false, active: false, spendCapCents: 0, spentCents: 0, centsPerHour: 0 }),
|
||||
payment: z
|
||||
.object({
|
||||
pastDueSince: z.number().nullable().catch(null),
|
||||
graceEndsAt: z.number().nullable().catch(null),
|
||||
suspendRunning: z.boolean().catch(false)
|
||||
})
|
||||
.catch({ pastDueSince: null, graceEndsAt: null, suspendRunning: false }),
|
||||
canStartSandbox: z.boolean().catch(false),
|
||||
reason: z
|
||||
.enum([
|
||||
'no_subscription',
|
||||
'subscription_inactive',
|
||||
'payment_past_due',
|
||||
'payment_grace_expired',
|
||||
'included_hours_exhausted',
|
||||
'overage_cap_reached'
|
||||
])
|
||||
.nullable()
|
||||
.catch(null),
|
||||
canManageBilling: z.boolean().catch(false),
|
||||
offers: OffersSchema.optional().transform((value) => value ?? null)
|
||||
})
|
||||
|
||||
export function normalizeEntitlement(value: Record<string, unknown>): OrcaBillingEntitlement {
|
||||
return EntitlementSchema.parse(value)
|
||||
}
|
||||
|
||||
// Only Stripe's hosted pages may be opened from a billing response.
|
||||
const STRIPE_HOSTS = new Set(['checkout.stripe.com', 'billing.stripe.com'])
|
||||
|
||||
export function assertStripeHostedUrl(value: unknown): string {
|
||||
if (typeof value !== 'string' || !URL.canParse(value)) {
|
||||
throw new Error('invalid_billing_url')
|
||||
}
|
||||
const url = new URL(value)
|
||||
if (
|
||||
url.protocol !== 'https:' ||
|
||||
!STRIPE_HOSTS.has(url.hostname) ||
|
||||
url.username ||
|
||||
url.password
|
||||
) {
|
||||
throw new Error('invalid_billing_url')
|
||||
}
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
export async function fetchEntitlement(
|
||||
endpoints: BillingEndpoints,
|
||||
token: string,
|
||||
fetchImpl: typeof fetch = fetch
|
||||
): Promise<OrcaBillingEntitlement> {
|
||||
return normalizeEntitlement(
|
||||
await billingRequest(endpoints, token, 'GET', 'v1/billing/entitlements', undefined, fetchImpl)
|
||||
)
|
||||
}
|
||||
|
||||
export async function createCheckoutUrl(
|
||||
endpoints: BillingEndpoints,
|
||||
token: string,
|
||||
args: OrcaBillingCheckoutArgs,
|
||||
fetchImpl: typeof fetch = fetch
|
||||
): Promise<string> {
|
||||
const body = await billingRequest(
|
||||
endpoints,
|
||||
token,
|
||||
'POST',
|
||||
'v1/billing/checkout',
|
||||
{ plan: args.plan, interval: args.interval, seats: args.seats },
|
||||
fetchImpl
|
||||
)
|
||||
return assertStripeHostedUrl(UrlBody.safeParse(body).data?.url)
|
||||
}
|
||||
|
||||
export async function createPortalUrl(
|
||||
endpoints: BillingEndpoints,
|
||||
token: string,
|
||||
fetchImpl: typeof fetch = fetch
|
||||
): Promise<string> {
|
||||
const body = await billingRequest(endpoints, token, 'POST', 'v1/billing/portal', {}, fetchImpl)
|
||||
return assertStripeHostedUrl(UrlBody.safeParse(body).data?.url)
|
||||
}
|
||||
|
||||
export async function updateOverage(
|
||||
endpoints: BillingEndpoints,
|
||||
token: string,
|
||||
args: OrcaBillingOverageArgs,
|
||||
fetchImpl: typeof fetch = fetch
|
||||
): Promise<OrcaBillingEntitlement> {
|
||||
return normalizeEntitlement(
|
||||
await billingRequest(
|
||||
endpoints,
|
||||
token,
|
||||
'POST',
|
||||
'v1/billing/overage',
|
||||
args.spendCapCents === undefined
|
||||
? { enabled: args.enabled }
|
||||
: { enabled: args.enabled, spendCapCents: args.spendCapCents },
|
||||
fetchImpl
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('electron', () => ({ app: { isPackaged: false } }))
|
||||
|
||||
import { getBillingEndpoints } from './billing-config'
|
||||
import {
|
||||
billingCheckoutArgsFromUnknown,
|
||||
billingOverageArgsFromUnknown
|
||||
} from '../ipc/profiles/orca-profile-billing-handlers'
|
||||
|
||||
const authConfig = { apiBaseUrl: 'https://login.onorca.dev' }
|
||||
|
||||
describe('billing config', () => {
|
||||
it('is unconfigured until a billing URL is set', () => {
|
||||
expect(getBillingEndpoints(authConfig, {}, true)).toBeNull()
|
||||
})
|
||||
|
||||
it('derives the token endpoint from Orca Cloud auth', () => {
|
||||
expect(
|
||||
getBillingEndpoints(authConfig, { ORCA_BILLING_API_URL: 'https://billing.onorca.dev' }, true)
|
||||
).toEqual({
|
||||
billingApiUrl: 'https://billing.onorca.dev',
|
||||
billingTokenEndpoint: 'https://login.onorca.dev/v1/desktop/auth/billing-token'
|
||||
})
|
||||
})
|
||||
|
||||
it('allows loopback http only in development builds', () => {
|
||||
const env = { ORCA_BILLING_API_URL: 'http://127.0.0.1:8790' }
|
||||
expect(getBillingEndpoints(authConfig, env, false)?.billingApiUrl).toBe('http://127.0.0.1:8790')
|
||||
expect(getBillingEndpoints(authConfig, env, true)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('billing IPC args', () => {
|
||||
it('accepts Team checkout with an interval and 1..500 seats', () => {
|
||||
expect(billingCheckoutArgsFromUnknown({ plan: 'team', interval: 'year', seats: 5 })).toEqual({
|
||||
plan: 'team',
|
||||
interval: 'year',
|
||||
seats: 5
|
||||
})
|
||||
for (const bad of [
|
||||
{ plan: 'pro', interval: 'month', seats: 1 },
|
||||
{ plan: 'team', interval: 'week', seats: 1 },
|
||||
{ plan: 'team', interval: 'month', seats: 0 },
|
||||
{ plan: 'team', interval: 'month', seats: 501 },
|
||||
{ plan: 'team', interval: 'month', seats: 1, extra: true },
|
||||
null
|
||||
]) {
|
||||
expect(() => billingCheckoutArgsFromUnknown(bad)).toThrow('invalid_orca_billing_checkout')
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts overage toggles with an optional whole-cent cap', () => {
|
||||
expect(billingOverageArgsFromUnknown({ enabled: false })).toEqual({ enabled: false })
|
||||
expect(billingOverageArgsFromUnknown({ enabled: true, spendCapCents: 5000 })).toEqual({
|
||||
enabled: true,
|
||||
spendCapCents: 5000
|
||||
})
|
||||
for (const bad of [
|
||||
{ enabled: 'yes' },
|
||||
{ enabled: true, spendCapCents: -1 },
|
||||
{ enabled: true, spendCapCents: 1.5 }
|
||||
]) {
|
||||
expect(() => billingOverageArgsFromUnknown(bad)).toThrow('invalid_orca_billing_overage')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { cleanCloudServiceUrl } from '../../shared/cloud-service-url'
|
||||
import type { OrcaCloudAuthConfig } from '../orca-profiles/profile-cloud-auth-config'
|
||||
import type { BillingEndpoints } from './billing-client'
|
||||
|
||||
/**
|
||||
* Billing is opt-in per build until the service is deployed: no production default yet, so
|
||||
* packaged builds report 'unconfigured' and the menu item stays hidden behind its capability flag.
|
||||
*/
|
||||
export function getBillingEndpoints(
|
||||
authConfig: Pick<OrcaCloudAuthConfig, 'apiBaseUrl'>,
|
||||
env: NodeJS.ProcessEnv,
|
||||
packaged: boolean
|
||||
): BillingEndpoints | null {
|
||||
const allowLoopbackHttp = !packaged
|
||||
const billingApiUrl = cleanCloudServiceUrl(env.ORCA_BILLING_API_URL, allowLoopbackHttp)
|
||||
if (!billingApiUrl) {
|
||||
return null
|
||||
}
|
||||
const billingTokenEndpoint =
|
||||
cleanCloudServiceUrl(env.ORCA_CLOUD_BILLING_TOKEN_URL, allowLoopbackHttp) ??
|
||||
new URL('/v1/desktop/auth/billing-token', `${authConfig.apiBaseUrl}/`).toString()
|
||||
return { billingApiUrl, billingTokenEndpoint }
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// Plan & billing for the active Orca profile's signed-in account. Results are values, never
|
||||
// throws, so the renderer can show a precise state. A billing-service refusal never signs the
|
||||
// user out; only Orca Cloud auth rejecting the session does (via runWithFreshOrcaCloudSession).
|
||||
import { app, shell } from 'electron'
|
||||
import type {
|
||||
OrcaBillingCheckoutArgs,
|
||||
OrcaBillingOpenResult,
|
||||
OrcaBillingOverageArgs,
|
||||
OrcaBillingOverageResult,
|
||||
OrcaBillingStatusResult
|
||||
} from '../../shared/orca-billing'
|
||||
import { getOrcaCloudAuthConfig } from '../orca-profiles/profile-cloud-auth-config'
|
||||
import { runWithFreshOrcaCloudSession } from '../orca-profiles/profile-cloud-session-refresh'
|
||||
import { ensureActiveOrcaProfile } from '../orca-profiles/profile-index-store'
|
||||
import {
|
||||
BillingServiceError,
|
||||
createCheckoutUrl,
|
||||
createPortalUrl,
|
||||
fetchEntitlement,
|
||||
mintBillingToken,
|
||||
updateOverage,
|
||||
type BillingEndpoints
|
||||
} from './billing-client'
|
||||
import { getBillingEndpoints } from './billing-config'
|
||||
|
||||
type Ready =
|
||||
| {
|
||||
ok: true
|
||||
run: <T>(
|
||||
call: (token: string, endpoints: BillingEndpoints) => Promise<T>
|
||||
) => Promise<T | 'signed-out'>
|
||||
}
|
||||
| { ok: false; status: 'unconfigured' | 'signed-out' }
|
||||
|
||||
function prepare(userDataPath: string): Ready {
|
||||
const auth = getOrcaCloudAuthConfig()
|
||||
if (!auth.configured) {
|
||||
return { ok: false, status: 'unconfigured' }
|
||||
}
|
||||
const endpoints = getBillingEndpoints(auth.config, process.env, app.isPackaged)
|
||||
if (!endpoints) {
|
||||
return { ok: false, status: 'unconfigured' }
|
||||
}
|
||||
const active = ensureActiveOrcaProfile(userDataPath)
|
||||
if (!active.profile.cloud) {
|
||||
return { ok: false, status: 'signed-out' }
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
run: async (call) => {
|
||||
const result = await runWithFreshOrcaCloudSession(
|
||||
auth.config,
|
||||
active,
|
||||
userDataPath,
|
||||
async (session) => call(await mintBillingToken(endpoints, session.accessToken), endpoints)
|
||||
)
|
||||
return result.status === 'ok' ? result.value : 'signed-out'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const message = (error: unknown): string => (error instanceof Error ? error.message : String(error))
|
||||
|
||||
export async function getOrcaBillingStatus(userDataPath: string): Promise<OrcaBillingStatusResult> {
|
||||
const ready = prepare(userDataPath)
|
||||
if (!ready.ok) {
|
||||
return { status: ready.status }
|
||||
}
|
||||
try {
|
||||
const entitlement = await ready.run((token, endpoints) => fetchEntitlement(endpoints, token))
|
||||
return entitlement === 'signed-out' ? { status: 'signed-out' } : { status: 'ok', entitlement }
|
||||
} catch (error) {
|
||||
return { status: 'failed', error: message(error) }
|
||||
}
|
||||
}
|
||||
|
||||
function openResultForError(error: unknown): OrcaBillingOpenResult {
|
||||
if (error instanceof BillingServiceError) {
|
||||
if (error.statusCode === 403) {
|
||||
return { status: 'forbidden' }
|
||||
}
|
||||
if (error.errorCode === 'already_subscribed') {
|
||||
return { status: 'already-subscribed' }
|
||||
}
|
||||
if (error.errorCode === 'no_billing_account') {
|
||||
return { status: 'no-billing-account' }
|
||||
}
|
||||
}
|
||||
return { status: 'failed', error: message(error) }
|
||||
}
|
||||
|
||||
async function openHosted(
|
||||
userDataPath: string,
|
||||
createUrl: (token: string, endpoints: BillingEndpoints) => Promise<string>
|
||||
): Promise<OrcaBillingOpenResult> {
|
||||
const ready = prepare(userDataPath)
|
||||
if (!ready.ok) {
|
||||
return { status: ready.status }
|
||||
}
|
||||
try {
|
||||
const url = await ready.run(createUrl)
|
||||
if (url === 'signed-out') {
|
||||
return { status: 'signed-out' }
|
||||
}
|
||||
// createUrl already restricted this to Stripe's hosted Checkout/Portal hosts.
|
||||
await shell.openExternal(url)
|
||||
return { status: 'opened' }
|
||||
} catch (error) {
|
||||
return openResultForError(error)
|
||||
}
|
||||
}
|
||||
|
||||
export function openOrcaBillingCheckout(
|
||||
userDataPath: string,
|
||||
args: OrcaBillingCheckoutArgs
|
||||
): Promise<OrcaBillingOpenResult> {
|
||||
return openHosted(userDataPath, (token, endpoints) => createCheckoutUrl(endpoints, token, args))
|
||||
}
|
||||
|
||||
export function openOrcaBillingPortal(userDataPath: string): Promise<OrcaBillingOpenResult> {
|
||||
return openHosted(userDataPath, (token, endpoints) => createPortalUrl(endpoints, token))
|
||||
}
|
||||
|
||||
export async function setOrcaBillingOverage(
|
||||
userDataPath: string,
|
||||
args: OrcaBillingOverageArgs
|
||||
): Promise<OrcaBillingOverageResult> {
|
||||
const ready = prepare(userDataPath)
|
||||
if (!ready.ok) {
|
||||
return { status: ready.status }
|
||||
}
|
||||
try {
|
||||
const entitlement = await ready.run((token, endpoints) => updateOverage(endpoints, token, args))
|
||||
return entitlement === 'signed-out' ? { status: 'signed-out' } : { status: 'ok', entitlement }
|
||||
} catch (error) {
|
||||
if (error instanceof BillingServiceError) {
|
||||
if (error.statusCode === 403) {
|
||||
return { status: 'forbidden' }
|
||||
}
|
||||
if (error.errorCode === 'no_subscription') {
|
||||
return { status: 'no-subscription' }
|
||||
}
|
||||
if (error.errorCode === 'spend_cap_too_high') {
|
||||
return { status: 'cap-too-high' }
|
||||
}
|
||||
}
|
||||
return { status: 'failed', error: message(error) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { z } from 'zod'
|
||||
import type {
|
||||
OrcaBillingCheckoutArgs,
|
||||
OrcaBillingOpenResult,
|
||||
OrcaBillingOverageArgs,
|
||||
OrcaBillingOverageResult,
|
||||
OrcaBillingStatusResult
|
||||
} from '../../../shared/orca-billing'
|
||||
import {
|
||||
ORCA_BILLING_INTERVALS,
|
||||
ORCA_BILLING_MAX_SEATS,
|
||||
ORCA_BILLING_PLAN_IDS
|
||||
} from '../../../shared/orca-billing'
|
||||
import {
|
||||
getOrcaBillingStatus,
|
||||
openOrcaBillingCheckout,
|
||||
openOrcaBillingPortal,
|
||||
setOrcaBillingOverage
|
||||
} from '../../billing/billing-service'
|
||||
import { getProfileUserDataPath } from '../../orca-profiles/profile-storage-paths'
|
||||
|
||||
const CheckoutArgs = z
|
||||
.object({
|
||||
plan: z.enum(ORCA_BILLING_PLAN_IDS),
|
||||
interval: z.enum(ORCA_BILLING_INTERVALS),
|
||||
seats: z.number().int().min(1).max(ORCA_BILLING_MAX_SEATS)
|
||||
})
|
||||
.strict()
|
||||
|
||||
// Cents; the billing service enforces the plan's maximum.
|
||||
const OverageArgs = z
|
||||
.object({
|
||||
enabled: z.boolean(),
|
||||
spendCapCents: z.number().int().min(0).max(100_000_000).optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export function billingCheckoutArgsFromUnknown(args: unknown): OrcaBillingCheckoutArgs {
|
||||
const parsed = CheckoutArgs.safeParse(args)
|
||||
if (!parsed.success) {
|
||||
throw new Error('invalid_orca_billing_checkout')
|
||||
}
|
||||
return parsed.data
|
||||
}
|
||||
|
||||
export function billingOverageArgsFromUnknown(args: unknown): OrcaBillingOverageArgs {
|
||||
const parsed = OverageArgs.safeParse(args)
|
||||
if (!parsed.success) {
|
||||
throw new Error('invalid_orca_billing_overage')
|
||||
}
|
||||
return parsed.data
|
||||
}
|
||||
|
||||
export function registerOrcaProfileBillingHandlers(): void {
|
||||
ipcMain.handle('orcaProfiles:billingStatus', async (): Promise<OrcaBillingStatusResult> =>
|
||||
getOrcaBillingStatus(getProfileUserDataPath())
|
||||
)
|
||||
ipcMain.handle(
|
||||
'orcaProfiles:billingOpenCheckout',
|
||||
async (_event, rawArgs: unknown): Promise<OrcaBillingOpenResult> =>
|
||||
openOrcaBillingCheckout(getProfileUserDataPath(), billingCheckoutArgsFromUnknown(rawArgs))
|
||||
)
|
||||
ipcMain.handle(
|
||||
'orcaProfiles:billingSetOverage',
|
||||
async (_event, rawArgs: unknown): Promise<OrcaBillingOverageResult> =>
|
||||
setOrcaBillingOverage(getProfileUserDataPath(), billingOverageArgsFromUnknown(rawArgs))
|
||||
)
|
||||
ipcMain.handle('orcaProfiles:billingOpenPortal', async (): Promise<OrcaBillingOpenResult> =>
|
||||
openOrcaBillingPortal(getProfileUserDataPath())
|
||||
)
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
} from '../../orca-profiles/profile-cloud-service'
|
||||
import { refreshCurrentOrcaProfileAuth } from '../../orca-profiles/profile-cloud-capability-refresh'
|
||||
import { registerOrcaProfileOrgMemberHandlers } from './orca-profile-org-members-handlers'
|
||||
import { registerOrcaProfileBillingHandlers } from './orca-profile-billing-handlers'
|
||||
import { onOrcaCloudSessionInvalidated } from '../../orca-profiles/profile-cloud-session-invalidation'
|
||||
import {
|
||||
broadcastOrcaProfileAuthStatusChanged,
|
||||
@@ -319,4 +320,5 @@ export function registerOrcaProfileHandlers(
|
||||
)
|
||||
|
||||
registerOrcaProfileOrgMemberHandlers()
|
||||
registerOrcaProfileBillingHandlers()
|
||||
}
|
||||
|
||||
@@ -25,6 +25,13 @@ import type {
|
||||
TransferOrcaProfileProjectArgs,
|
||||
TransferOrcaProfileProjectResult
|
||||
} from '../../shared/orca-profiles'
|
||||
import type {
|
||||
OrcaBillingCheckoutArgs,
|
||||
OrcaBillingOpenResult,
|
||||
OrcaBillingOverageArgs,
|
||||
OrcaBillingOverageResult,
|
||||
OrcaBillingStatusResult
|
||||
} from '../../shared/orca-billing'
|
||||
|
||||
export type OrcaProfileApi = {
|
||||
list: () => Promise<OrcaProfileListResult>
|
||||
@@ -64,4 +71,12 @@ export type OrcaProfileApi = {
|
||||
orgMemberRemove: (
|
||||
args: OrcaProfileOrgMemberRemoveArgs
|
||||
) => Promise<OrcaProfileOrgMemberMutationResult>
|
||||
/** Plan & billing for the signed-in account (Orca Cloud billing service). */
|
||||
billingStatus: () => Promise<OrcaBillingStatusResult>
|
||||
/** Opens hosted Stripe Checkout in the browser. */
|
||||
billingOpenCheckout: (args: OrcaBillingCheckoutArgs) => Promise<OrcaBillingOpenResult>
|
||||
/** Opens the Stripe Customer Portal in the browser. */
|
||||
billingOpenPortal: () => Promise<OrcaBillingOpenResult>
|
||||
/** Opt in/out of sandbox overage and set the monthly spend cap (org admins). */
|
||||
billingSetOverage: (args: OrcaBillingOverageArgs) => Promise<OrcaBillingOverageResult>
|
||||
}
|
||||
|
||||
@@ -64,5 +64,9 @@ export const orcaProfilesApi = {
|
||||
orgMemberInvite: (args) => ipcRenderer.invoke('orcaProfiles:orgMemberInvite', args),
|
||||
orgInviteRevoke: (args) => ipcRenderer.invoke('orcaProfiles:orgInviteRevoke', args),
|
||||
orgMemberChangeRole: (args) => ipcRenderer.invoke('orcaProfiles:orgMemberChangeRole', args),
|
||||
orgMemberRemove: (args) => ipcRenderer.invoke('orcaProfiles:orgMemberRemove', args)
|
||||
orgMemberRemove: (args) => ipcRenderer.invoke('orcaProfiles:orgMemberRemove', args),
|
||||
billingStatus: () => ipcRenderer.invoke('orcaProfiles:billingStatus'),
|
||||
billingOpenCheckout: (args) => ipcRenderer.invoke('orcaProfiles:billingOpenCheckout', args),
|
||||
billingOpenPortal: () => ipcRenderer.invoke('orcaProfiles:billingOpenPortal'),
|
||||
billingSetOverage: (args) => ipcRenderer.invoke('orcaProfiles:billingSetOverage', args)
|
||||
} satisfies PreloadApi['orcaProfiles']
|
||||
|
||||
@@ -5,6 +5,7 @@ import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { ConfirmationDialogProvider } from './components/confirmation-dialog'
|
||||
import { BrowserWebAuthnAccountDialog } from './components/browser-webauthn-account-dialog'
|
||||
import { BrowserExtensionInstallDialog } from './components/browser-extension-install-dialog'
|
||||
import { PlansDialogHost } from './components/billing/plans/PlansDialogHost'
|
||||
import { BrowserPagePrompts } from './components/browser-pane/browser-page-prompts'
|
||||
import { LinkRoutingPreferenceDialogProvider } from './components/link-routing-preference-dialog'
|
||||
import { SkillFreshnessNudge } from './components/skills/SkillFreshnessNudge'
|
||||
@@ -117,6 +118,7 @@ function App(): React.JSX.Element {
|
||||
/>
|
||||
<BrowserWebAuthnAccountDialog />
|
||||
<BrowserExtensionInstallDialog />
|
||||
<PlansDialogHost />
|
||||
</LinkRoutingPreferenceDialogProvider>
|
||||
</ConfirmationDialogProvider>
|
||||
{createPortal(
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { AlertTriangle, ExternalLink } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useIntlLocale, useTranslate } from '@/i18n/use-translate'
|
||||
import type { OrcaBillingEntitlement } from '../../../../shared/orca-billing'
|
||||
|
||||
/**
|
||||
* Failed renewal: new sandboxes are paused at once; running ones are suspended when the grace
|
||||
* ends. Stripe keeps retrying the card and emails the billing contact meanwhile.
|
||||
*/
|
||||
export function PaymentIssueNotice({
|
||||
entitlement,
|
||||
busy,
|
||||
onUpdatePayment
|
||||
}: {
|
||||
entitlement: OrcaBillingEntitlement
|
||||
busy: boolean
|
||||
onUpdatePayment: () => void
|
||||
}): React.JSX.Element | null {
|
||||
const t = useTranslate()
|
||||
const locale = useIntlLocale()
|
||||
const { payment } = entitlement
|
||||
if (payment.pastDueSince === null) {
|
||||
return null
|
||||
}
|
||||
const when = (ms: number): string =>
|
||||
new Intl.DateTimeFormat(locale, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
timeZoneName: 'short'
|
||||
}).format(ms)
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-start gap-3 rounded-md border border-destructive/40 p-3"
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-destructive" />
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<p className="text-sm font-medium">
|
||||
{payment.suspendRunning
|
||||
? t('auto.components.billing.paymentSuspended', 'Sandboxes suspended: payment overdue')
|
||||
: t('auto.components.billing.paymentFailed', 'Payment failed')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{payment.suspendRunning || payment.graceEndsAt === null
|
||||
? t(
|
||||
'auto.components.billing.paymentSuspendedBody',
|
||||
'Running sandboxes were suspended and new ones cannot start. Update your payment method; everything resumes once the invoice is paid.'
|
||||
)
|
||||
: t(
|
||||
'auto.components.billing.paymentGraceBody',
|
||||
'New sandboxes are paused. Running sandboxes will be suspended on {{value0}} unless the payment goes through. Stripe will retry the card and email your billing contact.',
|
||||
{ value0: when(payment.graceEndsAt) }
|
||||
)}
|
||||
</p>
|
||||
{entitlement.canManageBilling ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={onUpdatePayment}
|
||||
>
|
||||
{t('auto.components.billing.updatePayment', 'Update payment method')}
|
||||
<ExternalLink />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { defineFixture } from '../../component-fixtures/component-fixture'
|
||||
import { PlanBillingView } from './PlanBillingSection'
|
||||
import { sampleTeam } from './plan-billing-fixtures'
|
||||
|
||||
const HOUR = 3600
|
||||
|
||||
const handlers = {
|
||||
loading: false,
|
||||
busy: false,
|
||||
notice: null,
|
||||
onRefresh: () => {},
|
||||
onPortal: () => {},
|
||||
onSetOverage: () => {},
|
||||
onSeePlans: () => {}
|
||||
}
|
||||
|
||||
export const activeTeam = defineFixture({
|
||||
width: 600,
|
||||
render: () => <PlanBillingView {...handlers} result={{ status: 'ok', entitlement: sampleTeam }} />
|
||||
})
|
||||
|
||||
export const noPlan = defineFixture({
|
||||
width: 680,
|
||||
render: () => (
|
||||
<PlanBillingView
|
||||
{...handlers}
|
||||
result={{
|
||||
status: 'ok',
|
||||
entitlement: {
|
||||
...sampleTeam,
|
||||
plan: null,
|
||||
planLabel: null,
|
||||
status: 'none',
|
||||
interval: null,
|
||||
seats: 0,
|
||||
period: null,
|
||||
usageWindow: null,
|
||||
includedSeconds: 0,
|
||||
usedSeconds: 0,
|
||||
remainingIncludedSeconds: 0,
|
||||
canStartSandbox: false,
|
||||
reason: 'no_subscription'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
export const overageCapReached = defineFixture({
|
||||
width: 600,
|
||||
render: () => (
|
||||
<PlanBillingView
|
||||
{...handlers}
|
||||
result={{
|
||||
status: 'ok',
|
||||
entitlement: {
|
||||
...sampleTeam,
|
||||
interval: 'year',
|
||||
seats: 5,
|
||||
includedSeconds: 300 * HOUR,
|
||||
usedSeconds: 333 * HOUR,
|
||||
remainingIncludedSeconds: 0,
|
||||
usageWarningPercent: 100,
|
||||
overageSeconds: 33 * HOUR,
|
||||
overage: {
|
||||
optedIn: true,
|
||||
active: true,
|
||||
spendCapCents: 2500,
|
||||
spentCents: 2500,
|
||||
centsPerHour: 75
|
||||
},
|
||||
canStartSandbox: false,
|
||||
reason: 'overage_cap_reached'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
export const pastDueMember = defineFixture({
|
||||
width: 600,
|
||||
render: () => (
|
||||
<PlanBillingView
|
||||
{...handlers}
|
||||
result={{
|
||||
status: 'ok',
|
||||
entitlement: {
|
||||
...sampleTeam,
|
||||
status: 'past_due',
|
||||
usedSeconds: 100 * HOUR,
|
||||
remainingIncludedSeconds: 20 * HOUR,
|
||||
usageWarningPercent: 80,
|
||||
payment: {
|
||||
pastDueSince: Date.UTC(2026, 10, 8, 9),
|
||||
graceEndsAt: Date.UTC(2026, 10, 12, 9),
|
||||
suspendRunning: false
|
||||
},
|
||||
canStartSandbox: false,
|
||||
reason: 'payment_past_due',
|
||||
canManageBilling: false
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
export const suspendedAdmin = defineFixture({
|
||||
width: 600,
|
||||
render: () => (
|
||||
<PlanBillingView
|
||||
{...handlers}
|
||||
result={{
|
||||
status: 'ok',
|
||||
entitlement: {
|
||||
...sampleTeam,
|
||||
status: 'past_due',
|
||||
payment: {
|
||||
pastDueSince: Date.UTC(2026, 10, 3, 9),
|
||||
graceEndsAt: Date.UTC(2026, 10, 7, 9),
|
||||
suspendRunning: true
|
||||
},
|
||||
canStartSandbox: false,
|
||||
reason: 'payment_grace_expired'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,228 @@
|
||||
import { useState } from 'react'
|
||||
import { CreditCard, ExternalLink, RefreshCw, Sparkles } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useIntlLocale, useTranslate } from '@/i18n/use-translate'
|
||||
import { formatCents } from './plans/plans-pricing'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type {
|
||||
OrcaBillingOpenResult,
|
||||
OrcaBillingOverageArgs,
|
||||
OrcaBillingOverageResult,
|
||||
OrcaBillingStatusResult
|
||||
} from '../../../../shared/orca-billing'
|
||||
import { openPlansPage } from './plans/plans-page-store'
|
||||
import { PaymentIssueNotice } from './PaymentIssueNotice'
|
||||
import { OverageControls, UsageSummary } from './PlanBillingUsage'
|
||||
import {
|
||||
denyReasonCopy,
|
||||
openFailureCopy,
|
||||
overageFailureCopy,
|
||||
statusLabel
|
||||
} from './plan-billing-copy'
|
||||
import { planBillingTone } from './plan-billing-format'
|
||||
import { useOrcaBillingStatus } from './use-orca-billing-status'
|
||||
|
||||
export function PlanBillingSection(): React.JSX.Element | null {
|
||||
const t = useTranslate()
|
||||
const { loading, result, refresh } = useOrcaBillingStatus(true)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [notice, setNotice] = useState<string | null>(null)
|
||||
|
||||
const run = async (open: () => Promise<OrcaBillingOpenResult>): Promise<void> => {
|
||||
setBusy(true)
|
||||
setNotice(null)
|
||||
const outcome = await open().catch((error: unknown): OrcaBillingOpenResult => ({
|
||||
status: 'failed',
|
||||
error: String(error)
|
||||
}))
|
||||
setBusy(false)
|
||||
setNotice(
|
||||
outcome.status === 'opened'
|
||||
? t(
|
||||
'auto.components.billing.continueInBrowser',
|
||||
'Continue in your browser. This updates when you come back.'
|
||||
)
|
||||
: openFailureCopy(outcome, t)
|
||||
)
|
||||
}
|
||||
|
||||
const setOverage = async (args: OrcaBillingOverageArgs): Promise<void> => {
|
||||
setBusy(true)
|
||||
setNotice(null)
|
||||
const outcome = await window.api.orcaProfiles
|
||||
.billingSetOverage(args)
|
||||
.catch((error: unknown): OrcaBillingOverageResult => ({
|
||||
status: 'failed',
|
||||
error: String(error)
|
||||
}))
|
||||
setBusy(false)
|
||||
if (outcome.status === 'ok') {
|
||||
refresh()
|
||||
} else {
|
||||
setNotice(overageFailureCopy(outcome, t))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PlanBillingView
|
||||
result={result}
|
||||
loading={loading}
|
||||
busy={busy}
|
||||
notice={notice}
|
||||
onRefresh={refresh}
|
||||
onPortal={() => void run(() => window.api.orcaProfiles.billingOpenPortal())}
|
||||
onSetOverage={(args) => void setOverage(args)}
|
||||
onSeePlans={() => openPlansPage('settings')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type PlanBillingViewProps = {
|
||||
result: OrcaBillingStatusResult | null
|
||||
loading: boolean
|
||||
busy: boolean
|
||||
notice: string | null
|
||||
onRefresh: () => void
|
||||
onPortal: () => void
|
||||
onSetOverage: (args: OrcaBillingOverageArgs) => void
|
||||
/** Opens the plans page (pricing, Checkout, Enterprise contact). */
|
||||
onSeePlans: () => void
|
||||
}
|
||||
|
||||
export function PlanBillingView({
|
||||
result,
|
||||
loading,
|
||||
busy,
|
||||
notice,
|
||||
onRefresh,
|
||||
onPortal,
|
||||
onSetOverage,
|
||||
onSeePlans
|
||||
}: PlanBillingViewProps): React.JSX.Element | null {
|
||||
const t = useTranslate()
|
||||
const locale = useIntlLocale()
|
||||
if (!result || result.status === 'unconfigured' || result.status === 'signed-out') {
|
||||
// The pane above already handles sign-in; billing stays out of builds without a service.
|
||||
return null
|
||||
}
|
||||
|
||||
const header = (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
|
||||
{t('auto.components.billing.title', 'Plan & billing')}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
disabled={loading}
|
||||
onClick={onRefresh}
|
||||
aria-label={t('auto.components.billing.refresh', 'Refresh plan')}
|
||||
>
|
||||
<RefreshCw className={cn(loading && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (result.status === 'failed') {
|
||||
return (
|
||||
<div className="space-y-3 border-t border-border/60 pt-5">
|
||||
{header}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('auto.components.billing.loadFailed', 'Could not load your plan. Try again.')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const entitlement = result.entitlement
|
||||
const tone = planBillingTone(entitlement)
|
||||
const hasPlan = entitlement.plan !== null && entitlement.status !== 'canceled'
|
||||
const annualCents = entitlement.offers?.team.annualPriceCentsPerSeat ?? null
|
||||
const teamFrom = annualCents === null ? null : formatCents(Math.round(annualCents / 12), locale)
|
||||
|
||||
return (
|
||||
<div className="space-y-4 border-t border-border/60 pt-5">
|
||||
{header}
|
||||
{hasPlan ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CreditCard className="size-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">{entitlement.planLabel}</p>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('auto.components.billing.seatsInterval', '{{value0}} seats · {{value1}}', {
|
||||
value0: String(entitlement.seats),
|
||||
value1:
|
||||
entitlement.interval === 'year'
|
||||
? t('auto.components.billing.annual', 'Annual')
|
||||
: t('auto.components.billing.monthly', 'Monthly')
|
||||
})}
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
tone === 'blocked' ? 'destructive' : tone === 'warning' ? 'secondary' : 'outline'
|
||||
}
|
||||
>
|
||||
{statusLabel(entitlement.status, t)}
|
||||
</Badge>
|
||||
</div>
|
||||
<UsageSummary entitlement={entitlement} />
|
||||
<OverageControls
|
||||
// Re-seed the cap field when the saved cap changes.
|
||||
key={entitlement.overage.spendCapCents}
|
||||
entitlement={entitlement}
|
||||
busy={busy}
|
||||
onSetOverage={onSetOverage}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<PaymentIssueNotice entitlement={entitlement} busy={busy} onUpdatePayment={onPortal} />
|
||||
|
||||
{entitlement.reason && entitlement.payment.pastDueSince === null ? (
|
||||
<p className="text-xs text-muted-foreground">{denyReasonCopy(entitlement.reason, t)}</p>
|
||||
) : null}
|
||||
|
||||
{!hasPlan && teamFrom ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
'auto.components.billing.teamFrom',
|
||||
'Team starts at {{value0}} per seat per month, billed annually.',
|
||||
{
|
||||
value0: teamFrom
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{hasPlan ? null : (
|
||||
<Button type="button" size="sm" onClick={onSeePlans}>
|
||||
<Sparkles />
|
||||
{t('auto.components.billing.choosePlan', 'Choose a plan')}
|
||||
</Button>
|
||||
)}
|
||||
{hasPlan && entitlement.canManageBilling && entitlement.payment.pastDueSince === null ? (
|
||||
<Button type="button" variant="outline" size="sm" disabled={busy} onClick={onPortal}>
|
||||
{t('auto.components.billing.manageBilling', 'Manage billing')}
|
||||
<ExternalLink />
|
||||
</Button>
|
||||
) : null}
|
||||
{hasPlan ? (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onSeePlans}>
|
||||
{t('auto.components.billing.comparePlans', 'Compare plans')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!entitlement.canManageBilling ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('auto.components.billing.askAdmin', 'Ask an org owner or admin to change the plan.')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{notice ? <p className="text-xs text-muted-foreground">{notice}</p> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useIntlLocale, useTranslate } from '@/i18n/use-translate'
|
||||
import type {
|
||||
OrcaBillingEntitlement,
|
||||
OrcaBillingOverageArgs
|
||||
} from '../../../../shared/orca-billing'
|
||||
import { formatDollars, formatHours, includedUsagePercent } from './plan-billing-format'
|
||||
|
||||
export function UsageSummary({
|
||||
entitlement
|
||||
}: {
|
||||
entitlement: OrcaBillingEntitlement
|
||||
}): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
const locale = useIntlLocale()
|
||||
const date = (ms: number): string =>
|
||||
new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(ms)
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-baseline justify-between gap-3 text-xs">
|
||||
<span className="font-medium">
|
||||
{t(
|
||||
'auto.components.billing.hoursUsedMonth',
|
||||
'{{value0}} of {{value1}} hours used this month',
|
||||
{
|
||||
value0: formatHours(entitlement.usedSeconds),
|
||||
value1: formatHours(entitlement.includedSeconds)
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{t('auto.components.billing.hoursLeft', '{{value0}} hours left', {
|
||||
value0: formatHours(entitlement.remainingIncludedSeconds)
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={includedUsagePercent(entitlement)}
|
||||
aria-label={t('auto.components.billing.usageLabel', 'Included hours used')}
|
||||
/>
|
||||
{entitlement.usageWarningPercent !== null ? (
|
||||
<p className="text-xs font-medium">
|
||||
{entitlement.usageWarningPercent >= 100
|
||||
? t('auto.components.billing.warn100', 'All included hours for this month are used.')
|
||||
: t(
|
||||
'auto.components.billing.warnPercent',
|
||||
'You have used {{value0}}% of this month’s included hours.',
|
||||
{
|
||||
value0: String(entitlement.usageWarningPercent)
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{entitlement.usageWindow
|
||||
? t('auto.components.billing.resets', 'Hours reset {{value0}}', {
|
||||
value0: date(entitlement.usageWindow.end)
|
||||
})
|
||||
: null}
|
||||
{entitlement.period
|
||||
? ` · ${
|
||||
entitlement.cancelAtPeriodEnd
|
||||
? t('auto.components.billing.endsOn', 'Plan ends {{value0}}', {
|
||||
value0: date(entitlement.period.end)
|
||||
})
|
||||
: t('auto.components.billing.renewsOn', 'Renews {{value0}}', {
|
||||
value0: date(entitlement.period.end)
|
||||
})
|
||||
}`
|
||||
: null}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function OverageControls({
|
||||
entitlement,
|
||||
busy,
|
||||
onSetOverage
|
||||
}: {
|
||||
entitlement: OrcaBillingEntitlement
|
||||
busy: boolean
|
||||
onSetOverage: (args: OrcaBillingOverageArgs) => void
|
||||
}): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
const { overage } = entitlement
|
||||
const [capDollars, setCapDollars] = useState(String(Math.round(overage.spendCapCents / 100)))
|
||||
const parsedCap = Number(capDollars)
|
||||
const capValid = capDollars.trim() !== '' && Number.isInteger(parsedCap) && parsedCap >= 0
|
||||
const rate = formatDollars(overage.centsPerHour)
|
||||
return (
|
||||
<div className="space-y-3 rounded-md border border-border/60 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{t('auto.components.billing.overageTitle', 'Overage')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
'auto.components.billing.overageDescription',
|
||||
'Keep starting sandboxes after included hours at {{value0}}/hr Standard ({{value1}}/hr Large).',
|
||||
{ value0: rate, value1: formatDollars(overage.centsPerHour * 2) }
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={overage.optedIn}
|
||||
disabled={busy || !entitlement.canManageBilling}
|
||||
onCheckedChange={(enabled) => onSetOverage({ enabled })}
|
||||
aria-label={t('auto.components.billing.overageToggle', 'Allow overage')}
|
||||
/>
|
||||
</div>
|
||||
{overage.optedIn ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
'auto.components.billing.overageSpent',
|
||||
'{{value0}} of {{value1}} monthly cap spent',
|
||||
{
|
||||
value0: formatDollars(overage.spentCents),
|
||||
value1: formatDollars(overage.spendCapCents)
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
{entitlement.canManageBilling ? (
|
||||
<form
|
||||
className="flex items-center gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (capValid) {
|
||||
onSetOverage({ enabled: true, spendCapCents: parsedCap * 100 })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('auto.components.billing.capLabel', 'Monthly cap ($)')}
|
||||
</span>
|
||||
<div className="w-24">
|
||||
<Input
|
||||
inputMode="numeric"
|
||||
value={capDollars}
|
||||
onChange={(event) => setCapDollars(event.target.value)}
|
||||
aria-label={t('auto.components.billing.capLabel', 'Monthly cap ($)')}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" size="sm" variant="outline" disabled={busy || !capValid}>
|
||||
{t('auto.components.billing.saveCap', 'Save')}
|
||||
</Button>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// User-facing copy for billing states, kept apart from the components that render it.
|
||||
import type { Translate } from '@/i18n/use-translate'
|
||||
import type {
|
||||
OrcaBillingDenyReason,
|
||||
OrcaBillingOpenResult,
|
||||
OrcaBillingOverageResult
|
||||
} from '../../../../shared/orca-billing'
|
||||
|
||||
export function denyReasonCopy(reason: OrcaBillingDenyReason, t: Translate): string {
|
||||
switch (reason) {
|
||||
case 'no_subscription':
|
||||
return t('auto.components.billing.reasonNoPlan', 'Hosted sandboxes need a paid plan.')
|
||||
case 'subscription_inactive':
|
||||
return t(
|
||||
'auto.components.billing.reasonInactive',
|
||||
'Your plan is not active. Choose a plan to start sandboxes.'
|
||||
)
|
||||
case 'payment_grace_expired':
|
||||
return t(
|
||||
'auto.components.billing.reasonGraceExpired',
|
||||
'Your payment is overdue, so sandboxes are suspended. Update your payment method to resume.'
|
||||
)
|
||||
case 'payment_past_due':
|
||||
return t(
|
||||
'auto.components.billing.reasonPastDue',
|
||||
'Your last payment failed. Update your payment method to start new sandboxes.'
|
||||
)
|
||||
case 'included_hours_exhausted':
|
||||
return t(
|
||||
'auto.components.billing.reasonExhaustedOptIn',
|
||||
'You have used all included hours this month. Turn on overage to keep starting sandboxes.'
|
||||
)
|
||||
case 'overage_cap_reached':
|
||||
return t(
|
||||
'auto.components.billing.reasonCapRaise',
|
||||
'You reached this month’s overage spend cap. An admin can raise it.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function statusLabel(status: string, t: Translate): string {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return t('auto.components.billing.statusActive', 'Active')
|
||||
case 'trialing':
|
||||
return t('auto.components.billing.statusTrial', 'Trial')
|
||||
case 'past_due':
|
||||
return t('auto.components.billing.statusPastDue', 'Payment due')
|
||||
case 'canceled':
|
||||
return t('auto.components.billing.statusCanceled', 'Canceled')
|
||||
default:
|
||||
return t('auto.components.billing.statusInactive', 'Inactive')
|
||||
}
|
||||
}
|
||||
|
||||
export function openFailureCopy(result: OrcaBillingOpenResult, t: Translate): string | null {
|
||||
switch (result.status) {
|
||||
case 'opened':
|
||||
return null
|
||||
case 'forbidden':
|
||||
return t(
|
||||
'auto.components.billing.openForbidden',
|
||||
'Only an org owner or admin can manage billing.'
|
||||
)
|
||||
case 'already-subscribed':
|
||||
return t(
|
||||
'auto.components.billing.openAlreadySubscribed',
|
||||
'This account already has a plan. Use Manage billing to change it.'
|
||||
)
|
||||
case 'no-billing-account':
|
||||
return t(
|
||||
'auto.components.billing.openNoAccount',
|
||||
'There is no billing account yet. Choose a plan first.'
|
||||
)
|
||||
case 'signed-out':
|
||||
return t('auto.components.billing.openSignedOut', 'Sign in to Orca again to manage billing.')
|
||||
case 'unconfigured':
|
||||
return t('auto.components.billing.openUnconfigured', 'Billing is unavailable in this build.')
|
||||
case 'failed':
|
||||
return t('auto.components.billing.openFailed', 'Could not reach billing. Try again.')
|
||||
}
|
||||
}
|
||||
|
||||
export function overageFailureCopy(result: OrcaBillingOverageResult, t: Translate): string | null {
|
||||
switch (result.status) {
|
||||
case 'ok':
|
||||
return null
|
||||
case 'cap-too-high':
|
||||
return t('auto.components.billing.capTooHigh', 'That spend cap is above the allowed maximum.')
|
||||
case 'no-subscription':
|
||||
return t('auto.components.billing.overageNoPlan', 'Overage needs an active plan.')
|
||||
case 'forbidden':
|
||||
return t(
|
||||
'auto.components.billing.openForbidden',
|
||||
'Only an org owner or admin can manage billing.'
|
||||
)
|
||||
case 'signed-out':
|
||||
return t('auto.components.billing.openSignedOut', 'Sign in to Orca again to manage billing.')
|
||||
case 'unconfigured':
|
||||
return t('auto.components.billing.openUnconfigured', 'Billing is unavailable in this build.')
|
||||
case 'failed':
|
||||
return t('auto.components.billing.openFailed', 'Could not reach billing. Try again.')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Shared sample entitlements for billing tests and component fixtures.
|
||||
import type { OrcaBillingEntitlement, OrcaBillingOffers } from '../../../../shared/orca-billing'
|
||||
|
||||
const HOUR = 3600
|
||||
|
||||
export const sampleOffers: OrcaBillingOffers = {
|
||||
team: {
|
||||
label: 'Team',
|
||||
monthlyPriceLabel: '$50/seat/mo',
|
||||
annualPriceLabel: '$480/seat/yr',
|
||||
monthlyPriceCentsPerSeat: 5000,
|
||||
annualPriceCentsPerSeat: 48000,
|
||||
largeMachineWeight: 2,
|
||||
includedHoursPerSeat: 60,
|
||||
concurrentSandboxesPerSeat: 3,
|
||||
idleSleepMinutes: 15,
|
||||
overageCentsPerHour: 75,
|
||||
defaultSpendCapCents: 2500
|
||||
},
|
||||
enterprise: {
|
||||
label: 'Enterprise',
|
||||
priceLabel: 'Custom',
|
||||
contactUrl: 'https://onorca.dev/contact-sales'
|
||||
}
|
||||
}
|
||||
|
||||
export const sampleTeam: OrcaBillingEntitlement = {
|
||||
plan: 'team',
|
||||
planLabel: 'Team',
|
||||
status: 'active',
|
||||
interval: 'month',
|
||||
seats: 2,
|
||||
seatChange: null,
|
||||
period: { start: Date.UTC(2026, 10, 3), end: Date.UTC(2026, 11, 3) },
|
||||
usageWindow: { start: Date.UTC(2026, 10, 3), end: Date.UTC(2026, 11, 3) },
|
||||
cancelAtPeriodEnd: false,
|
||||
includedSeconds: 120 * HOUR,
|
||||
usedSeconds: 41.5 * HOUR,
|
||||
remainingIncludedSeconds: 78.5 * HOUR,
|
||||
usageWarningPercent: null,
|
||||
overageSeconds: 0,
|
||||
overage: { optedIn: false, active: false, spendCapCents: 2500, spentCents: 0, centsPerHour: 75 },
|
||||
payment: { pastDueSince: null, graceEndsAt: null, suspendRunning: false },
|
||||
canStartSandbox: true,
|
||||
reason: null,
|
||||
canManageBilling: true,
|
||||
offers: sampleOffers
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
formatDollars,
|
||||
formatHours,
|
||||
includedUsagePercent,
|
||||
planBillingTone
|
||||
} from './plan-billing-format'
|
||||
import { sampleTeam as base } from './plan-billing-fixtures'
|
||||
|
||||
describe('plan billing format', () => {
|
||||
it('formats hours with at most one decimal', () => {
|
||||
expect(formatHours(0)).toBe('0')
|
||||
expect(formatHours(3600)).toBe('1')
|
||||
expect(formatHours(5400)).toBe('1.5')
|
||||
expect(formatHours(3600 + 1)).toBe('1')
|
||||
expect(formatHours(-5)).toBe('0')
|
||||
})
|
||||
|
||||
it('formats dollars, dropping cents for whole amounts', () => {
|
||||
expect(formatDollars(75)).toBe('$0.75')
|
||||
expect(formatDollars(150)).toBe('$1.50')
|
||||
expect(formatDollars(2500)).toBe('$25')
|
||||
})
|
||||
|
||||
it('clamps the usage bar', () => {
|
||||
expect(includedUsagePercent({ ...base, usedSeconds: 24 * 3600 })).toBe(20)
|
||||
expect(includedUsagePercent({ ...base, usedSeconds: 200 * 3600 })).toBe(100)
|
||||
expect(includedUsagePercent({ ...base, includedSeconds: 0 })).toBe(0)
|
||||
})
|
||||
|
||||
it('flags exhausted, ending and blocked plans', () => {
|
||||
expect(planBillingTone(base)).toBe('ok')
|
||||
expect(planBillingTone({ ...base, remainingIncludedSeconds: 0 })).toBe('warning')
|
||||
expect(planBillingTone({ ...base, cancelAtPeriodEnd: true })).toBe('warning')
|
||||
expect(planBillingTone({ ...base, canStartSandbox: false, reason: 'payment_past_due' })).toBe(
|
||||
'blocked'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { OrcaBillingEntitlement } from '../../../../shared/orca-billing'
|
||||
|
||||
/** Hours with at most one decimal; whole numbers drop the decimal. */
|
||||
export function formatHours(seconds: number): string {
|
||||
const hours = Math.max(0, seconds) / 3600
|
||||
const rounded = Math.round(hours * 10) / 10
|
||||
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1)
|
||||
}
|
||||
|
||||
/** Percent of included hours used, clamped to 0..100 (overage shows as full). */
|
||||
export function includedUsagePercent(entitlement: OrcaBillingEntitlement): number {
|
||||
if (entitlement.includedSeconds <= 0) {
|
||||
return 0
|
||||
}
|
||||
return Math.min(100, Math.round((entitlement.usedSeconds / entitlement.includedSeconds) * 100))
|
||||
}
|
||||
|
||||
export type PlanBillingTone = 'ok' | 'warning' | 'blocked'
|
||||
|
||||
export function planBillingTone(entitlement: OrcaBillingEntitlement): PlanBillingTone {
|
||||
if (!entitlement.canStartSandbox) {
|
||||
return 'blocked'
|
||||
}
|
||||
return entitlement.remainingIncludedSeconds === 0 || entitlement.cancelAtPeriodEnd
|
||||
? 'warning'
|
||||
: 'ok'
|
||||
}
|
||||
|
||||
/** "$0.75", "$25" — whole dollars drop the cents. */
|
||||
export function formatDollars(cents: number): string {
|
||||
const dollars = Math.max(0, cents) / 100
|
||||
return Number.isInteger(dollars) ? `$${dollars}` : `$${dollars.toFixed(2)}`
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Building2, ExternalLink } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useTranslate } from '@/i18n/use-translate'
|
||||
import type { OrcaBillingOffers } from '../../../../../shared/orca-billing'
|
||||
import { PlanCardShell } from './PlanCardShell'
|
||||
|
||||
export function EnterprisePlanCard({
|
||||
enterprise,
|
||||
onContactSales
|
||||
}: {
|
||||
enterprise: OrcaBillingOffers['enterprise']
|
||||
onContactSales: () => void
|
||||
}): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
return (
|
||||
<PlanCardShell
|
||||
id="plans-enterprise-title"
|
||||
title={
|
||||
<>
|
||||
<Building2 className="size-4 text-muted-foreground" />
|
||||
{enterprise.label}
|
||||
</>
|
||||
}
|
||||
price={<p className="text-3xl font-semibold tracking-tight">{enterprise.priceLabel}</p>}
|
||||
priceNote={t('auto.components.billing.plans.entPriced', 'Priced for your organization')}
|
||||
featuresLead={t('auto.components.billing.plans.entLead', 'Everything in Team, plus:')}
|
||||
features={[
|
||||
t('auto.components.billing.plans.entByoc', 'Run sandboxes in your own cloud account'),
|
||||
t('auto.components.billing.plans.entSso', 'SSO, SCIM provisioning and audit logs'),
|
||||
t('auto.components.billing.plans.entNetwork', 'Network policy and egress controls'),
|
||||
t('auto.components.billing.plans.entTerms', 'Custom limits, invoicing and terms')
|
||||
]}
|
||||
footer={
|
||||
<>
|
||||
<Button type="button" variant="outline" className="w-full" onClick={onContactSales}>
|
||||
{t('auto.components.billing.plans.contactSales', 'Contact sales')}
|
||||
<ExternalLink />
|
||||
</Button>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
{t(
|
||||
'auto.components.billing.plans.entHint',
|
||||
'Tell us about your team and where you run.'
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Check } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* Shared plan card layout. Both cards use the same rows (title, price, details, features,
|
||||
* footer) and stretch to the same height, so their dividers and buttons line up.
|
||||
*/
|
||||
export function PlanCardShell({
|
||||
id,
|
||||
title,
|
||||
badge,
|
||||
price,
|
||||
priceNote,
|
||||
details,
|
||||
featuresLead,
|
||||
features,
|
||||
footer
|
||||
}: {
|
||||
id: string
|
||||
title: ReactNode
|
||||
badge?: ReactNode
|
||||
price: ReactNode
|
||||
priceNote: ReactNode
|
||||
details?: ReactNode
|
||||
featuresLead?: string
|
||||
features: string[]
|
||||
footer: ReactNode
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<section
|
||||
aria-labelledby={id}
|
||||
className="flex h-full flex-col gap-5 rounded-xl border border-border bg-card p-5 shadow-xs"
|
||||
>
|
||||
<div className="flex min-h-6 items-center justify-between gap-3">
|
||||
<h3 id={id} className="flex items-center gap-2 text-base font-semibold">
|
||||
{title}
|
||||
</h3>
|
||||
{badge}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{price}
|
||||
<p className="text-xs text-muted-foreground">{priceNote}</p>
|
||||
</div>
|
||||
{details}
|
||||
<div className="flex-1 space-y-2.5">
|
||||
{featuresLead ? (
|
||||
<p className="text-xs font-medium text-muted-foreground">{featuresLead}</p>
|
||||
) : null}
|
||||
<ul className="space-y-2.5">
|
||||
{features.map((feature) => (
|
||||
<li key={feature} className="flex items-start gap-2 text-sm text-pretty">
|
||||
<Check className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{/* Fixed footer height (button + up to two lines) keeps both cards' dividers level. */}
|
||||
<div className="flex min-h-[6rem] flex-col justify-start gap-2 border-t border-border/60 pt-4">
|
||||
{footer}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { useTranslate } from '@/i18n/use-translate'
|
||||
import { PlansView, isSubscribed, type PlansViewProps } from './PlansView'
|
||||
|
||||
/** The plans page surface: heading plus plans. Rendered inside a <Dialog>. */
|
||||
export function PlansDialogContent(props: PlansViewProps): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
const subscribed = isSubscribed(props.result)
|
||||
return (
|
||||
<DialogContent
|
||||
scrollable
|
||||
className="sm:max-w-4xl"
|
||||
// Focus the dialog itself so no button looks pre-selected; Tab reaches the actions.
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault()
|
||||
if (event.currentTarget instanceof HTMLElement) {
|
||||
event.currentTarget.focus()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogHeader className="items-center text-center">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
|
||||
{t('auto.components.billing.plans.eyebrow', 'Orca Cloud')}
|
||||
</p>
|
||||
<DialogTitle size="display">
|
||||
{subscribed
|
||||
? t('auto.components.billing.plans.titleSubscribed', 'Your Orca Cloud plan')
|
||||
: t('auto.components.billing.plans.title', 'Cloud sandboxes for your whole team')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="max-w-xl">
|
||||
{subscribed
|
||||
? t(
|
||||
'auto.components.billing.plans.subtitleSubscribed',
|
||||
'Usage this month, your plan, and what Enterprise adds.'
|
||||
)
|
||||
: t(
|
||||
'auto.components.billing.plans.subtitle2',
|
||||
'Run Orca agents in hosted sandboxes that keep going when you step away.'
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<PlansView {...props} />
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useState } from 'react'
|
||||
import { Dialog } from '@/components/ui/dialog'
|
||||
import { useTranslate } from '@/i18n/use-translate'
|
||||
import type { OrcaBillingOpenResult } from '../../../../../shared/orca-billing'
|
||||
import { openFailureCopy } from '../plan-billing-copy'
|
||||
import { useOrcaBillingStatus } from '../use-orca-billing-status'
|
||||
import { PlansDialogContent } from './PlansDialogContent'
|
||||
import { closePlansPage, usePlansPageState } from './plans-page-store'
|
||||
import { useOrgSeatCount } from './use-org-seat-count'
|
||||
import { useAppStore } from '@/store/index'
|
||||
|
||||
/**
|
||||
* The plans page. A dialog because choosing a plan is a decision step in front of Checkout;
|
||||
* it reads the billing service's offers, so prices and limits are never hardcoded here.
|
||||
*/
|
||||
export function PlansDialogHost(): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
const { open } = usePlansPageState()
|
||||
const billing = useOrcaBillingStatus(open)
|
||||
const orgId = useAppStore((state) => state.orcaProfileAuthStatus?.cloud?.activeOrgId ?? null)
|
||||
const seats = useOrgSeatCount(open ? orgId : null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [notice, setNotice] = useState<string | null>(null)
|
||||
|
||||
const run = async (action: () => Promise<OrcaBillingOpenResult>): Promise<void> => {
|
||||
setBusy(true)
|
||||
setNotice(null)
|
||||
const outcome = await action().catch((error: unknown): OrcaBillingOpenResult => ({
|
||||
status: 'failed',
|
||||
error: String(error)
|
||||
}))
|
||||
setBusy(false)
|
||||
setNotice(
|
||||
outcome.status === 'opened'
|
||||
? t(
|
||||
'auto.components.billing.continueInBrowser',
|
||||
'Continue in your browser. This updates when you come back.'
|
||||
)
|
||||
: openFailureCopy(outcome, t)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) {
|
||||
closePlansPage()
|
||||
setNotice(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{open ? (
|
||||
<PlansDialogContent
|
||||
// Remount once the status arrives so the interval starts fresh.
|
||||
key={billing.result ? 'ready' : 'loading'}
|
||||
result={billing.result}
|
||||
busy={busy}
|
||||
notice={notice}
|
||||
seats={seats}
|
||||
onCheckout={({ interval, seats: count }) =>
|
||||
void run(() =>
|
||||
window.api.orcaProfiles.billingOpenCheckout({ plan: 'team', interval, seats: count })
|
||||
)
|
||||
}
|
||||
onManage={() => void run(() => window.api.orcaProfiles.billingOpenPortal())}
|
||||
onContactSales={(url) => void window.api.shell.openUrl(url)}
|
||||
onRetry={billing.refresh}
|
||||
/>
|
||||
) : null}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useTranslate } from '@/i18n/use-translate'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { OrcaBillingInterval } from '../../../../../shared/orca-billing'
|
||||
|
||||
/** Monthly / Annual switch with a sliding thumb (still under reduced motion). */
|
||||
export function PlansIntervalToggle({
|
||||
value,
|
||||
savingsPercent,
|
||||
disabled,
|
||||
onChange
|
||||
}: {
|
||||
value: OrcaBillingInterval
|
||||
savingsPercent: number | null
|
||||
disabled: boolean
|
||||
onChange: (interval: OrcaBillingInterval) => void
|
||||
}): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
const options: { interval: OrcaBillingInterval; label: string; hint: string | null }[] = [
|
||||
{ interval: 'month', label: t('auto.components.billing.plans.monthly', 'Monthly'), hint: null },
|
||||
{
|
||||
interval: 'year',
|
||||
label: t('auto.components.billing.plans.annual', 'Annual'),
|
||||
hint:
|
||||
savingsPercent === null
|
||||
? null
|
||||
: t('auto.components.billing.plans.saveShort', '−{{value0}}%', {
|
||||
value0: String(savingsPercent)
|
||||
})
|
||||
}
|
||||
]
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t('auto.components.billing.plans.billingPeriod', 'Billing period')}
|
||||
className="relative grid grid-cols-2 rounded-full border border-border bg-muted p-0.5"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'absolute inset-y-0.5 left-0.5 w-[calc(50%-2px)] rounded-full border border-border bg-background shadow-xs transition-transform duration-200 ease-out motion-reduce:transition-none dark:bg-secondary',
|
||||
value === 'year' && 'translate-x-full'
|
||||
)}
|
||||
/>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.interval}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-pressed={value === option.interval}
|
||||
onClick={() => onChange(option.interval)}
|
||||
className={cn(
|
||||
'relative z-10 flex items-center justify-center gap-1.5 rounded-full px-4 py-1 text-xs font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:opacity-60',
|
||||
value === option.interval
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
{option.hint ? (
|
||||
<span className="rounded-full bg-foreground/10 px-1.5 text-[10px] font-semibold tabular-nums">
|
||||
{option.hint}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { defineFixture } from '../../../component-fixtures/component-fixture'
|
||||
import type { OrcaBillingEntitlement } from '../../../../../shared/orca-billing'
|
||||
import { sampleTeam } from '../plan-billing-fixtures'
|
||||
import { Dialog } from '@/components/ui/dialog'
|
||||
import { PlansDialogContent } from './PlansDialogContent'
|
||||
import type { PlansViewProps } from './PlansView'
|
||||
|
||||
const HOUR = 3600
|
||||
|
||||
const handlers = {
|
||||
busy: false,
|
||||
notice: null,
|
||||
seats: 4,
|
||||
onCheckout: () => {},
|
||||
onManage: () => {},
|
||||
onContactSales: () => {},
|
||||
onRetry: () => {}
|
||||
}
|
||||
|
||||
const noPlan: OrcaBillingEntitlement = {
|
||||
...sampleTeam,
|
||||
plan: null,
|
||||
planLabel: null,
|
||||
status: 'none',
|
||||
interval: null,
|
||||
seats: 0,
|
||||
period: null,
|
||||
usageWindow: null,
|
||||
includedSeconds: 0,
|
||||
usedSeconds: 0,
|
||||
remainingIncludedSeconds: 0,
|
||||
canStartSandbox: false,
|
||||
reason: 'no_subscription'
|
||||
}
|
||||
|
||||
const width = 1280
|
||||
|
||||
function Page(props: Pick<PlansViewProps, 'result'> & Partial<PlansViewProps>): React.JSX.Element {
|
||||
return (
|
||||
<Dialog open modal={false}>
|
||||
<PlansDialogContent {...handlers} {...props} />
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export const choosePlan = defineFixture({
|
||||
width,
|
||||
render: () => <Page result={{ status: 'ok', entitlement: noPlan }} />
|
||||
})
|
||||
|
||||
export const memberCannotBuy = defineFixture({
|
||||
width,
|
||||
render: () => (
|
||||
<Page result={{ status: 'ok', entitlement: { ...noPlan, canManageBilling: false } }} />
|
||||
)
|
||||
})
|
||||
|
||||
export const currentPlan = defineFixture({
|
||||
width,
|
||||
render: () => (
|
||||
<Page
|
||||
result={{
|
||||
status: 'ok',
|
||||
entitlement: {
|
||||
...sampleTeam,
|
||||
interval: 'year',
|
||||
seats: 4,
|
||||
includedSeconds: 240 * HOUR,
|
||||
usedSeconds: 96 * HOUR,
|
||||
remainingIncludedSeconds: 144 * HOUR
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
export const paymentFailed = defineFixture({
|
||||
width,
|
||||
render: () => (
|
||||
<Page
|
||||
result={{
|
||||
status: 'ok',
|
||||
entitlement: {
|
||||
...sampleTeam,
|
||||
status: 'past_due',
|
||||
payment: {
|
||||
pastDueSince: Date.UTC(2026, 10, 8, 9),
|
||||
graceEndsAt: Date.UTC(2026, 10, 12, 9),
|
||||
suspendRunning: false
|
||||
},
|
||||
canStartSandbox: false,
|
||||
reason: 'payment_past_due'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
export const checkoutOpened = defineFixture({
|
||||
width,
|
||||
render: () => (
|
||||
<Page
|
||||
busy
|
||||
notice="Continue in your browser. This updates when you come back."
|
||||
result={{ status: 'ok', entitlement: noPlan }}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
export const loading = defineFixture({
|
||||
width,
|
||||
render: () => <Page result={null} />
|
||||
})
|
||||
|
||||
export const loadFailed = defineFixture({
|
||||
width,
|
||||
render: () => <Page result={{ status: 'failed', error: 'timeout' }} />
|
||||
})
|
||||
@@ -0,0 +1,273 @@
|
||||
import { useState } from 'react'
|
||||
import { KeyRound, LaptopMinimal, RefreshCw, Zap } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { useIntlLocale, useTranslate } from '@/i18n/use-translate'
|
||||
import {
|
||||
ORCA_BILLING_MAX_SEATS,
|
||||
type OrcaBillingEntitlement,
|
||||
type OrcaBillingInterval,
|
||||
type OrcaBillingStatusResult
|
||||
} from '../../../../../shared/orca-billing'
|
||||
import { PaymentIssueNotice } from '../PaymentIssueNotice'
|
||||
import { statusLabel } from '../plan-billing-copy'
|
||||
import { formatHours, includedUsagePercent } from '../plan-billing-format'
|
||||
import { EnterprisePlanCard } from './EnterprisePlanCard'
|
||||
import { PlansIntervalToggle } from './PlansIntervalToggle'
|
||||
import { TeamPlanCard, type TeamPlanCardMode } from './TeamPlanCard'
|
||||
import { annualSavingsPercent, clampSeats } from './plans-pricing'
|
||||
|
||||
export type PlansViewProps = {
|
||||
result: OrcaBillingStatusResult | null
|
||||
busy: boolean
|
||||
notice: string | null
|
||||
/** Seats Checkout will bill: org members, or 1 for a personal account. */
|
||||
seats: number
|
||||
onCheckout: (args: { interval: OrcaBillingInterval; seats: number }) => void
|
||||
onManage: () => void
|
||||
onContactSales: (url: string) => void
|
||||
onRetry: () => void
|
||||
}
|
||||
|
||||
export function isSubscribed(result: OrcaBillingStatusResult | null): boolean {
|
||||
return (
|
||||
result?.status === 'ok' &&
|
||||
result.entitlement.plan !== null &&
|
||||
result.entitlement.status !== 'canceled'
|
||||
)
|
||||
}
|
||||
|
||||
function ValueProps(): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
// Same promises as the marketing site's /cloud page.
|
||||
const items = [
|
||||
{
|
||||
icon: LaptopMinimal,
|
||||
title: t('auto.components.billing.plans.vpRunningTitle', 'Agents keep running'),
|
||||
body: t(
|
||||
'auto.components.billing.plans.vpRunningBody2',
|
||||
'Sandboxes keep working while you are away and report back when they finish.'
|
||||
)
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
title: t('auto.components.billing.plans.vpWakeTitle', 'Wake in seconds'),
|
||||
body: t(
|
||||
'auto.components.billing.plans.vpWakeBody',
|
||||
'Idle sandboxes sleep to save hours and resume right where they left off.'
|
||||
)
|
||||
},
|
||||
{
|
||||
icon: KeyRound,
|
||||
title: t('auto.components.billing.plans.vpSecretsTitle', 'Secrets never stored in the VM'),
|
||||
body: t(
|
||||
'auto.components.billing.plans.vpSecretsBody2',
|
||||
'Credentials are injected into outbound requests and never touch the VM’s disk.'
|
||||
)
|
||||
}
|
||||
]
|
||||
return (
|
||||
<ul className="grid gap-4 sm:grid-cols-3">
|
||||
{items.map((item) => (
|
||||
<li key={item.title} className="flex items-start gap-3">
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||
<item.icon className="size-4" />
|
||||
</span>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-medium">{item.title}</p>
|
||||
<p className="text-xs leading-5 text-muted-foreground text-pretty">{item.body}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
function CurrentPlanStrip({
|
||||
entitlement
|
||||
}: {
|
||||
entitlement: OrcaBillingEntitlement
|
||||
}): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
const locale = useIntlLocale()
|
||||
const paymentIssue = entitlement.payment.pastDueSince !== null
|
||||
const resets = entitlement.usageWindow
|
||||
? new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(entitlement.usageWindow.end)
|
||||
: null
|
||||
return (
|
||||
<div className="space-y-2 rounded-xl border border-border bg-card p-4 shadow-xs">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="font-medium">{entitlement.planLabel}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{t('auto.components.billing.plans.seatsAndPeriod', 'Seats: {{value0}} · {{value1}}', {
|
||||
value0: String(entitlement.seats),
|
||||
value1:
|
||||
entitlement.interval === 'year'
|
||||
? t('auto.components.billing.annual', 'Annual')
|
||||
: t('auto.components.billing.monthly', 'Monthly')
|
||||
})}
|
||||
</span>
|
||||
{/* The payment banner carries the alarm; the badge stays quiet. */}
|
||||
<Badge variant={paymentIssue ? 'outline' : 'secondary'}>
|
||||
{statusLabel(entitlement.status, t)}
|
||||
</Badge>
|
||||
</div>
|
||||
<Progress
|
||||
value={includedUsagePercent(entitlement)}
|
||||
aria-label={t('auto.components.billing.usageLabel', 'Included hours used')}
|
||||
/>
|
||||
<div className="flex flex-wrap justify-between gap-2 text-xs text-muted-foreground tabular-nums">
|
||||
<span>
|
||||
{t(
|
||||
'auto.components.billing.hoursUsedMonth',
|
||||
'{{value0}} of {{value1}} hours used this month',
|
||||
{
|
||||
value0: formatHours(entitlement.usedSeconds),
|
||||
value1: formatHours(entitlement.includedSeconds)
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
{resets
|
||||
? t(
|
||||
'auto.components.billing.plans.leftResets',
|
||||
'{{value0}} hours left · resets {{value1}}',
|
||||
{
|
||||
value0: formatHours(entitlement.remainingIncludedSeconds),
|
||||
value1: resets
|
||||
}
|
||||
)
|
||||
: null}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PlaceholderCards({
|
||||
failed,
|
||||
onRetry
|
||||
}: {
|
||||
failed: boolean
|
||||
onRetry: () => void
|
||||
}): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ValueProps />
|
||||
<div className="relative grid gap-4 md:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
|
||||
<div aria-hidden className="h-[26rem] rounded-xl border border-border bg-muted/40" />
|
||||
<div aria-hidden className="h-[26rem] rounded-xl border border-border bg-muted/40" />
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
|
||||
{failed ? (
|
||||
<>
|
||||
<p>
|
||||
{t(
|
||||
'auto.components.billing.plans.loadFailed',
|
||||
'Could not load plans. Try again in a moment.'
|
||||
)}
|
||||
</p>
|
||||
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
|
||||
<RefreshCw />
|
||||
{t('auto.components.billing.plans.retry', 'Try again')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LoadingSpinner />
|
||||
<p>{t('auto.components.billing.plans.loading', 'Loading plans…')}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PlansView(props: PlansViewProps): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
const [interval, setInterval] = useState<OrcaBillingInterval>('month')
|
||||
const seats = clampSeats(props.seats, ORCA_BILLING_MAX_SEATS)
|
||||
const result = props.result
|
||||
|
||||
if (result?.status === 'signed-out' || result?.status === 'unconfigured') {
|
||||
return (
|
||||
<p className="py-12 text-center text-sm text-muted-foreground">
|
||||
{result.status === 'signed-out'
|
||||
? t('auto.components.billing.openSignedOut', 'Sign in to Orca again to manage billing.')
|
||||
: t('auto.components.billing.openUnconfigured', 'Billing is unavailable in this build.')}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (!result || result.status !== 'ok' || !result.entitlement.offers) {
|
||||
return <PlaceholderCards failed={result !== null} onRetry={props.onRetry} />
|
||||
}
|
||||
|
||||
const entitlement = result.entitlement
|
||||
const offers = result.entitlement.offers
|
||||
const subscribed = isSubscribed(result)
|
||||
const mode: TeamPlanCardMode = subscribed
|
||||
? 'current'
|
||||
: entitlement.canManageBilling
|
||||
? 'choose'
|
||||
: 'locked'
|
||||
const paymentIssue = entitlement.payment.pastDueSince !== null
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{subscribed ? (
|
||||
<>
|
||||
<PaymentIssueNotice
|
||||
entitlement={entitlement}
|
||||
busy={props.busy}
|
||||
onUpdatePayment={props.onManage}
|
||||
/>
|
||||
<CurrentPlanStrip entitlement={entitlement} />
|
||||
</>
|
||||
) : (
|
||||
<ValueProps />
|
||||
)}
|
||||
{mode === 'choose' ? (
|
||||
<PlansIntervalToggle
|
||||
value={interval}
|
||||
savingsPercent={annualSavingsPercent(offers.team)}
|
||||
disabled={props.busy}
|
||||
onChange={setInterval}
|
||||
/>
|
||||
) : null}
|
||||
<div className="grid items-stretch gap-4 md:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
|
||||
<TeamPlanCard
|
||||
team={offers.team}
|
||||
interval={subscribed && entitlement.interval ? entitlement.interval : interval}
|
||||
seats={seats}
|
||||
mode={mode}
|
||||
busy={props.busy}
|
||||
notice={props.notice}
|
||||
hideManage={paymentIssue}
|
||||
onCheckout={() => props.onCheckout({ interval, seats })}
|
||||
onManage={props.onManage}
|
||||
/>
|
||||
<EnterprisePlanCard
|
||||
enterprise={offers.enterprise}
|
||||
onContactSales={() => props.onContactSales(offers.enterprise.contactUrl)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 text-center text-xs text-muted-foreground text-pretty">
|
||||
<p>
|
||||
{t(
|
||||
'auto.components.billing.plans.footHours',
|
||||
'Hours reset every month, including on annual plans. When they run out, new sandboxes wait until the next month unless overage is on.'
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{t(
|
||||
'auto.components.billing.plans.footTerms',
|
||||
'Prices in USD, before tax. Overage stays off until an owner or admin turns it on and sets a spend cap. Cancel anytime; your plan runs to the end of the period.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { ExternalLink, Lock, Users } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { useIntlLocale, useTranslate } from '@/i18n/use-translate'
|
||||
import type { OrcaBillingInterval, OrcaBillingOffers } from '../../../../../shared/orca-billing'
|
||||
import { PlanCardShell } from './PlanCardShell'
|
||||
import { formatCents, perSeatMonthlyCents, periodTotalCents } from './plans-pricing'
|
||||
|
||||
export type TeamPlanCardMode = 'choose' | 'current' | 'locked'
|
||||
|
||||
export function TeamPlanCard({
|
||||
team,
|
||||
interval,
|
||||
seats,
|
||||
mode,
|
||||
busy,
|
||||
notice,
|
||||
hideManage,
|
||||
onCheckout,
|
||||
onManage
|
||||
}: {
|
||||
team: OrcaBillingOffers['team']
|
||||
interval: OrcaBillingInterval
|
||||
seats: number
|
||||
/** choose: can buy; current: already on Team; locked: only owners/admins can buy. */
|
||||
mode: TeamPlanCardMode
|
||||
busy: boolean
|
||||
notice: string | null
|
||||
/** A payment banner already offers the same Portal action. */
|
||||
hideManage: boolean
|
||||
onCheckout: () => void
|
||||
onManage: () => void
|
||||
}): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
const locale = useIntlLocale()
|
||||
const monthly = perSeatMonthlyCents(team, interval)
|
||||
const fullMonthly = team.monthlyPriceCentsPerSeat
|
||||
const total = periodTotalCents(team, interval, seats)
|
||||
const discounted =
|
||||
interval === 'year' && monthly !== null && fullMonthly !== null && monthly < fullMonthly
|
||||
|
||||
const features = [
|
||||
t(
|
||||
'auto.components.billing.plans.featureHours2',
|
||||
'{{value0}} Standard hours (4 vCPU / 8 GB) per seat each month, pooled across your team',
|
||||
{ value0: String(team.includedHoursPerSeat) }
|
||||
),
|
||||
t(
|
||||
'auto.components.billing.plans.featureLarge2',
|
||||
'Large (8 vCPU / 16 GB) uses hours at {{value0}}×',
|
||||
{
|
||||
value0: String(team.largeMachineWeight)
|
||||
}
|
||||
),
|
||||
t('auto.components.billing.plans.featureConcurrency', '{{value0}} running sandboxes per seat', {
|
||||
value0: String(team.concurrentSandboxesPerSeat)
|
||||
}),
|
||||
t('auto.components.billing.plans.featureSleep2', 'Sleeps after {{value0}} minutes idle', {
|
||||
value0: String(team.idleSleepMinutes)
|
||||
}),
|
||||
t(
|
||||
'auto.components.billing.plans.featureOverage',
|
||||
'Optional overage at {{value0}}/hr ({{value1}}/hr Large) with a monthly spend cap',
|
||||
{
|
||||
value0: formatCents(team.overageCentsPerHour, locale),
|
||||
value1: formatCents(team.overageCentsPerHour * team.largeMachineWeight, locale)
|
||||
}
|
||||
)
|
||||
]
|
||||
|
||||
const price = (
|
||||
// Keyed so the figure fades in again when the period changes.
|
||||
<p
|
||||
key={interval}
|
||||
className="flex items-baseline gap-1.5 animate-in fade-in-0 slide-in-from-bottom-1 duration-200 motion-reduce:animate-none"
|
||||
>
|
||||
{discounted && fullMonthly !== null ? (
|
||||
<span className="text-lg text-muted-foreground line-through tabular-nums">
|
||||
{formatCents(fullMonthly, locale)}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-3xl font-semibold tracking-tight tabular-nums">
|
||||
{monthly === null
|
||||
? interval === 'month'
|
||||
? team.monthlyPriceLabel
|
||||
: team.annualPriceLabel
|
||||
: formatCents(monthly, locale)}
|
||||
</span>
|
||||
{monthly === null ? null : (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('auto.components.billing.plans.perSeatMonth', 'per seat / month')}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)
|
||||
|
||||
const priceNote =
|
||||
interval === 'year'
|
||||
? team.annualPriceCentsPerSeat === null
|
||||
? t('auto.components.billing.plans.billedYearly', 'Billed yearly')
|
||||
: t(
|
||||
'auto.components.billing.plans.billedYearlyAmount',
|
||||
'Billed {{value0}} per seat yearly',
|
||||
{
|
||||
value0: formatCents(team.annualPriceCentsPerSeat, locale)
|
||||
}
|
||||
)
|
||||
: t('auto.components.billing.plans.billedMonthly', 'Billed monthly')
|
||||
|
||||
const details =
|
||||
mode === 'choose' ? (
|
||||
<div className="flex items-baseline justify-between gap-3 rounded-md bg-muted/60 px-3 py-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('auto.components.billing.plans.seatsOne', 'Seats: {{value0}} (one per team member)', {
|
||||
value0: String(seats)
|
||||
})}
|
||||
</p>
|
||||
{total === null ? null : (
|
||||
<p className="text-sm font-medium tabular-nums" aria-live="polite">
|
||||
{interval === 'year'
|
||||
? t('auto.components.billing.plans.totalYear', '{{value0}} / year', {
|
||||
value0: formatCents(total, locale)
|
||||
})
|
||||
: t('auto.components.billing.plans.totalMonth', '{{value0}} / month', {
|
||||
value0: formatCents(total, locale)
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : null
|
||||
|
||||
let footer: React.ReactNode
|
||||
if (mode === 'choose') {
|
||||
footer = (
|
||||
<>
|
||||
<Button type="button" className="w-full" disabled={busy} onClick={onCheckout}>
|
||||
{busy ? <LoadingSpinner /> : null}
|
||||
{busy
|
||||
? t('auto.components.billing.plans.waiting', 'Waiting for checkout…')
|
||||
: t('auto.components.billing.plans.checkout', 'Continue to checkout')}
|
||||
{busy ? null : <ExternalLink />}
|
||||
</Button>
|
||||
<p role="status" className="text-center text-xs text-muted-foreground text-pretty">
|
||||
{notice ??
|
||||
t(
|
||||
'auto.components.billing.plans.seatsFollow2',
|
||||
'Seats follow your team: new members add a seat now; removed seats drop off at renewal.'
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
} else if (mode === 'current') {
|
||||
footer = hideManage ? (
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
{t(
|
||||
'auto.components.billing.plans.fixPaymentAbove',
|
||||
'Update your payment method above to keep your plan active.'
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
disabled={busy}
|
||||
onClick={onManage}
|
||||
>
|
||||
{t('auto.components.billing.manageBilling', 'Manage billing')}
|
||||
<ExternalLink />
|
||||
</Button>
|
||||
<p role="status" className="text-center text-xs text-muted-foreground">
|
||||
{notice ??
|
||||
t(
|
||||
'auto.components.billing.plans.manageHint',
|
||||
'Switch monthly or annual, update payment, or cancel.'
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
} else {
|
||||
footer = (
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<Lock className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<p className="text-pretty">
|
||||
{t(
|
||||
'auto.components.billing.plans.lockedBody',
|
||||
'Only an owner or admin of your org can choose a plan. Ask one of them to open Plan & billing in Orca.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PlanCardShell
|
||||
id="plans-team-title"
|
||||
title={
|
||||
<>
|
||||
<Users className="size-4 text-muted-foreground" />
|
||||
{team.label}
|
||||
</>
|
||||
}
|
||||
badge={
|
||||
mode === 'current' ? (
|
||||
<Badge variant="secondary">
|
||||
{t('auto.components.billing.plans.currentPlan', 'Current plan')}
|
||||
</Badge>
|
||||
) : null
|
||||
}
|
||||
price={price}
|
||||
priceNote={priceNote}
|
||||
details={details}
|
||||
features={features}
|
||||
footer={footer}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Open state for the plans page. Any entry point (account menu, Settings, a "start a cloud
|
||||
// sandbox" upsell) calls openPlansPage(); PlansDialogHost renders it.
|
||||
import { useSyncExternalStore } from 'react'
|
||||
|
||||
export type PlansPageSource = 'account-menu' | 'settings' | 'sandbox' | 'other'
|
||||
|
||||
type PlansPageState = { open: boolean; source: PlansPageSource | null }
|
||||
|
||||
let state: PlansPageState = { open: false, source: null }
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
function set(next: PlansPageState): void {
|
||||
state = next
|
||||
for (const listener of listeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
export function openPlansPage(source: PlansPageSource = 'other'): void {
|
||||
set({ open: true, source })
|
||||
}
|
||||
|
||||
export function closePlansPage(): void {
|
||||
set({ open: false, source: state.source })
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
}
|
||||
|
||||
export function getPlansPageState(): PlansPageState {
|
||||
return state
|
||||
}
|
||||
|
||||
export function usePlansPageState(): PlansPageState {
|
||||
return useSyncExternalStore(subscribe, getPlansPageState)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { sampleOffers } from '../plan-billing-fixtures'
|
||||
import {
|
||||
annualSavingsPercent,
|
||||
clampSeats,
|
||||
formatCents,
|
||||
perSeatMonthlyCents,
|
||||
periodTotalCents
|
||||
} from './plans-pricing'
|
||||
import { closePlansPage, getPlansPageState, openPlansPage } from './plans-page-store'
|
||||
|
||||
const team = sampleOffers.team
|
||||
|
||||
describe('plans pricing', () => {
|
||||
it('shows annual as its monthly equivalent and totals per period', () => {
|
||||
expect(perSeatMonthlyCents(team, 'month')).toBe(5000)
|
||||
expect(perSeatMonthlyCents(team, 'year')).toBe(4000)
|
||||
expect(periodTotalCents(team, 'month', 3)).toBe(15000)
|
||||
expect(periodTotalCents(team, 'year', 3)).toBe(144000)
|
||||
expect(periodTotalCents(team, 'year', 0)).toBe(48000)
|
||||
})
|
||||
|
||||
it('computes the annual saving and hides it when annual is not cheaper', () => {
|
||||
expect(annualSavingsPercent(team)).toBe(20)
|
||||
expect(annualSavingsPercent({ ...team, annualPriceCentsPerSeat: 60000 })).toBeNull()
|
||||
expect(annualSavingsPercent({ ...team, monthlyPriceCentsPerSeat: null })).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to labels when an older service sends no cents', () => {
|
||||
const legacy = { ...team, monthlyPriceCentsPerSeat: null, annualPriceCentsPerSeat: null }
|
||||
expect(perSeatMonthlyCents(legacy, 'month')).toBeNull()
|
||||
expect(periodTotalCents(legacy, 'year', 2)).toBeNull()
|
||||
})
|
||||
|
||||
it('formats whole and fractional dollars', () => {
|
||||
expect(formatCents(5000, 'en-US')).toBe('$50')
|
||||
expect(formatCents(75, 'en-US')).toBe('$0.75')
|
||||
expect(formatCents(144000, 'en-US')).toBe('$1,440')
|
||||
})
|
||||
|
||||
it('clamps seats to 1..max', () => {
|
||||
expect(clampSeats(0, 500)).toBe(1)
|
||||
expect(clampSeats(Number.NaN, 500)).toBe(1)
|
||||
expect(clampSeats(900, 500)).toBe(500)
|
||||
expect(clampSeats(3.6, 500)).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('plans page store', () => {
|
||||
it('opens from any entry point and closes', () => {
|
||||
openPlansPage('sandbox')
|
||||
expect(getPlansPageState()).toEqual({ open: true, source: 'sandbox' })
|
||||
closePlansPage()
|
||||
expect(getPlansPageState().open).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
// Price math for the plans page. Every number comes from the billing service's offers.
|
||||
import type { OrcaBillingInterval, OrcaBillingOffers } from '../../../../../shared/orca-billing'
|
||||
|
||||
type TeamOffer = OrcaBillingOffers['team']
|
||||
|
||||
/** What one seat costs per month on this interval (annual shown as its monthly equivalent). */
|
||||
export function perSeatMonthlyCents(team: TeamOffer, interval: OrcaBillingInterval): number | null {
|
||||
if (interval === 'month') {
|
||||
return team.monthlyPriceCentsPerSeat
|
||||
}
|
||||
return team.annualPriceCentsPerSeat === null
|
||||
? null
|
||||
: Math.round(team.annualPriceCentsPerSeat / 12)
|
||||
}
|
||||
|
||||
/** The amount billed each period for `seats` seats. */
|
||||
export function periodTotalCents(
|
||||
team: TeamOffer,
|
||||
interval: OrcaBillingInterval,
|
||||
seats: number
|
||||
): number | null {
|
||||
const perSeat =
|
||||
interval === 'month' ? team.monthlyPriceCentsPerSeat : team.annualPriceCentsPerSeat
|
||||
return perSeat === null ? null : perSeat * Math.max(1, seats)
|
||||
}
|
||||
|
||||
/** Whole-percent saving of annual over twelve monthly payments, or null when not cheaper. */
|
||||
export function annualSavingsPercent(team: TeamOffer): number | null {
|
||||
const monthly = team.monthlyPriceCentsPerSeat
|
||||
const annual = team.annualPriceCentsPerSeat
|
||||
if (monthly === null || annual === null || monthly <= 0) {
|
||||
return null
|
||||
}
|
||||
const percent = Math.round((1 - annual / (monthly * 12)) * 100)
|
||||
return percent > 0 ? percent : null
|
||||
}
|
||||
|
||||
/** "$50", "$40", "$0.75", "$1,200" in the given locale. */
|
||||
export function formatCents(cents: number, locale: string): string {
|
||||
const whole = cents % 100 === 0
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: whole ? 0 : 2,
|
||||
maximumFractionDigits: whole ? 0 : 2
|
||||
}).format(cents / 100)
|
||||
}
|
||||
|
||||
export const MIN_SEATS = 1
|
||||
|
||||
export function clampSeats(value: number, max: number): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return MIN_SEATS
|
||||
}
|
||||
return Math.min(max, Math.max(MIN_SEATS, Math.round(value)))
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Seats Team bills at Checkout: the active org's members (pending invites excluded), or 1 for a
|
||||
* personal account. Billing re-reads the count from auth; this is only for showing the total.
|
||||
*/
|
||||
export function useOrgSeatCount(orgId: string | null): number {
|
||||
const [members, setMembers] = useState<{ orgId: string; count: number } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!orgId) {
|
||||
return
|
||||
}
|
||||
let disposed = false
|
||||
void window.api.orcaProfiles
|
||||
.orgMembersList({ orgId })
|
||||
.then((result) => {
|
||||
if (!disposed && result.status === 'ok') {
|
||||
setMembers({ orgId, count: result.roster.members.length })
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
return () => {
|
||||
disposed = true
|
||||
}
|
||||
}, [orgId])
|
||||
|
||||
if (!orgId) {
|
||||
return 1
|
||||
}
|
||||
return members?.orgId === orgId ? Math.max(1, members.count) : 1
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const openSettingsTarget = vi.fn()
|
||||
const openSettingsPage = vi.fn()
|
||||
vi.mock('@/store/index', () => ({
|
||||
useAppStore: { getState: () => ({ openSettingsTarget, openSettingsPage }) }
|
||||
}))
|
||||
|
||||
import {
|
||||
getRegisteredAccountMenuItems,
|
||||
selectVisibleAccountMenuItems
|
||||
} from '../sidebar/account-menu/account-menu-items'
|
||||
import { BILLING_CAPABILITY, registerPlanBillingMenuItem } from './register-plan-billing-menu-item'
|
||||
import { getPlansPageState } from './plans/plans-page-store'
|
||||
|
||||
describe('Plan & billing and Team account menu items', () => {
|
||||
it('register behind the billing capability and open Orca account settings', () => {
|
||||
const dispose = registerPlanBillingMenuItem()
|
||||
const items = getRegisteredAccountMenuItems()
|
||||
expect(items.map((item) => [item.id, item.label(), item.requiredCapability])).toEqual([
|
||||
['orca.plan-billing', 'Plan & billing', BILLING_CAPABILITY],
|
||||
['orca.team', 'Team', BILLING_CAPABILITY]
|
||||
])
|
||||
items[0]?.onSelect()
|
||||
expect(getPlansPageState()).toEqual({ open: true, source: 'account-menu' })
|
||||
items[1]?.onSelect()
|
||||
expect(openSettingsTarget).toHaveBeenCalledWith({ pane: 'orca-account', 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({})).toEqual([])
|
||||
expect(visible({ [BILLING_CAPABILITY]: true })).toEqual(['orca.plan-billing', 'orca.team'])
|
||||
|
||||
dispose()
|
||||
expect(getRegisteredAccountMenuItems()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { CreditCard, Users } from 'lucide-react'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useAppStore } from '@/store/index'
|
||||
import { registerAccountMenuItem } from '../sidebar/account-menu/account-menu-items'
|
||||
import { openPlansPage } from './plans/plans-page-store'
|
||||
|
||||
/** Cloud capability flag that turns billing on per account (server-controlled rollout). */
|
||||
export const BILLING_CAPABILITY = 'billing'
|
||||
|
||||
function openAccountSettings(): void {
|
||||
const state = useAppStore.getState()
|
||||
state.openSettingsTarget({ pane: 'orca-account', repoId: null })
|
||||
state.openSettingsPage()
|
||||
}
|
||||
|
||||
/** Account menu: "Plan & billing" opens the plans page; "Team" opens the Orca account settings. */
|
||||
export function registerPlanBillingMenuItem(): () => void {
|
||||
const disposeBilling = registerAccountMenuItem({
|
||||
id: 'orca.plan-billing',
|
||||
label: () => translate('auto.components.billing.menuItem', 'Plan & billing'),
|
||||
icon: CreditCard,
|
||||
order: 10,
|
||||
requiredCapability: BILLING_CAPABILITY,
|
||||
onSelect: () => openPlansPage('account-menu')
|
||||
})
|
||||
const disposeTeam = registerAccountMenuItem({
|
||||
id: 'orca.team',
|
||||
label: () => translate('auto.components.billing.team.menuItem', 'Team'),
|
||||
icon: Users,
|
||||
order: 11,
|
||||
requiredCapability: BILLING_CAPABILITY,
|
||||
onSelect: openAccountSettings
|
||||
})
|
||||
return () => {
|
||||
disposeBilling()
|
||||
disposeTeam()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useState } from 'react'
|
||||
import { useConfirmationDialog } from '@/components/confirmation-dialog-context'
|
||||
import { useTranslate } from '@/i18n/use-translate'
|
||||
import type { OrcaProfileOrgMemberMutationResult } from '../../../../../shared/orca-profiles'
|
||||
import { useOrcaBillingStatus } from '../use-orca-billing-status'
|
||||
import { TeamMembersView } from './TeamMembersView'
|
||||
import { mutationFailureCopy } from './team-roles'
|
||||
import { useOrgRoster } from './use-org-roster'
|
||||
|
||||
/**
|
||||
* Team members for the active org: list, invite by email (accepted at Orca's sign-in provider),
|
||||
* change roles, remove. Membership lives in Orca Cloud auth; Team seats follow it automatically
|
||||
* (auth pushes removals, billing re-reads the count), so nothing here touches Stripe.
|
||||
*/
|
||||
export function TeamMembersSection({
|
||||
orgId,
|
||||
viewerUserId
|
||||
}: {
|
||||
orgId: string
|
||||
viewerUserId: string | null
|
||||
}): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
const confirm = useConfirmationDialog()
|
||||
const roster = useOrgRoster(orgId)
|
||||
const billing = useOrcaBillingStatus(true)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [notice, setNotice] = useState<string | null>(null)
|
||||
|
||||
const run = async (
|
||||
action: () => Promise<OrcaProfileOrgMemberMutationResult>,
|
||||
success: string
|
||||
): Promise<void> => {
|
||||
setBusy(true)
|
||||
setNotice(null)
|
||||
const outcome = await action().catch((error: unknown): OrcaProfileOrgMemberMutationResult => ({
|
||||
status: 'failed',
|
||||
error: String(error)
|
||||
}))
|
||||
setBusy(false)
|
||||
setNotice(outcome.status === 'ok' ? success : mutationFailureCopy(outcome, t))
|
||||
if (outcome.status === 'ok') {
|
||||
roster.refresh()
|
||||
billing.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
const entitlement = billing.result?.status === 'ok' ? billing.result.entitlement : null
|
||||
const billedSeats = entitlement && entitlement.plan !== null ? entitlement.seats : null
|
||||
|
||||
return (
|
||||
<TeamMembersView
|
||||
result={roster.result}
|
||||
loading={roster.loading}
|
||||
busy={busy}
|
||||
notice={notice}
|
||||
billedSeats={billedSeats}
|
||||
seatChange={entitlement?.seatChange ?? null}
|
||||
viewerUserId={viewerUserId}
|
||||
onRefresh={() => {
|
||||
roster.refresh()
|
||||
billing.refresh()
|
||||
}}
|
||||
onInvite={(email, role) =>
|
||||
void run(
|
||||
() => window.api.orcaProfiles.orgMemberInvite({ orgId, email, role }),
|
||||
t(
|
||||
'auto.components.billing.team.invited',
|
||||
'Invite sent to {{value0}}. They join (and become a billed seat) when they accept.',
|
||||
{ value0: email }
|
||||
)
|
||||
)
|
||||
}
|
||||
onRevoke={(email) =>
|
||||
void run(
|
||||
() => window.api.orcaProfiles.orgInviteRevoke({ orgId, email }),
|
||||
t('auto.components.billing.team.revoked', 'Invite revoked.')
|
||||
)
|
||||
}
|
||||
onChangeRole={(member, role) => {
|
||||
if (member.userId) {
|
||||
const userId = member.userId
|
||||
void run(
|
||||
() => window.api.orcaProfiles.orgMemberChangeRole({ orgId, userId, role }),
|
||||
t('auto.components.billing.team.roleChanged', 'Role updated.')
|
||||
)
|
||||
}
|
||||
}}
|
||||
onRemove={(member) => {
|
||||
const userId = member.userId
|
||||
if (!userId) {
|
||||
return
|
||||
}
|
||||
void confirm({
|
||||
title: t('auto.components.billing.team.removeTitle', 'Remove {{value0}}?', {
|
||||
value0: member.displayName ?? member.email
|
||||
}),
|
||||
description: t(
|
||||
'auto.components.billing.team.removeDescription2',
|
||||
'They lose access to the org right away. Their seat stays paid until your plan renews, then drops off.'
|
||||
),
|
||||
confirmLabel: t('auto.components.billing.team.removeConfirm', 'Remove member'),
|
||||
confirmVariant: 'destructive'
|
||||
}).then((accepted) => {
|
||||
if (accepted) {
|
||||
void run(
|
||||
() => window.api.orcaProfiles.orgMemberRemove({ orgId, userId }),
|
||||
t('auto.components.billing.team.removed', 'Member removed.')
|
||||
)
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { defineFixture } from '../../../component-fixtures/component-fixture'
|
||||
import type { OrcaOrgMembersRoster } from '../../../../../shared/orca-profiles'
|
||||
import { TeamMembersView } from './TeamMembersView'
|
||||
|
||||
const roster: OrcaOrgMembersRoster = {
|
||||
members: [
|
||||
{ userId: 'u1', email: 'ada@acme.dev', displayName: 'Ada Lovelace', role: 'owner' },
|
||||
{ userId: 'u2', email: 'grace@acme.dev', displayName: 'Grace Hopper', role: 'admin' },
|
||||
{ userId: 'u3', email: 'linus@acme.dev', role: 'member' },
|
||||
{ userId: null, email: 'sso-only@acme.dev', role: 'member' }
|
||||
],
|
||||
pendingInvites: [
|
||||
{ email: 'margaret@acme.dev', role: 'member', createdAt: Date.UTC(2026, 10, 7) }
|
||||
],
|
||||
viewerRole: 'owner',
|
||||
canManageMembers: true
|
||||
}
|
||||
|
||||
const handlers = {
|
||||
loading: false,
|
||||
busy: false,
|
||||
onRefresh: () => {},
|
||||
onInvite: () => {},
|
||||
onRevoke: () => {},
|
||||
onChangeRole: () => {},
|
||||
onRemove: () => {}
|
||||
}
|
||||
|
||||
export const ownerView = defineFixture({
|
||||
width: 600,
|
||||
render: () => (
|
||||
<TeamMembersView
|
||||
{...handlers}
|
||||
notice={null}
|
||||
billedSeats={5}
|
||||
seatChange={{ seatsAtRenewal: 4, effectiveAt: Date.UTC(2026, 11, 3) }}
|
||||
viewerUserId="u1"
|
||||
result={{ status: 'ok', roster }}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
export const memberView = defineFixture({
|
||||
width: 600,
|
||||
render: () => (
|
||||
<TeamMembersView
|
||||
{...handlers}
|
||||
notice={null}
|
||||
billedSeats={null}
|
||||
seatChange={null}
|
||||
viewerUserId="u3"
|
||||
result={{
|
||||
status: 'ok',
|
||||
roster: { ...roster, pendingInvites: [], viewerRole: 'member', canManageMembers: false }
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,280 @@
|
||||
import { useState } from 'react'
|
||||
import { Mail, RefreshCw, UserMinus, UserPlus } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import { useIntlLocale, useTranslate } from '@/i18n/use-translate'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type {
|
||||
OrcaOrgMember,
|
||||
OrcaOrgRole,
|
||||
OrcaProfileOrgMembersListResult
|
||||
} from '../../../../../shared/orca-profiles'
|
||||
import { assignableRoles, canActOnRole, roleLabel } from './team-roles'
|
||||
|
||||
export type TeamMembersViewProps = {
|
||||
result: OrcaProfileOrgMembersListResult | null
|
||||
loading: boolean
|
||||
busy: boolean
|
||||
notice: string | null
|
||||
/** Seats Team bills (from billing), or null when there is no plan or billing is off. */
|
||||
billedSeats: number | null
|
||||
/** Seats paid this period stay until renewal, then drop to the member count. */
|
||||
seatChange: { seatsAtRenewal: number; effectiveAt: number } | null
|
||||
viewerUserId: string | null
|
||||
onRefresh: () => void
|
||||
onInvite: (email: string, role: OrcaOrgRole) => void
|
||||
onRevoke: (email: string) => void
|
||||
onChangeRole: (member: OrcaOrgMember, role: OrcaOrgRole) => void
|
||||
onRemove: (member: OrcaOrgMember) => void
|
||||
}
|
||||
|
||||
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
function RoleSelect({
|
||||
value,
|
||||
roles,
|
||||
disabled,
|
||||
onChange,
|
||||
label
|
||||
}: {
|
||||
value: OrcaOrgRole
|
||||
roles: OrcaOrgRole[]
|
||||
disabled: boolean
|
||||
onChange: (role: OrcaOrgRole) => void
|
||||
label: string
|
||||
}): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onValueChange={(next) => {
|
||||
const role = roles.find((candidate) => candidate === next)
|
||||
if (role) {
|
||||
onChange(role)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="sm" aria-label={label}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roles.map((role) => (
|
||||
<SelectItem key={role} value={role}>
|
||||
{roleLabel(role, t)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
export function TeamMembersView(props: TeamMembersViewProps): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
const { result, loading, busy, notice, billedSeats } = props
|
||||
const locale = useIntlLocale()
|
||||
const [email, setEmail] = useState('')
|
||||
const [inviteRole, setInviteRole] = useState<OrcaOrgRole>('member')
|
||||
|
||||
const header = (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
|
||||
{t('auto.components.billing.team.title', 'Team')}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
disabled={loading}
|
||||
onClick={props.onRefresh}
|
||||
aria-label={t('auto.components.billing.team.refresh', 'Refresh team')}
|
||||
>
|
||||
<RefreshCw className={cn(loading && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!result || result.status !== 'ok') {
|
||||
return (
|
||||
<div className="space-y-3 border-t border-border/60 pt-5">
|
||||
{header}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{result === null
|
||||
? t('auto.components.billing.team.loading', 'Loading your team…')
|
||||
: t('auto.components.billing.team.loadFailed', 'Could not load your team. Try again.')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const roster = result.roster
|
||||
const manager = roster.canManageMembers
|
||||
const roles = assignableRoles(roster.viewerRole)
|
||||
const emailValid = EMAIL.test(email.trim())
|
||||
|
||||
return (
|
||||
<div className="space-y-4 border-t border-border/60 pt-5">
|
||||
{header}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{billedSeats === null
|
||||
? t('auto.components.billing.team.memberCount', '{{value0}} members', {
|
||||
value0: String(roster.members.length)
|
||||
})
|
||||
: t(
|
||||
'auto.components.billing.team.memberSeats',
|
||||
'{{value0}} members · {{value1}} seats billed. Seats follow membership; pending invites are free until accepted.',
|
||||
{ value0: String(roster.members.length), value1: String(billedSeats) }
|
||||
)}
|
||||
{props.seatChange
|
||||
? ` ${t(
|
||||
'auto.components.billing.team.seatsDrop',
|
||||
'Seats paid this period stay until {{value0}}, then drop to {{value1}}.',
|
||||
{
|
||||
value0: new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(
|
||||
props.seatChange.effectiveAt
|
||||
),
|
||||
value1: String(props.seatChange.seatsAtRenewal)
|
||||
}
|
||||
)}`
|
||||
: null}
|
||||
</p>
|
||||
|
||||
<ul className="divide-y divide-border/60 rounded-md border border-border/60">
|
||||
{roster.members.map((member) => {
|
||||
const self = member.userId !== null && member.userId === props.viewerUserId
|
||||
const actionable =
|
||||
manager &&
|
||||
!self &&
|
||||
member.userId !== null &&
|
||||
canActOnRole(roster.viewerRole, member.role)
|
||||
return (
|
||||
<li key={member.email} className="flex items-center gap-3 px-3 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{member.displayName ?? member.email}
|
||||
{self ? (
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||
{t('auto.components.billing.team.you', '(you)')}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
{member.displayName ? (
|
||||
<p className="truncate text-xs text-muted-foreground">{member.email}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{actionable ? (
|
||||
<RoleSelect
|
||||
value={member.role}
|
||||
roles={roles}
|
||||
disabled={busy}
|
||||
onChange={(role) => props.onChangeRole(member, role)}
|
||||
label={t('auto.components.billing.team.roleFor', 'Role for {{value0}}', {
|
||||
value0: member.email
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<Badge variant="outline">{roleLabel(member.role, t)}</Badge>
|
||||
)}
|
||||
{actionable ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
disabled={busy}
|
||||
onClick={() => props.onRemove(member)}
|
||||
aria-label={t('auto.components.billing.team.remove', 'Remove {{value0}}', {
|
||||
value0: member.email
|
||||
})}
|
||||
>
|
||||
<UserMinus />
|
||||
</Button>
|
||||
) : null}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{manager && roster.pendingInvites.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium">
|
||||
{t('auto.components.billing.team.pending', 'Pending invites')}
|
||||
</p>
|
||||
<ul className="divide-y divide-border/60 rounded-md border border-dashed border-border/60">
|
||||
{roster.pendingInvites.map((invite) => (
|
||||
<li key={invite.email} className="flex items-center gap-3 px-3 py-2">
|
||||
<Mail className="size-4 text-muted-foreground" />
|
||||
<p className="min-w-0 flex-1 truncate text-sm">{invite.email}</p>
|
||||
<Badge variant="outline">{roleLabel(invite.role, t)}</Badge>
|
||||
{canActOnRole(roster.viewerRole, invite.role) ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => props.onRevoke(invite.email)}
|
||||
>
|
||||
{t('auto.components.billing.team.revoke', 'Revoke')}
|
||||
</Button>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{manager ? (
|
||||
<form
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (emailValid) {
|
||||
props.onInvite(email.trim(), inviteRole)
|
||||
setEmail('')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="min-w-48 flex-1">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder={t(
|
||||
'auto.components.billing.team.invitePlaceholder',
|
||||
'teammate@company.com'
|
||||
)}
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
aria-label={t('auto.components.billing.team.inviteEmail', 'Email to invite')}
|
||||
/>
|
||||
</div>
|
||||
<RoleSelect
|
||||
value={inviteRole}
|
||||
roles={roles}
|
||||
disabled={busy}
|
||||
onChange={setInviteRole}
|
||||
label={t('auto.components.billing.team.inviteRole', 'Role for the invite')}
|
||||
/>
|
||||
<Button type="submit" size="sm" disabled={busy || !emailValid}>
|
||||
<UserPlus />
|
||||
{t('auto.components.billing.team.invite', 'Invite')}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
'auto.components.billing.team.askAdmin',
|
||||
'Only owners and admins can invite or remove members.'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{notice ? <p className="text-xs text-muted-foreground">{notice}</p> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { assignableRoles, canActOnRole, canManageMembers } from './team-roles'
|
||||
|
||||
describe('team roles (mirrors auth org-roles)', () => {
|
||||
it('lets owners act on everyone, admins on admins and members, members on no one', () => {
|
||||
expect(canActOnRole('owner', 'owner')).toBe(true)
|
||||
expect(canActOnRole('admin', 'owner')).toBe(false)
|
||||
expect(canActOnRole('admin', 'admin')).toBe(true)
|
||||
expect(canActOnRole('admin', 'member')).toBe(true)
|
||||
expect(canActOnRole('member', 'member')).toBe(false)
|
||||
expect(canManageMembers('member')).toBe(false)
|
||||
})
|
||||
|
||||
it('offers only assignable roles', () => {
|
||||
expect(assignableRoles('owner')).toEqual(['owner', 'admin', 'member'])
|
||||
expect(assignableRoles('admin')).toEqual(['admin', 'member'])
|
||||
expect(assignableRoles('member')).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
// Mirrors Orca Cloud auth's role rules (apps/auth/src/accounts/org-roles.ts) so the UI only offers
|
||||
// actions the server will accept: owner > admin > member; only owners act on owners.
|
||||
import type { Translate } from '@/i18n/use-translate'
|
||||
import type {
|
||||
OrcaOrgRole,
|
||||
OrcaProfileOrgMemberMutationResult
|
||||
} from '../../../../../shared/orca-profiles'
|
||||
|
||||
export const ORG_ROLES: readonly OrcaOrgRole[] = ['owner', 'admin', 'member']
|
||||
|
||||
export function canManageMembers(role: OrcaOrgRole): boolean {
|
||||
return role === 'owner' || role === 'admin'
|
||||
}
|
||||
|
||||
export function canActOnRole(actor: OrcaOrgRole, target: OrcaOrgRole): boolean {
|
||||
if (!canManageMembers(actor)) {
|
||||
return false
|
||||
}
|
||||
return target !== 'owner' || actor === 'owner'
|
||||
}
|
||||
|
||||
/** Roles the viewer may assign (to an invite or an existing member). */
|
||||
export function assignableRoles(actor: OrcaOrgRole): OrcaOrgRole[] {
|
||||
return ORG_ROLES.filter((role) => canActOnRole(actor, role))
|
||||
}
|
||||
|
||||
export function roleLabel(role: OrcaOrgRole, t: Translate): string {
|
||||
switch (role) {
|
||||
case 'owner':
|
||||
return t('auto.components.billing.team.roleOwner', 'Owner')
|
||||
case 'admin':
|
||||
return t('auto.components.billing.team.roleAdmin', 'Admin')
|
||||
case 'member':
|
||||
return t('auto.components.billing.team.roleMember', 'Member')
|
||||
}
|
||||
}
|
||||
|
||||
export function mutationFailureCopy(
|
||||
result: OrcaProfileOrgMemberMutationResult,
|
||||
t: Translate
|
||||
): string | null {
|
||||
switch (result.status) {
|
||||
case 'ok':
|
||||
return null
|
||||
case 'conflict':
|
||||
return result.reason === 'already_member'
|
||||
? t('auto.components.billing.team.alreadyMember', 'That person is already a member.')
|
||||
: t('auto.components.billing.team.alreadyInvited', 'That person already has an invite.')
|
||||
case 'invalid':
|
||||
return result.reason === 'cannot_remove_self'
|
||||
? t('auto.components.billing.team.cannotRemoveSelf', 'You cannot remove yourself.')
|
||||
: t('auto.components.billing.team.cannotChangeOwnRole', 'You cannot change your own role.')
|
||||
case 'forbidden':
|
||||
return t(
|
||||
'auto.components.billing.team.forbidden',
|
||||
'Only owners and admins can manage members, and only owners can manage owners.'
|
||||
)
|
||||
case 'not-found':
|
||||
return t('auto.components.billing.team.notFound', 'That member or invite no longer exists.')
|
||||
case 'reconnect-required':
|
||||
return t('auto.components.billing.openSignedOut', 'Sign in to Orca again to manage billing.')
|
||||
case 'unconfigured':
|
||||
return t('auto.components.billing.openUnconfigured', 'Billing is unavailable in this build.')
|
||||
case 'failed':
|
||||
return t('auto.components.billing.team.failed', 'Could not update the team. Try again.')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { OrcaProfileOrgMembersListResult } from '../../../../../shared/orca-profiles'
|
||||
|
||||
type RosterState = { loading: boolean; result: OrcaProfileOrgMembersListResult | null }
|
||||
|
||||
function readRoster(orgId: string): Promise<OrcaProfileOrgMembersListResult> {
|
||||
return window.api.orcaProfiles
|
||||
.orgMembersList({ orgId })
|
||||
.catch((error: unknown): OrcaProfileOrgMembersListResult => ({
|
||||
status: 'failed',
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}))
|
||||
}
|
||||
|
||||
/** The org's live roster (members, pending invites, viewer role), re-read on `refresh()`. */
|
||||
export function useOrgRoster(orgId: string): RosterState & { refresh: () => void } {
|
||||
const [state, setState] = useState<RosterState>({ loading: true, result: null })
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
const refresh = useCallback(() => setReloadKey((key) => key + 1), [])
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false
|
||||
setState((previous) => ({ ...previous, loading: true }))
|
||||
void readRoster(orgId).then((result) => {
|
||||
if (!disposed) {
|
||||
setState({ loading: false, result })
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
disposed = true
|
||||
}
|
||||
}, [orgId, reloadKey])
|
||||
|
||||
return { ...state, refresh }
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { OrcaBillingStatusResult } from '../../../../shared/orca-billing'
|
||||
|
||||
type BillingStatusState = {
|
||||
loading: boolean
|
||||
result: OrcaBillingStatusResult | null
|
||||
}
|
||||
|
||||
function readBillingStatus(): Promise<OrcaBillingStatusResult> {
|
||||
return window.api.orcaProfiles
|
||||
.billingStatus()
|
||||
.catch((error: unknown): OrcaBillingStatusResult => ({
|
||||
status: 'failed',
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the billing status while `enabled`, again on `refresh()`, and whenever the window regains
|
||||
* focus (the user comes back from Checkout or the Portal in their browser).
|
||||
*/
|
||||
export function useOrcaBillingStatus(
|
||||
enabled: boolean
|
||||
): BillingStatusState & { refresh: () => void } {
|
||||
const [state, setState] = useState<BillingStatusState>({ loading: enabled, result: null })
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
const refresh = useCallback(() => setReloadKey((key) => key + 1), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return
|
||||
}
|
||||
let disposed = false
|
||||
let latest = 0
|
||||
const load = (): void => {
|
||||
const request = ++latest
|
||||
setState((previous) => ({ ...previous, loading: true }))
|
||||
void readBillingStatus().then((result) => {
|
||||
// A newer request, or unmounting, supersedes this one.
|
||||
if (!disposed && request === latest) {
|
||||
setState({ loading: false, result })
|
||||
}
|
||||
})
|
||||
}
|
||||
load()
|
||||
window.addEventListener('focus', load)
|
||||
return () => {
|
||||
disposed = true
|
||||
window.removeEventListener('focus', load)
|
||||
}
|
||||
}, [enabled, reloadKey])
|
||||
|
||||
return { ...state, refresh }
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import { useTranslate, type Translate } from '@/i18n/use-translate'
|
||||
import { useAppStore } from '@/store/index'
|
||||
import { OrcaProfileSignOutConfirmDialog } from '../../../orca-profiles/OrcaProfileSignOutConfirmDialog'
|
||||
import { OrcaSignInButton, OrcaSignInWaitNotice } from '../../../OrcaSignInProgress'
|
||||
import { PlanBillingSection } from '../../../billing/PlanBillingSection'
|
||||
import { TeamMembersSection } from '../../../billing/team/TeamMembersSection'
|
||||
|
||||
function accountStatusCopy(
|
||||
state: 'local' | 'unconfigured' | 'connected' | 'reconnect-required' | undefined,
|
||||
@@ -128,6 +130,14 @@ export function OrcaAccountSettingsPane(): React.JSX.Element {
|
||||
{connected ? null : <OrcaSignInWaitNotice className="basis-full" />}
|
||||
</div>
|
||||
|
||||
{connected ? <PlanBillingSection /> : null}
|
||||
{connected && authStatus?.cloud?.activeOrgId ? (
|
||||
<TeamMembersSection
|
||||
orgId={authStatus.cloud.activeOrgId}
|
||||
viewerUserId={authStatus.cloud.userId}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-4 border-t border-border/60 pt-5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
|
||||
{t('auto.components.settings.orcaAccount.benefitsTitle', 'Included with your account')}
|
||||
|
||||
@@ -1,330 +0,0 @@
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
BookOpen,
|
||||
CircleHelp,
|
||||
ExternalLink,
|
||||
Github,
|
||||
Keyboard,
|
||||
Loader2,
|
||||
MessageSquareText,
|
||||
RefreshCw,
|
||||
RotateCw,
|
||||
School,
|
||||
ScrollText,
|
||||
Settings
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import logo from '../../../../../resources/logo.svg'
|
||||
import { useAppStore } from '@/store'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel'
|
||||
import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
|
||||
import { showOnboardingFromRenderer } from '../onboarding/show-onboarding-event'
|
||||
import { SetupGuideProgressRing } from '../setup-guide/SetupGuideProgressRing'
|
||||
import { useSetupGuideProgress } from '../setup-guide/use-setup-guide-progress'
|
||||
import { lazyWithRetry } from '@/lib/lazy-with-retry'
|
||||
import type * as SidebarFeedbackDialogModule from './sidebar-feedback/SidebarFeedbackDialog'
|
||||
import { useTranslate } from '@/i18n/use-translate'
|
||||
import { getUpdateCheckClickOptions, getUpdateCheckHint } from '@/lib/update-check-click-options'
|
||||
import { openAppHttpLink } from '@/lib/browser/navigation/app-http-link-open'
|
||||
|
||||
// Keep feedback off the boot graph; menu warming and selection share the same lazy module entry.
|
||||
const loadSidebarFeedbackDialog = (): Promise<typeof SidebarFeedbackDialogModule> =>
|
||||
import('./sidebar-feedback/SidebarFeedbackDialog')
|
||||
|
||||
const SidebarFeedbackDialog = lazyWithRetry(
|
||||
() => loadSidebarFeedbackDialog().then((module) => ({ default: module.SidebarFeedbackDialog })),
|
||||
{ reloadKey: 'sidebar-feedback-dialog' }
|
||||
)
|
||||
|
||||
const DOCS_URL = 'https://www.onorca.dev/docs'
|
||||
const CHANGELOG_URL = 'https://onorca.dev/changelog'
|
||||
const GITHUB_URL = 'https://github.com/stablyai/orca'
|
||||
const DISCORD_URL = 'https://discord.gg/fzjDKHxv8Q'
|
||||
const X_URL = 'https://x.com/orca_build'
|
||||
const NO_UPDATE_CHECK_MODIFIERS = {
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false
|
||||
}
|
||||
|
||||
function DiscordIcon(): React.JSX.Element {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" className="size-3.5 fill-current">
|
||||
<path d="M16.0742 4.45014C14.9244 3.92097 13.7106 3.54556 12.4638 3.3335C12.2932 3.64011 12.1388 3.95557 12.0013 4.27856C10.6732 4.07738 9.32261 4.07738 7.99451 4.27856C7.85694 3.9556 7.70257 3.64014 7.53203 3.3335C6.28441 3.54735 5.06981 3.92365 3.91889 4.45291C1.63401 7.85128 1.01462 11.1652 1.32431 14.4322C2.6624 15.426 4.16009 16.1819 5.7523 16.6668C6.11082 16.1821 6.42806 15.6678 6.70066 15.1295C6.18289 14.9351 5.68315 14.6953 5.20723 14.4128C5.33249 14.3215 5.45499 14.2274 5.57336 14.136C6.95819 14.7907 8.46965 15.1302 9.99997 15.1302C11.5303 15.1302 13.0418 14.7907 14.4266 14.136C14.5463 14.2343 14.6688 14.3284 14.7927 14.4128C14.3159 14.6957 13.8152 14.9361 13.2965 15.1309C13.5688 15.669 13.8861 16.1828 14.2449 16.6668C15.8385 16.1838 17.3373 15.4283 18.6756 14.4335C19.039 10.645 18.0549 7.36145 16.0742 4.45014ZM7.09294 12.423C6.22992 12.423 5.51693 11.6357 5.51693 10.6671C5.51693 9.69852 6.20514 8.90427 7.09019 8.90427C7.97524 8.90427 8.68272 9.69852 8.66758 10.6671C8.65244 11.6357 7.97248 12.423 7.09294 12.423ZM12.907 12.423C12.0426 12.423 11.3324 11.6357 11.3324 10.6671C11.3324 9.69852 12.0206 8.90427 12.907 8.90427C13.7934 8.90427 14.4954 9.69852 14.4803 10.6671C14.4651 11.6357 13.7865 12.423 12.907 12.423Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function XIcon(): React.JSX.Element {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" className="size-3.5 fill-current">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ExternalMenuItem({
|
||||
label,
|
||||
url,
|
||||
icon
|
||||
}: {
|
||||
label: string
|
||||
url: string
|
||||
icon: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<DropdownMenuItem onSelect={() => openAppHttpLink(url)}>
|
||||
{icon}
|
||||
{label}
|
||||
<ExternalLink className="ml-auto size-3 text-muted-foreground" />
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
export function SidebarSettingsHelpMenu(): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
const openModal = useAppStore((s) => s.openModal)
|
||||
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
|
||||
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
|
||||
const updateStatus = useAppStore((s) => s.updateStatus)
|
||||
const setupProgress = useSetupGuideProgress(true, false, false)
|
||||
|
||||
const settingsShortcut = useShortcutKeyDetails('app.settings')
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [feedbackOpen, setFeedbackOpen] = useState(false)
|
||||
// Retain the mounted dialog through its closing animation.
|
||||
const [feedbackDialogMounted, setFeedbackDialogMounted] = useState(false)
|
||||
const [isRestartingOrca, setIsRestartingOrca] = useState(false)
|
||||
const lastShowOnboardingAtRef = React.useRef(0)
|
||||
const updateCheckModifiersRef = React.useRef(NO_UPDATE_CHECK_MODIFIERS)
|
||||
const mountedRef = useMountedRef()
|
||||
const updateCheckHint = getUpdateCheckHint()
|
||||
|
||||
const showMilestones =
|
||||
setupProgress.ready && setupProgress.coreDoneCount < setupProgress.coreTotal
|
||||
|
||||
const handleMenuOpenChange = (open: boolean): void => {
|
||||
setMenuOpen(open)
|
||||
updateCheckModifiersRef.current = NO_UPDATE_CHECK_MODIFIERS
|
||||
if (open) {
|
||||
// Warm while users read the menu so feedback is loaded before selection.
|
||||
void loadSidebarFeedbackDialog().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenFeedback = (): void => {
|
||||
setFeedbackDialogMounted(true)
|
||||
setFeedbackOpen(true)
|
||||
}
|
||||
|
||||
const handleShowOnboarding = (): void => {
|
||||
const now = Date.now()
|
||||
if (now - lastShowOnboardingAtRef.current < 500) {
|
||||
return
|
||||
}
|
||||
lastShowOnboardingAtRef.current = now
|
||||
void showOnboardingFromRenderer()
|
||||
}
|
||||
|
||||
const handleRestartOrca = (): void => {
|
||||
if (isRestartingOrca) {
|
||||
return
|
||||
}
|
||||
setIsRestartingOrca(true)
|
||||
toast.info(t('auto.components.sidebar.SidebarSettingsHelpMenu.5161eef55d', 'Restarting Orca…'))
|
||||
void window.api.app.restart().catch((error) => {
|
||||
if (mountedRef.current) {
|
||||
setIsRestartingOrca(false)
|
||||
toast.error(
|
||||
t('auto.components.sidebar.SidebarSettingsHelpMenu.4e8f5710d3', "Couldn't restart Orca."),
|
||||
{
|
||||
description: error instanceof Error ? error.message : undefined
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const openShortcutsSettings = (): void => {
|
||||
openSettingsTarget({ pane: 'shortcuts', repoId: null })
|
||||
openSettingsPage()
|
||||
}
|
||||
|
||||
const handleCheckForUpdatesPointerDown = (event: React.PointerEvent): void => {
|
||||
updateCheckModifiersRef.current = {
|
||||
altKey: event.altKey,
|
||||
ctrlKey: event.ctrlKey,
|
||||
metaKey: event.metaKey,
|
||||
shiftKey: event.shiftKey
|
||||
}
|
||||
}
|
||||
|
||||
const handleCheckForUpdates = (): void => {
|
||||
const modifiers = updateCheckModifiersRef.current
|
||||
updateCheckModifiersRef.current = NO_UPDATE_CHECK_MODIFIERS
|
||||
void window.api.updater.check(getUpdateCheckClickOptions(modifiers))
|
||||
}
|
||||
|
||||
const openMilestones = (): void => {
|
||||
openModal('setup-guide', { telemetrySource: 'help_menu' })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
aria-label={t(
|
||||
'auto.components.sidebar.SidebarSettingsHelpMenu.a428c25998',
|
||||
'Settings'
|
||||
)}
|
||||
className="text-muted-foreground"
|
||||
onClick={openSettingsPage}
|
||||
>
|
||||
<Settings className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4} className="flex items-center gap-1.5">
|
||||
{t('auto.components.sidebar.SidebarSettingsHelpMenu.a428c25998', 'Settings')}
|
||||
{settingsShortcut.keys.length > 0 ? (
|
||||
<ShortcutKeyCombo
|
||||
keys={settingsShortcut.keys}
|
||||
doubleTap={settingsShortcut.doubleTap}
|
||||
className="gap-0.5"
|
||||
keyCapClassName="min-w-0 border-background/20 bg-background/10 px-1 py-0 text-[10px] text-background shadow-none"
|
||||
separatorClassName="text-[10px] text-background/70"
|
||||
/>
|
||||
) : null}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenu modal={false} open={menuOpen} onOpenChange={handleMenuOpenChange}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
aria-label={t(
|
||||
'auto.components.sidebar.SidebarSettingsHelpMenu.2991a0106c',
|
||||
'Help'
|
||||
)}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<CircleHelp className="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{t('auto.components.sidebar.SidebarSettingsHelpMenu.2991a0106c', 'Help')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent side="top" align="start" sideOffset={8} className="w-52">
|
||||
<DropdownMenuItem onSelect={openShortcutsSettings}>
|
||||
<Keyboard className="size-3.5" />
|
||||
{t(
|
||||
'auto.components.sidebar.SidebarSettingsHelpMenu.e565171a7c',
|
||||
'Keyboard Shortcuts'
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={handleOpenFeedback}>
|
||||
<MessageSquareText className="size-3.5" />
|
||||
{t('auto.components.sidebar.SidebarSettingsHelpMenu.4cf5b868d7', 'Send Feedback')}
|
||||
</DropdownMenuItem>
|
||||
{showMilestones ? (
|
||||
<DropdownMenuItem onSelect={openMilestones}>
|
||||
<img
|
||||
src={logo}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="size-3.5 object-contain invert opacity-55 dark:invert-0"
|
||||
/>
|
||||
{t('auto.components.sidebar.SidebarSettingsHelpMenu.f8a2c91d4e', 'Milestones')}
|
||||
<SetupGuideProgressRing
|
||||
done={setupProgress.coreDoneCount}
|
||||
total={setupProgress.coreTotal}
|
||||
sizeClassName="size-4"
|
||||
className="ml-auto"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
className="whitespace-nowrap"
|
||||
onClick={handleShowOnboarding}
|
||||
onSelect={handleShowOnboarding}
|
||||
>
|
||||
<School className="size-3.5" />
|
||||
{t('auto.components.sidebar.SidebarSettingsHelpMenu.b7e4d2a19c', 'Onboarding')}
|
||||
</DropdownMenuItem>
|
||||
<ExternalMenuItem
|
||||
label={t('auto.components.sidebar.SidebarSettingsHelpMenu.cdc87f897e', 'Docs')}
|
||||
url={DOCS_URL}
|
||||
icon={<BookOpen className="size-3.5" />}
|
||||
/>
|
||||
<ExternalMenuItem
|
||||
label={t('auto.components.sidebar.SidebarSettingsHelpMenu.5f83d86d92', 'Changelog')}
|
||||
url={CHANGELOG_URL}
|
||||
icon={<ScrollText className="size-3.5" />}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<ExternalMenuItem
|
||||
label={t('auto.components.sidebar.SidebarSettingsHelpMenu.5687ab246a', 'GitHub')}
|
||||
url={GITHUB_URL}
|
||||
icon={<Github className="size-3.5" />}
|
||||
/>
|
||||
<DropdownMenuItem onSelect={() => openAppHttpLink(DISCORD_URL)}>
|
||||
<DiscordIcon />
|
||||
{t('auto.components.sidebar.SidebarSettingsHelpMenu.eb9884e55b', 'Discord')}
|
||||
<ExternalLink className="ml-auto size-3 text-muted-foreground" />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => openAppHttpLink(X_URL)}>
|
||||
<XIcon />
|
||||
{t('auto.components.sidebar.SidebarSettingsHelpMenu.c4f8e1b72a', 'X')}
|
||||
<ExternalLink className="ml-auto size-3 text-muted-foreground" />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
disabled={updateStatus.state === 'checking' || updateStatus.state === 'downloading'}
|
||||
onPointerDown={handleCheckForUpdatesPointerDown}
|
||||
onSelect={handleCheckForUpdates}
|
||||
title={updateCheckHint}
|
||||
>
|
||||
{updateStatus.state === 'checking' ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-3.5" />
|
||||
)}
|
||||
{t('auto.components.sidebar.SidebarSettingsHelpMenu.29c56f30ee', 'Check for Updates')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={handleRestartOrca} disabled={isRestartingOrca}>
|
||||
<RotateCw className="size-3.5" />
|
||||
{t('auto.components.sidebar.SidebarSettingsHelpMenu.ad3d3ed7f1', 'Restart Orca')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{feedbackDialogMounted ? (
|
||||
<React.Suspense fallback={null}>
|
||||
<SidebarFeedbackDialog open={feedbackOpen} onOpenChange={setFeedbackOpen} />
|
||||
</React.Suspense>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { defineFixture } from '../../component-fixtures/component-fixture'
|
||||
import type { OrcaProfileAuthStatus } from '../../../../shared/orca-profiles'
|
||||
import SidebarToolbar from './SidebarToolbar'
|
||||
|
||||
const settle = (ms = 300): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
const openAccountMenu = async (frame: HTMLElement): Promise<void> => {
|
||||
frame
|
||||
.querySelector('[data-account-menu-trigger]')
|
||||
?.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0 }))
|
||||
await settle()
|
||||
}
|
||||
|
||||
const openHelpSubmenu = async (frame: HTMLElement): Promise<void> => {
|
||||
await openAccountMenu(frame)
|
||||
const help = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-slot="dropdown-menu-sub-trigger"]')
|
||||
).find((element) => element.textContent?.includes('Help'))
|
||||
help?.focus()
|
||||
help?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }))
|
||||
await settle()
|
||||
}
|
||||
|
||||
const signedIn: OrcaProfileAuthStatus = {
|
||||
activeProfileId: 'local-default',
|
||||
configured: true,
|
||||
state: 'connected',
|
||||
persistence: 'encrypted',
|
||||
cloud: {
|
||||
cloudProfileId: 'fixture',
|
||||
userId: 'fixture-user',
|
||||
email: 'ada@example.com',
|
||||
displayName: 'Ada Lovelace',
|
||||
linkedAt: 0
|
||||
}
|
||||
}
|
||||
|
||||
const signedOut: OrcaProfileAuthStatus = {
|
||||
activeProfileId: 'local-default',
|
||||
configured: true,
|
||||
state: 'local',
|
||||
persistence: 'encrypted'
|
||||
}
|
||||
|
||||
const footer = ({
|
||||
width = 303,
|
||||
height = 96,
|
||||
authStatus = signedOut,
|
||||
interact
|
||||
}: {
|
||||
width?: number
|
||||
height?: number
|
||||
authStatus?: OrcaProfileAuthStatus
|
||||
interact?: (frame: HTMLElement) => Promise<void>
|
||||
}) =>
|
||||
defineFixture({
|
||||
width,
|
||||
store: { persistedUIReady: true, orcaProfileAuthStatus: authStatus },
|
||||
interact,
|
||||
render: () => (
|
||||
<div
|
||||
style={{ height }}
|
||||
className="flex flex-col bg-worktree-sidebar text-worktree-sidebar-foreground"
|
||||
>
|
||||
<SidebarToolbar workspaceBoardOpen={false} onWorkspaceBoardToggle={() => {}} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export const signedOutFooter = footer({})
|
||||
export const signedInFooter = footer({ authStatus: signedIn })
|
||||
export const narrowSignedInFooter = footer({ width: 220, authStatus: signedIn })
|
||||
export const signedOutMenu = footer({ height: 260, interact: openAccountMenu })
|
||||
export const signedInMenu = footer({ height: 260, authStatus: signedIn, interact: openAccountMenu })
|
||||
export const helpSubmenu = footer({
|
||||
width: 560,
|
||||
height: 480,
|
||||
authStatus: signedIn,
|
||||
interact: openHelpSubmenu
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
||||
import { ScrollToCurrentWorkspaceToolbarButton } from './worktree-list/navigation/ScrollToCurrentWorkspaceToolbarButton'
|
||||
import { SidebarSettingsHelpMenu } from './SidebarSettingsHelpMenu'
|
||||
import { SidebarAccountMenu } from './account-menu/SidebarAccountMenu'
|
||||
import { useTranslate } from '@/i18n/use-translate'
|
||||
import { useAppStore } from '@/store'
|
||||
import { hasFeatureInteraction } from '../../../../shared/feature-interactions'
|
||||
@@ -69,7 +69,7 @@ const SidebarToolbar = React.memo(function SidebarToolbar({
|
||||
<div className="mt-auto shrink-0">
|
||||
<div className="flex items-center justify-between border-t border-worktree-sidebar-border px-2 py-1.5">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<SidebarSettingsHelpMenu />
|
||||
<SidebarAccountMenu />
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<ScrollToCurrentWorkspaceToolbarButton />
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// @vitest-environment happy-dom
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import type { OrcaProfileAuthStatus } from '../../../../../shared/orca-profiles'
|
||||
import { registerAccountMenuItem } from './account-menu-items'
|
||||
import { SidebarAccountMenu } from './SidebarAccountMenu'
|
||||
|
||||
vi.mock('../../setup-guide/use-setup-guide-progress', () => ({
|
||||
useSetupGuideProgress: () => ({ ready: false, coreDoneCount: 0, coreTotal: 0 })
|
||||
}))
|
||||
|
||||
const initialState = useAppStore.getState()
|
||||
const disposers: (() => void)[] = []
|
||||
|
||||
const connected: OrcaProfileAuthStatus = {
|
||||
activeProfileId: 'local-default',
|
||||
configured: true,
|
||||
state: 'connected',
|
||||
persistence: 'encrypted',
|
||||
cloud: {
|
||||
cloudProfileId: 'cp',
|
||||
userId: 'u',
|
||||
email: 'ada@example.com',
|
||||
displayName: 'Ada Lovelace',
|
||||
linkedAt: 0
|
||||
},
|
||||
capabilities: { flags: { 'team-admin': true }, refreshedAt: 0 }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
for (const dispose of disposers.splice(0)) {
|
||||
dispose()
|
||||
}
|
||||
useAppStore.setState(initialState, true)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function renderOpenMenu(): HTMLElement {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<SidebarAccountMenu />
|
||||
</TooltipProvider>
|
||||
)
|
||||
const trigger = screen.getByRole('button', { name: 'Account' })
|
||||
fireEvent.pointerDown(trigger, { button: 0, pointerType: 'mouse', ctrlKey: false })
|
||||
return trigger
|
||||
}
|
||||
|
||||
it('groups the account header, Settings, and Help, and opens Settings', () => {
|
||||
const openSettingsPage = vi.fn()
|
||||
useAppStore.setState({ orcaProfileAuthStatus: connected, openSettingsPage })
|
||||
renderOpenMenu()
|
||||
|
||||
expect(screen.getByRole('menuitem', { name: /Ada Lovelace/ })).toHaveTextContent(
|
||||
'ada@example.com'
|
||||
)
|
||||
expect(screen.getByRole('menuitem', { name: 'Help' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Settings' }))
|
||||
expect(openSettingsPage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('starts sign-in from the header when signed out', () => {
|
||||
const connectCurrentOrcaProfile = vi.fn(async () => null)
|
||||
useAppStore.setState({
|
||||
orcaProfileAuthStatus: {
|
||||
activeProfileId: 'local-default',
|
||||
configured: true,
|
||||
state: 'local',
|
||||
persistence: 'encrypted'
|
||||
},
|
||||
connectCurrentOrcaProfile
|
||||
})
|
||||
renderOpenMenu()
|
||||
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /Sign in/ }))
|
||||
expect(connectCurrentOrcaProfile).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('renders registered items only when their capability flag is on', () => {
|
||||
const onSelect = vi.fn()
|
||||
disposers.push(
|
||||
registerAccountMenuItem({
|
||||
id: 'gated',
|
||||
label: () => 'Gated item',
|
||||
requiredCapability: 'team-admin',
|
||||
onSelect
|
||||
}),
|
||||
registerAccountMenuItem({
|
||||
id: 'hidden',
|
||||
label: () => 'Hidden item',
|
||||
requiredCapability: 'not-granted',
|
||||
onSelect: vi.fn()
|
||||
})
|
||||
)
|
||||
useAppStore.setState({ orcaProfileAuthStatus: connected })
|
||||
renderOpenMenu()
|
||||
|
||||
expect(screen.queryByRole('menuitem', { name: 'Hidden item' })).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Gated item' }))
|
||||
expect(onSelect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('hides capability-gated items while signed out', () => {
|
||||
disposers.push(
|
||||
registerAccountMenuItem({
|
||||
id: 'gated',
|
||||
label: () => 'Gated item',
|
||||
requiredCapability: 'team-admin',
|
||||
onSelect: vi.fn()
|
||||
})
|
||||
)
|
||||
useAppStore.setState({
|
||||
orcaProfileAuthStatus: { ...connected, state: 'reconnect-required' }
|
||||
})
|
||||
renderOpenMenu()
|
||||
|
||||
expect(screen.getByRole('menuitem', { name: /Sign in again/ })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('menuitem', { name: 'Gated item' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('omits the account header in builds without Orca sign-in', () => {
|
||||
useAppStore.setState({
|
||||
orcaProfileAuthStatus: {
|
||||
activeProfileId: 'local-default',
|
||||
configured: false,
|
||||
state: 'unconfigured',
|
||||
persistence: 'none'
|
||||
}
|
||||
})
|
||||
renderOpenMenu()
|
||||
|
||||
expect(screen.queryByRole('menuitem', { name: /Sign in/ })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('menuitem', { name: 'Settings' })).toBeInTheDocument()
|
||||
})
|
||||
@@ -0,0 +1,221 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Settings, UserRound } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAppStore } from '@/store'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel'
|
||||
import { useTranslate } from '@/i18n/use-translate'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSetupGuideProgress } from '../../setup-guide/use-setup-guide-progress'
|
||||
import { getAccountMenuIdentity, type AccountMenuIdentity } from './account-menu-identity'
|
||||
import { selectVisibleAccountMenuItems, useRegisteredAccountMenuItems } from './account-menu-items'
|
||||
import {
|
||||
loadSidebarFeedbackDialog,
|
||||
SidebarFeedbackDialog,
|
||||
SidebarHelpSubmenu
|
||||
} from './SidebarHelpSubmenu'
|
||||
|
||||
function AccountAvatar({
|
||||
identity,
|
||||
className
|
||||
}: {
|
||||
identity: AccountMenuIdentity
|
||||
className?: string
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-center rounded-full font-medium',
|
||||
identity.signedIn
|
||||
? 'bg-foreground/85 text-background'
|
||||
: 'border border-border bg-muted text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{identity.signedIn ? identity.initial : <UserRound className="size-[62%]" />}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function SidebarAccountMenu(): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
const authStatus = useAppStore((s) => s.orcaProfileAuthStatus)
|
||||
const signingIn = useAppStore((s) => Boolean(s.orcaProfileSignInProgress))
|
||||
const connectCurrentOrcaProfile = useAppStore((s) => s.connectCurrentOrcaProfile)
|
||||
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
|
||||
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
|
||||
const setupProgress = useSetupGuideProgress(true, false, false)
|
||||
const settingsShortcut = useOptionalShortcutLabel('app.settings')
|
||||
const registeredItems = useRegisteredAccountMenuItems()
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [feedbackOpen, setFeedbackOpen] = useState(false)
|
||||
// Retain the mounted dialog through its closing animation.
|
||||
const [feedbackDialogMounted, setFeedbackDialogMounted] = useState(false)
|
||||
const [isRestartingOrca, setIsRestartingOrca] = useState(false)
|
||||
const mountedRef = useMountedRef()
|
||||
|
||||
const identity = getAccountMenuIdentity(authStatus)
|
||||
const extensionItems = selectVisibleAccountMenuItems(registeredItems, authStatus)
|
||||
const milestones =
|
||||
setupProgress.ready && setupProgress.coreDoneCount < setupProgress.coreTotal
|
||||
? { done: setupProgress.coreDoneCount, total: setupProgress.coreTotal }
|
||||
: null
|
||||
const accountLabel = t('auto.components.sidebar.SidebarAccountMenu.account', 'Account')
|
||||
|
||||
const handleMenuOpenChange = (open: boolean): void => {
|
||||
setMenuOpen(open)
|
||||
if (open) {
|
||||
// Warm while users read the menu so feedback is loaded before selection.
|
||||
void loadSidebarFeedbackDialog().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
const openAccountSettings = (): void => {
|
||||
openSettingsTarget({ pane: 'orca-account', repoId: null })
|
||||
openSettingsPage()
|
||||
}
|
||||
|
||||
// Builds without Orca sign-in (and the unloaded state) have no account to show or connect.
|
||||
const showAccountHeader = identity.signedIn || authStatus?.configured === true
|
||||
|
||||
const handleAccountHeaderSelect = (): void => {
|
||||
// The account pane hosts sign-out and the pending sign-in link.
|
||||
if (identity.signedIn || signingIn) {
|
||||
openAccountSettings()
|
||||
return
|
||||
}
|
||||
void connectCurrentOrcaProfile()
|
||||
}
|
||||
|
||||
const handleOpenFeedback = (): void => {
|
||||
setFeedbackDialogMounted(true)
|
||||
setFeedbackOpen(true)
|
||||
}
|
||||
|
||||
const handleRestartOrca = (): void => {
|
||||
if (isRestartingOrca) {
|
||||
return
|
||||
}
|
||||
setIsRestartingOrca(true)
|
||||
toast.info(t('auto.components.sidebar.SidebarSettingsHelpMenu.5161eef55d', 'Restarting Orca…'))
|
||||
void window.api.app.restart().catch((error) => {
|
||||
if (mountedRef.current) {
|
||||
setIsRestartingOrca(false)
|
||||
toast.error(
|
||||
t('auto.components.sidebar.SidebarSettingsHelpMenu.4e8f5710d3', "Couldn't restart Orca."),
|
||||
{
|
||||
description: error instanceof Error ? error.message : undefined
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu modal={false} open={menuOpen} onOpenChange={handleMenuOpenChange}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
aria-label={accountLabel}
|
||||
data-account-menu-trigger=""
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<AccountAvatar identity={identity} className="size-[18px] text-[10px]" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{identity.signedIn ? identity.name : accountLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent side="top" align="start" sideOffset={8} className="w-60">
|
||||
{showAccountHeader ? (
|
||||
<>
|
||||
<DropdownMenuItem onSelect={handleAccountHeaderSelect} data-account-menu-header="">
|
||||
<AccountAvatar identity={identity} className="size-7 text-xs" />
|
||||
<span className="flex min-w-0 flex-col">
|
||||
{identity.signedIn ? (
|
||||
<>
|
||||
<span className="truncate font-medium">{identity.name}</span>
|
||||
{identity.name !== identity.email ? (
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{identity.email}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="truncate font-medium">
|
||||
{identity.needsReconnect
|
||||
? t('auto.components.settings.orcaAccount.signInAgain', 'Sign in again')
|
||||
: t('auto.components.sidebar.SidebarAccountMenu.signIn', 'Sign in')}
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{signingIn
|
||||
? t('auto.components.OrcaSignInProgress.signingIn', 'Signing in…')
|
||||
: t('auto.components.settings.orcaAccount.account', 'Orca account')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
) : null}
|
||||
{extensionItems.length > 0 ? (
|
||||
<>
|
||||
{extensionItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
data-account-menu-item={item.id}
|
||||
onSelect={item.onSelect}
|
||||
>
|
||||
{Icon ? <Icon className="size-3.5" /> : null}
|
||||
{item.label()}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
) : null}
|
||||
<DropdownMenuItem onSelect={openSettingsPage}>
|
||||
<Settings className="size-3.5" />
|
||||
{t('auto.components.sidebar.SidebarSettingsHelpMenu.a428c25998', 'Settings')}
|
||||
{settingsShortcut ? (
|
||||
<DropdownMenuShortcut>{settingsShortcut}</DropdownMenuShortcut>
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
<SidebarHelpSubmenu
|
||||
milestones={milestones}
|
||||
onOpenFeedback={handleOpenFeedback}
|
||||
restartPending={isRestartingOrca}
|
||||
onRestart={handleRestartOrca}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{feedbackDialogMounted ? (
|
||||
<React.Suspense fallback={null}>
|
||||
<SidebarFeedbackDialog open={feedbackOpen} onOpenChange={setFeedbackOpen} />
|
||||
</React.Suspense>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
BookOpen,
|
||||
CircleHelp,
|
||||
ExternalLink,
|
||||
Github,
|
||||
Keyboard,
|
||||
Loader2,
|
||||
MessageSquareText,
|
||||
RefreshCw,
|
||||
RotateCw,
|
||||
School,
|
||||
ScrollText
|
||||
} from 'lucide-react'
|
||||
import logo from '../../../../../../resources/logo.svg'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { showOnboardingFromRenderer } from '../../onboarding/show-onboarding-event'
|
||||
import { SetupGuideProgressRing } from '../../setup-guide/SetupGuideProgressRing'
|
||||
import { lazyWithRetry } from '@/lib/lazy-with-retry'
|
||||
import type * as SidebarFeedbackDialogModule from '../sidebar-feedback/SidebarFeedbackDialog'
|
||||
import { useTranslate } from '@/i18n/use-translate'
|
||||
import { getUpdateCheckClickOptions, getUpdateCheckHint } from '@/lib/update-check-click-options'
|
||||
import { openAppHttpLink } from '@/lib/browser/navigation/app-http-link-open'
|
||||
|
||||
// Keep feedback off the boot graph; menu warming and selection share the same lazy module entry.
|
||||
export const loadSidebarFeedbackDialog = (): Promise<typeof SidebarFeedbackDialogModule> =>
|
||||
import('../sidebar-feedback/SidebarFeedbackDialog')
|
||||
|
||||
export const SidebarFeedbackDialog = lazyWithRetry(
|
||||
() => loadSidebarFeedbackDialog().then((module) => ({ default: module.SidebarFeedbackDialog })),
|
||||
{ reloadKey: 'sidebar-feedback-dialog' }
|
||||
)
|
||||
|
||||
const DOCS_URL = 'https://www.onorca.dev/docs'
|
||||
const CHANGELOG_URL = 'https://onorca.dev/changelog'
|
||||
const GITHUB_URL = 'https://github.com/stablyai/orca'
|
||||
const DISCORD_URL = 'https://discord.gg/fzjDKHxv8Q'
|
||||
const X_URL = 'https://x.com/orca_build'
|
||||
const NO_UPDATE_CHECK_MODIFIERS = {
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false
|
||||
}
|
||||
|
||||
function DiscordIcon(): React.JSX.Element {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" className="size-3.5 fill-current">
|
||||
<path d="M16.0742 4.45014C14.9244 3.92097 13.7106 3.54556 12.4638 3.3335C12.2932 3.64011 12.1388 3.95557 12.0013 4.27856C10.6732 4.07738 9.32261 4.07738 7.99451 4.27856C7.85694 3.9556 7.70257 3.64014 7.53203 3.3335C6.28441 3.54735 5.06981 3.92365 3.91889 4.45291C1.63401 7.85128 1.01462 11.1652 1.32431 14.4322C2.6624 15.426 4.16009 16.1819 5.7523 16.6668C6.11082 16.1821 6.42806 15.6678 6.70066 15.1295C6.18289 14.9351 5.68315 14.6953 5.20723 14.4128C5.33249 14.3215 5.45499 14.2274 5.57336 14.136C6.95819 14.7907 8.46965 15.1302 9.99997 15.1302C11.5303 15.1302 13.0418 14.7907 14.4266 14.136C14.5463 14.2343 14.6688 14.3284 14.7927 14.4128C14.3159 14.6957 13.8152 14.9361 13.2965 15.1309C13.5688 15.669 13.8861 16.1828 14.2449 16.6668C15.8385 16.1838 17.3373 15.4283 18.6756 14.4335C19.039 10.645 18.0549 7.36145 16.0742 4.45014ZM7.09294 12.423C6.22992 12.423 5.51693 11.6357 5.51693 10.6671C5.51693 9.69852 6.20514 8.90427 7.09019 8.90427C7.97524 8.90427 8.68272 9.69852 8.66758 10.6671C8.65244 11.6357 7.97248 12.423 7.09294 12.423ZM12.907 12.423C12.0426 12.423 11.3324 11.6357 11.3324 10.6671C11.3324 9.69852 12.0206 8.90427 12.907 8.90427C13.7934 8.90427 14.4954 9.69852 14.4803 10.6671C14.4651 11.6357 13.7865 12.423 12.907 12.423Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function XIcon(): React.JSX.Element {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" className="size-3.5 fill-current">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ExternalMenuItem({
|
||||
label,
|
||||
url,
|
||||
icon
|
||||
}: {
|
||||
label: string
|
||||
url: string
|
||||
icon: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<DropdownMenuItem onSelect={() => openAppHttpLink(url)}>
|
||||
{icon}
|
||||
{label}
|
||||
<ExternalLink className="ml-auto size-3 text-muted-foreground" />
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
export type SidebarHelpMilestones = { done: number; total: number }
|
||||
|
||||
/** Every entry the sidebar's former "?" menu offered, now nested under Help in the account menu. */
|
||||
export function SidebarHelpSubmenu({
|
||||
milestones,
|
||||
onOpenFeedback,
|
||||
restartPending,
|
||||
onRestart
|
||||
}: {
|
||||
milestones: SidebarHelpMilestones | null
|
||||
onOpenFeedback: () => void
|
||||
restartPending: boolean
|
||||
onRestart: () => void
|
||||
}): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
const openModal = useAppStore((s) => s.openModal)
|
||||
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
|
||||
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
|
||||
const updateStatus = useAppStore((s) => s.updateStatus)
|
||||
const lastShowOnboardingAtRef = React.useRef(0)
|
||||
const updateCheckModifiersRef = React.useRef(NO_UPDATE_CHECK_MODIFIERS)
|
||||
const updateCheckHint = getUpdateCheckHint()
|
||||
|
||||
const handleShowOnboarding = (): void => {
|
||||
const now = Date.now()
|
||||
if (now - lastShowOnboardingAtRef.current < 500) {
|
||||
return
|
||||
}
|
||||
lastShowOnboardingAtRef.current = now
|
||||
void showOnboardingFromRenderer()
|
||||
}
|
||||
|
||||
const openShortcutsSettings = (): void => {
|
||||
openSettingsTarget({ pane: 'shortcuts', repoId: null })
|
||||
openSettingsPage()
|
||||
}
|
||||
|
||||
const handleCheckForUpdatesPointerDown = (event: React.PointerEvent): void => {
|
||||
updateCheckModifiersRef.current = {
|
||||
altKey: event.altKey,
|
||||
ctrlKey: event.ctrlKey,
|
||||
metaKey: event.metaKey,
|
||||
shiftKey: event.shiftKey
|
||||
}
|
||||
}
|
||||
|
||||
const handleCheckForUpdates = (): void => {
|
||||
const modifiers = updateCheckModifiersRef.current
|
||||
updateCheckModifiersRef.current = NO_UPDATE_CHECK_MODIFIERS
|
||||
void window.api.updater.check(getUpdateCheckClickOptions(modifiers))
|
||||
}
|
||||
|
||||
const openMilestones = (): void => {
|
||||
openModal('setup-guide', { telemetrySource: 'help_menu' })
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<CircleHelp className="size-3.5" />
|
||||
{t('auto.components.sidebar.SidebarSettingsHelpMenu.2991a0106c', 'Help')}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-52">
|
||||
<DropdownMenuItem onSelect={openShortcutsSettings}>
|
||||
<Keyboard className="size-3.5" />
|
||||
{t('auto.components.sidebar.SidebarSettingsHelpMenu.e565171a7c', 'Keyboard Shortcuts')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={onOpenFeedback}>
|
||||
<MessageSquareText className="size-3.5" />
|
||||
{t('auto.components.sidebar.SidebarSettingsHelpMenu.4cf5b868d7', 'Send Feedback')}
|
||||
</DropdownMenuItem>
|
||||
{milestones ? (
|
||||
<DropdownMenuItem onSelect={openMilestones}>
|
||||
<img
|
||||
src={logo}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="size-3.5 object-contain invert opacity-55 dark:invert-0"
|
||||
/>
|
||||
{t('auto.components.sidebar.SidebarSettingsHelpMenu.f8a2c91d4e', 'Milestones')}
|
||||
<SetupGuideProgressRing
|
||||
done={milestones.done}
|
||||
total={milestones.total}
|
||||
sizeClassName="size-4"
|
||||
className="ml-auto"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
className="whitespace-nowrap"
|
||||
onClick={handleShowOnboarding}
|
||||
onSelect={handleShowOnboarding}
|
||||
>
|
||||
<School className="size-3.5" />
|
||||
{t('auto.components.sidebar.SidebarSettingsHelpMenu.b7e4d2a19c', 'Onboarding')}
|
||||
</DropdownMenuItem>
|
||||
<ExternalMenuItem
|
||||
label={t('auto.components.sidebar.SidebarSettingsHelpMenu.cdc87f897e', 'Docs')}
|
||||
url={DOCS_URL}
|
||||
icon={<BookOpen className="size-3.5" />}
|
||||
/>
|
||||
<ExternalMenuItem
|
||||
label={t('auto.components.sidebar.SidebarSettingsHelpMenu.5f83d86d92', 'Changelog')}
|
||||
url={CHANGELOG_URL}
|
||||
icon={<ScrollText className="size-3.5" />}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<ExternalMenuItem
|
||||
label={t('auto.components.sidebar.SidebarSettingsHelpMenu.5687ab246a', 'GitHub')}
|
||||
url={GITHUB_URL}
|
||||
icon={<Github className="size-3.5" />}
|
||||
/>
|
||||
<ExternalMenuItem
|
||||
label={t('auto.components.sidebar.SidebarSettingsHelpMenu.eb9884e55b', 'Discord')}
|
||||
url={DISCORD_URL}
|
||||
icon={<DiscordIcon />}
|
||||
/>
|
||||
<ExternalMenuItem
|
||||
label={t('auto.components.sidebar.SidebarSettingsHelpMenu.c4f8e1b72a', 'X')}
|
||||
url={X_URL}
|
||||
icon={<XIcon />}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
disabled={updateStatus.state === 'checking' || updateStatus.state === 'downloading'}
|
||||
onPointerDown={handleCheckForUpdatesPointerDown}
|
||||
onSelect={handleCheckForUpdates}
|
||||
title={updateCheckHint}
|
||||
>
|
||||
{updateStatus.state === 'checking' ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-3.5" />
|
||||
)}
|
||||
{t('auto.components.sidebar.SidebarSettingsHelpMenu.29c56f30ee', 'Check for Updates')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={onRestart} disabled={restartPending}>
|
||||
<RotateCw className="size-3.5" />
|
||||
{t('auto.components.sidebar.SidebarSettingsHelpMenu.ad3d3ed7f1', 'Restart Orca')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { OrcaProfileAuthStatus } from '../../../../../shared/orca-profiles'
|
||||
|
||||
export type AccountMenuIdentity =
|
||||
| { signedIn: false; needsReconnect: boolean }
|
||||
| { signedIn: true; initial: string; name: string; email: string }
|
||||
|
||||
export function getAccountMenuIdentity(
|
||||
authStatus: OrcaProfileAuthStatus | null
|
||||
): AccountMenuIdentity {
|
||||
const cloud = authStatus?.cloud
|
||||
if (authStatus?.state !== 'connected' || !cloud) {
|
||||
return { signedIn: false, needsReconnect: authStatus?.state === 'reconnect-required' }
|
||||
}
|
||||
const name = cloud.displayName?.trim() || cloud.email
|
||||
// Array.from keeps a leading astral character (e.g. emoji) whole instead of half a surrogate pair.
|
||||
const initial = (Array.from(name.trim())[0] ?? '?').toLocaleUpperCase()
|
||||
return { signedIn: true, initial, name, email: cloud.email }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import {
|
||||
getRegisteredAccountMenuItems,
|
||||
registerAccountMenuItem,
|
||||
selectVisibleAccountMenuItems,
|
||||
type AccountMenuItem
|
||||
} from './account-menu-items'
|
||||
|
||||
const disposers: (() => void)[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const dispose of disposers.splice(0)) {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
|
||||
function register(id: string, order?: number): () => void {
|
||||
const entry: AccountMenuItem = { id, label: () => id, order, onSelect: () => {} }
|
||||
const dispose = registerAccountMenuItem(entry)
|
||||
disposers.push(dispose)
|
||||
return dispose
|
||||
}
|
||||
|
||||
function visibleIds(): string[] {
|
||||
return selectVisibleAccountMenuItems(getRegisteredAccountMenuItems(), null).map(
|
||||
(entry) => entry.id
|
||||
)
|
||||
}
|
||||
|
||||
it('sorts by order, keeping registration order for ties', () => {
|
||||
register('b', 10)
|
||||
register('a', 10)
|
||||
register('c', 5)
|
||||
expect(visibleIds()).toEqual(['c', 'b', 'a'])
|
||||
})
|
||||
|
||||
it('replaces an entry with the same id and ignores the replaced entry disposer', () => {
|
||||
const disposeOriginal = register('item', 1)
|
||||
register('other', 2)
|
||||
register('item', 3)
|
||||
disposeOriginal()
|
||||
expect(visibleIds()).toEqual(['other', 'item'])
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import type { ComponentType } from 'react'
|
||||
import type { OrcaProfileAuthStatus } from '../../../../../shared/orca-profiles'
|
||||
|
||||
export type AccountMenuItem = {
|
||||
/** Stable id; registering the same id again replaces the earlier entry. */
|
||||
id: string
|
||||
/** Called at render time so registered labels follow the active locale. */
|
||||
label: () => string
|
||||
icon?: ComponentType<{ className?: string }>
|
||||
/** Lower sorts first; ties keep registration order. */
|
||||
order?: number
|
||||
/** Hide the item unless the signed-in account reports this capability flag as true. */
|
||||
requiredCapability?: string
|
||||
onSelect: () => void
|
||||
}
|
||||
|
||||
let items: readonly AccountMenuItem[] = []
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
function emit(): void {
|
||||
for (const listener of listeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
/** Adds an entry to the sidebar account menu; returns a disposer that removes exactly this entry. */
|
||||
export function registerAccountMenuItem(item: AccountMenuItem): () => void {
|
||||
items = [...items.filter((existing) => existing.id !== item.id), item]
|
||||
emit()
|
||||
return () => {
|
||||
if (!items.includes(item)) {
|
||||
return
|
||||
}
|
||||
items = items.filter((existing) => existing !== item)
|
||||
emit()
|
||||
}
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
}
|
||||
|
||||
export function getRegisteredAccountMenuItems(): readonly AccountMenuItem[] {
|
||||
return items
|
||||
}
|
||||
|
||||
export function selectVisibleAccountMenuItems(
|
||||
registered: readonly AccountMenuItem[],
|
||||
authStatus: OrcaProfileAuthStatus | null
|
||||
): AccountMenuItem[] {
|
||||
const flags = authStatus?.state === 'connected' ? authStatus.capabilities?.flags : undefined
|
||||
return registered
|
||||
.filter((item) => !item.requiredCapability || flags?.[item.requiredCapability] === true)
|
||||
.map((item, index) => ({ item, index }))
|
||||
.sort((a, b) => (a.item.order ?? 0) - (b.item.order ?? 0) || a.index - b.index)
|
||||
.map(({ item }) => item)
|
||||
}
|
||||
|
||||
export function useRegisteredAccountMenuItems(): readonly AccountMenuItem[] {
|
||||
return useSyncExternalStore(subscribe, getRegisteredAccountMenuItems)
|
||||
}
|
||||
@@ -6272,6 +6272,10 @@
|
||||
"selectedProjects_one": "{{count}} Projekt",
|
||||
"selectedProjects_other": "{{count}} Projekte"
|
||||
},
|
||||
"SidebarAccountMenu": {
|
||||
"account": "Konto",
|
||||
"signIn": "Anmelden"
|
||||
},
|
||||
"SidebarSettingsHelpMenu": {
|
||||
"ad3d3ed7f1": "Orca neu starten",
|
||||
"29c56f30ee": "Nach Updates suchen",
|
||||
|
||||
@@ -6581,6 +6581,10 @@
|
||||
"selectedProjects_one": "{{count}} project",
|
||||
"selectedProjects_other": "{{count}} projects"
|
||||
},
|
||||
"SidebarAccountMenu": {
|
||||
"account": "Account",
|
||||
"signIn": "Sign in"
|
||||
},
|
||||
"SidebarSettingsHelpMenu": {
|
||||
"ad3d3ed7f1": "Restart Orca",
|
||||
"29c56f30ee": "Check for Updates",
|
||||
@@ -19524,6 +19528,147 @@
|
||||
"setting-global-off": "Folder trust is turned off in Agents settings, so agents always ask.",
|
||||
"keyword-trust": "trust",
|
||||
"keyword-hooks": "hooks"
|
||||
},
|
||||
"billing": {
|
||||
"reasonNoPlan": "Hosted sandboxes need a paid plan.",
|
||||
"reasonInactive": "Your plan is not active. Choose a plan to start sandboxes.",
|
||||
"reasonPastDue": "Your last payment failed. Update your payment method to start new sandboxes.",
|
||||
"openForbidden": "Only an org owner or admin can manage billing.",
|
||||
"openAlreadySubscribed": "This account already has a plan. Use Manage billing to change it.",
|
||||
"openNoAccount": "There is no billing account yet. Choose a plan first.",
|
||||
"openSignedOut": "Sign in to Orca again to manage billing.",
|
||||
"openUnconfigured": "Billing is unavailable in this build.",
|
||||
"openFailed": "Could not reach billing. Try again.",
|
||||
"hoursLeft": "{{value0}} hours left",
|
||||
"usageLabel": "Included hours used",
|
||||
"endsOn": "Plan ends {{value0}}",
|
||||
"renewsOn": "Renews {{value0}}",
|
||||
"continueInBrowser": "Continue in your browser. This updates when you come back.",
|
||||
"title": "Plan & billing",
|
||||
"refresh": "Refresh plan",
|
||||
"loadFailed": "Could not load your plan. Try again.",
|
||||
"askAdmin": "Ask an org owner or admin to change the plan.",
|
||||
"menuItem": "Plan & billing",
|
||||
"statusActive": "Active",
|
||||
"statusTrial": "Trial",
|
||||
"statusPastDue": "Payment due",
|
||||
"statusCanceled": "Canceled",
|
||||
"statusInactive": "Inactive",
|
||||
"reasonExhaustedOptIn": "You have used all included hours this month. Turn on overage to keep starting sandboxes.",
|
||||
"reasonCapRaise": "You reached this month’s overage spend cap. An admin can raise it.",
|
||||
"capTooHigh": "That spend cap is above the allowed maximum.",
|
||||
"overageNoPlan": "Overage needs an active plan.",
|
||||
"hoursUsedMonth": "{{value0}} of {{value1}} hours used this month",
|
||||
"warn100": "All included hours for this month are used.",
|
||||
"warnPercent": "You have used {{value0}}% of this month’s included hours.",
|
||||
"resets": "Hours reset {{value0}}",
|
||||
"overageTitle": "Overage",
|
||||
"overageDescription": "Keep starting sandboxes after included hours at {{value0}}/hr Standard ({{value1}}/hr Large).",
|
||||
"overageToggle": "Allow overage",
|
||||
"overageSpent": "{{value0}} of {{value1}} monthly cap spent",
|
||||
"capLabel": "Monthly cap ($)",
|
||||
"saveCap": "Save",
|
||||
"monthly": "Monthly",
|
||||
"annual": "Annual",
|
||||
"seatsInterval": "{{value0}} seats · {{value1}}",
|
||||
"paymentSuspended": "Sandboxes suspended: payment overdue",
|
||||
"paymentFailed": "Payment failed",
|
||||
"paymentSuspendedBody": "Running sandboxes were suspended and new ones cannot start. Update your payment method; everything resumes once the invoice is paid.",
|
||||
"paymentGraceBody": "New sandboxes are paused. Running sandboxes will be suspended on {{value0}} unless the payment goes through. Stripe will retry the card and email your billing contact.",
|
||||
"updatePayment": "Update payment method",
|
||||
"reasonGraceExpired": "Your payment is overdue, so sandboxes are suspended. Update your payment method to resume.",
|
||||
"team": {
|
||||
"menuItem": "Team",
|
||||
"invited": "Invite sent to {{value0}}. They join (and become a billed seat) when they accept.",
|
||||
"revoked": "Invite revoked.",
|
||||
"roleChanged": "Role updated.",
|
||||
"removeTitle": "Remove {{value0}}?",
|
||||
"removeConfirm": "Remove member",
|
||||
"removed": "Member removed.",
|
||||
"title": "Team",
|
||||
"refresh": "Refresh team",
|
||||
"loading": "Loading your team…",
|
||||
"loadFailed": "Could not load your team. Try again.",
|
||||
"memberCount": "{{value0}} members",
|
||||
"memberSeats": "{{value0}} members · {{value1}} seats billed. Seats follow membership; pending invites are free until accepted.",
|
||||
"you": "(you)",
|
||||
"roleFor": "Role for {{value0}}",
|
||||
"remove": "Remove {{value0}}",
|
||||
"pending": "Pending invites",
|
||||
"revoke": "Revoke",
|
||||
"invitePlaceholder": "teammate@company.com",
|
||||
"inviteEmail": "Email to invite",
|
||||
"inviteRole": "Role for the invite",
|
||||
"invite": "Invite",
|
||||
"askAdmin": "Only owners and admins can invite or remove members.",
|
||||
"roleOwner": "Owner",
|
||||
"roleAdmin": "Admin",
|
||||
"roleMember": "Member",
|
||||
"alreadyMember": "That person is already a member.",
|
||||
"alreadyInvited": "That person already has an invite.",
|
||||
"cannotRemoveSelf": "You cannot remove yourself.",
|
||||
"cannotChangeOwnRole": "You cannot change your own role.",
|
||||
"forbidden": "Only owners and admins can manage members, and only owners can manage owners.",
|
||||
"notFound": "That member or invite no longer exists.",
|
||||
"failed": "Could not update the team. Try again.",
|
||||
"seatsDrop": "Seats paid this period stay until {{value0}}, then drop to {{value1}}.",
|
||||
"removeDescription2": "They lose access to the org right away. Their seat stays paid until your plan renews, then drops off."
|
||||
},
|
||||
"manageBilling": "Manage billing",
|
||||
"choosePlan": "Choose a plan",
|
||||
"comparePlans": "Compare plans",
|
||||
"plans": {
|
||||
"entByoc": "Run sandboxes in your own cloud account",
|
||||
"entSso": "SSO, SCIM provisioning and audit logs",
|
||||
"entNetwork": "Network policy and egress controls",
|
||||
"entTerms": "Custom limits, invoicing and terms",
|
||||
"entPriced": "Priced for your organization",
|
||||
"contactSales": "Contact sales",
|
||||
"eyebrow": "Orca Cloud",
|
||||
"title": "Cloud sandboxes for your whole team",
|
||||
"monthly": "Monthly",
|
||||
"annual": "Annual",
|
||||
"billingPeriod": "Billing period",
|
||||
"vpRunningTitle": "Agents keep running",
|
||||
"vpWakeTitle": "Wake in seconds",
|
||||
"vpWakeBody": "Idle sandboxes sleep to save hours and resume right where they left off.",
|
||||
"vpSecretsTitle": "Secrets never stored in the VM",
|
||||
"loading": "Loading plans…",
|
||||
"loadFailed": "Could not load plans. Try again in a moment.",
|
||||
"featureConcurrency": "{{value0}} running sandboxes per seat",
|
||||
"featureOverage": "Optional overage at {{value0}}/hr ({{value1}}/hr Large) with a monthly spend cap",
|
||||
"currentPlan": "Current plan",
|
||||
"perSeatMonth": "per seat / month",
|
||||
"billedMonthly": "Billed monthly",
|
||||
"totalYear": "{{value0}} / year",
|
||||
"totalMonth": "{{value0}} / month",
|
||||
"checkout": "Continue to checkout",
|
||||
"entLead": "Everything in Team, plus:",
|
||||
"entHint": "Tell us about your team and where you run.",
|
||||
"titleSubscribed": "Your Orca Cloud plan",
|
||||
"subtitleSubscribed": "Usage this month, your plan, and what Enterprise adds.",
|
||||
"subtitle2": "Run Orca agents in hosted sandboxes that keep going when you step away.",
|
||||
"saveShort": "−{{value0}}%",
|
||||
"vpRunningBody2": "Sandboxes keep working while you are away and report back when they finish.",
|
||||
"vpSecretsBody2": "Credentials are injected into outbound requests and never touch the VM’s disk.",
|
||||
"seatsAndPeriod": "Seats: {{value0}} · {{value1}}",
|
||||
"leftResets": "{{value0}} hours left · resets {{value1}}",
|
||||
"retry": "Try again",
|
||||
"footHours": "Hours reset every month, including on annual plans. When they run out, new sandboxes wait until the next month unless overage is on.",
|
||||
"footTerms": "Prices in USD, before tax. Overage stays off until an owner or admin turns it on and sets a spend cap. Cancel anytime; your plan runs to the end of the period.",
|
||||
"featureHours2": "{{value0}} Standard hours (4 vCPU / 8 GB) per seat each month, pooled across your team",
|
||||
"featureLarge2": "Large (8 vCPU / 16 GB) uses hours at {{value0}}×",
|
||||
"featureSleep2": "Sleeps after {{value0}} minutes idle",
|
||||
"billedYearly": "Billed yearly",
|
||||
"billedYearlyAmount": "Billed {{value0}} per seat yearly",
|
||||
"seatsOne": "Seats: {{value0}} (one per team member)",
|
||||
"waiting": "Waiting for checkout…",
|
||||
"fixPaymentAbove": "Update your payment method above to keep your plan active.",
|
||||
"manageHint": "Switch monthly or annual, update payment, or cancel.",
|
||||
"lockedBody": "Only an owner or admin of your org can choose a plan. Ask one of them to open Plan & billing in Orca.",
|
||||
"seatsFollow2": "Seats follow your team: new members add a seat now; removed seats drop off at renewal."
|
||||
},
|
||||
"teamFrom": "Team starts at {{value0}} per seat per month, billed annually."
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
|
||||
@@ -5872,6 +5872,10 @@
|
||||
"selectedProjects_other": "{{count}} proyectos",
|
||||
"selectedProjects_many": "{{count}} proyectos"
|
||||
},
|
||||
"SidebarAccountMenu": {
|
||||
"account": "Cuenta",
|
||||
"signIn": "Iniciar sesión"
|
||||
},
|
||||
"SidebarSettingsHelpMenu": {
|
||||
"ad3d3ed7f1": "Reiniciar Orca",
|
||||
"29c56f30ee": "Buscar actualizaciones",
|
||||
|
||||
@@ -6112,6 +6112,10 @@
|
||||
"selectedProjects_other": "{{count}} projets",
|
||||
"selectedProjects_many": "{{count}} projets"
|
||||
},
|
||||
"SidebarAccountMenu": {
|
||||
"account": "Compte",
|
||||
"signIn": "Se connecter"
|
||||
},
|
||||
"SidebarSettingsHelpMenu": {
|
||||
"ad3d3ed7f1": "Redémarrer Orca",
|
||||
"29c56f30ee": "Rechercher des mises à jour",
|
||||
|
||||
@@ -5626,6 +5626,10 @@
|
||||
"allProjects": "すべてのプロジェクト",
|
||||
"selectedProjects_other": "{{count}} 件のプロジェクト"
|
||||
},
|
||||
"SidebarAccountMenu": {
|
||||
"account": "アカウント",
|
||||
"signIn": "サインイン"
|
||||
},
|
||||
"SidebarSettingsHelpMenu": {
|
||||
"ad3d3ed7f1": "Orca を再起動",
|
||||
"29c56f30ee": "アップデートをチェックする",
|
||||
|
||||
@@ -5626,6 +5626,10 @@
|
||||
"allProjects": "모든 프로젝트",
|
||||
"selectedProjects_other": "프로젝트 {{count}}개"
|
||||
},
|
||||
"SidebarAccountMenu": {
|
||||
"account": "계정",
|
||||
"signIn": "로그인"
|
||||
},
|
||||
"SidebarSettingsHelpMenu": {
|
||||
"ad3d3ed7f1": "Orca 재시작",
|
||||
"29c56f30ee": "업데이트 확인",
|
||||
|
||||
@@ -11960,6 +11960,10 @@
|
||||
"cloneFolderTakenByRepo": "\"{{value0}}\" nesta pasta já é outro repositório, então este clone precisa da sua própria pasta.",
|
||||
"cloneFolderNotEmpty": "\"{{value0}}\" já existe nesta pasta, então este clone precisa da sua própria pasta."
|
||||
},
|
||||
"SidebarAccountMenu": {
|
||||
"account": "Conta",
|
||||
"signIn": "Entrar"
|
||||
},
|
||||
"SidebarSettingsHelpMenu": {
|
||||
"ad3d3ed7f1": "Reiniciar o Orca",
|
||||
"29c56f30ee": "Verificar Atualizações",
|
||||
|
||||
@@ -6558,6 +6558,10 @@
|
||||
"selectedProjects_few": "{{count}} проекта",
|
||||
"selectedProjects_many": "{{count}} проектов"
|
||||
},
|
||||
"SidebarAccountMenu": {
|
||||
"account": "Аккаунт",
|
||||
"signIn": "Войти"
|
||||
},
|
||||
"SidebarSettingsHelpMenu": {
|
||||
"ad3d3ed7f1": "Перезапустить Orca",
|
||||
"29c56f30ee": "Проверить наличие обновлений",
|
||||
|
||||
@@ -6256,6 +6256,10 @@
|
||||
"selectedProjects_one": "{{count}} proje",
|
||||
"selectedProjects_other": "{{count}} proje"
|
||||
},
|
||||
"SidebarAccountMenu": {
|
||||
"account": "Hesap",
|
||||
"signIn": "Oturum aç"
|
||||
},
|
||||
"SidebarSettingsHelpMenu": {
|
||||
"ad3d3ed7f1": "Orca'yı Yeniden Başlat",
|
||||
"29c56f30ee": "Güncellemeleri Kontrol Et",
|
||||
|
||||
@@ -6112,6 +6112,10 @@
|
||||
"allProjects": "所有專案",
|
||||
"selectedProjects_other": "{{count}} 個專案"
|
||||
},
|
||||
"SidebarAccountMenu": {
|
||||
"account": "帳戶",
|
||||
"signIn": "登入"
|
||||
},
|
||||
"SidebarSettingsHelpMenu": {
|
||||
"ad3d3ed7f1": "重新啟動 Orca",
|
||||
"29c56f30ee": "檢查更新",
|
||||
|
||||
@@ -5626,6 +5626,10 @@
|
||||
"allProjects": "所有项目",
|
||||
"selectedProjects_other": "{{count}} 个项目"
|
||||
},
|
||||
"SidebarAccountMenu": {
|
||||
"account": "账户",
|
||||
"signIn": "登录"
|
||||
},
|
||||
"SidebarSettingsHelpMenu": {
|
||||
"ad3d3ed7f1": "重启 Orca",
|
||||
"29c56f30ee": "检查更新",
|
||||
|
||||
@@ -31,11 +31,14 @@ import { handleGuestWindowCloseMessage } from './components/browser-pane/host-gu
|
||||
import { installOsFileDropCancellationGuard } from './lib/os-file-drop-cancellation-guard'
|
||||
import { installBodyOverlayAttributes } from './lib/body-overlay-attributes'
|
||||
import { applyWindowMaterialToDocument } from './lib/window-material-runtime'
|
||||
import { registerPlanBillingMenuItem } from './components/billing/register-plan-billing-menu-item'
|
||||
|
||||
const disposeOsFileDropGuard = installOsFileDropCancellationGuard()
|
||||
import.meta.hot?.dispose(disposeOsFileDropGuard)
|
||||
const disposeBodyOverlayAttributes = installBodyOverlayAttributes()
|
||||
import.meta.hot?.dispose(disposeBodyOverlayAttributes)
|
||||
const disposePlanBillingMenuItem = registerPlanBillingMenuItem()
|
||||
import.meta.hot?.dispose(disposePlanBillingMenuItem)
|
||||
recordRendererCrashBreadcrumb('renderer_bootstrap_started', { dev: import.meta.env.DEV })
|
||||
installRendererCrashDiagnostics()
|
||||
installTypingLatencyDiagnostic()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user