SaaS Starter

Usage metering

Free-tier caps and quota enforcement with per-org meters.

The usage bounded context provides quota enforcement so downstream products can ship freemium pricing: free up to N events per organization, paid subscription after. Built on top of the billing module — usage reads the active plan via a cross-module port, doesn't replace it.

When to use it

  • A use case creates a billable event (a task, a job, a record, a credit burn) and you want the Free tier to be capped while paying tiers run free or with a higher limit.
  • You need a fast read-side (GET /api/v1/usage/me) the frontend polls to render quota bars and trigger upgrade modals.

If you don't need either, skip this module — it's strictly opt-in. The enforcer ships disabled by default (USAGE_METERING_ENABLED=false).

Concepts

ConceptDescription
MeterA named event you count (e.g. tickets_created, jobs_created). Registered in code at boot.
Reset cadencelifetime (counter never resets), monthly, or yearly. Property of the meter, not the plan.
Plan → cap mappingPer-priceId, per-meter integer cap. null = unlimited, undefined = misconfiguration (treated as 0).
CounterPer (org, meter, period) row in usage_counters. Lifetime meters use the epoch sentinel + null end so a single query works.
EventAppend-only audit row in usage_events. Used for debugging and as a replay source for usage-based billing.
Quota enforcerThe port use cases call before persisting; returns Result<void, QuotaExceededError>.

Add a meter to your product

Two files in your downstream's bootstrap:

// apps/server/src/bootstrap/usage-meters.ts
import type { AwilixContainer } from 'awilix';
import type { ContainerRegistry } from './container.js';

export const wireUsageMeters = (
  container: AwilixContainer<ContainerRegistry>,
): void => {
  const registry = container.cradle.meterRegistry;
  registry.register({
    key: 'tickets_created',
    displayName: 'Tickets',
    unit: 'ticket',
    resetCadence: 'lifetime',
  });
};
// apps/server/src/bootstrap/usage-plans.ts
import { InMemoryMeterRegistry } from '../modules/usage/infrastructure/in-memory-meter-registry.js';
import { env } from './env.js';

export const buildMeterRegistry = (): InMemoryMeterRegistry =>
  new InMemoryMeterRegistry({
    [env.USAGE_FREE_PRICE_ID]: { tickets_created: 50 },
    [env.POLAR_PRICE_PRO_USD]: { tickets_created: null },
    [env.POLAR_PRICE_PRO_ARS]: { tickets_created: null },
  });

Then in container.ts, override the default registry:

meterRegistry: asValue(buildMeterRegistry()),

And call wireUsageMeters(container) at startup before syncMeterDefinitionsUseCase.execute().

Wire enforcement into your use case

// modules/tickets/application/use-cases/create-ticket.ts
async execute(input: CreateTicketInput): Promise<Result<Ticket, DomainError>> {
  const quota = await this.quotaEnforcer.checkAndIncrement({
    organizationId: input.organizationId,
    meterKey: 'tickets_created',
    actorUserId: input.actorUserId,
    metadata: { source: input.source },
  });
  if (!quota.ok) return err(quota.error);
  // ... existing creation logic ...
}

If the cap is exceeded the call returns QuotaExceededError, which the HTTP layer translates to:

HTTP/1.1 402 Payment Required
{
  "code": "QUOTA_EXCEEDED",
  "message": "Quota exceeded for tickets_created: 50 of 50 used",
  "meter": "tickets_created",
  "cap": 50,
  "current": 50
}

The frontend's 402 interceptor reads this and opens the upgrade modal.

Reset cadence

The meter declares its cadence at registration:

  • lifetime — counter only ever grows. Backfill orgs from a SQL one-shot if you want existing usage to count; otherwise everyone starts at 0.
  • monthly — counter resets at the first day of each UTC month. A BullMQ job (usage-period-rollover) ticks hourly to insert the next window's counter row. Self-healing: if the cron misses a tick, the enforcer creates the row on first write.
  • yearly — same shape, year boundaries.

Rollover is non-destructive: the previous window's row stays in place, giving you a free time series for analytics.

Downgrade behavior

An organization that exceeded its free cap under Pro, then cancels, keeps all its existing data — only further creation is blocked. We never destroy user data because of subscription status.

Read model

GET /api/v1/usage/me

200 OK
{
  "priceId": "price_pro_usd",
  "meters": [
    {
      "key": "tickets_created",
      "displayName": "Tickets",
      "unit": "ticket",
      "count": 47,
      "cap": null,
      "periodStart": "1970-01-01T00:00:00.000Z",
      "periodEnd": null
    }
  ]
}

The frontend's useUsage() hook polls this on mount and invalidates after every successful mutation that could increment.

Race safety

checkAndIncrement runs the entire flow in a single prisma.$transaction:

  1. Resolve plan + cap + cadence.
  2. Compute the active window.
  3. Conditional UPDATE on usage_counters with WHERE count < cap guard.
  4. If UPDATE missed, INSERT (P2002 race recovery if another tx inserted first).
  5. Append the UsageEvent.

The conditional UPDATE is what makes two concurrent POSTs at count = cap - 1 deterministic — the first commit wins, the second sees affectedRows = 0 and returns QuotaExceededError.

Operational

  • Feature flag. USAGE_METERING_ENABLED=false (default) short-circuits every call. Flip to true after registering meters and validating in staging.
  • Boot sync. index.ts runs syncMeterDefinitionsUseCase on every cold start, idempotent. Without this the FK on usage_counters.meterKey rejects writes.
  • Rollover schedule. schedulePeriodRollover() installs an hourly job at startup. No-op when Redis isn't configured.
  • Free price ID. USAGE_FREE_PRICE_ID (default price_free_synthetic) is a string key, not a real Polar product. The Free tier has no checkout.

Extension points

The architecture accommodates patterns the MVP doesn't ship. See the design spec for the full list. Highlights:

  • Soft limits / warnings. Add an enforcement: 'hard' | 'soft' | 'meter' field to UsageMeterDefinition and branch in the enforcer.
  • Polar usage-based billing. Async listener over UsageEvent batches events to Polar's Meters API for per-unit overage pricing.
  • Threshold notifications. Emit usage.threshold_reached from the enforcer when crossing 80% / 100%; hook a mailer listener.
  • Multi-subject metering (per-user, per-workspace). Generalize the schema's organizationId to subjectType + subjectId. Higher cost — not recommended unless the product actually needs it.

On this page