Subscription Gating
Plan-based access control that sits on top of RBAC: roles decide who can do something, subscriptions decide whether the account has paid for it.
1. Overview
Eziseller is a SaaS with tiered plans (Starter, Growth, Professional). Some actions are gated by whether you have an active subscription at all (e.g. creating an order), some by whether your plan includes a feature (e.g. WhatsApp API, Shareable Catalog), and some by quota (e.g. 100 orders/month). Gating is layered: authenticateToken proves identity, requireOwner/requireRole (RBAC) proves role, then subscription middleware proves entitlement. The order matters — role checks run first so STAFF get a 403 for role reasons, not a misleading "subscription required" error. The frontend mirrors the backend with SubscriptionGate (blocks dashboard on expired subs) and useFeature (hides UI for features not on the current plan), but the backend is the authoritative enforcer.
2. Architecture
Role checks run before subscription checks so error codes stay meaningful. Feature-level gating (canUserAccess) is called inline in route handlers, not as standalone middleware — there is no requireFeature middleware in this codebase.
3. Data model
SubscriptionPlan.features is a JSON array of { name, included }; limits is { ordersPerMonth, products, teamMembers, templates } where -1 means unlimited. See schema.prisma.
4. Key flows
4.1 Gated write request (create order)
The UPDATE ... WHERE ordersCreated < limit is the single atomic enforcement point for the monthly orders quota — see subscription.ts:L118-L133.
4.2 Feature check (WhatsApp)
Matching is case-insensitive substring on the feature name string stored in plan.features. The FE uses a parallel, stricter enum-based system (src/lib/features/registry.ts) — the two must stay aligned manually.
5. Gating decision tree
6. Key files
- backend/middleware/subscription.ts:L8-L48 —
requireSubscription(existence check) - backend/middleware/subscription.ts:L51-L195 —
checkSubscriptionLimitwith atomic orders increment at L118 - backend/middleware/subscription.ts:L200-L252 —
incrementUsage(fallback / no-op when atomic path ran) - backend/middleware/subscription.ts:L255-L277 —
canUserAccess(feature flag helper) - backend/lib/subscription-limits.ts — parallel non-atomic implementation, do not use
- backend/routes/orders.ts:L371, routes/products.ts:L334, routes/team.ts:L52, routes/templates.ts:L267 — middleware usage
- backend/routes/conversations.ts:L19 — inline
canUserAccess('WhatsApp API') - src/components/subscription/SubscriptionGate.tsx — FE dashboard gate, mounted in dashboard/layout.tsx:L111
- src/hooks/useFeature.ts — FE per-feature hook
- src/lib/features/registry.ts, src/lib/features/plan-features.ts — FE feature enum + plan mapping
7. Env vars & config
Subscription gating itself has no dedicated env vars. It depends on the subscription data populated by Razorpay webhooks and the nightly subscription cron.
| Var | Purpose |
|---|---|
DATABASE_URL | Where plans, subscriptions, and usage live |
MAINTENANCE_MODE | Short-circuits all routes before gating runs |
Plan data is seeded via npm run db:seed.
8. Gotchas & troubleshooting
- Two
checkSubscriptionLimit/incrementUsageimplementations. The middleware inbackend/middleware/subscription.tsis the correct one (atomic SQL update for orders).backend/lib/subscription-limits.tsis an older non-race-safe duplicate — it does read-then-write so two concurrent order creates can both pass the limit. New code must use the middleware version. - Cancelled subscriptions still pass the gate until period end, but
SubscriptionGatelets them through too.requireSubscriptiononly acceptsactive/trial, so acancelledstatus user is immediately locked out server-side even while their paid period is still running. The cron eventually flipscancelledtoexpiredat period end. See subscription-lifecycle.md. - SUPER_ADMIN impersonation bypass risk. When a super-admin impersonates a store owner, the JWT carries the target
userIdbut none of the subscription middleware checks whether the session is an impersonation. An admin impersonating an expired user would currently pass gating and consume their quota. Deferred — see admin pending work. - Feature matching is a substring, case-insensitive search over
plan.features[].name. Renaming a feature display name in the seed silently breakscanUserAccess('WhatsApp API')checks. - FE and BE feature lists are independent.
src/lib/features/registry.tsenums are not synced toSubscriptionPlan.featuresin the DB. The UI can hide a button while the backend still allows it, or vice versa. - Atomic orders increment fires before the order is created. If the handler later throws, the counter has already advanced — the user loses one quota unit. Acceptable tradeoff for the race-free guarantee; no compensating decrement exists.
checkSubscriptionLimit('orders')setsreq.usageAlreadyIncremented. Routes must passreqintoincrementUsage(userId, 'orders', req)or they will double-count. Non-orders limit types are counted live from the DB (no counter), soincrementUsageis a no-op for them.SubscriptionGateexempts/dashboard/billing,/dashboard/settings,/pricing. Any new page that must work when the subscription is expired (e.g. a future "export my data" page) needs to be added to theexemptPageslist.
9. Extension points
Adding a new feature flag:
- Add constant to
FEATURESin src/lib/features/registry.ts + metadata. - Add it to each plan's list in src/lib/features/plan-features.ts.
- Add
{ name: 'My Feature', included: true/false }to eachSubscriptionPlan.featuresJSON (seed migration). - On the backend, gate the route inline:
if (!(await canUserAccess(userId, 'My Feature'))) return res.status(403).... - On the frontend,
const { hasFeature, requireUpgrade } = useFeature(FEATURES.MY_FEATURE).
Adding a new quota type (e.g. webhooksPerMonth): extend SubscriptionUsage columns, the limitType union, the limits JSON shape, and the switch in checkSubscriptionLimit. For atomic enforcement, copy the raw-SQL pattern from the orders branch — do not extend the non-atomic helper in backend/lib/subscription-limits.ts.
10. Related docs
- authentication.md — JWT /
authenticateToken - rbac.md — role guards that run before subscription guards
- ../billing/subscription-lifecycle.md — status transitions and the cron that expires cancelled subs
- ../billing/usage-limits.md — quota counters and reset semantics
- ../billing/razorpay-integration.md — where
UserSubscriptionrows come from - ../../04-admin/super-admin-portal.md — impersonation context