VibeWeek
Home/Grow/Quotas, Limits & Plan Enforcement

Quotas, Limits & Plan Enforcement

⬅️ Day 6: Grow Overview

If you're building a B2B SaaS with tiered pricing in 2026 — Free vs Pro vs Enterprise, or usage-based pricing — you need to enforce plan limits in product. The naive approach: hardcode "if user.plan == 'free' && count > 100" scattered across the codebase. The structured approach: centralize plan definitions, expose limits as data, enforce at API boundaries, surface usage in product, prompt upgrade gracefully when limits hit. Plan enforcement has tricky edge cases (downgrade with too many items, trial expirations, grace periods) — get them wrong and you either lose customers (over-aggressive enforcement) or leave revenue on the table (under-enforcement). (See usage-based-billing-chat.md for billing mechanics; rate-limiting-abuse-chat.md for abuse rate-limiting. This is about plan-tier feature enforcement.)

1. Decide what to limit — and what not to

Most products limit too much (everything paywalled creates friction) or too little (no incentive to upgrade).

Decide what to limit per plan.

Common limits in B2B SaaS:

Resource counts:
- # users / seats
- # projects / workspaces
- # records (rows, contacts, tickets)
- # files / storage GB
- # API calls / month
- # AI / token credits

Feature gates:
- Advanced analytics
- Custom branding
- API access
- SSO / SAML
- Audit logs
- Custom roles / RBAC
- Priority support
- Integrations (premium ones)

Behavior limits:
- # exports per day
- # invites per month (anti-spam)
- # webhook deliveries

Don't limit (usually):
- Core product functionality (drives adoption)
- Things that won't drive upgrade (token gates that just frustrate)
- Things that hurt your data quality (invite gating reduces network effects)

Decision framework:
- What signals upgrade-readiness? (heavy users; team admins)
- What's expensive on backend? (AI tokens, large storage)
- What's marketing-friendly to limit? (seats; common Pro/Enterprise gate)

For [SAAS PRODUCT], output:
1. Limits per plan tier
2. Rationale for each
3. What's NOT limited (free)
4. Soft limits vs hard limits
5. Upgrade prompts at limit

The "limit fewer things, enforce them well" rule: 3-5 well-chosen limits drive more upgrades than 20 nickel-and-dime gates that frustrate users.

2. Centralize plan definitions

Plans-as-data, not plans-as-if-statements.

Centralize plan configuration.

Schema (JSON / DB):

plans = [
  {
    "id": "free",
    "name": "Free",
    "price": 0,
    "limits": {
      "users": 3,
      "projects": 5,
      "storage_gb": 1,
      "api_calls_per_month": 1000,
      "ai_credits_per_month": 100
    },
    "features": {
      "sso": false,
      "audit_logs": false,
      "custom_branding": false,
      "priority_support": false
    }
  },
  {
    "id": "pro",
    "name": "Pro",
    "price": 49,
    "limits": {
      "users": 25,
      "projects": 50,
      "storage_gb": 50,
      "api_calls_per_month": 100000,
      "ai_credits_per_month": 5000
    },
    "features": {
      "sso": false,
      "audit_logs": true,
      "custom_branding": true,
      "priority_support": false
    }
  },
  {
    "id": "enterprise",
    "name": "Enterprise",
    "price": "Custom",
    "limits": {
      "users": -1,  // unlimited
      "projects": -1,
      "storage_gb": -1,
      "api_calls_per_month": -1,
      "ai_credits_per_month": -1
    },
    "features": {
      "sso": true,
      "audit_logs": true,
      "custom_branding": true,
      "priority_support": true
    }
  }
]

Storage:
- Database table OR config file
- Versioned (changes affect existing customers vs new)
- Environment-overridable (different limits dev vs prod)

Access pattern:
- Single function: getPlanLimits(orgId) → plan limits object
- Cached aggressively (plan rarely changes)
- Server-side authoritative

Anti-patterns:
- "if (plan === 'free')" scattered throughout codebase
- Plans hardcoded in frontend JS
- Different limits in different places (drift)

Output:
1. Schema (DB or config)
2. Versioning strategy
3. Access pattern (caching)
4. Override mechanism (custom enterprise contracts)
5. Migration path when plans change

The "single source of truth" discipline: every limit check goes through one function. Otherwise: drift, inconsistencies, hard-to-reason-about pricing changes.

3. Track usage — counters + measurements

Need accurate usage data to enforce limits.

Implement usage tracking.

Counter types:

Atomic counters (count of things):
- # users in org
- # projects
- # files
- Updated on create / delete

Period counters (resets each period):
- API calls this month
- AI credits used
- Exports today
- Reset at billing cycle (monthly typical)

Cumulative measurements (totals):
- Storage GB used
- Bandwidth GB transferred
- Updated on each upload / transfer

Storage:

Atomic: query the DB (count rows where org_id = X)
- Pro: always accurate
- Con: query cost on every check

Cached counters: Redis / DB row with count
- Update on create / delete events
- Pro: fast lookup
- Con: drift risk (must reconcile periodically)

Period counters: Redis with TTL or DB row with reset_at
- Reset by cron at billing cycle
- Pro: simple
- Con: distributed system race conditions

Cumulative: aggregate from event log
- Each upload → emit usage event
- Sum events for period

For high-volume:
- Real-time accurate counters expensive
- Sample / batch for non-critical
- Use ClickHouse / TimescaleDB for usage analytics

For low-volume (most B2B SaaS):
- DB queries are fine (<1000 ops / second)

Output:
1. Counter types per limit
2. Storage strategy
3. Reset schedule (period counters)
4. Reconciliation cron (cached counters)
5. Reporting queries (usage dashboard)

The reconciliation rule: cached counters drift. Run nightly job that recounts from source-of-truth and corrects. Without it, counters slowly diverge.

4. Enforce at API boundary — server-side

Client-side enforcement is suggestion, not enforcement. Server-side is truth.

Enforce limits server-side.

Pattern (Next.js Route Handler / Express):

async function POST(req: Request) {
  const { user, org } = await authenticate(req);
  
  // Check limit before action
  const planLimits = await getPlanLimits(org.id);
  const currentCount = await getProjectCount(org.id);
  
  if (planLimits.projects !== -1 && currentCount >= planLimits.projects) {
    return Response.json({
      error: 'PLAN_LIMIT_EXCEEDED',
      limit: 'projects',
      current: currentCount,
      max: planLimits.projects,
      message: `Your ${plan.name} plan is limited to ${planLimits.projects} projects.`,
      upgradeUrl: '/billing/upgrade'
    }, { status: 402 });  // 402 Payment Required
  }
  
  // Proceed with creation
  const project = await createProject(req);
  return Response.json(project);
}

Error response convention:
- HTTP 402 (Payment Required) for plan-limit errors
- Or 403 (Forbidden) with specific error code
- Body includes: limit name, current, max, plan name, upgrade URL

Middleware approach (cleaner):

const enforceLimit = (limitKey: string) => async (req, res, next) => {
  const planLimits = await getPlanLimits(req.org.id);
  const current = await getCurrentUsage(req.org.id, limitKey);
  
  if (planLimits[limitKey] !== -1 && current >= planLimits[limitKey]) {
    return res.status(402).json({
      error: 'PLAN_LIMIT_EXCEEDED',
      limit: limitKey,
      current, max: planLimits[limitKey]
    });
  }
  next();
};

// Usage:
router.post('/projects', enforceLimit('projects'), createProject);

Race conditions:
- Two simultaneous create requests both pass the check, both create → exceed limit
- Solution: atomic increment with check (Redis INCR + compare-and-set)
- Or: optimistic with reconciliation (allow brief over-limit; reconcile)
- Or: DB-level constraint (project count must be <= max for plan)

Output:
1. Enforcement middleware
2. Error response format
3. Race-condition handling
4. Atomic counter pattern
5. Idempotency for retries

The race-condition trap: easily reproducible at scale. Two creates at once → both check, both pass, both succeed → over-limit. Solution: atomic INCR with check, or DB constraint.

5. Surface usage in UI — proactive transparency

Don't surprise users at the limit. Show usage progressively.

Surface usage in UI.

Patterns:

Usage bar in settings:
- "Projects: 8 / 10 used" with progress bar
- Color shifts: green → yellow at 80% → red at 100%
- Pro upgrade CTA when >80%

Inline near action:
- Create-project button shows "8/10 used"
- On click at 100%: don't error, show upgrade modal

Banner / nudge:
- "You've used 80% of your free projects. Upgrade for unlimited."
- Dismissable (don't repeat too often)

Email notifications:
- 75% used: "You're approaching your limit"
- 100% used: "You've hit your limit. Upgrade to continue."
- Don't spam; coordinate with in-app notifications

Approaching-limit logic:
- Calculate at 75% / 90% / 100% thresholds
- Send notification at first threshold crossing
- Don't re-notify within X days

For team-wide limits (users / projects):
- Notify org admin (not all users)
- Show admin dashboard

Anti-patterns:
- Show limit only at 100% (no warning)
- Hide usage entirely (frustrating)
- Bombard with upgrade prompts (annoying)

Output:
1. Usage UI patterns per limit
2. Threshold notifications
3. Email coordination
4. Admin-only vs all-user visibility
5. Dismissal / re-notification rules

The "no surprise" discipline: show usage continuously. Notify at 75%. Then user can plan; not get blocked unexpectedly.

6. Upgrade prompts — convert at limit moment

When a user hits a limit, the moment matters. They want to do something; you have leverage.

Design upgrade prompts at limits.

Modal / inline pattern:

When user hits limit (e.g., trying to create 11th project on 10-project plan):

Modal title: "Upgrade to create unlimited projects"

Body:
- Current: "You're on the Free plan with 10 projects."
- Benefit: "Pro includes unlimited projects, custom branding, and SSO."
- Pricing: "$49/mo (cancel anytime)"
- CTA: "Upgrade to Pro" (primary)
- Secondary: "Compare plans" (link to pricing)
- Tertiary: "Talk to sales" (for enterprise needs)

Soft vs hard:
- Soft: allow current creation, show modal after with "you'll need to upgrade for the next one"
- Hard: block creation, show modal blocking action
- Recommendation: hard for resource creation; soft for usage-based (graceful degradation)

Upgrade flow:
- 1-click upgrade if cards on file (Stripe billing)
- Else: redirect to checkout
- After upgrade: return to original action; complete it

A/B testing:
- Test prompt copy
- Test pricing display
- Test CTA placement
- Track conversion rate

Anti-patterns:
- Modal that doesn't explain WHAT plan to upgrade to (just "upgrade")
- Hidden pricing in modal (require click to see)
- No "compare plans" option for non-trivial decisions
- Aggressive upgrade pop-ups before limit hit

Output:
1. Modal copy templates per limit
2. Upgrade flow integration
3. Plan-comparison CTA
4. Sales hand-off (for enterprise)
5. A/B test framework

The "upgrade-at-action" rule: upgrade prompts at the moment of friction convert better than ambient nudges. User wants something; you offer way to get it.

7. Handle downgrade — too many existing items

When a user downgrades, they may exceed new plan's limits.

Handle downgrade-with-existing-overage.

Scenario: User on Pro (50 projects, 30 used) downgrades to Free (5 projects).

Options:

Option A: Block downgrade
- Tell user: "You have 30 projects. Free plan allows 5. Delete 25 first."
- Pro: simple; user knows what to do
- Con: friction; user may abandon

Option B: Soft enforcement
- Allow downgrade; user keeps 30 projects
- Block creating NEW projects (already over)
- Until they delete 25, can't create more
- Grace period to act

Option C: Force reduction
- Allow downgrade; mark oldest 25 projects as read-only / archived
- User can choose which to keep
- Pro: doesn't lose data
- Con: bad UX

Option D: Automatic deletion (don't do this)
- Delete excess on downgrade
- Pro: enforces limit
- Con: data loss; legal exposure

Recommendation: Option B (soft enforcement) for most B2B SaaS.

Implementation:
- Plan changes via Stripe webhook
- Don't delete data on downgrade
- Add "over-limit" flag
- Block new creates until under limit
- Show banner: "You're over your project limit. Delete projects or upgrade."

Edge cases:
- Trial expiration (similar to downgrade)
- Subscription cancellation (grace period, then read-only)
- Failed payment (don't downgrade immediately; retry)

Output:
1. Downgrade policy (B recommended)
2. Over-limit banner / messaging
3. Stripe webhook integration
4. Grace period definition
5. User notification (email + in-app)

The data-loss avoidance rule: NEVER auto-delete user data on downgrade. They paid for it; they expect to retrieve it. Soft enforcement + clear messaging is fair.

8. Trial expirations and grace periods

Trial users are different from paid downgrades. Plan separately.

Handle trial expiration.

Trial states:
- Active trial (Day 1-14)
- Trial ending soon (Day 12-14)
- Trial expired (Day 14+)
- Converted to paid

Communications:
- Day 7: "How's your trial going? [Show value]"
- Day 12: "2 days left in trial. [Upgrade options]"
- Day 14: "Trial ended. Upgrade to keep using."
- Day 17: "Last chance — your data will be archived in 7 days."
- Day 21: "Your data is archived. Upgrade to restore."
- Day 90: Hard delete (with notice)

Grace periods:
- Day 14-21: read-only access (login + view; no edits)
- Day 21-90: archived (no access; data preserved)
- Day 90+: hard delete (data removed)

Conversion best practices:
- Make payment frictionless (card in advance, no payment surprises)
- Don't surprise with charge
- Easy reactivation if cancelled

Reactivation:
- Re-enter card; restore data
- Welcome-back email
- Continue where you left off

Anti-patterns:
- Auto-charge without warning at trial end
- Immediate hard delete at trial end
- No expiration warnings
- Confusing "Free vs Trial" semantics

Output:
1. Trial state machine
2. Email cadence
3. Grace period definition
4. Reactivation flow
5. Hard delete cadence + GDPR compliance

The hard-delete-with-notice: GDPR + general decency requires giving users opportunity to retrieve data before delete. 90 days is industry standard.

9. Custom enterprise contracts — overrides

Enterprise customers negotiate custom terms. System needs to support overrides.

Support custom enterprise terms.

Use cases:
- "Unlimited" projects for an org on Pro plan
- Higher API limit for one specific customer
- SSO included even though their tier doesn't have it
- Custom price, custom limits

Schema:
- org_overrides table:
  - org_id, limit_key, value, expires_at, reason

Resolution:
- getPlanLimits(orgId) checks overrides FIRST
- Falls back to plan's limits
- Per-key resolution (some override, some not)

Examples:
- Free plan org with sso=true override → SSO works for them
- Pro plan org with users=200 override → exceeds Pro's 25-user default

UI:
- Don't show "your plan" if overrides differ
- Show actual limits in settings
- Internal admin tool to set overrides

Sales process:
- Sales reps create overrides via internal tool
- Approval workflow for non-standard requests
- Audit log of overrides + reason

Expiration:
- Some overrides have expiry (renewal-tied)
- Notify before expiry
- Re-negotiate or revert

Anti-patterns:
- Overrides stored in spreadsheet (drifts from system)
- No audit trail
- Sales reps can grant unlimited free (no approval)
- Customers don't know what they're entitled to

Output:
1. Override schema
2. Resolution logic
3. Internal admin tool
4. Approval workflow
5. Audit + expiry

The "internal admin tool" requirement: at scale, sales / CS need to grant overrides without engineering. Build a simple admin UI; cheaper than a Slack ping every time.

10. Test enforcement at edges

Plan enforcement is full of edge cases. Test them.

Test plan enforcement edge cases.

Test cases:

Race conditions:
- Two simultaneous create requests at limit
- Verify exactly one succeeds (not both)

Period transitions:
- API calls used right at month-end transition
- Verify reset happens cleanly (no lost calls)
- Time zone: org's billing cycle vs server time

Plan upgrades / downgrades:
- Upgrade mid-month: limits expand immediately
- Downgrade mid-month: when does new limit apply?
- Trial → Paid: limits transition cleanly

Edge values:
- 0 vs 1 (off-by-one)
- Unlimited (-1 or null) handled correctly
- Negative (data corruption)

Concurrent users:
- 10 users all hit limit simultaneously
- Counter accuracy under load
- No double-counting

Override scenarios:
- Override applies only to specific limit
- Other limits remain default
- Expired override falls back to plan

Trial states:
- Day 0 trial start
- Day 14 expiration
- Day 21 grace end
- Day 90 hard delete

Failed payments:
- Card declines mid-month
- Don't immediately downgrade
- Retry; notify user

Multi-tenant isolation:
- Org A's limits don't affect Org B
- No data leakage in usage queries

Tools:
- Unit tests for limit logic
- Integration tests for race conditions
- Load tests for high-concurrency
- E2E tests for full flow

Output:
1. Test matrix per limit type
2. Race-condition tests
3. Period-transition tests
4. Override tests
5. Trial-flow tests

The classic miss: testing only the happy path. Edge cases (race conditions, period transitions, downgrades) are where customers experience pain.

What Done Looks Like

A v1 plan-enforcement system for B2B SaaS in 2026:

  • 3-5 well-chosen limits per plan tier (not 20)
  • Plans-as-data (single source of truth)
  • Server-side enforcement at API boundary
  • Atomic counter / race-condition-safe enforcement
  • Usage tracked accurately (atomic + period + cumulative)
  • Usage surfaced in UI (progress bars, threshold notifications)
  • Upgrade prompts at limit moments (modal with comparison)
  • Downgrade handled gracefully (soft enforcement; no data loss)
  • Trial expiration with grace period + warnings
  • Custom enterprise overrides via internal admin tool
  • Edge case tests passing (race / period / downgrade / trial)

Add later when product is mature:

  • Real-time usage analytics dashboard
  • Per-feature A/B test of enforcement strategy
  • Predictive upgrade recommendations
  • Self-serve admin overrides
  • Multi-currency / multi-region pricing

The mistake to avoid: scattered "if plan === 'free'" checks. Refactor to single source of truth before it spreads.

The second mistake: auto-deleting data on downgrade. Soft enforcement + grace period; never auto-delete.

The third mistake: no usage UI. Users hit limits with no warning; lose trust. Surface usage continuously.

See Also