Mapping Plan Prices to Feature Entitlements
Somewhere in every SaaS codebase there is a function that takes a plan name and returns a boolean. It works, it is fast, and it is the reason adding a plan requires touching six services. Replacing it means answering a narrow question well: given the price IDs a customer is subscribed to, which features do they get and at what limits? This guide sits under Entitlements & Feature Gating and covers the mapping itself — where it lives, how it composes, and how to migrate onto it without a flag day.
You face this decision the first time a price changes without a plan changing: a new SKU for the same tier, a regional price, a grandfathered rate. The plan name stayed business but the price ID is different, and every check keyed on the name silently keeps working while every check keyed on the price silently breaks — or vice versa. Deciding which identifier is authoritative, and writing it down once, is most of the work.
Trade-offs
The mapping has to live somewhere. There are three realistic homes, and the choice determines who can change it and how fast a change propagates.
| Approach | Change latency | Auditability | Works offline | Best for |
|---|---|---|---|---|
| Provider price metadata | Instant, no deploy | Weak — provider audit log only | No, needs an API call or sync | Small catalogues, few features |
| Owned join table | Instant, no deploy | Strong — your own history | Yes | Most products |
| Code constants | Deploy cycle | Strong — git history | Yes | Very small teams, stable catalogues |
Provider metadata is seductive because it puts the mapping next to the price. It fails on two counts: the metadata field is a string map with no schema, so a typo in feature_key produces a silently missing capability, and the change history is whatever the provider’s audit log happens to retain. An owned table gets a foreign key, a constraint, and your own migration history.
Step-by-Step Implementation
1. Enumerate features before touching prices
List every capability the product currently gates, with its kind and its unit. Doing this first exposes the duplicates — can_export, export_enabled, and exports are usually the same feature checked three ways — and the list is almost always shorter than expected. Twenty to forty features is typical for a mature product.
2. Create the mapping with real constraints
CREATE TABLE price_features (
price_id TEXT NOT NULL,
feature_key TEXT NOT NULL REFERENCES features(feature_key),
limit_value NUMERIC, -- NULL = unlimited, 0 = explicitly denied
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (price_id, feature_key)
);
-- Guard against a typo becoming a missing capability.
ALTER TABLE price_features
ADD CONSTRAINT price_features_limit_sane
CHECK (limit_value IS NULL OR limit_value >= 0);
The foreign key onto features is the single most valuable line here. It converts “this price grants sso_saml_” from a silent no-op into a write that fails.
3. Define precedence once
When a customer holds several subscription items, or a plan grant and a contract grant, some rule must decide the outcome. Write it once as a pure function and never re-implement it at a call site.
def fold_limits(kind: str, values: list[float | None]) -> float | None | bool:
"""Highest single grant wins; an explicit 0 always denies."""
if any(v == 0 for v in values):
return False if kind == "boolean" else 0
if kind == "boolean":
return any(bool(v) for v in values)
if any(v is None for v in values):
return None # unlimited absorbs finite grants
return max(values)
“Highest wins” beats “sum” as a default because it is explainable to a support agent without a spreadsheet, and because additive stacking makes double-granting a silent upgrade rather than a visible one. Choose summing only where the commercial model genuinely sells add-on packs, and mark those features explicitly.
4. Derive grants from subscription items
def grants_for_subscription(sub, price_features) -> list[dict]:
"""One grant row per (price, feature), scoped to the item's active window."""
out = []
for item in sub.items:
for feature_key, limit in price_features.get(item.price_id, {}).items():
out.append({
"account_id": sub.account_id,
"feature_key": feature_key,
# Seat-like features scale with the item quantity; others do not.
"limit_value": None if limit is None else limit * (item.quantity if feature_key in QUANTITY_SCALED else 1),
"source": f"price:{item.price_id}",
"effective_from": sub.current_period_start,
"effective_to": sub.cancel_at or None,
})
return out
The QUANTITY_SCALED set is where a subtle bug lives. A price granting “5 projects” at quantity 3 usually means 15 projects; a price granting sso_saml at quantity 3 still means SSO, not three SSOs. Being explicit about which features scale prevents a seat purchase from silently multiplying an unrelated limit.
5. Retire plan-name checks incrementally
Waiting a full billing cycle rather than a week matters because the interesting disagreements happen at renewals, trial ends, and downgrades — events that most accounts experience monthly at most. A week of quiet logs proves only that steady-state accounts agree.
Keeping the Mapping and the Catalogue in Sync
The mapping is a projection of commercial intent, and commercial intent changes on a different cadence than code. Three synchronisation problems recur, and each has a cheap structural answer.
The first is new prices appearing without mapping rows. This happens whenever someone can create a price outside your application — the provider dashboard, a sales tool, an import script. The structural fix is to make the mapping a required step in the only sanctioned path for creating a price, and to detect violations rather than trusting the process. A nightly job that lists price IDs on active subscriptions and diffs them against price_features finds the gap within a day, which is fast enough that the affected customer usually has not noticed.
The second is feature drift: the product ships a capability and gates it on a feature key that no price grants, so the capability is dark for everyone, or gates it on nothing at all, so it is free for everyone. Both are invisible without a check. Enumerate the feature keys the code actually references — a grep in CI is sufficient — and assert that each one exists in the features table and is granted by at least one active price. A key referenced by code but granted by nothing is either a launch waiting on pricing or a bug.
The third is price versioning. Payment providers generally treat prices as immutable, so a price change means a new price ID and existing subscribers stay on the old one. That is a feature, not a limitation: it means the mapping naturally records what each vintage of a plan granted. The failure mode is a catalogue cleanup that archives old prices and deletes their mapping rows, which silently revokes features from every grandfathered subscriber. Archive prices by marking them unavailable for new subscriptions; never delete their mapping.
-- Prices in use that have no entitlement mapping — should always be empty.
SELECT DISTINCT si.price_id
FROM subscription_items si
JOIN subscriptions s USING (subscription_id)
WHERE s.status IN ('active', 'trialing', 'past_due')
AND NOT EXISTS (
SELECT 1 FROM price_features pf WHERE pf.price_id = si.price_id
);
Run that query on a schedule and alert on any row. It is three lines of SQL and it catches the failure mode that generates the most support tickets in this design.
Where the Mapping Should Not Be Consulted
An equally important discipline is deciding which code is allowed to read price_features at all. The answer is: only the resolver. Everything else reads the resolved snapshot.
The temptation to read the mapping directly shows up in reporting (“which accounts have SSO?”), in the customer portal (“what does my plan include?”), and in sales tooling (“what would this customer get on Business?”). Each of those is a legitimate question, and each of them answered by joining price_features at the call site will eventually disagree with what the product actually enforces — because the call site will forget contract grants, or trial grants, or the precedence rule, or the effective window.
Serve reporting from the resolved snapshot table. Serve the portal from the same resolver the API uses, so what the customer is told matches what they experience. For “what would they get on Business?”, run the resolver against a hypothetical set of grants rather than reimplementing the fold — a preview_grants() entry point costs a few lines and guarantees the preview and the reality use identical logic.
This is the same discipline that makes the fold a pure function in the first place. One implementation, several entry points, zero divergence.
Verification & Testing
Assert the mapping is total: every price ID referenced by an active subscription must appear in price_features, and the test that checks this should run against production data in a nightly job, not just against fixtures. A price added in the provider dashboard without a corresponding mapping row is the single most common production incident in this design, and it presents as a customer paying for a plan whose features never arrive.
Then test the fold directly with a table of cases: a single grant, two overlapping grants at different limits, an unlimited grant beside a finite one, an explicit zero beside a positive grant, and an expired grant beside a live one. Assert commutativity by shuffling the input order and comparing results — the fold must be order-independent, because grants arrive from a set, not a sequence.
Finally, snapshot the resolved entitlements for one account on each plan and check the snapshots into version control. A pricing change that unintentionally alters what an existing plan grants will fail that test loudly, which is exactly the moment you want to know.
Gotchas & Production Pitfalls
- Prices created outside the mapping. Someone adds a price in the provider dashboard for a one-off deal; the mapping has no row; the customer gets nothing. Make the nightly totality check page someone, and consider blocking the subscription creation path on an unmapped price.
- Quantity scaling applied blindly. Multiplying every limit by the item quantity turns a 3-seat purchase into 3× the storage. Keep an explicit set of quantity-scaled features.
- Zero and null confused.
NULLmeaning unlimited and0meaning denied must never be collapsed into one nullable column read withCOALESCE(limit, 0)— that expression silently converts unlimited into denied. - Grandfathered prices forgotten. Legacy prices no longer sold still have active subscribers. Deleting their mapping rows during a catalogue cleanup revokes features from paying customers, usually discovered by support rather than monitoring.
- Feature keys renamed in place. Renaming
exportstodata_exportswithout a migration leaves every existing grant pointing at a key the product no longer checks. Add the new key, dual-write, then retire the old one.
Frequently Asked Questions
Should the mapping live in the same database as billing? Yes, if it can. The mapping is read constantly and changes rarely, so it is a good candidate for caching, but keeping it transactionally close to subscription data means a plan change and its entitlement consequences can be reasoned about together.
What about products where features are per-seat rather than per-account? Model the account-level limit here and let the seat assignment live in your permissions system. Entitlements answer “how many editors may this account have”; the permission layer answers “is this user one of them”.
How do I handle add-on purchases? As additional subscription items with their own prices, each contributing grants through the same mapping. Add-ons are the case where summing rather than max is usually correct, so mark those features explicitly rather than changing the global rule.
Can I generate the mapping from the pricing page? It is a reasonable consistency check, not a source. The pricing page is marketing copy that changes for marketing reasons; a test asserting the page and the mapping agree catches drift in both directions without coupling one to the other.