This page is about the first. Trainee subscriptions involve no payment provider.
Pieces
@repo/paymentsholds the plan catalog, the provider and the plan resolution helpers.@repo/apiexposes 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 haslegacy: 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. priceIdvalues are Polar product ids. Current ones can be overridden withPRICE_ID_<PLAN>_V2, legacy ones come fromPRICE_ID_<PLAN>_MONTHLY.- Trials are configured on the Polar products.
trialPeriodDays: 30in the config is display only. solois 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 onsolo.TODO_POLAR_IDis the prefix of a product id that was never filled in.isPlaceholderProductIdstops 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 thePurchase 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:
- An admin grant wins. A grant is a
SUBSCRIPTIONrow whosecustomerIdisMANUAL_PURCHASE_CUSTOMER_ID(admin:manual). - 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.
- Otherwise the studio is on the free plan, with
source: 'none'.
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.
- A missing
POLAR_WEBHOOK_SECRETanswers500. validateEvent(body, headers, secret)verifies the signature. A failure answers400.- The event is handled:
Design points:
- An unknown product is acknowledged with
200. A 4xx would make Polar redeliver forever. - Redeliveries are idempotent.
subscriptionIdis 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.
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
Coachrows, 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:
canChangeis true forOWNERandHEAD_COACH.planChoiceRequiredis true when the studio finished the/startwizard, 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 oRPCpayments router in @repo/api, not through /v1:
Guards in
modules/payments/lib/billing.ts:
requireOrganizationMemberandrequireBillingManager: only organization rolesownerandadminmay change billing.SELF_SERVE_PLAN_IDS:start,grow,pro,elite,enterprise.MAX_TEAM_SEATSis 100. Beyond that is a sales conversation.teamSeatFloorandteamSeatsFor: seats are never set below the number of active coaches, and never below 1.
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 theadmin.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.