Pricing and plans without a payment processor
Why my products take payment through a redirect and an admin grant instead of a checkout integration, what that costs, and the entitlement bugs that biteβ¦
The slug on this post mentions Stripe. What I want to explain is why my products do not use it, or any other processor β and, more usefully, the entitlement problems that are identical whichever way you take the money.
What I do instead
Payment happens through a single redirect to one payment page shared across every product I run. Someone pays, and then I set their plan from the admin panel. There is no webhook, no callback, no checkout session, no processor SDK in any bundle.
That is a real trade and I will state both sides.
What it costs: it is manual. Somebody pays and waits for me. It does not scale past the volume one person can handle, and it means an upgrade is not instant.
What it buys: no processor integration to maintain across a portfolio, no webhook endpoint that has to be idempotent and correct or a paying customer silently gets nothing, no PCI surface, no per-transaction fee, and no dependency whose API version I have to track in twenty codebases.
At the scale I operate β one person, many products β the second column wins comfortably. At a different scale it would not, and I would not pretend otherwise.
A grant is three facts, not one
This is the design point that transfers regardless of how you charge. An entitlement is not "this user is on Pro". It is three things:
- the plan they are on now,
- the plan they fall back to,
- and the date it happens.
A grant with no end date is a free tier with extra steps, and it is the most common bug in a manual system. The second most common: expiring by a scheduled job only. One missed run and a lapsed plan silently continues, which nobody reports because nobody complains about getting more than they paid for.
So the downgrade resolves on read. The user's effective plan is computed from those three facts every time it is asked for, and any job that tidies up is a convenience rather than the mechanism.
null is unlimited, and Infinity is a bug
The one that has bitten me hardest, and it is two lines:
JSON.stringify({ entries: Infinity }) // '{"entries":null}'
Store a limit as Infinity, round-trip it through JSON or a JSONB column, and it comes back as null. If your code reads a missing limit as "blocked", the tier you meant to be unlimited blocks every write. If it reads it as "unlimited", you have a different problem.
The resolution has to be asymmetric, and it took me a while to see why:
// Client: null means unlimited.
const withinLimit = (count: number, limit: number | null) => limit === null || count < limit;
Server-side it inverts β absence must fail closed, and only an explicit null means unlimited. A typo in a limit key and a genuinely unlimited tier look identical to jsonb ->> 'key', and one of those two readings hands out unlimited access on a misspelling.
A tier is a row, not an enum value
Storing the plan as a Postgres enum makes adding a plan a migration and a deploy, and enum values can never be removed. Worse, it forces every policy to hardcode the limits, and those copies drift.
A row in a plans table β key, rank, active flag, limits as JSONB β gives the same validity guarantee through a foreign key, and lets the database read the limits directly. Order by rank rather than by spelling, never rename a shipped key, deactivate rather than delete, and resolve an unknown key to the lowest active plan.
The one people miss: do not hardcode the plan key set downstream. A deployed z.enum([...]) in a validation schema starts rejecting a user's own profile the day you add a tier.
The client explains, the server refuses
Both always exist. The client hides the button and explains the limit, which is the user experience. The server refuses the write, which is the boundary. A client-only gate is bypassed with devtools; a server-only gate is correct and hostile, because the person finds out by being rejected after doing the work.
And the pricing page renders from the same records the enforcement reads. A hand-written comparison table is a second source of truth, and it lies by omission the first time a limit is added and nobody updates it.
Downgrade never deletes
When somebody stops paying, content over the new limit becomes read-only and the app says so. It does not disappear.
Deleting somebody's data because they stopped paying may be destroying their only copy, and no reasonable reading of a subscription lapse includes that. It also makes the upgrade path trivial: everything is still there, so paying again restores access rather than requiring recovery.
Metered features, and the rule I hold about bringing your own key
Anything that costs money per use β AI features above all β needs a different treatment from a storage limit, because the cost is mine and it scales with enthusiasm.
The shape I use: the free tier gets a real allowance rather than a taste, paid tiers get a larger one, and anyone using their own API key is not metered at all, on any tier. A call somebody else is paying for costs me nothing, and rationing it would be charging rent on their own credit card.
Two implementation details that are easy to get wrong. The allowance is a value on the plan row, never a literal in feature code β a hardcoded number is a bug even when it is currently the right number, because it does not move when the plan does. And enforcement increments and tests in a single statement, then refunds on failure with a floor at zero; a read-then-write pair lets two concurrent requests both see the same remaining count and both proceed.
The refusal message says it is a limit and says what to do about it. "Something went wrong" for a quota is the single most frustrating error in software, because the person cannot tell whether to retry, wait, or pay.
Say the price honestly, and never write a never-claim
One rule I follow absolutely: never write "there is no paid tier", or any permanent claim about money, anywhere β not in the interface, not in structured data, not in a machine-readable file, not in a store listing.
That kind of sentence outlives the release that falsifies it. It ends up in a documentation site nobody re-reads, in a JSON-LD block nobody looks at, in a cached answer an AI gives about your product a year after it stopped being true. And because it is a claim about pricing, being wrong about it is not a stale detail β it is the product appearing to have changed its terms quietly.
The same applies to the free tier's description. Describe what it includes today, and let the pricing page render from the same records the enforcement reads, so it cannot lie by omission the first time a limit is added.
The store rule, if you ship a mobile app
Purchase is web-only. The Android app shows plan status and gates features, and it never sells and never links out to a payment page β because the boundary is selling, and "upgrade on our website" is itself the anti-steering violation people get rejected for. The app can know what plan you are on. It cannot take you to the till.
https://aoneahsan.com/blog/saas-pricing-stripe-custom-redirects