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
Where the mapping lives Provider metadata changes instantly but audits weakly, an owned table balances both, and code constants audit well but need a deploy. Provider metadata instant change weak audit trail needs a sync job Owned join table instant change full history the default choice Code constants deploy to change reviewed in git stalls on pricing tests
An owned table wins in most products because pricing changes faster than deploys and needs a history the provider does not keep.

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)
Grant precedence fold A plan grant, a contract grant, and an explicit denial are folded by taking the highest limit unless an explicit zero is present, in which case the feature is denied. plan grant · 50 GB contract grant · 150 GB expired grant · 500 GB drop expired, then take the max resolved · 150 GB
An expired grant contributes nothing and an explicit zero overrides everything — the rest is a maximum.

“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

Migration path off plan-name checks Both checks run in parallel and disagreements are logged, then the old check is removed once the log is silent for a full billing cycle. Plan-name check still authoritative Shadow resolver logs disagreement Silent for a cycle zero mismatches Swap never a flag day — the mismatch log is the release criterion a full billing cycle exposes renewals, trials, and downgrades
Run both, log disagreements, and let a quiet log for one full cycle be the signal to remove the old path.

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. NULL meaning unlimited and 0 meaning denied must never be collapsed into one nullable column read with COALESCE(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 exports to data_exports without 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.