Docs / Accounts, plans & billing

Accounts, Plans & Billing

This document describes the implementation in this repository. Items called out as pending are not production guarantees.

Accounts and workspaces

Current persistence

The control plane's durable store is an interface (internal/platform.Store), not D1 or Durable Objects: an embedded bbolt file for local/self-hosted deployments, or Postgres for the live hosted deployment (production completed this migration and cut over its real data — see Platform architecture). Either backend durably stores:

Hosted browser routing and close checkpoints are still held in the running control-plane process. They are not durable hosted-session records in the store. API-key spend caps are also not implemented. Additive durable lifecycle state requires a separate schema decision; do not infer it from the stored web-session bucket.

Plan catalog and enforced limits

New personal workspaces start on Free. /api/plans exposes the catalog used by the console:

PlanDisplay price/monthIncluded instance-hoursStorage shownConcurrent hosted sessions
Free$052 GB1
Pro$4910050 GB5
Scale$299750500 GB25

The current enforcement boundary is narrower than the catalog:

Usage and metering

Usage records are stored in the durable store (bbolt or Postgres) and deduplicated per workspace by idempotency_key.

Implemented production paths:

Reserved dimensions such as pool_slot_seconds, concurrent_peak, storage_gb_hours, stream_seconds, and egress_gb have no production emitter yet. There is no hourly Worker/D1 aggregation job and no Stripe usage-record or meter-event publisher. Actions, steps, session creation, and AI tokens are not metered; AI calls are BYOK.

Stripe behavior

Stripe is optional at runtime. The current backend provides:

Checkout and Portal return HTTP 503 without STRIPE_SECRET_KEY; Checkout also returns 503 when the selected plan's STRIPE_PRICE_PRO or STRIPE_PRICE_SCALE value is absent. Webhook delivery requires STRIPE_WEBHOOK_SECRET independently of those API settings.

The webhook records Checkout customer/subscription identifiers, applies customer.subscription.created and .updated plan/status/renewal fields, and drops canceled, unpaid, incomplete-expired, or deleted subscriptions back to Free. Unknown event types are acknowledged without changing plan state. If an active or past-due subscription ID is already recorded locally, a second Checkout is refused with guidance to use the portal instead.

This is not yet the final production billing safety boundary. TASK-88 is integrated as 13496fc: checkout reserves a workspace billing generation atomically, prevents concurrent active/pending reservations, and uses a stable generation-bound idempotency key.

TASK-94 is integrated (re-landed 2026-08-14 after a first attempt was reverted): local time never rolls a checkout generation. A Checkout can complete just before Stripe's deadline while its webhook arrives later, so only a signed, matching checkout.session.expired releases an abandoned reservation.

The first attempt was reverted because it introduced two worse defects; the re-landing carries the two properties that close them, and both are covered by mutation-controlled tests:

If Stripe exhausts expiry-webhook delivery on a session that DID get created, the reservation still needs operator reconciliation: recover the generation from GET /api/account/export (it is the second half of the agentbrowser-checkout:<ws>:<gen> idempotency key) and resend the matching signed event. Local elapsed time is never sufficient evidence that no paid Checkout completed.

TASK-98 is integrated: a signed event is routed to its workspace before any Price validation. Workspace metadata and the durable customer index are resolved independently — stale or deleted metadata falls back to the known customer owner, so a signed cancellation can no longer be acknowledged against a ghost workspace while the real owner stays on a paid plan. An event with neither a live metadata target nor a customer binding is an acknowledged no-op rather than a multi-day Stripe retry caused by irrelevant local Price configuration; an event whose two live hints identify different workspaces fails without mutation, so Stripe retries while the tenant conflict is investigated. A known workspace with an unknown Price still fails closed.

TASK-88, TASK-94, and TASK-98 are all integrated with their controls green. Self-serve billing's structural gate is closed; the remaining work is configuration: the two recurring prices and the signed webhook, then proving checkout, portal, delayed expiry/completion and subscription events against the intended Stripe account.

Export and account deletion

GET /api/account/export downloads account metadata containing the user, owned workspaces, API-key metadata, vault-secret names/origins, complete device registrations (including push-registration tokens), and usage totals. It excludes passwords, password hashes, API-key hashes, vault values, artifact records and contents, webhook definitions, and scheduled-job definitions. Treat the downloaded file as sensitive account data; it is not a secret-free archive.

POST /api/account/delete requires the current password and the exact confirmation string DELETE.

Accordingly, the current implementation provides a filesystem-backed local cascade, not a complete production right-to-erasure guarantee for every storage, billing, and fleet configuration.