Opting out left the metered overage item on the Stripe subscription, so every later
invoice carried a '0 std hour x Orca sandbox overage' $0 line (confirmed on a
throwaway test-mode subscription). The item now comes off at once when nothing was
metered this Stripe period, or at the first sync after the period rolls over when
there is usage still to invoice. Re-opting in within 24h got the removed item's id
replayed by Stripe idempotency (also confirmed live); the add now retries under a
key naming the removed item. Nothing meters while opted out or with a $0 cap.
Plan & billing now reads '<Team> · Team plan', shows the last 30 days of sandbox
hours as a bar chart, and (owners/admins) the top workspaces and members. Members
see the team chart and their own hours. The overage toggle becomes one 'Monthly
spend cap' field ($0 = off, 'Turn on with a $25 cap'); the rate is a one-line
hint; alert levels are read-only values. The manual refresh icon is gone.
Billing Worker: additive GET /v1/billing/usage (read-only over usage_events,
no migration).
#305 moved billing's GitHub install-url call onto a service binding because same-account Workers
can't fetch each other (Cloudflare error 1042). No prod billing config exists yet (launch runbook
step 1.2); this test fails if the new wrangler.prod.jsonc names SANDBOX_CONTROL_URL without the
binding, or binds another environment's control plane.
- The composer read the setup session with a bare readCloudSetupSession() call in render. The React
Compiler memoizes that on the session id, so the panel kept its first (empty) read: rows never
moved past 'In browser', the finished session never auto-started the workspace, and the GitHub
row stayed 'Needed' after the browser said 'You're all set'. Read it through
useCloudSetupSession (useSyncExternalStore with a per-poll cached snapshot) instead.
- Billing asked the control plane for the GitHub install URL with a plain fetch to its workers.dev
origin. Same-account Workers can't do that (Cloudflare error 1042), so every setup GitHub step
showed 'Orca Cloud setup is unavailable'. Bind the control plane as a service (SANDBOX_CONTROL)
in the dev config and send the request through it when bound.
Found by the NP-4 end-to-end run (np4-e2e) on m4air against staging.
Live m4air run: a signed-in owner whose active team never had a trial was sent
to T3/Checkout. Only teams bound in the browser got the trial. Session creation
now runs the same grant (N11 limits) for the active team, so the chain skips
the plan page.
A team that setup creates or picks gets the trial when it binds, with the email,
GitHub user and ip abuse keys. Teams that can't get one go through T3 and
Checkout.
Billing Worker:
- trials + trial_claims (migration 0005): 2 sandbox hours per new team, no card, no Stripe.
- POST /v1/billing/trial grants it (idempotent, any member); grantTrial/trialOffer exported for
the setup session. offers.trial on GET entitlements only when this caller could still claim it.
- Abuse limits: once per team, Orca user, normalized verified email, GitHub user (token claim)
and GitHub owner (POST /v1/internal/trial/github from the binding; a reused identity revokes),
disposable-domain blocklist, 3 grants per IP per day. Keys stored as SHA-256 hashes.
- Entitlement trial {includedSeconds, usedSeconds, remainingSeconds, exhausted}; metered from
usage_events, concurrency 1, never overage; trial_used_up (suspendRunning) / trial_unavailable.
- Any subscription ends the trial; unused trial hours lapse.
- e2e: trial path (grant, no Stripe customer, concurrency, metering to used up, once per user,
plan ends it) with cleanup of the run's abuse keys.
Desktop: trial_used_up / trial_unavailable classify as 'plan'; copy in billing, phone and CLI;
"Trial" badge, TrialPlanSummary in Plan & billing and the plans page strip, account menu row
"Trial · {{count}} sandbox hours left" (Choose plan for owners/admins); billingClaimTrial IPC
and claimOrcaCloudTrial() for the composer.
- Signed out, Set up and start opens one tab. The authorize page shows the
setup account step (A2). Once the code is exchanged, the loopback holds the tab,
creates the billing setup session and 302s the same tab into the setup pages.
- Signed in, the session is created and its start URL is opened.
- IPC: orcaProfiles.cloudSetupStart / cloudSetupGet / cloudSetupLink (Copy setup
link mints a fresh one-time link).
- Renderer: cloud-setup-session.ts polls the session (2 s for 3 min, then 10 s),
keeps a snapshot for the composer and tracker, and switches the profile to the
team setup chose.
- Plans dialog: Checkout joins the open setup session. A personal account
(409 team_required) goes through setup instead, which makes or picks a team
first.
- Billing: POST /v1/cloud-setup/sessions/:id/link.
Setup session API on the billing Worker: the desktop creates a session after
sign-in and polls its steps (account, team, plan/trial, github); the setup pages
on Orca Cloud auth redeem a one-time browser ticket, bind the team (granting the
trial through a port np4-trial's module plugs into), open Checkout that returns
to the setup pages, and ask the control plane for the GitHub install URL. The
control plane flips the GitHub step via POST /v1/internal/cloud-setup/github.
Checkout now refuses personal accounts with 409 team_required: a user: plan
could never move to a team later.
- N9: orca://cloud/setup/return?session=cs_… is routed (open-url + second
instance), validated, focuses Orca and fans out to renderer listeners
(onCloudSetupReturn) so the setup session re-reads at once.
- N10: cloud_setup_step_{started,done,failed}, cloud_setup_first_sandbox and
cloud_setup_plan_chosen, enum-only, derived from the setup rows.
- X1: setup error kinds (timed_out, checkout_canceled, bank_step,
trial_unavailable, team_create_failed, wrong_account) and their actions.
- B2: billing POST /v1/billing/team-plans returns other teams' plan state with
the caller's own token; auth confirms membership from that token (no new
credential). Desktop IPC billingTeamPlans + useSwitchToPlannedTeam. The team
switch logic moves out of SidebarTeamSubmenu so the composer can reuse it.
- Overage stays $1.00 per sandbox hour (ORCA_CLOUD_OVERAGE_CENTS_PER_HOUR) and is now billed by
the minute: a per-minute metered TEST price (unit_amount_decimal = rate / 60 to 12 decimals,
transform_quantity divide_by 60, round up), created the same way by stripe-test-setup.ts.
capSeconds rounds the cap down to whole minutes and spentCents mirrors the invoice, so the
cap math matches Stripe. The meter still counts seconds.
- Opting in re-prices an overage item still on an older price (e.g. $0.75 / h) instead of adding
a second item on the same meter, and removes any extra stale one.
- Alerts at 75%, 90% and 100% of included hours (warnAtPercent).
- New top-level entitlement.suspendRunning: payment grace expired, or at the limit (out of hours
with overage off, or at the spend cap). The start check and usage answers carry it, so the
control plane puts running sandboxes to sleep (snapshot kept, never deleted). Payment fields and
/v1/internal/suspensions stay payment-only, so no data-retention clock starts at a cap.
- Copy (desktop billing panel, plan card, build sheet, phone, CLI): "billed by the minute" after
the rate; at the limit "Running sandboxes go to sleep and new ones can't start. Nothing is
deleted, and your files are safe."
DS-34:
- One billing store (billing-summary-store) replaces the summary, the hook's local state and the
Run-on picker cache; it keeps the last good result through a failed read and still drops answers
from before a team or account switch (DS-17).
- Skeleton while Plan & billing loads.
- '{{count}} sandbox hours left' and '{{count}} sandbox hours over the included amount this month'
as i18n plurals; no bare 'hours'; the unreachable 100% branch is gone.
- Usage warning driven by usageWarningPercent ('Your team has used {{percent}}% of this month's
sandbox hours.'), in Settings, the plans page and the account menu.
- capTooHigh states the maximum ('The spend cap can be at most {{max}}.'), from offers.maxSpendCapCents.
- Plans dialog focuses its heading on open, so Tab reaches the interval toggle before the cards.
- Payment notice uses InlineError; the build card's overage buttons land on the overage section.
- Account menu names the team once and adds the hours-left line.
DS-44: warning tone at 90%+, '$' as an input prefix, neutral paid check, 24px dialog close hit
area, no <p> inside the CheckoutPanel <dl>.
Line items: '{{seats}} x {{price}} per seat, billed yearly' / 'per seat per month'
(plural keys); totals '{{total}} per year' / 'per month'; Worker plan labels
'$50 per seat per month' and '$480 per seat, billed yearly'. Drops /mo, /yr,
/year and /seat/month strings.
- Seats billed = the period's peak membership: adds above paid seats are charged now
(always_invoice); removals never credit mid-period and only lower the quantity inside
seatTrueDownHours of renewal (proration none), so remove/re-add churn can't dodge or
double-charge. The cron applies deferred decreases from the last pushed count.
- Included hours and caps come from billed seats; per-member concurrency cap
(member_concurrency_limit); usage attributed to the member who started it.
- Start check returns billed `seats` and the derived caps for the control plane.
- Desktop shows when paid seats drop at renewal; proration copy updated.
- Design doc: seat-fairness decision with the GitHub/Cursor/Slack/Linear comparison.
A pricing/plans page reachable from the account menu ("Plan & billing"), the
Settings plan section ("Choose a plan" / "Compare plans"), and any upsell via
openPlansPage(). Everything comes from the billing service's offers: Team
monthly/annual (with the computed annual saving), pooled hours, Large weight,
concurrency, idle sleep, opt-in overage with spend cap, and Enterprise contact.
- States: choose (admin), locked (member), current plan with usage, payment
failure (status-first), checkout in progress, loading and failure with retry.
- Seats are read-only: one per team member (billing re-reads the count).
- Shared card shell keeps both cards' footers level; sliding interval toggle and
price fade respect reduced motion; dialog opens without a pre-focused button.
- Worker offers gain per-seat cents and the Large weight for totals.
- Copy matches the marketing /cloud promises: agents keep running, wake in
seconds, secrets never stored in the VM.
Worker
- Seats = org members from Orca Cloud auth (single source of truth; pending invites
never count; min 1): auth pushes POST /v1/internal/seats, billing pulls
/v1/internal/billing/org-members at Checkout and in a reconcile cron, fails closed
(503) if auth is configured but unreachable. No token claim carries a count.
- Proration: adds always_invoice, removals create_prorations (Stripe default).
- Billing admin actions require a token minted in the last 6 minutes.
- Payment failure: past_due_since (migration 0003); new starts stop at once;
after 96 h (paymentGraceHours) reason payment_grace_expired + suspendRunning on
check/usage, and GET /v1/internal/suspensions for the control plane.
- Portal config: monthly<->annual + cancel only (seats follow membership).
- e2e: seat add/remove proration, grace expiry -> suspend -> pay -> resume.
Desktop
- Team section + account menu item: members/roles, invite, change role, remove
with confirm, revoke invites (owners/admins, auth role rules mirrored).
- Payment issue banner with suspension date and Update payment method.
* feat(billing): Team-only pricing, opt-in overage with spend cap, live Stripe test-mode e2e
- Catalog: Team $50/seat/mo or $480/seat/yr, 60 Standard h/seat/month pooled
(monthly windows on annual plans), Large 2x, 3 running/seat, 15-min idle sleep;
Enterprise is contact-only. All numbers/labels in BILLING_PLANS defaults.
- Overage is opt-in (adds the metered item), $0.75/h, monthly spend cap (default
$25, admin-raisable) enforced in the meter outbox; 80%/100% usage warnings.
- Checkout: plan/interval/seats with adjustable quantity; portal configuration var.
- Workers reject fetch redirect 'error': use 'manual'. Log route errors.
- scripts/stripe-test-setup.ts creates the test catalog + portal configuration;
scripts/e2e-test-mode.ts drives Checkout (headless, test card), overage, meter,
cap, annual, test-clock renewal, payment failure and cancel.
- Desktop: Team/Enterprise cards with interval + seats, usage warnings, overage
toggle + cap; split into copy/offers/usage modules.
* feat(billing): dev deployment config, dev operator keys, return page
- wrangler.dev.jsonc for orca-billing-dev (workers.dev, dev D1, test-mode prices).
- BILLING_DEV_JWKS (issuer orca-billing-dev-test) accepted only when BILLING_ENV
is not prod, for e2e runs until auth mints billing tokens.
- GET /return: static 'return to Orca' page for Checkout/Portal redirects.
- Design doc: final pricing, dev URL and resources for the control plane.
- Worker: BILLING_PLANS JSON overrides the plan catalog (labels, display price,
hours, limits); entitlements return the plan list so the desktop shows no
hardcoded prices. STRIPE_AUTOMATIC_TAX=true adds automatic_tax and tax id
collection to Checkout (default off). Setup script takes prices from env.
- Desktop: register Plan & billing through the sidebar account menu
(merged nwparker/sidebar-account-menu) and drop the local adapter.
- Register the billing Worker's tests with the test-runner check.
Test-mode-only foundation for paid Orca plans: hosted Checkout/Portal,
signed + idempotent Stripe webhooks that re-read subscriptions, a D1 ledger
for sandbox usage with overage-only Billing meter events, and entitlement
checks for the desktop and the sandbox control plane.