The billing module answers one question for the rest of the backend: what plan is this studio on, and does it have room for one more trainee or team member. It has a single HTTP endpoint that reports the plan and usage. Most of the module is a guard that other modules call before they write. Checkout, plan changes, seat changes and the customer portal are not in this module. They are oRPC procedures in packages/api/src/modules/payments/procedures/ (create-checkout-link, change-subscription, set-seats, create-customer-portal-link, list-purchases), not Express routes under /v1.

Mount point and auth

Source: apps/core-api/src/modules/billing/. The router has no role guard. Any staff role can read the plan. The response tells the client whether the viewer may change it (canChange).

GET /v1/web/billing/plan

Returns the studio’s plan, its limits and current usage. Auth: web lane, any role. There are no parameters.
string
solo when there is no paid purchase, otherwise the purchased or granted plan id from the catalog in packages/payments/src/config.ts. A purchase on a product outside the catalog reads as custom.
string
none (no paid purchase), manual (granted from the admin panel, nothing billed) or billed (a Polar subscription).
string | null
The purchase status from the provider, for example active or trialing. null without a purchase.
object
trainees and includedSeats. Each is a number or null for no cap.
integer | null
Team seats the studio may fill, the coach included. On a seat-priced subscription this is the provider’s seat count, never fewer than the plan includes. Otherwise it is the plan’s includedSeats. null means no cap, either because the plan is unlimited or because the provider could not be reached.
object
boolean
A grandfathered subscription on its old product and price.
boolean
true when the studio is billed on a seat-priced product, so seats can change through the set-seats procedure.
boolean
true when the caller’s role is OWNER or HEAD_COACH.
boolean
true when all three hold: the studio finished the /start wizard (settings.onboarding.completedAt is set), it has no paid plan (source is none), and it holds more than the free plan allows (trainees over limits.trainees, or coaches over seats). The web app keeps the owner on the plan screen, or on the team page to cancel invites, until this is false. Studios created before the wizard wrote its finish stamp never get it.
Errors: UNAUTHORIZED (401) authentication required. The seat lookup fails open, so a provider outage does not turn into an error here.

Plan catalog limits

From packages/payments/src/config.ts. Every paid plan includes the coach plus one team member.

How the plan is resolved

createStudioPlanResolver in billing.plan.ts returns a StudioPlan.
  1. repo.purchasesOf(studioId) loads the Purchase rows of the studio’s organization (Studio.externalOrgId), oldest first.
  2. resolveStudioPlanPurchase from @repo/payments picks the deciding row. An admin grant wins. Next is a billed subscription that is not a hidden add-on, with a catalog product preferred over an unknown one. With neither, the studio is on the free plan. Purchase status is not read: a revoked subscription has no row because the webhook deletes it.
  3. No purchase: planId is solo with the free limits.
  4. A purchase on a product the catalog does not know: planId is custom, no limits, seats is null. A warning is logged once per price id per process.
  5. A catalog plan that is not seat-priced (a grant, a legacy price, or a price with no seatAmount): seats is the plan’s includedSeats.
  6. A seat-priced subscription: seats are read from the provider and floored at the plan’s included seats.
Callers that only need the trainee cap pass { seats: false } so they never wait on the provider.

Seat lookup and cache

Seats live on the Polar subscription. There is no database column. billing.seats.ts reads them on demand. The Redis key is perform:billing:subscription: followed by the subscription id. The reader returns { known: false } when the provider cannot answer, and the caller then does not enforce seats. A provider outage costs one wait per retry window, not one per request. installBillingSeatCache(ctx) is called once in server.ts. It subscribes to onSubscriptionChange from @repo/payments, so every subscription webhook and every plan or seat change seen in this process rewrites the cache at once. A revoked change deletes the entry. A snapshot older than the cached one (by modifiedAt) is ignored, because webhooks can arrive out of order.

Plan limit guards

billingFor(ctx) in billing.wiring.ts builds one shared set per app context: repo, getStudioPlan, limits and service. Other routers take billingFor(ctx).limits. The lock is pg_advisory_xact_lock(hashtext('perform:billing:' + scope + ':' + studioId)) inside a Prisma transaction with a 15 second timeout (LOCK_TRANSACTION_TIMEOUT_MS). It makes parallel creates queue, so imports and automation webhooks cannot all pass a count taken before any of them wrote.

Who calls them

What counts

  • Trainees. Client rows with deletedAt null and status other than ARCHIVED. The coach’s own trainee row, created by the /start bootstrap, is never counted. Nothing marks that row, so selfClientIdOf matches it on the last 9 digits of the owner’s coach phone, then on the owner’s email, oldest match first.
  • Team. Active Coach rows, including the owner and unclaimed invites.

The onboarding exemption

isOnboarding is true when settings.onboarding.bootstrappedAt is set, settings.onboarding.completedAt is not, and the studio was created less than ONBOARDING_WINDOW_MS ago. That constant is STUDIO_ONBOARDING_WINDOW_MS from @perform/types: 72 hours. Only server-written state is read. Seat checks are skipped while it is true, because invites sent from the wizard come before the plan choice. Trainee checks in withTraineeRoom and assertCanAddTrainees do not consult it. The onboarding service applies its own exemption for the wizard import.

The refusal

A studio already over its plan keeps everything it has. It only cannot add more. A refusal is an AppError with code FORBIDDEN (403) and a details object the web app uses to open its upgrade dialog:
reason is PLAN_LIMIT_TRAINEES or PLAN_LIMIT_SEATS. The message is a full sentence because the automation lane shows it to the coach as is. planLimitDetailsOf(error) returns the details for a plan refusal and null for any other error.