Free sample · Chapter 1 + Chapter 9 excerpt

See the architecture, then see it handle a real billing edge.

Start with the complete first chapter, then jump ahead to a section from the plan-change chapter on trial behavior and proration.

Chapter 1 · Complete

A subscription starts with money, but the rest of your application shouldn't reason about money every time it decides whether an account may invite another member. That decision needs several kinds of state, and they change for different reasons.

Stripe may say a subscription is active. The application still needs to know which product plan that subscription represents, which features the plan includes, how many members it allows, whether this particular user may invite people, and what to do if the Stripe Price isn't recognized. Putting all of that behind one account.subscription_status == "active" check is how a small integration turns into billing code scattered through every controller and job.

This guide builds the common account-level subscription case with three application plans:

:free
:pro
:business

Pro and Business have monthly and yearly Stripe Prices. Stripe Checkout collects the payment method, Pay synchronizes provider records into Rails, and the application turns that local billing state into product behavior.

What this guide targets

The reference implementation is built against Rails 8.1 (Migration[8.1]), Ruby 3.2 or newer for Data.define (verified on Ruby 4.0.6), Pay ~> 12.1.0 (the 12.1.x line is supported; 12.1.0 is the version actually verified, and a new Pay minor such as 12.2 needs re-verification before this recommendation expands; Pay 11.x is no longer supported, since 11.7.1's resume wrote status active regardless of what Stripe actually returned, a bug fixed in 11.8/12), stripe-ruby ~> 19.6.2 against Stripe API version 2026-08-26.dahlia, and PostgreSQL as the verified database. The partial unique index behind Checkout's single-flight guarantee needs a database with partial-index support (PostgreSQL, SQLite); the guarantee itself is one in-flight local operation, not one Stripe Session. Adjust versions if the host application differs; agent/PROVIDER-VERIFICATION.md, included with this book, records the exact verified combination.

Six separate questions

Billing discussions often use “plan,” “subscription,” and “access” as if they were interchangeable. They aren't.

Did money change hands?

A payment is a financial event. Stripe represents the attempt with objects such as an Invoice and PaymentIntent. Payments can succeed, fail, be refunded, or require authentication. A subscription can remain in place while one invoice needs attention, so a successful payment is not a permanent access flag.

What recurring relationship does the provider have?

Stripe owns the provider subscription lifecycle. It knows billing periods, upcoming invoices, trial dates, cancellation scheduling, and retry state. A Stripe subscription can be trialing, active, past_due, unpaid, incomplete, incomplete_expired, canceled, or paused.

Those states are inputs to your application policy, not the whole policy.

Which application plan applies?

The application plan is a stable product identity such as :pro. It should survive a Stripe Price replacement, a move from monthly to yearly billing, and ordinary wording changes on the pricing page.

Stripe Price IDs are deployment configuration:

price_1Qw...

They're useful for talking to Stripe, but terrible domain language. If a controller asks whether price_1Qw... may add a member, Stripe has become the product model.

Was this capability purchased?

An entitlement answers a Boolean product question:

Entitlements.enabled?(account: account, key: :team_members)

It doesn't answer how many members are allowed, and it doesn't decide whether the signed-in user has permission to invite them.

How much may the account use?

A limit answers a quantitative question:

Limits.value(account: account, key: :members)
# => 5

Keeping limits separate matters as soon as one plan enables team membership with five seats and another enables the same capability without a fixed ceiling.

May this user perform the action?

Authorization answers a user-permission question. An owner might be allowed to manage billing while an ordinary member isn't. Both users belong to the same paid account and therefore see the same purchased product capability.

A mutation usually needs all three product checks in an intentional order:

authorize account, :invite_member?

unless Entitlements.enabled?(account: account, key: :team_members)
  return redirect_to billing_settings_path, alert: "Upgrade to invite members."
end

limit = Limits.value(account: account, key: :members)
usage = account.memberships.count
if !limit.infinite? && usage >= limit
  return redirect_to account_members_path, alert: "Remove a member or change plans."
end

account.account_invitations.create!(invitation_params)

Hiding the invitation form makes the interface nicer, but this server-side check is what enforces the rule.

The ownership chain

The architecture we will build is layered:

Stripe
   ↓
Pay
   ↓
application-owned Billing boundary
   ↓
stable application plan
   ↓
PlanCatalog
   ↓
Entitlements / Limits
   ↓
product behavior

Stripe owns money and provider-side billing machinery. Pay owns repetitive Rails integration work, including provider customers, subscriptions, charges, webhook receipt, and synchronization. The application owns the meaning of Pro, the decision that a failed renewal suspends a feature, the number of members allowed, and the billing page a customer uses.

That gives ordinary product code a small vocabulary:

Billing.subscription(account: account)
Billing.plan_key(account: account)
Entitlements.enabled?(account: account, key: :team_members)
Limits.value(account: account, key: :members)

Provider mutations also go through Billing:

Billing.checkout_url(...)
Billing.plan_change_url(...)
Billing.payment_method_url(...)
Billing.reconcile(account: account)

Billing owns mapping, failure policy, local selection, and safe provider transitions. If the wrapper only renamed Stripe::Customer.create, it wouldn't be earning its place.

Account is the billing subject

In a multi-tenant SaaS, the account or workspace receives the product value. Members come and go, owners transfer responsibility, and several users share the same limits. The Stripe Customer and Pay customer therefore belong to Account.

User --< Membership >-- Account --< Product records
                              |
                              +-- Pay customers
                              +-- Billing checkouts

The account has a billing email that can change independently of one user's login address. Policies decide which memberships may manage billing. A request-supplied account ID is never proof that the user belongs to the account.

Local reads, deliberate remote work

Stripe is a remote system. A call can be slow, rate-limited, unavailable, or return after a request has already timed out. An ordinary page render shouldn't depend on any of those conditions.

The application reads local Pay rows to determine the current subscription and plan. Webhooks update those rows. A verified Checkout return may synchronize to close a race, and a reconciliation operation may query Stripe when an operator or customer asks for repair.

ordinary render:   local database only
webhook handling:  provider event → local Pay records
checkout return:   verified provider read → Pay sync
reconciliation:    explicit provider read → Pay sync

This does mean local state can briefly lag Stripe. That is a normal distributed-system condition, and we will give it an observable repair path instead of hiding a network call inside active?.

Billing relationship is not paid access

Suppose a renewal fails. The account still has a subscription at Stripe, an invoice to pay, and a Customer Portal it needs to reach. Your product may decide that past_due doesn't grant paid capabilities. Both statements can be true:

subscription.billing_relationship? # => true
subscription.paid_access?           # => false
subscription.portal_available?      # => true

The distinction prevents two damaging shortcuts. First, the app doesn't keep granting paid behavior just because a provider record exists. Second, it doesn't hide the recovery path because access was suspended.

Free needs the same care. Free is the application's plan when no paid subscription grants access; Stripe has no such status. If Stripe has an active subscription whose Price can't be mapped, that's a configuration failure with provider context, and treating it as an ordinary Free account would hide the bug.

What we are building

We will first learn enough direct Stripe vocabulary to understand the operations Pay performs. Then we will create the plan catalog and the Billing module, build a simple hosted Checkout, and replace its fragile request-only identity with a durable local operation.

The second half turns synchronized subscription state into access, a useful Rails billing page, upgrades, downgrades, interval changes, trials, cancellation, payment recovery, reconciliation, and tests. The implementation stays to one base subscription item with base-plan entitlements and limits, which keeps the guide useful without pretending every billing system is simple forever.

Follow one decision through the layers

It helps to trace a single request before there is much code. An account owner submits an invitation for a sixth member while the account is on Pro.

The controller first loads the Account through the signed-in membership and asks the policy whether this user may invite. That prevents a user from operating on another tenant and prevents an ordinary member from performing an owner or administrator action.

Entitlements then confirms that Pro includes team members. Limits resolves the current effective plan through Billing and returns five. The mutation counts the product's definition of used member capacity and rejects the sixth invitation.

No layer needs a provider object:

request account context
   ↓
authorization: may this user invite?
   ↓
entitlement: does this account have team members?
   ↓
limit: how many members may this account have?
   ↓
usage: how many slots does this product consider used?
   ↓
mutation or actionable rejection

Now suppose the account is past_due. Billing resolves effective paid access to Free while retaining the configured Pro mapping and a billing relationship. The same entitlement lookup returns false through the effective plan. The owner can still enter billing settings because that route is authorized separately and deliberately excluded from paid-feature enforcement.

Global “account active” filters are usually too blunt. They tend to block billing recovery, background cleanup, data export, or other behavior that should survive payment failure. Enforce the paid capability at the specific check that needs it, and define any whole-account suspension policy separately.

Write the invariants down

Billing integrations accumulate shortcuts when their rules exist only in one developer's head. Keep a short architecture contract near the code. At minimum it should say:

  • which model is billed
  • which application plan keys are stable
  • where Price mappings live
  • which local records ordinary reads use
  • which states grant paid access
  • which states retain a recoverable billing relationship
  • how Checkout concurrency and returns are verified
  • who may manage billing
  • how hard-limit downgrades are rejected
  • how reconciliation is invoked and observed

These statements are more useful than a diagram by itself because they give code review something concrete to reject. A new view that calls account.payment_processor.subscription violates the local Billing API rule even if it appears to work. A new plan comparison that posts price_id violates stable application identity even if strong parameters permit only a string.

The Agent Companion included with this book turns these invariants into contracts and a review checklist. The reference implementation demonstrates them, but the contract matters when a host application uses different controller names, membership roles, or presentation code.

Decide failure posture before failure arrives

The provider will eventually return a state you didn't see in the first happy-path test. Decide the safe posture now.

For this implementation:

  • missing or unknown configuration raises
  • a paid Price mapping error suspends paid capabilities and alerts operations
  • a provider outage does not break ordinary local reads
  • a remote action the customer triggers returns stable feedback and reports the underlying error
  • a recoverable subscription blocks replacement Checkout
  • billing recovery routes remain reachable without paid access
  • product data is preserved when capability is lost
  • a downgrade that violates a hard limit is rejected before provider work

Failing closed doesn't mean pretending nothing exists. It means denying the capability whose purchase cannot be established while preserving enough truthful state to diagnose and repair billing.

Chapter 9 · Excerpt

Upgrade, Downgrade, and Change Billing Cycles

The first chapter establishes the boundaries. This excerpt jumps ahead to a place those boundaries matter: changing a subscription without accidentally turning a product decision into a surprise charge.

Why in-trial plan changes are blocked

plan_change_url raises Billing::TrialPlanChangeBlocked for a trialing subscription whose trial hasn't ended, or has no recorded end date at all. A trial end date in the past doesn't trigger the block; that row is stale and the ordinary paid-access path handles it.

The reference hasn't verified how Stripe's subscription_update_confirm flow treats a trial, so this is a verification gap it doesn't paper over. Stripe's Customer Portal has a trial_update_behavior setting on the subscription-update configuration. Its default, end_trial, ends the trial and invoices immediately the moment the customer confirms a plan change, which is a real financial event a customer clicking “Review plan” during a free trial does not expect.

Stripe also offers continue_trial, which is supposed to keep the trial running through the change, but two things aren't verified against the installed Stripe API version: whether the hosted subscription_update_confirm flow actually honors continue_trial the same way the general subscription_update flow does, and how an interval change (monthly to yearly, say) moves the trial's billing anchor under that setting. Shipping a guess about either one risks silently invoicing a trialing customer.

Verify the financial behavior, not just the API call.

If your product wants self-service plan changes during trial, set trial_update_behavior: continue_trial, remove the trial guard, and verify the result against a Stripe test clock before shipping it. Advance the clock past the original trial boundary and confirm the trial length, billing anchor, and first invoice date all match what you told the customer.

Understand proration without rebuilding it

Changing a Price in the middle of a period can create a credit for unused time and a charge for the new plan. Stripe Portal configuration determines how the hosted update handles proration.

The Rails page should not promise an exact charge using its own date arithmetic. It can say that Stripe will show the amount before confirmation. The hosted page has the current invoice, discounts, credits, billing anchor, and provider settings needed for the calculation.

Choose a policy and test it in sandbox:

  • upgrades commonly apply immediately with proration
  • downgrades may apply immediately with credit or follow a configured schedule
  • interval changes may shift the billing anchor and charge timing

The reference allows current Portal behavior to confirm the supported change. If your product requires all downgrades at period end, introduce an application plan-change operation with a scheduled effective date and test it directly. Don't imply that a Portal default guarantees your business policy.

Why not call Pay swap directly?

Pay exposes pay_subscription.swap(price_id, ...). Its Stripe adapter updates the subscription item, applies proration options, validates a resulting PaymentIntent when present, and synchronizes the local row. That can be the right choice when you need a server-controlled immediate change.

The reference prefers hosted subscription_update_confirm because the customer sees Stripe's financial preview before Rails authorizes the mutation. Stripe presents prorations and handles 3D Secure or payment failure in the same flow.

If you choose swap, keep it inside Billing and make these decisions explicit:

  • proration_behavior
  • immediate versus period-boundary effect
  • payment that requires customer action
  • idempotency and repeated submissions
  • local durable operation state
  • customer confirmation of a charge or credit
  • synchronization after provider success and local failure

Do not replace the targeted flow with one unreviewed subscription.swap(target_price) call in a controller. Pay removes provider boilerplate; it does not choose product or financial policy.

That’s the complete first chapter and an excerpt from Chapter 9.
The full guide continues through Checkout, lifecycle state, entitlements, recovery, reconciliation, and testing.
Get Billing in Rails 8