Entitlements & Feature Gating for SaaS Billing
Billing decides what a customer pays for; entitlements decide what the product actually lets them do. Those two answers drift apart the moment a payment fails, a plan is downgraded mid-cycle, or a sales-negotiated exception is bolted onto a standard price. An entitlement layer is the translation service between them: it consumes subscription state and emits a fast, unambiguous answer to “may this account use this feature right now, and how much of it is left?” This page sits under Subscription Billing Architecture & Pricing Models and covers the schema, the derivation pipeline, the enforcement point, and the revocation rules that keep the product honest without turning every request into a billing query.
The failure mode this subsystem exists to prevent is silent over-service. A customer downgrades from Business to Starter, the plan change lands correctly in the billing system, the invoice is right — and the product keeps serving the SSO integration and the 50 GB storage ceiling for another eleven months because nothing ever recomputed what the account was allowed to have. The mirror failure is just as damaging: a payment retry succeeds at 03:00, the subscription lifecycle states move back to active, and the customer stays locked out until a cache expires. Both are entitlement bugs, not billing bugs, and neither shows up in revenue reporting.
The reason entitlements deserve their own subsystem — rather than a plan_name column read at the call site — is that the mapping from money to capability is many-to-many and time-varying. One price grants several features. One feature is granted by several prices. A negotiated contract grants a feature no price grants. A trial grants everything for fourteen days. A past-due account keeps read access but loses writes. Encoding that as if (plan === 'business') scattered across forty services guarantees that the fortieth one is wrong, and that adding a plan is a release across every service that ever mentioned a plan name.
Prerequisites
An entitlement layer is a derivation on top of state that must already be trustworthy. If subscription status is eventually consistent across services, entitlements inherit that inconsistency and amplify it into visible product bugs. Each item below is load-bearing.
The Entitlement Data Model
The single most useful design decision is to stop treating a feature as a property of a plan and start treating a grant as a row: this account has this feature, at this limit, from this instant until that one, because of this source. Grants compose. A plan grants five, a contract amendment grants a sixth, a trial grants a temporary seventh, and the resolver folds them into one answer with a documented precedence rule.
-- The catalogue side: features exist independently of the prices that sell them.
CREATE TABLE features (
feature_key TEXT PRIMARY KEY, -- 'sso_saml', 'api_calls', 'seats'
kind TEXT NOT NULL, -- 'boolean' | 'quota' | 'seat'
reset_period TEXT, -- NULL, 'billing_period', 'calendar_month'
display_name TEXT NOT NULL
);
CREATE TABLE price_features (
price_id TEXT NOT NULL, -- matches the PSP price id, e.g. price_1P9x...
feature_key TEXT NOT NULL REFERENCES features(feature_key),
limit_value NUMERIC, -- NULL = unlimited; 0 = explicitly denied
PRIMARY KEY (price_id, feature_key)
);
-- The account side: every grant is explicit, time-bounded, and attributable.
CREATE TABLE entitlement_grants (
grant_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID NOT NULL,
feature_key TEXT NOT NULL REFERENCES features(feature_key),
limit_value NUMERIC,
source TEXT NOT NULL, -- 'price:price_1P9x', 'contract:ord_884', 'trial'
effective_from TIMESTAMPTZ NOT NULL,
effective_to TIMESTAMPTZ, -- NULL = open-ended
subscription_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ON entitlement_grants (account_id, feature_key, effective_from DESC);
Three columns carry most of the value. source makes every capability auditable — when support asks why an account has SSO, the answer is a row, not a guess. effective_to makes revocation a write rather than a delete, which preserves history and makes “what were they entitled to on 14 June?” answerable. And limit_value = 0 being distinct from NULL matters: an explicit zero is a denial that must beat an inherited grant, whereas NULL means unlimited. Collapsing those two into one nullable integer is the bug that eventually gives a free-tier account unlimited API calls.
Why grants beat a plan-to-feature lookup table
A lookup table that maps plan to feature list is the obvious first design, and it survives contact with reality for about one quarter. The pressure that breaks it always arrives from the commercial side rather than engineering. A prospect will sign if they get the audit log without moving to the enterprise tier. A customer churning over a storage ceiling is offered double the storage for the remainder of their term. A partnership programme grants a feature bundle to accounts with a particular attribute, unrelated to what they pay. Every one of those is a legitimate business decision, and every one of them is impossible to express as a plan-to-feature mapping without inventing a fake plan.
Fake plans are the real cost. Once you create business_plus_auditlog_customer_884, you have forked your catalogue: pricing analysis now has to know which plans are real, the customer portal has to hide the synthetic ones, and revenue reporting groups by a dimension that no longer means what it says. A grant row attributed to contract:ord_884 sidesteps all of that. The plan stays business, the price stays the list price, and the exception is visible, dated, and attributable to the order that authorised it.
The second advantage is reversibility. A grant with an effective_to in the future expires without anyone remembering to act. The retention offer that doubled storage for six months simply stops applying, and the resolver produces the correct smaller number on the day it should. Compare that with a fake plan, which requires a human to notice a date in a spreadsheet and migrate the customer back — a task that is skipped often enough that “temporary” concessions become permanent margin leakage.
Modelling seats without a race condition
Seats deserve special mention because they are the only entitlement kind where the count is itself a billable quantity. A boolean feature costs nothing extra to grant; a quota is usually enforced against a ceiling that was already paid for. A seat, on the other hand, typically drives the invoice: adding one changes what the customer owes, which means the entitlement check and the billing mutation must agree or the two systems diverge in a way finance will eventually notice.
The correct shape is a reservation. The invite flow reserves a seat inside the same transaction that creates the pending invitation, the reservation is released if the invitation expires or is revoked, and the billing mutation happens on acceptance. Skipping the reservation and checking count(members) < limit at invite time means two administrators inviting the last seat simultaneously both pass the check, both invitations are accepted, and the account is one seat over a limit it is not being billed for. That gap is small per account and enormous in aggregate.
-- Reserve inside the transaction that creates the invitation.
BEGIN;
SELECT seat_limit FROM entitlement_snapshots
WHERE account_id = $1 FOR UPDATE; -- serialise on the account, not the table
INSERT INTO seat_reservations (account_id, invitation_id, reserved_at)
SELECT $1, $2, now()
WHERE (SELECT count(*) FROM account_members WHERE account_id = $1)
+ (SELECT count(*) FROM seat_reservations WHERE account_id = $1 AND released_at IS NULL)
< (SELECT seat_limit FROM entitlement_snapshots WHERE account_id = $1);
-- zero rows inserted means the seat was not available; roll back the invitation too
COMMIT;
The FOR UPDATE on the account’s snapshot row is what makes concurrent invites serialise on that account alone. It is a deliberately narrow lock: contention is per account, which is exactly the granularity where the invariant lives, and no other account’s invite path is affected.
The kind column drives the resolver’s fold. Boolean features fold with OR-then-explicit-deny. Quota features fold with MAX across grants, because a contract that adds 100 GB on top of a plan’s 50 GB should yield 150 GB only if it is written as an additive grant — otherwise the higher single grant wins. That distinction should be a column too if your commercial model needs both, and the safest default is “highest single grant wins” because it is the rule support staff can explain without a spreadsheet.
Deriving and Publishing the Snapshot
Resolving grants on every request is correct and far too slow. The pattern that scales is to derive a snapshot whenever anything that could change the answer changes, and to serve reads exclusively from that snapshot. The derivation is a pure function of grants plus the clock, which makes it trivially testable and safe to recompute from scratch at any time.
1. Fold grants into a snapshot
from dataclasses import dataclass
from datetime import datetime
from typing import Iterable
@dataclass(frozen=True)
class Grant:
feature_key: str
kind: str # 'boolean' | 'quota' | 'seat'
limit_value: float | None # None means unlimited
source: str
effective_from: datetime
effective_to: datetime | None
def resolve(grants: Iterable[Grant], now: datetime) -> dict[str, object]:
"""Pure fold: highest single grant wins; an explicit 0 always denies."""
out: dict[str, object] = {}
for g in grants:
if g.effective_from > now:
continue
if g.effective_to is not None and g.effective_to <= now:
continue
if g.kind == "boolean":
out[g.feature_key] = out.get(g.feature_key, False) or bool(g.limit_value)
else:
current = out.get(g.feature_key, 0)
if g.limit_value is None or current is None:
out[g.feature_key] = None # unlimited absorbs everything
else:
out[g.feature_key] = max(current, g.limit_value)
# An explicit denial beats any inherited grant, so apply zeros last.
for g in grants:
if g.limit_value == 0 and g.effective_from <= now and (g.effective_to is None or g.effective_to > now):
out[g.feature_key] = 0 if g.kind != "boolean" else False
return out
Making this a pure function is not stylistic. It means the snapshot for any account at any instant can be recomputed in a test, in a support tool, or in a backfill job, and every one of those paths produces the identical answer the API would have produced. Any logic that reads a database inside the fold destroys that property.
2. Publish with a monotonic version
The snapshot must carry a version that only increases, and the read store must reject a write whose version is not greater than the one it holds. Without that, two consumers processing a plan change and a payment recovery out of order will leave the account with whichever snapshot happened to arrive last — which is exactly the out-of-order hazard described in resolving out-of-order webhook delivery issues.
def publish(redis, account_id: str, snapshot: dict, version: int) -> bool:
"""Compare-and-set on version; a stale writer loses silently and safely."""
key = f"ent:{account_id}"
lua = """
local cur = redis.call('HGET', KEYS[1], 'version')
if cur and tonumber(cur) >= tonumber(ARGV[1]) then return 0 end
redis.call('HSET', KEYS[1], 'version', ARGV[1], 'data', ARGV[2])
return 1
"""
return bool(redis.eval(lua, 1, key, version, json.dumps(snapshot)))
Use the subscription’s own updated-at sequence or the event’s ordinal as the version — never wall-clock time on the publisher, which reintroduces clock skew as a correctness problem.
Enforcement at the Request Boundary
Enforcement belongs at one boundary, not at every call site. In practice that means a middleware that resolves the account’s snapshot once per request and attaches it to the request context, plus a small set of helpers that services call. Boolean features are a lookup. Quotas need a counter. Seats need a counter and a reservation, because two admins inviting the last available user simultaneously must not both succeed.
// Express middleware: one snapshot read per request, attached to context.
import type { Request, Response, NextFunction } from "express";
export async function withEntitlements(req: Request, res: Response, next: NextFunction) {
const accountId = req.auth.accountId;
const snap = await entitlementStore.get(accountId); // ~1ms, read-only
if (!snap) return res.status(503).json({ error: "entitlements_unavailable" });
req.entitlements = snap;
next();
}
export function requireFeature(featureKey: string) {
return (req: Request, res: Response, next: NextFunction) => {
if (req.entitlements[featureKey] === true) return next();
return res.status(402).json({
error: "feature_not_entitled",
feature: featureKey,
upgrade_url: `/billing/upgrade?feature=${featureKey}`,
});
};
}
Returning 402 Payment Required rather than 403 Forbidden for a billing denial is worth the small unconventionality: it separates “you are not allowed” from “your plan does not include this”, which lets the client render an upgrade path instead of an error, and lets your own dashboards count monetisable friction distinctly from permission bugs.
For quotas, the check and the increment must be one atomic operation, and the key must include the period so the reset is implicit rather than a scheduled job:
def consume_quota(redis, account_id: str, feature: str, limit, period_key: str, n: int = 1) -> bool:
if limit is None:
return True # unlimited
key = f"quota:{account_id}:{feature}:{period_key}"
used = redis.incrby(key, n)
if used == n: # first write in this period
redis.expire(key, 40 * 24 * 3600) # outlive the longest billing period
if used > limit:
redis.decrby(key, n) # give back the overshoot
return False
return True
The decrby on rejection matters more than it looks. Without it, a client that keeps retrying against a full quota inflates the counter far past the limit, and the usage number you show in the customer portal — and possibly the number you bill from — becomes fiction.
Downgrade, Revocation, and Grace
Revocation is where entitlement systems earn their keep or cause incidents. The instinct is to revoke the instant a downgrade is recorded; the correct behaviour is almost always more nuanced, because the customer has already paid for the period and because data created under the higher plan still exists.
| Situation | Revoke boolean features | Enforce lower quota | Data created above the new limit |
|---|---|---|---|
| Downgrade at period end | At period end | At period end | Retained, read-only above limit |
| Immediate downgrade with credit | Immediately | Immediately | Retained, read-only above limit |
| Payment failed, in retry window | Keep | Keep | Untouched |
Dunning exhausted, subscription unpaid |
Revoke writes, keep reads | Freeze counters | Retained for the export window |
| Voluntary cancellation | At period end | At period end | Export window then purge per policy |
The row that causes the most production pain is the fourth. When grace-period retry logic finally gives up, the account must degrade in a way that is recoverable: never delete, never hard-fail reads, and never revoke the ability to update the payment method or export data. An account that cannot log in to fix its card is an account that will not be recovered.
The “retained, read-only above limit” rule deserves a precise definition, because it is the difference between a downgrade that feels fair and one that feels like theft. If a customer drops from 50 projects to 10 and had 32, the correct behaviour is that all 32 stay visible and readable, none can be edited beyond the tenth by creation order, and creating a 33rd is denied. Deleting 22 projects because a plan changed is data loss caused by a billing event, and no amount of terms-of-service language makes that acceptable to a customer who upgrades back a week later.
Performance & Scale Considerations
The entitlement check sits on the hot path of every authenticated request, so its budget is measured in hundreds of microseconds, not milliseconds. Three techniques get you there. First, keep the snapshot small — a flat map of feature key to limit, serialised once, ideally under 2 KB, so it fits comfortably in a single Redis value and can be attached to a request context without allocation pressure. Second, cache the snapshot in-process with a short TTL (5–15 seconds) keyed by account and version; the version in the key means an invalidation is a new key rather than a purge, and a stale entry can never outlive its TTL. Third, never let the entitlement store be a hard dependency for reads: if it is unavailable, decide deliberately whether to fail open on boolean features (better for availability, worse for revenue leakage) or fail closed, and encode that decision per feature rather than globally.
Quota counters have a different scaling profile. They are write-heavy and per-account, which makes them a natural fit for a sharded key space but a poor fit for a single Postgres row under contention — a hot account hammering one row will serialise on the row lock and add tens of milliseconds under load. Keep counters in Redis on the request path and fold them into durable storage asynchronously, exactly as the metered billing event pipeline does, then reconcile the two nightly. The Redis number is what gates the request; the durable number is what you bill from.
Snapshot recomputation cost is worth measuring separately. A fan-out event like “this plan’s feature set changed” touches every account on that plan, which at 50,000 subscribers is 50,000 snapshot writes. Do that as a rate-limited backfill with a bounded concurrency, not as a synchronous loop inside an admin request, and make the job resumable by ordering on account id so a restart continues rather than repeats.
Testing Strategy
Entitlement bugs are expensive and quiet, which makes them ideal candidates for property-based and table-driven testing rather than example tests. The resolver being a pure function means you can enumerate grant combinations exhaustively: for every pair of grants over the same feature, assert that the fold is commutative and that an explicit zero always wins regardless of order. Commutativity is the property that protects you from event-ordering bugs, because it means two grants applied in either sequence yield the same snapshot.
Time-travel tests are the second essential layer. With an injectable clock, assert the snapshot at period_end - 1s still includes the higher plan and at period_end + 1s does not, and that a trial grant with effective_to set disappears exactly at its boundary rather than a cache TTL later. Then test the concurrency case that actually bites: fire N parallel seat invitations against a limit of N-1 and assert exactly N-1 succeed — a test that fails immediately if someone replaces the atomic reservation with a read-then-write.
Finally, test the degraded paths explicitly. Assert that with the entitlement store unreachable the API returns your chosen failure mode rather than a 500, that a stale snapshot version is rejected by the compare-and-set, and that an account in unpaid can still reach the payment-method update endpoint. That last assertion has saved more revenue than any optimisation on this page.
Frequently Asked Questions
Should entitlements live in the billing service or the product? Neither, ideally — they belong in a thin service that consumes billing events and serves the product. Putting them in billing couples product releases to billing releases; putting them in the product means every service reimplements the fold. A separate resolver with a read-only API is the boundary that stays stable as both sides change.
Can I just read the plan name at the call site instead? It works until the second exception. The moment sales grants one customer a feature outside their plan, or a trial grants a temporary capability, plan-name checks start accumulating special cases in every service. The grant table costs an afternoon and removes that entire class of change.
What should happen when the entitlement store is down? Decide per feature and write it down. Revenue-critical gates (seat limits, storage ceilings) should fail closed with a clear error; low-risk boolean features can fail open to protect availability. A blanket policy either takes the product down during a cache incident or hands out paid features for free.
How do I keep quota counters and billed usage consistent? Treat the fast counter as the enforcement number and the durable event store as the billing number, then reconcile nightly and alert on divergence beyond a threshold. They will differ slightly — rejected requests, clock boundaries — and pretending otherwise leads to a system where every discrepancy is an emergency.
Do entitlements need their own audit trail? Yes, and the grant table is it. Because grants are closed by setting effective_to rather than deleted, the table answers “what could this account do on any past date” directly, which is what a security review or a customer dispute actually asks.
Related
- Subscription Billing Architecture & Pricing Models
- Subscription Lifecycle States
- Hybrid Pricing Models for SaaS
- Trial Period Management
- Grace Period & Retry Logic
- Database Sync & Consistency Patterns
- Mapping Plan Prices to Feature Entitlements
- Enforcing Seat Limits and Overage at the API Edge
- Caching Entitlement Checks Without Stale Access