Core
Auth
Subscription Gating

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

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.

VarPurpose
DATABASE_URLWhere plans, subscriptions, and usage live
MAINTENANCE_MODEShort-circuits all routes before gating runs

Plan data is seeded via npm run db:seed.

8. Gotchas & troubleshooting

  • Two checkSubscriptionLimit / incrementUsage implementations. The middleware in backend/middleware/subscription.ts is the correct one (atomic SQL update for orders). backend/lib/subscription-limits.ts is 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 SubscriptionGate lets them through too. requireSubscription only accepts active / trial, so a cancelled status user is immediately locked out server-side even while their paid period is still running. The cron eventually flips cancelled to expired at period end. See subscription-lifecycle.md.
  • SUPER_ADMIN impersonation bypass risk. When a super-admin impersonates a store owner, the JWT carries the target userId but 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 breaks canUserAccess('WhatsApp API') checks.
  • FE and BE feature lists are independent. src/lib/features/registry.ts enums are not synced to SubscriptionPlan.features in 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') sets req.usageAlreadyIncremented. Routes must pass req into incrementUsage(userId, 'orders', req) or they will double-count. Non-orders limit types are counted live from the DB (no counter), so incrementUsage is a no-op for them.
  • SubscriptionGate exempts /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 the exemptPages list.

9. Extension points

Adding a new feature flag:

  1. Add constant to FEATURES in src/lib/features/registry.ts + metadata.
  2. Add it to each plan's list in src/lib/features/plan-features.ts.
  3. Add { name: 'My Feature', included: true/false } to each SubscriptionPlan.features JSON (seed migration).
  4. On the backend, gate the route inline: if (!(await canUserAccess(userId, 'My Feature'))) return res.status(403)....
  5. 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