Core
Billing
Usage Limits

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:

PlanordersPerMonthproductsteamMemberstemplates
Starter5050010
Growth2502001-1
Professional1000-14-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" < $3

Because 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

7. Env vars & config

VarRequiredPurposeWhat breaks
DATABASE_URLyesPostgres connection for Prismaall limit checks 500

No env var toggles limit enforcement. To disable, you must bypass the middleware in the route file.

8. Gotchas & troubleshooting

  • Two incrementUsage implementations. backend/middleware/subscription.ts is race-safe (atomic UPDATE + upsert). backend/lib/subscription-limits.ts uses read-then-update and will over-allocate under concurrency. Always import from middleware/subscription. The lib version is still referenced in some older paths — audit before reusing.
  • Public checkout charges the store owner's quota. public-catalog.ts calls incrementUsage(userId, 'orders', req) where userId is the store owner. Without req.usageAlreadyIncremented set (no middleware ran), this hits the non-atomic upsert branch and does not check the cap — a burst of checkouts can exceed ordersPerMonth. 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 on 402 (Payment Required) will treat quota errors as generic failures. See backend/middleware/subscription.ts:164-175.
  • No orders counter for non-order limits in middleware. Products / team / templates use COUNT(*) and ignore the productsCreated/teamMembersAdded/templatesCreated columns 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, not subscriptionId alone. On renewal, a new row is created. Historical usage rows are retained indefinitely — there is no retention job.
  • limit === -1 means unlimited and short-circuits before the UPDATE. If a seed ever accidentally stores 0, it will block all writes (0 is a valid cap meaning "none allowed", e.g. Starter teamMembers: 0).
  • checkAndEnforceLimit in the lib is unused in routes. Do not wire it into new code paths.

9. Extension points

  • New limit type: add field to Plan.limits JSON + new case in the switch in backend/middleware/subscription.ts:86-155. For monthly counters, add a column to SubscriptionUsage and mirror the atomic UPDATE pattern. For current-state counters, add a prisma.X.count({ where: { userId } }).
  • Soft warnings: before 403, emit a usage >= 0.8 * limit event from the middleware so the FE can show an upgrade nudge without blocking.
  • Per-feature gating: use canUserAccess(userId, feature) (matches plan.features[].name case-insensitively) — see subscription-gating.md.

10. Related docs