Gate a feature behind a subscription
Example used to test this page: only Pro users may set items.priority
(from recipe 1).
The backend refuses it on the free plan, and the form disables the input
and links to the upgrade page.
The rule: gate every path that writes or uses the gated thing (create, update, bulk, MCP tool), through one check they all call, with a test for each path.
How entitlement works
BillingService.getEntitlement(userId) is the only call you need. Branch
on its paidAccess field. On a deploy without Stripe, billing is dormant
and every user is on the free plan (paidAccess: false), so a Pro-only
feature is off for everyone there. Gate on paidAccess, never on whether
billing is enabled.
Backend steps
Inject
BillingServiceinto your service's constructor, asItemsServicedoes. Do not importBillingModuleinto your module: it is global and registered once inapp.module.ts, and registering it twice is a boot error.Pick the shape.
- On or off (a Pro-only field, route or action): read the
entitlement and throw
ForbiddenExceptionwhenpaidAccessis false. Put the check in one function and call it from every path that writes or uses the feature. - A counted limit (free plan gets N of something): copy
ItemsService.createinbackend/src/items/items.service.tsexactly. Gating features explains its lock and why each part is there.
Either way, read the entitlement before any transaction opens:
const entitlement = await this.billingService.getEntitlement( input.createdBy, );getEntitlementtakes a pool connection of its own, so calling it inside a transaction can deadlock the pool under load.- On or off (a Pro-only field, route or action): read the
entitlement and throw
Decide whose plan counts. For a row a user owns, that is usually the owner's plan, not the caller's: admins can edit other people's items (
ItemsController.updateallowsuser.is_admin), and an admin's plan says nothing about the owner's. The update path has the owner asexisting.created_byin the controller; pass that down to the check.Find every path. Search for every caller that writes the field or uses the feature: create, update, bulk imports, seed scripts that run in production, and tools in
backend/src/mcp-admin/.Test each path in the service spec, with
BillingServicemocked the wayitems.service.spec.tsdoes:{ provide: BillingService, useValue: billing },One test per path for the free plan (refused) and one for paid access (allowed). A path with no test is a path nobody has proved is gated.
Frontend steps
Read the entitlement on the page with
getEntitlement()fromfrontend/lib/billing.ts. It returnsnullwhen it cannot be read (signed out, backend down); treatnullas no access. ReadgetBillingEnabled()too, asapp/settings/page.tsxdoes:const billingEnabled = await getBillingEnabled();Hide or disable the control for users without
paidAccess, and link to/pricingonly whenbillingEnabledis true. With billing dormant/pricingis a 404, so an upgrade link there leads nowhere. This is for the user's benefit only; the backend check is what enforces the gate.Update the plan copy if the feature is a selling point: the
tierslist infrontend/app/page.tsxand the cards infrontend/app/pricing/PricingCards.tsx(keep the two in sync).Run
./verify.sh.
To try the paid path in a running app you need Stripe configured; see Stripe dashboard setup. The unit tests with a mocked entitlement cover both paths without it.
What the test run found
- Gating
priorityincreatealone leftupdateopen: a free-plan test throughupdatefailed (Received promise resolved instead of rejected). With one check called from both, both tests passed and verify was green. - Step 3, whose plan counts, was not part of that run.
Related
- Gating features - the free-plan cap in detail
- Billing architecture