Billing here means the studio paying Perform for its SaaS plan. It lives in the auth schema next to the organization. It is a different thing from what a trainee pays a studio. Trainee plans are the Product and ClientSubscription models in the public schema. They are bookkeeping for the coach and never touch a payment provider. See Clients, products and subscriptions.

Purchase

Table auth.purchase. One row per provider subscription or one-time order that maps to a plan in the catalog.

PurchaseType

Lifecycle

packages/payments/src/provider/polar/index.ts webhookHandler is the only writer besides the admin panel: Because revoked subscriptions are deleted, plan resolution never has to read status.

Customer ids

config.billingAttachedTo is 'organization' in packages/payments/src/config.ts. So:
  • Organization.paymentsCustomerId holds the provider customer id.
  • User.paymentsCustomerId exists for the user-attached mode and is not used in the current configuration.
Both are written by setCustomerIdToEntity and read by getCustomerIdFromEntity in packages/payments/src/lib/customer.ts.

From purchases to a plan

resolveStudioPlanPurchase(purchases) in packages/payments/src/lib/studio-plan.ts picks the row that decides the plan:
  1. A manual grant wins: a SUBSCRIPTION row whose customerId is the manual sentinel.
  2. Otherwise a billed subscription that is not the hidden teamSeat add-on. A row whose product is in the catalog wins over one whose product is unknown.
  3. With neither, the studio is on the free plan and the result has source: 'none'.
The result carries source (none, manual or billed), planId, the winning purchase, the matched price, and isLegacyPrice. createStudioPlanResolver in apps/core-api/src/modules/billing/billing.plan.ts turns that into a StudioPlan with limits and seats. A billed row on a product the catalog does not know resolves to plan id custom (UNKNOWN_PRODUCT_PLAN_ID).

Plan limits

Limits come from config.plans in packages/payments/src/config.ts. null means no cap. Each paid plan has a current price (fixed fee plus a per-seat amount for seats beyond the included ones) and a legacy: true price that is never sold again but still resolves existing purchases. Amounts and seat pricing are in the same config file.

Where limits are enforced

createPlanLimits in apps/core-api/src/modules/billing/billing.limits.ts guards writes in the domain modules. No billing state is stored in the public schema. Usage is counted live. A refusal is an AppError with ErrorCode.FORBIDDEN built by planLimitError. Its details carry reason (PLAN_LIMIT_TRAINEES or PLAN_LIMIT_SEATS), limit, count and planId. planLimitDetailsOf(error) extracts them. Studios still inside the onboarding wizard are exempt from the seat check. isOnboarding reads only server-written state: the studio was bootstrapped by the wizard, has no completion stamp, and was created less than ONBOARDING_WINDOW_MS ago. It never reads User.onboardingComplete.

Seats

For plans that price seats, the seat count is the provider subscription’s seats value, which covers the whole team including the owner. It is not stored in Postgres. apps/core-api/src/modules/billing/billing.seats.ts caches it in Redis for SEAT_CACHE_TTL_SECONDS (10 minutes) and refreshes the cache from the in-process onSubscriptionChange events that the webhook publishes. The provider side, the checkout flow and the oRPC procedures are covered in Payments and billing.