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
| Concept | Description |
|---|---|
| Meter | A named event you count (e.g. tickets_created, jobs_created). Registered in code at boot. |
| Reset cadence | lifetime (counter never resets), monthly, or yearly. Property of the meter, not the plan. |
| Plan → cap mapping | Per-priceId, per-meter integer cap. null = unlimited, undefined = misconfiguration (treated as 0). |
| Counter | Per (org, meter, period) row in usage_counters. Lifetime meters use the epoch sentinel + null end so a single query works. |
| Event | Append-only audit row in usage_events. Used for debugging and as a replay source for usage-based billing. |
| Quota enforcer | The 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:
- Resolve plan + cap + cadence.
- Compute the active window.
- Conditional UPDATE on
usage_counterswithWHERE count < capguard. - If UPDATE missed, INSERT (P2002 race recovery if another tx inserted first).
- 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 totrueafter registering meters and validating in staging. - Boot sync.
index.tsrunssyncMeterDefinitionsUseCaseon every cold start, idempotent. Without this the FK onusage_counters.meterKeyrejects writes. - Rollover schedule.
schedulePeriodRollover()installs an hourly job at startup. No-op when Redis isn't configured. - Free price ID.
USAGE_FREE_PRICE_ID(defaultprice_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 toUsageMeterDefinitionand branch in the enforcer. - Polar usage-based billing. Async listener over
UsageEventbatches events to Polar's Meters API for per-unit overage pricing. - Threshold notifications. Emit
usage.threshold_reachedfrom the enforcer when crossing 80% / 100%; hook a mailer listener. - Multi-subject metering (per-user, per-workspace). Generalize the
schema's
organizationIdtosubjectType + subjectId. Higher cost — not recommended unless the product actually needs it.