Skip to content

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.

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.
  • maxSeats refuses invitations past the limit. Open invitations count.
  • The order of PLAN_IDS is low to high.
  • usage includes 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 on features, never on plan names, so moving a feature is a one-line change.

  1. 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' to features on Pro and Business. The payment provider does not need to know.

  2. 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 402 with Projects is part of the Pro plan. Upgrade to use it., naming the cheapest plan that has it.

  3. 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.

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()).

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.