This page covers the studio’s own subscription to Perform. The plans a studio sells to its trainees are a different feature (settings/plans, backed by /products). The payment provider integration runs in the core API. The web app renders plans, starts checkouts through oRPC and reacts to plan limits.

The catalog mirror

packages/payments/config.ts is a UI copy of the backend catalog. The file header states that the backend copy is authoritative, because it drives checkout and webhooks. Keep the two in sync.
Each paid plan has two prices: the current one (fixed fee plus seatAmount of EXTRA_SEAT_AMOUNT, 49, for every seat beyond the included ones, with a 30 day trial) and a legacy price that keeps grandfathered subscriptions resolving to their plan. findPriceByPlanId in lib/plans.ts never returns a legacy price, so checkout never offers one. priceId values are provider product ids. Environment variables PRICE_ID_*_V2 and PRICE_ID_*_MONTHLY override them. In client bundles the env backed values are stripped. That is fine, because purchases arrive from the server with planId and planPrice already resolved. billingAttachedTo: "organization" means purchases belong to the studio. requireActiveSubscription: false means a studio with no purchase is not blocked.

Resolving the active plan

createPurchasesHelper(purchases) in packages/payments/lib/helper.ts returns { activePlan, hasSubscription, hasPurchase }. activePlan is chosen in this order:
  1. A subscription purchase whose customerId is admin:manual (granted from the admin panel). It wins over billed rows and the result carries manual: true.
  2. A subscription on a plan that is not hidden.
  3. Any subscription.
  4. A one time purchase.
  5. With requireActiveSubscription off, the virtual plan free (NO_PURCHASE_PLAN_ID).
A purchase with priceId === "admin:custom" resolves to the custom plan. isFreeTierPlanId(id) is true for both free and solo. Purchases are loaded:

The plan as the API enforces it

Limits are enforced by the core API, which also knows usage. fetchBillingPlan(ctx) in modules/payments/lib/billing-plan-server.ts reads GET /v1/web/billing/plan and normalizes it tolerantly into:
It returns null when the API cannot say. Every caller treats it as best effort. fetchTeamCounts(ctx) counts the team the way the plan prices it: active coach rows including the owner, and how many have not signed in yet (no externalUserId).

Screens

Billing settings

/{slug}/settings/billing renders three blocks from modules/payments/components: CustomerPortalButton calls orpc.payments.createCustomerPortalLink with redirectUrl set to the current page and then navigates to the returned URL.

The plan picker

/choose-plan renders PlanGaugePicker. It is dark in both themes by design and is on the theme check’s exemption list. The page works out a CurrentPlan on the server (plan id, seats, legacy price flag, canChange, manual) and passes it down with team and trainee counts. All logic sits in modules/payments/lib/plan-picker.ts and is unit tested in plan-picker.test.ts:
  • TIERS lists seven stops: 5, 25, 50, 100, 250, 500 and 500+.
  • tierLock decides whether a stop is unavailable because the studio already has more trainees or seats than it allows.
  • seatBounds, clampSeats, initialSeats, extraSeats and monthlyTotal drive the seat stepper. MAX_SELF_SERVE_SEATS is 30.
  • pickerAction(entry, state) returns what the button does:
The checkout redirectUrl is /checkout-return?organizationId={id}.

Checkout return

/checkout-return renders CheckoutReturnContent, which polls orpc.payments.listPurchases with refetchInterval. The checkout has landed when the active plan is backed by a real purchase row (purchaseId present and the id is not the virtual free). Then it replaces the route with /. If nothing lands within the maximum wait, it replaces the route with /choose-plan.

Pricing table

PricingTable is the older card layout. It uses the same checkout mutation and sends a signed out visitor to /signup.

Plan limits

When a studio is at its trainee or seat limit, the core API refuses the create call with 403 and details: { reason, limit, count, planId }. The reason is PLAN_LIMIT_TRAINEES or PLAN_LIMIT_SEATS. A server action cannot rethrow that usefully, so actions catch it and return a PlanLimitRefusal:
The view checks the result with isPlanLimitRefusal(value) and opens PlanLimitDialog. The dialog is mounted in four places: the trainee board (ClientsView.tsx), the trainee plan banner (PlanBanner.tsx), the team member dialog and the CRM (converting a lead into a trainee). PlanLimitDialog calls the loadPlanLimitContext(slug) action to get the organization id and the current BillingPlanInfo. From there it offers the ways out: add a seat with orpc.payments.setSeats when canBuySeat(plan) allows it, or open the plan picker.

Types

packages/payments/types.ts defines Purchase, PlanPrice, PlanLimits, the three plan shapes (PaidPlan, FreePlan, EnterprisePlan) and PaymentsConfig. It also still exports provider function types (CreateCheckoutLink, PaymentProvider and others) from the time the provider code lived in this repo. Nothing in the frontend implements them now. packages/i18n/translations/{locale}/shared.json holds the pricing namespace, the only shared translation scope.