Charge for a feature
Your pricing is PLANS in packages/contracts/src/billing.ts. The API, the billing page and
Stripe all follow it. To connect a provider, see Stripe or
Polar; everything on this page works the same with either.
Set your plans
Section titled “Set your plans”pro: { id: 'pro', name: 'Pro', description: 'For getting real work done.', price: { month: 1200, year: 12_000 }, // cents; the yearly price is for the whole year pricing: 'flat', // or 'perSeat' maxSeats: 10, // null for no limit features: ['auditLog', 'aiAssistant'],},'flat'is one price for the account.'perSeat'bills per member and updates the provider as people join and leave. It only means something with teams on.maxSeatsrefuses invitations past the limit. Open invitations count.- The order of
PLAN_IDSis low to high. usageincludes so many units of a meter each month and prices the rest: see Charge for usage.
After any change to names or prices, run bun run billing:sync. Neither provider edits a price
in place: a new price replaces the old one for new subscriptions only. People already
subscribed keep paying the old price until you move them in Stripe or Polar.
Gate a feature
Section titled “Gate a feature”Gate on features, never on plan names, so moving a feature is a one-line change.
-
Name it and put it on the plans that include it:
export const FEATURES = ['auditLog', 'aiAssistant', 'prioritySupport', 'projects'] as const;export const FEATURE_LABELS: Record<Feature, string> = {// ...projects: 'Projects',};Then add
'projects'tofeatureson Pro and Business. The payment provider does not need to know. -
Guard the API, on a controller or route that is
@WorkspaceScoped():@WorkspaceScoped()@RequireFeature('projects')@Controller('workspaces/:workspaceId/projects')export class ProjectsController {}Without the feature it answers
402withProjects is part of the Pro plan. Upgrade to use it., naming the cheapest plan that has it. -
Guard the screen:
<RequireFeature feature="projects"><Projects /></RequireFeature>People see an offer to upgrade instead. For one button, use
useWorkspace().has('projects').
Always do both: the component decides what is shown, the API decides what is allowed.
Moving a feature to another plan is editing features. Workspaces on a plan that loses it
lose it at once, so tell them first.
Test it
Section titled “Test it”Give the test its own pricing so it keeps passing when yours changes:
const t = await createTestApp({ teams: false, plans: { free: { features: [] } } });const ada = await t.signUp({ name: 'Ada', email: 'ada@example.com' });
const res = await ada.get(`/api/workspaces/${workspaceId}/projects`);expect(res.status).toBe(402);The tests of the feature itself run on a plan that has it:
plans: { free: { features: ['projects'] } }.
To test a purchase, pass stripe: new FakeStripe() to createTestApp and deliver
stripe.completeCheckout() with t.webhook(). With Polar it is polar: new FakePolar() and
t.polarWebhook(polar.completeCheckout()).
Rules for code that touches billing
Section titled “Rules for code that touches billing”Only the webhook writes a workspace’s plan: to change a subscription, call the provider through
PaymentProvider and let the webhook record the result, and keep any webhook handler you add safe to receive twice. Why:
Billing.
Errors you may see: Troubleshooting.