Two different things are called a subscription in this codebase. Keep them apart. This page is about the first. Trainee subscriptions involve no payment provider.

Pieces

  • @repo/payments holds the plan catalog, the provider and the plan resolution helpers.
  • @repo/api exposes the billing actions to the web app as oRPC procedures.
  • apps/core-api/src/modules/billing/ reads the result and enforces limits on domain writes.

The plan catalog

packages/payments/src/config.ts is the catalog. Billing is attached to the organization (billingAttachedTo: 'organization') and an active subscription is not required (requireActiveSubscription: false). Every base plan is the full product. Plans differ only by limits. Rules encoded in the config:
  • Each paid plan has two prices. The first is the current one: a fixed fee plus a seat price of 49 for every seat beyond the included ones (seatAmount). The second has legacy: true: the fixed price product sold before the seat model. A legacy price still resolves an existing purchase to its plan and is never sold again.
  • priceId values are Polar product ids. Current ones can be overridden with PRICE_ID_<PLAN>_V2, legacy ones come from PRICE_ID_<PLAN>_MONTHLY.
  • Trials are configured on the Polar products. trialPeriodDays: 30 in the config is display only.
  • solo is a real Polar product whose price sits behind a trial that does not end, so its checkout stores a card and never charges. A studio with no purchase at all is also on solo.
  • TODO_POLAR_ID is the prefix of a product id that was never filled in. isPlaceholderProductId stops such an id from reaching Polar.
monthlyTotal(planId, price, seats) computes what a team pays: the fixed amount plus max(0, seats - includedSeats) * seatAmount.

Purchase rows

A studio’s billing state is the Purchase rows of its organization (auth schema). Seats are not stored. They live on the Polar subscription. resolveStudioPlanPurchase(purchases) in lib/studio-plan.ts picks the row that decides the plan:
  1. An admin grant wins. A grant is a SUBSCRIPTION row whose customerId is MANUAL_PURCHASE_CUSTOMER_ID (admin:manual).
  2. Otherwise a billed subscription that is not on a hidden plan. One on a catalog product wins over one on a product the catalog does not know.
  3. Otherwise the studio is on the free plan, with source: 'none'.
Status is not read. A revoked subscription has no row, because the webhook deletes it. The result is one of three sources: none, manual, billed.

The Polar provider

packages/payments/src/provider/index.ts exports the Polar implementation. A Stripe implementation exists in provider/stripe/ and is not exported.

The webhook

POST /api/webhooks/payments is routed by the Hono app to webhookHandler. The gateway forwards the raw body, which the signature needs.
  1. A missing POLAR_WEBHOOK_SECRET answers 500.
  2. validateEvent(body, headers, secret) verifies the signature. A failure answers 400.
  3. The event is handled:
Design points:
  • An unknown product is acknowledged with 200. A 4xx would make Polar redeliver forever.
  • Redeliveries are idempotent. subscriptionId is unique and an existing row short circuits the create.
  • The revoke can arrive after the row is already gone, when the organization was deleted first.

Subscription change events

lib/subscription-events.ts is an in-process publish and subscribe list stored on globalThis under a symbol, so every copy of the module shares it.
The webhook and updateSubscription publish. installBillingSeatCache(ctx), called once in server.ts, subscribes and writes the snapshot into the seat cache. This works because the webhook handler and core-api run in the same process.

Reading a studio’s plan in core-api

billingFor(ctx) returns one shared set per AppContext:
getStudioPlan(studioId, options?) returns a StudioPlan: Pass { seats: false } when you only need the trainee limit. That skips the seat lookup, so a trainee write never waits on Polar.

The seat cache

billing.seats.ts. Seats are read on demand and cached. The cache is rewritten by every subscription webhook and every plan or seat change, so a purchase shows up at once. If Polar cannot be reached the lookup returns “unknown” and the seat limit is not enforced. Billing must never block a studio from working.

Enforcing limits

limits is the guard described on Roles and permissions. The locking detail:
withStudioLock(studioId, scope, fn) opens a transaction, takes a Postgres advisory lock for the scope (trainees or seats), and runs the count and the write inside it. Parallel creates for the same studio queue behind the lock, so none of them passes a count taken before another wrote. The lock is released when the transaction ends. The routers that pass billingFor(ctx).limits into their services are the ones that create trainees or team members: clients, coaches, crm (convert a lead), onboarding (the import after /start) and automation-api. What counts:
  • Trainees: not deleted, not archived, and the coach’s own trainee row is excluded (ownerOf).
  • Team: active Coach rows, which includes the owner and people invited who have not signed in yet.

GET /v1/web/billing/plan

The one billing endpoint in core-api. Any staff role can read it. It returns a BillingPlanSummary:
  • canChange is true for OWNER and HEAD_COACH.
  • planChoiceRequired is true when the studio finished the /start wizard, has no paid plan, and holds more trainees or team members than the free plan allows. The web app keeps the owner on the plan screen until it is false.

Changing a plan

Changes go through the oRPC payments router in @repo/api, not through /v1: Guards in modules/payments/lib/billing.ts:
  • requireOrganizationMember and requireBillingManager: only organization roles owner and admin may change billing.
  • SELF_SERVE_PLAN_IDS: start, grow, pro, elite, enterprise.
  • MAX_TEAM_SEATS is 100. Beyond that is a sales conversation.
  • teamSeatFloor and teamSeatsFor: seats are never set below the number of active coaches, and never below 1.
A refusal is an ORPCError whose data.reason is one of NO_SUBSCRIPTION, MANUAL_PLAN, UNKNOWN_PLAN, LEGACY_PLAN, NOT_CONFIGURED, PROVIDER_ERROR, HAS_SUBSCRIPTION, PLAN_LIMIT_TRAINEES. Billing never follows the team on its own. Seats on a subscription change only when a manager sets them.

Admin grants

A platform admin can give a studio a plan without billing through the admin.studios.setPlan procedure, which calls setAdminStudioManualPlan. The grant is a purchase row with customer id admin:manual. A grant of the custom plan stores the sentinel product id admin:custom (CUSTOM_GRANT_PRICE_ID), because that plan has no Polar product.

What happens on delete

beforeOrganizationDelete and beforeUserDelete in @repo/auth cancel billed subscriptions with cancelSubscription before the rows go. An admin grant has no subscription behind it and simply cascades.

Env

POLAR_ACCESS_TOKEN, POLAR_WEBHOOK_SECRET, POLAR_SERVER, the PRICE_ID_*_V2 overrides, the legacy PRICE_ID_*_MONTHLY ids and PRICE_ID_TEAM_SEAT. They are read with process.env inside the package. A missing access token throws on the first Polar call, not at boot.