23 August 2026
Paid features without platform lock-in: MoR payments + entitlements in Postgres
Letting a Merchant of Record handle EU VAT while keeping the entitlement model in your own database — and the production bug that proved why that split matters.
saaspaymentspostgrespaddleentitlementseu-vat
There's one function in this whole series that carries the argument:
hasFeature(subject, feature) // reads our Postgres. Never calls the provider.
A payment provider outage cannot revoke access somebody paid for, because deciding what a customer is allowed to do never leaves our database. That's the entire thesis, and everything below is the work required to make it true.
The last post put a real product on the platform. This one charges for a feature — the last thing between an application that runs and a business.
What you'll get from this post
Two decisions that have to be made together — who takes the money, and who decides what a paying customer can do — plus a provider-agnostic entitlement schema worth copying, several genuinely surprising integration details, and a bug found in production data that made the case for owning the model better than any argument could.
The compliance trade, stated honestly
Selling self-serve across the EU means VAT: registration in the buyer's country or via the one-stop shop, the correct rate per jurisdiction, compliant invoices, filings, evidence retention. That's a compliance workload, not a coding one, and it lands on a one-person company before the first euro arrives.
A Merchant of Record becomes the legal seller. They register, charge, file and remit; you receive a payout. For a solo operation that is the single largest compliance item removed, and removed on day one rather than at the revenue level where hiring an accountant becomes rational.
The steelman for Stripe direct is strong: lower fees, the best API and documentation in the industry, better B2B invoicing, and nobody between you and the customer relationship. It loses for the first self-serve product for exactly one reason — being the merchant means EU VAT is permanently yours from the first sale, which is precisely the work being bought out of. For a B2B product with negotiated terms, where reverse-charge makes VAT largely the buyer's problem, Stripe direct is the better answer. The decision is per product, not per company.
Mollie deserves a mention because it's the sovereignty-first option: Dutch, EU-native, the strongest jurisdiction story of the three. It loses because it's a payment service provider rather than a Merchant of Record — VAT stays entirely ours, so it loses on both axes this decision is actually about. It stays the fallback if jurisdiction ever outweighs tax.
And this is worth naming plainly, because the rest of the series has been strict about it: this is the first component where the sovereignty position is knowingly compromised. There is no EU-sovereign Merchant of Record that is also a credible self-serve subscription platform. That trade is made explicitly here rather than discovered in an audit later.
The schema is the artifact
The provider knows about subscriptions. The product cares about capabilities. Keeping the map between them is what makes the provider replaceable:
customers -- our subject (the IdP's sub) -> opaque provider customer id
subscriptions -- provider subscription id, status, plan
entitlements -- (subject, feature, subscription) -> granted
webhook_events -- provider event id -> processed_at (the idempotency ledger)
Four tables. The identity spine is the identity provider's subject claim; the provider's customer id is a foreign key on our row, never the primary identity. Card data never touches this platform — checkout is hosted, and we store an opaque id.
Authorization at request time is always a local read. The provider API is never on the request path. That single rule is what makes the outage claim in the opening true, and it's also why switching providers rewrites one adapter file rather than every gated route.
The adapter boundary is deliberately tiny — three functions, and the
provider's SDK imported in exactly one file. In this case there's no SDK at
all: webhook signature verification is about forty lines of node:crypto
computing an HMAC over a timestamp and the raw body. A stable published
contract beats a moving SDK surface, and writing it yourself proves the
boundary is real rather than aspirational.
The bug that made the argument
The entitlements table was originally keyed on (subject, feature). Obvious,
minimal, and wrong.
With two overlapping subscriptions — the same person upgrading, or an old one not yet expired — both grant the same feature and collide on that key. Cancelling the older subscription would then revoke a grant the newer, still-paid subscription was providing. A paying customer silently loses access they're currently being charged for.
It was caught in production data, before it ever fired, by looking at the actual rows during testing. The fix puts the subscription in the key, so each subscription grants and revokes only its own entitlements.
Here's why it belongs in a post about lock-in rather than a post about
schemas: the bug was findable because the state was in our own Postgres.
It was a select away. Had entitlement state lived inside the provider,
expressed through their subscription model, the same flaw would have been
invisible until a customer complained — and unfixable except by whatever the
provider's data model allowed.
One caveat learned the hard way: a schema fix is forward-only. Deploying the corrected key does not resurrect grants the old model would have destroyed. That needs a replay through the normal path.
Verifying it end to end
The loop, run against the provider's sandbox with a test card, on one subscription from start to finish:
$ kubectl -n platform-pg exec platform-pg-1 -c postgres -- \
psql -U postgres -d starter -c 'select subject, feature, subscription from entitlements'
subject | feature | subscription
---------------------------------+---------+---------------
<keycloak-sub> | pro | <sub-id>
(1 row)
…and after cancelling:
subject | feature | subscription
---------+---------+--------------
(0 rows)
With the gated feature unlocking and re-locking on the dashboard to match.
Twelve webhook events delivered, twelve processed. The signature gate was
confirmed in production the useful way: unsigned and bogus-signature requests
both returned 403 with the database untouched.
(Identifiers redacted — real subject and subscription ids don't belong in a blog post any more than they belong in a screenshot.)
Use one subscription end to end when you test this. Overlapping subscriptions make the table hard to read — and they're exactly how the bug above was found, which is a good reason to reproduce them deliberately and a bad reason to have them running while you're verifying something else.
What this really buys you
Two things that look like one.
The Merchant of Record removes a compliance workload that would otherwise consume a solo founder before revenue exists. Owning the entitlement model means that convenience costs nothing structural: authorization survives their outage, the audit question "why was this user allowed to do that" is answered by a row in a database we back up and restore-test, and replacing the provider is one file.
Buy the compliance. Don't sell the model.
Gotchas
Every one of these cost real time.
- This provider has no hosted checkout page. The API returns a checkout URL pointing at your own domain with a transaction id appended, and that page must load their client-side script to open the overlay. A redirect-only integration — the obvious reading of the docs — silently dead-ends.
- The notification secret key is not the destination id. They're different fields, the UI shows the id first, and seeding the id fails every signature check with no useful error. An hour of debugging correct code.
- Build-time environment inlining breaks a runtime-config contract. Framework variables prefixed for client exposure are baked in at build time, which contradicts "the same image runs everywhere, configured by env." Read them server-side and pass them down as props.
- A webhook outage leaves permanent drift, not delay. A subscription created while verification was failing stayed invisible to the application until an unrelated later event arrived. There's no reconciliation job. Recovery is replaying the failed notification specifically — replaying an already-delivered one hits the idempotency ledger and correctly does nothing. If I rebuilt this, a periodic reconcile against the provider is the first thing I'd add.
- Environment variables are injected at container start. After the secret store rewrites a secret, delete the pod — a rolling restart edits the pod template and fights the GitOps controller's self-heal.
- Adding a key to a live secret is a production change. All extracts share one namespace and a single missing key fails the whole secret, which would have dropped the database URL and session secret off a running app. Seed first, then merge.
- Standalone builds only ship what the module graph traces. SQL kept in loose files worked locally and was simply absent from the image. Migrations became string constants in code.
- Lint was already failing before any of this. Nothing had ever run it, because the pipeline only built the image. Worth checking on day one rather than discovering it inside an unrelated change.
Where this leaves us
That's the series. Over eight posts a single cheap box in the EU became a platform that stores secrets, runs a database it can restore to a point in time, knows who its users are, hosts real office software, backs everything up with proven restores, can be rebuilt from nothing in under ninety minutes, runs a real SaaS, and takes money for it.
The through-line was never the tools. It was that every layer keeps the exit cheap: substrate, database, identity, backups, and now payments are each one replaceable component behind a boundary narrow enough to describe in a sentence. That's what portability actually costs — a series of small refusals to let a vendor's model become your model.
The platform is real now, and it runs. Next comes operating it in public: what breaks, what it costs, and what the numbers look like with something actually running on it.
I'm building this in the open, and I'll run it for EU teams who'd rather ship product than hire a platform engineer. If that's you, follow along via RSS — every build post lands there first — or start with what this project is about.