Usage limits
Per-plan quotas (orders, products, team members, templates) enforced at write time against a user's active subscription.
1. Overview
Every UserSubscription is bound to a Plan whose limits JSON caps how much the user can create. Monthly quotas (orders) are tracked in a SubscriptionUsage row keyed by the subscription's current billing period; lifetime/current quotas (products, team members, templates) are evaluated by counting live rows in the database. Limits are checked by Express middleware placed in front of every POST that creates a limited resource. Orders use a race-safe atomic UPDATE that increments only if the counter is still below the cap; the other resources rely on a read-then-insert pattern because their "usage" is derived, not stored. A value of -1 in plan.limits means unlimited.
2. Architecture
Two implementations exist: the race-safe middleware in backend/middleware/subscription.ts and an older non-atomic helper in backend/lib/subscription-limits.ts. Route handlers mount the middleware; the lib helpers are legacy.
3. Data model
Unique key @@unique([subscriptionId, periodStart, periodEnd]) on SubscriptionUsage is what makes the upsert safe. See schema.prisma.
Plan limits as seeded:
| Plan | ordersPerMonth | products | teamMembers | templates |
|---|---|---|---|---|
| Starter | 50 | 50 | 0 | 10 |
| Growth | 250 | 200 | 1 | -1 |
| Professional | 1000 | -1 | 4 | -1 |
4. Key flows
4.1 Order create (race-safe path)
The key SQL (from backend/middleware/subscription.ts:118-124):
UPDATE "subscription_usage"
SET "ordersCreated" = "ordersCreated" + 1, "updatedAt" = NOW()
WHERE "subscriptionId" = $1
AND "periodStart" = $2
AND "ordersCreated" < $3Because the WHERE clause holds a row-lock for the duration of the update, two concurrent requests cannot both read ordersCreated = limit - 1 and succeed. prisma.$executeRaw returns the row count; zero rows means the cap was already hit.
4.2 Products / team / templates (count-based)
No counter row is incremented. The middleware COUNTs the live resource (product.count, active STAFF users where ownerId = userId, messageTemplate.count) and rejects if >= limit. This means deleting a product frees quota immediately, unlike orders.
5. Counter reset / renewal
There is no dedicated "reset" job. Counters "reset" implicitly because usage rows are keyed by (subscriptionId, periodStart, periodEnd). When the billing cycle rolls over, currentPeriodStart on UserSubscription moves forward, and the next request's upsert creates a fresh SubscriptionUsage row starting at zero. The old row stays on disk as historical usage. The subscription cron in backend/schedulers/subscription-cron.ts handles lifecycle transitions (trial expiry, cancellation cleanup) but does not touch usage counters directly — see subscription-lifecycle.md.
6. Key files
- backend/middleware/subscription.ts:51-195 —
checkSubscriptionLimit, atomic UPDATE for orders - backend/middleware/subscription.ts:200-252 —
incrementUsagefallback (upsert, no-op when flagged) - backend/middleware/subscription.ts:255-299 —
canUserAccess,getUserPlanLimits - backend/lib/subscription-limits.ts:17-205 — legacy non-atomic helpers
- backend/routes/orders.ts:371 — mounts
checkSubscriptionLimit('orders') - backend/routes/orders.ts:619 — calls
incrementUsageafter insert (no-op on happy path) - backend/routes/products.ts:334 — products gate
- backend/routes/team.ts:52 — team invite gate
- backend/routes/templates.ts:267 — templates gate
- backend/routes/public-catalog.ts:660 — public checkout calls
incrementUsage(store owner's quota) - backend/prisma/schema.prisma:1389-1434 —
UserSubscription,SubscriptionUsage - backend/prisma/seed-plans.ts:20-88 — plan limits source of truth
7. Env vars & config
| Var | Required | Purpose | What breaks |
|---|---|---|---|
DATABASE_URL | yes | Postgres connection for Prisma | all limit checks 500 |
No env var toggles limit enforcement. To disable, you must bypass the middleware in the route file.
8. Gotchas & troubleshooting
- Two
incrementUsageimplementations.backend/middleware/subscription.tsis race-safe (atomic UPDATE + upsert).backend/lib/subscription-limits.tsuses read-then-update and will over-allocate under concurrency. Always import frommiddleware/subscription. The lib version is still referenced in some older paths — audit before reusing. - Public checkout charges the store owner's quota.
public-catalog.tscallsincrementUsage(userId, 'orders', req)whereuserIdis the store owner. Withoutreq.usageAlreadyIncrementedset (no middleware ran), this hits the non-atomic upsert branch and does not check the cap — a burst of checkouts can exceedordersPerMonth. The UPSERT is safe from duplicate rows but not from exceeding the limit. - Counters never decrement on cancel/refund. Cancelling or refunding an order does not roll back
ordersCreated. A user who cancels 50 orders in Starter still cannot create a 51st until the period rolls over. - Limit exceeded returns 403, not 402. The code is
LIMIT_EXCEEDED. Clients that only branch on402(Payment Required) will treat quota errors as generic failures. Seebackend/middleware/subscription.ts:164-175. - No
orderscounter for non-order limits in middleware. Products / team / templates useCOUNT(*)and ignore theproductsCreated/teamMembersAdded/templatesCreatedcolumns entirely. The columns exist in the schema and are written by the legacy lib helpers but are never read by the hot path. - Usage row is keyed by
periodStart, notsubscriptionIdalone. On renewal, a new row is created. Historical usage rows are retained indefinitely — there is no retention job. limit === -1means unlimited and short-circuits before the UPDATE. If a seed ever accidentally stores0, it will block all writes (0 is a valid cap meaning "none allowed", e.g. StarterteamMembers: 0).checkAndEnforceLimitin the lib is unused in routes. Do not wire it into new code paths.
9. Extension points
- New limit type: add field to
Plan.limitsJSON + new case in the switch inbackend/middleware/subscription.ts:86-155. For monthly counters, add a column toSubscriptionUsageand mirror the atomic UPDATE pattern. For current-state counters, add aprisma.X.count({ where: { userId } }). - Soft warnings: before 403, emit a
usage >= 0.8 * limitevent from the middleware so the FE can show an upgrade nudge without blocking. - Per-feature gating: use
canUserAccess(userId, feature)(matchesplan.features[].namecase-insensitively) — see subscription-gating.md.
10. Related docs
- subscription-lifecycle.md — trial / active / cancelled transitions, period rollover
- razorpay-integration.md — how payments update
currentPeriodEnd - ../auth/subscription-gating.md —
requireSubscriptionmiddleware and feature flags