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.paymentsCustomerIdholds the provider customer id.User.paymentsCustomerIdexists for the user-attached mode and is not used in the current configuration.
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:
- A manual grant wins: a
SUBSCRIPTIONrow whosecustomerIdis the manual sentinel. - Otherwise a billed subscription that is not the hidden
teamSeatadd-on. A row whose product is in the catalog wins over one whose product is unknown. - With neither, the studio is on the free plan and the result has
source: 'none'.
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 fromconfig.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’sseats 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.