Answers · Billing

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.

What ships

The portal route is in config/routes.rb and the controller creates a session with Stripe and redirects. The settings page links to it. A customer can update their card, download invoices, change plan and cancel, all on Stripe's pages, and all of it is PCI-handled by them.

The two things that make the redirect work

This is the part people lose an afternoon to, because both failures are silent.

The content security policy. The policy is enforced, and form-action covers the whole navigation a form starts, including the redirect. Stripe's host has to be listed in config/initializers/content_security_policy.rb or the button does nothing: no exception, no flash, nothing in the network tab.

Turbo. A form that redirects off-site needs data-turbo="false" on the form or the submit button. Without it Turbo submits via fetch and tries to follow the cross-origin redirect as an XHR, which connect-src blocks. Same dead-looking button, different layer.

Both are pinned by specs so a regression fails the build rather than the button.

Cancellation is not immediate, and that is correct

Stripe cancels at period end by default. The customer keeps access until the date they already paid for, then customer.subscription.deleted fires and the local Subscription row moves to canceled. Entitlement reads that row, so the gate closes on its own.

If you want immediate cancellation with a prorated refund, that is a portal configuration setting on Stripe's side, not a code change.

Verify the round trip

bin/dev

With no STRIPE_SECRET_KEY set, the local fake runs the same flow in process, so you can click through cancellation without a Stripe account. Then repeat against a test-mode key to exercise the real webhook.

Do not build your own cancel button

It is tempting, because it is three lines to set a status column. The problem is that it lies: the Stripe subscription keeps billing, the customer keeps being charged, and your app says they canceled. Cancel through Stripe and let the webhook tell you it happened.

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 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.

How do I see which accounts are actually paying me?

Query the Subscription table in the Rails console: active and trialing rows are the entitled accounts. One Shot ships no admin UI on purpose. For anything about money rather than access, read Stripe's dashboard instead, because the local row tracks entitlement and Stripe tracks what was actually charged.