Answers · Billing

How do I test a checkout flow without a Stripe account?

Do nothing. With STRIPE_SECRET_KEY unset, Billing.for(account) returns a local fake that runs checkout, entitlement and cancellation in process. You click through the real flow, the subscription row is written, and gated features unlock. Set the key when you want real Stripe, and the same code paths switch over.

Try it now

bin/dev

Sign in, open the pricing page, and buy. There is no card form, because there is no Stripe: the fake completes the session immediately and redirects back. The account now has an active subscription and anything behind include RequireEntitlement opens.

How the switch works

app/adapters/billing.rb is the seam. It returns a Stripe client when the credential is present and a deterministic local implementation when it is not:

Billing.for(account)

Nothing else in the app knows which one it got. Controllers, models and specs all call the same methods, so the code that runs against the fake is the code that runs against Stripe. The two implementations sit side by side in app/adapters/billing, which is worth reading once: the fake is short, and it tells you exactly what the real one is expected to do.

This is the pattern every external service in the kit uses, including bot checking and error reporting, which is why a fresh clone boots with zero external accounts.

What the fake does not simulate

Be clear about the boundary, because it matters before launch:

  • Card failures. The fake always succeeds. Declines, 3D Secure and expired cards need Stripe test mode with the published test card numbers.
  • Webhook timing. Real Stripe delivers checkout.session.completed asynchronously, sometimes after the user is already back on your site. One Shot handles that with an idempotent upsert that both the webhook and the checkout return call, so whichever arrives first wins, but the fake never exercises the race.
  • Proration and plan changes. Stripe's billing engine does that arithmetic and the fake does not.

Moving to real Stripe

Set three variables and nothing in your code changes:

STRIPE_SECRET_KEY=sk_test_... bin/dev

Use a test-mode key first. The flow is identical, and now declines and webhooks are real. See Stripe Setup for the webhook secret and which events to enable.

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.