You have written this line. So have I.
def can_access? stripe_subscription_id.present? end
It looked fine at the time. Probably week three of the project. Billing was working, Stripe was sending webhooks, and access was gating correctly. Ship it.
That line is not a billing integration. It is a governance failure you have deferred.
Here is what it actually says.
It says your product access decision is: does this user have a Stripe subscription ID stored in the database?
Not: do they have active access?
Not: are they on a trial?
Not: did an admin manually grant them access during a migration?
Not: are they on a partner deal that does not go through Stripe at all?
Not: are they in a payment grace period because their card declined this morning and you do not want to lock them out immediately?
Not: are they on a trial?
Not: did an admin manually grant them access during a migration?
Not: are they on a partner deal that does not go through Stripe at all?
Not: are they in a payment grace period because their card declined this morning and you do not want to lock them out immediately?
Just: does this column have a value.
It works perfectly until it does not.
The first problem arrives the day you want to offer a free trial that is not tied to a payment method. You cannot do it cleanly. The flag for “has a Stripe subscription” does not mean “has access.” You need access without Stripe. So someone adds a trial_ends_at column, and now can_access? becomes:
def can_access? stripe_subscription_id.present? || trial_ends_at&.future? end
Three months later it is:
def can_access? stripe_subscription_id.present? || trial_ends_at&.future? || partner_access? || admin_granted_access? || !payment_failed? && grace_period_ends_at&.future? end
And that is assuming the logic stayed in one place. It usually does not. Someone adds a second check in the controller. A third in a background job. A fourth in a webhook handler. Now the real access policy is a distributed system nobody wrote down and nobody owns.
The problem is not the line. It is what the line assumed.
stripe_subscription_id.present? assumes that payment state and access state are the same thing. That is the assumption that causes the rot.
They are not the same thing. They overlap most of the time in the simple case — and then they diverge in every edge case that matters:
- A card declines. Should access stop immediately? After a grace period? Only after manual review?
- A customer cancels. Should access stop at end of month, immediately, or not at all if they are on an annual plan?
- A partner deal has no Stripe subscription at all.
- An admin manually grants access to a user for investigation purposes.
- You switch from Stripe to a different payment processor.
Your product policy lives in the answer to those questions. Not in whether a foreign key has a value.
This is a governance problem, not just a code smell.
A code smell is something a future refactor can fix. This is different.
When access decisions are inferred from payment provider state, there is no authoritative place to answer the question: who has access, under what conditions, and why?
That matters for engineering — every test that checks access needs to talk to Stripe, or fake the ID, or seed payment records just to test product behaviour. But it also matters for support, operations, and compliance.
A support agent trying to grant temporary access has to create a Stripe record to do it. An audit trying to understand who had access on a specific date has to reconstruct it from payment webhooks. A migration to a new payment provider requires you to decide what happens to every product permission in the system.
None of that is a billing problem. It is a delivery governance problem. The product does not know its own access rules. It borrowed them from a third party without meaning to.
What should have existed from day one.
An entitlement layer. Not a complicated one — it does not need to be a microservice or an IAM system. Just an internal object that answers the access question:
class Entitlement
def active?
# One place. Internal truth. Can reason about trials, grace,
# partner deals, admin grants, and payment state.
end
endThe payment integration creates or updates an entitlement. It does not become the entitlement.
When the payment succeeds: create an active entitlement.
When the payment fails: transition the entitlement to a grace state.
When the trial ends: transition. When an admin grants access: create a different type of entitlement.
When the payment fails: transition the entitlement to a grace state.
When the trial ends: transition. When an admin grants access: create a different type of entitlement.
Stripe still processes the payment. Stripe does not own your access decision.
What to do if you are already here.
You probably are already here. Most systems with more than six months of payment integration are.
You do not need to rewrite it. You need to make the coupling visible and start bounding it.
Three immediate steps:
- Name the debt. Add a comment or an architectural decision record: access currently depends on payment provider state. Known gaps: trials, partner access, grace periods. Repayment trigger: first time we need to add a non-payment access path.
- Introduce a thin internal layer without rewriting the backend. Even if can_access? still reads Stripe state for now, put it behind an Entitlement or AccessPolicy object so there is one place to change it. Stop putting the Stripe ID check directly in controllers and background jobs.
- Write the test you currently cannot write. Write a test that checks access behaviour for a user with a grace period, a trial, or a manual admin grant — without seeding Stripe records. If you cannot write that test, you have found the exact seam that needs an internal policy layer.
You are not fixing the debt in one sprint. You are stopping it from getting worse and creating the boundary where you will eventually extract it cleanly.
The line that looked fine in week three is not the mistake.
The mistake is not naming the access policy as a separate thing from the payment integration — and leaving that assumption unchallenged until the support queue fills up with edge cases that should have been first-class product decisions.
This is part of an ongoing series on hidden critical domain boundaries — the domains that look like plumbing but are actually product-critical: payments, permissions, identity, AI governance, evidence capture, and external providers.
The domain separation thinking in this series is drawn from the patterns taught by thoughtbot— in particular this talk, which is worth an hour of your time if any of this resonated.