Elhadi.
← All posts
4 min readAI-written · in Suhaib Elhadi's voice

Entitlement, not purchase: modelling paid access so pricing can change

This update was drafted on a schedule by the AI I build with, from real project notes — part of the vibecoding experiment this blog documents.

Let me follow up on something I said in passing when I wrote about what RevenueCat handles so you don't have to. I mentioned that it separates "products" from "entitlements" and that the split matters. I want to actually make the case for that split, because it's the one data-model decision in a paid product that's genuinely hard to walk back, and most first drafts get it wrong — mine did.

The wrong version is intuitive. Someone pays you, so you write down what they paid for. A plan column on the user row: "pro_monthly". Or a purchases table with the SKU in it. It works on day one. It's a lie by month three.

Why it breaks

Because you will change your pricing. Not might — will. You'll rename the plans, you'll add a tier, you'll run a launch discount, you'll decide the $5 plan should be $8 but existing people keep the old price, you'll bundle two features that used to be separate. Every one of those is normal and every one of those, if your code checks if user.plan == "pro_monthly", is now a migration and a search-and-replace across your whole codebase, done nervously, in production, against real paying customers.

The thing your app actually needs to know is never "what did this person buy." It's "is this person allowed to do the thing they're trying to do right now." Those feel like the same question. They are not. The first one is about a transaction in the past. The second is about a capability in the present, and the whole point is that the mapping between them changes.

The shape that survives

Store entitlements, not purchases. An entitlement is a capability the user currently has — unlimited_tabs, export, no_ads — with an expiry. Your app only ever checks entitlements: does this user have export, yes or no. It never asks what plan they're on, because it doesn't care.

Then, separately, there's a mapping from products to entitlements. "The Pro plan grants unlimited_tabs and export and no_ads." That mapping is configuration. It lives in one place. When someone subscribes, you resolve their product through that map and write the resulting entitlements down. When you invent a new plan, you add a row to the map — you don't touch a single if statement in the app, because the app was only ever checking capabilities.

This is exactly what RevenueCat does and it's why I stopped fighting it. For tab. the subscription runs through RevenueCat, and my backend gets told "this user has the pro entitlement until this date." It has no idea whether that came from a monthly plan, an annual one, a promo code, or a family-share. It doesn't need to. The billing system's job is to turn money into an entitlement; my app's job is to respect the entitlement. Clean seam.

The parts that suddenly get easy

Grandfathering. Old users keep a capability new users have to pay more for? That's just an entitlement that's still valid. No special-casing. The user has unlimited_tabs; where it came from is history you can look up if you're curious and ignore if you're not.

Trials and comps. Giving a friend free access, or running a 14-day trial, is writing an entitlement row with an expiry. Same code path as a paying customer. You're not adding a is_comped boolean that you'll forget to check somewhere.

Changing what a tier includes. Move a feature from Pro down to the free tier: you change the product-to-entitlement map, and optionally backfill. The feature-gating code doesn't move because it was checking has('export'), not plan == 'pro'.

Switching billing providers. If I ever left RevenueCat, or added Stripe for a web version, the app doesn't change at all. Only the thing that writes entitlements changes. The 90% of the codebase that reads them is untouched.

The one rule

Never let a product name or a SKU past the billing layer. The moment "pro_monthly_v2" appears in a component or an API handler, you've coupled your feature code to your pricing, and pricing is the thing you most want to be free to change. Keep the check as dumb as possible: can this user do this, answered from a capabilities list, and let all the pricing complexity live in the boring config that maps money to capabilities.

My first instinct on every paid product is the plan string — write down what they bought, move on. It's the wrong instinct and I've had to catch it more than once. Now the rule is that every paid thing starts from the entitlement table, before there's even a paywall, because the paywall is the easy part and the data model is the part you're stuck with.

This one's auto-drafted from my notes on a schedule. If a number isn't in the notes, it doesn't show up here — I'd rather leave a blank than make something up.