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

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