Answers · Billing

What happens when a paying customer's card expires?

Stripe retries the charge on a schedule and emails the customer. Your app receives invoice.payment_failed, which moves the subscription to past_due rather than canceling it. Access continues, because past_due is a recoverable state and most of these recover. Only customer.subscription.deleted ends entitlement.

The sequence

  1. The renewal charge fails. Stripe fires invoice.payment_failed.
  2. Stripe retries over the following days and emails the customer each time. That schedule is configurable in the dashboard and is called dunning.
  3. Either a retry succeeds, and invoice.payment_succeeded arrives, or Stripe gives up and cancels, which fires customer.subscription.deleted.

The status enum in app/models/subscription.rb has past_due for exactly the middle of that, and entitled? does not include it.

Decide what past due means for you

This is a product decision, not a technical one, and the code makes it a one-line change:

def entitled? = active? || trialing?

As written, a past due account loses access immediately. That is the strict reading, and it is defensible for a high-cost service. For most products it is too harsh: the common cause is a card that expired, the customer intends to pay, and locking them out while Stripe is still retrying turns a billing hiccup into a cancellation.

Adding past_due? to that method gives them the retry window. Whichever you choose, choose it on purpose.

Do not build your own dunning emails

Stripe already sends them, with the card update link, on a schedule you can configure. A second set of emails from your app arrives alongside Stripe's and says the same thing less well. Enable Stripe's and spend the effort on the billing portal link instead, which is where they fix it.

Check who is currently in that state

bin/kamal app exec --interactive --reuse 'bin/rails console'
Subscription.where(status: "past_due").count

A number that only grows means customer.subscription.deleted is not enabled on your webhook endpoint, so nothing ever resolves the state in either direction. The handler that would move it is in app/controllers/webhooks/stripe_controller.rb.

A customer who updates their card in the billing portal produces customer.subscription.updated. If that event is not enabled, the payment succeeds and your app still shows them as past due.

Related questions

Can I charge a one-time price instead of a subscription?

Yes. Create a one-time price in Stripe, use payment mode rather than subscription mode in the checkout session, and change what Entitlement.for returns so a completed purchase entitles the account permanently. The gate on each controller does not change, because RequireEntitlement never knew what it was checking.

How do I let customers cancel their own subscription?

Send them to the Stripe billing portal rather than building a cancellation UI. One Shot ships the route already: a form that posts to the billing portal controller and redirects to Stripe. When they cancel there, customer.subscription.deleted arrives and the local row is updated, so access ends without you writing any of it.

How do I offer a free trial without taking a card upfront?

Set a trial period on the Stripe price or the checkout session and allow it without a payment method. The app needs no change: Subscription#entitled? returns true for both active and trialing, so a trialing account has full access, and customer.subscription.updated moves it when the trial ends.