read.markets/app/templates
Giorgio Gilestro 410afe0078 stripe: wire checkout, customer portal, and webhook for read.markets
Stripe is the merchant-on-record for read.markets after Polar/Paddle
both declined the financial-media category. This commit lands the
full subscription flow: an "Upgrade" button on /pricing now opens a
real Stripe-hosted Checkout, completes the subscription, and the
webhook flips user.tier to "paid" idempotently.

Endpoints
- POST /api/stripe/checkout (require_auth) — creates a hosted
  Checkout Session in subscription mode, passes user.id as
  client_reference_id + email as customer_email, returns the URL
  for the page-side JS to redirect to. Reuses an existing
  stripe_customer_id to avoid duplicate Stripe customers on repeat
  checkouts. allow_promotion_codes=True so the referral-credit
  redemption can attach a coupon at checkout once that flow ships.
- POST /api/stripe/portal (require_auth) — mints a Stripe Customer
  Portal session. Used by /settings; returns 404 until the user has
  a stripe_customer_id (i.e. completed at least one checkout).
- POST /api/stripe/webhook — signature-verified via
  stripe.Webhook.construct_event. Idempotent via UNIQUE on
  stripe_events.event_id. Event dispatch:
    checkout.session.completed       → grant paid, store IDs
    customer.subscription.created    → grant paid (active/trialing)
    customer.subscription.updated    → grant paid (active/trialing)
    customer.subscription.deleted    → drop to free, clear sub id
    invoice.paid / failed            → audit only
    charge.refunded                  → audit only
  Stripe-SDK objects don't expose dict.get(); we use the SDK for
  signature verification then re-parse the JSON body for handler
  dispatch — cleaner than reaching into StripeObject internals.

Schema (migration 0019)
- users.stripe_customer_id, users.stripe_subscription_id (nullable
  String(64), UNIQUE on customer_id).
- stripe_events table mirroring polar_events: event_id (unique),
  event_type, received_at, processed_at, error, raw payload
  (truncated to 16 KiB).

Settings (.env)
- STRIPE_API_KEY            (rk_test_… for dev, rk_live_… for GA)
- STRIPE_WEBHOOK_SECRET     (whsec_… from the dashboard endpoint)
- STRIPE_PRICE_MONTHLY      (price_xxx for £7/month)
- STRIPE_PRICE_ANNUAL       (price_xxx for £70/year)

Pricing page
- Free tier CTA unchanged.
- Paid CTA branches three ways: paid → "Manage subscription" to
  /settings; logged-in free → two buttons (£7/mo, £70/yr) that POST
  to /api/stripe/checkout and redirect; anonymous → /login?next=/pricing.
- Inline JS intercepts the button click, calls the checkout
  endpoint, redirects on success, surfaces errors via alert(). No
  Stripe.js dep — we use the hosted-checkout URL directly.

Polar handler stays in place for berengar.io / flyroom.net which
still ship through Polar. polar_* and stripe_* columns coexist
independently on the User row.

Tests
- 9 in tests/test_stripe_billing.py covering: bad signature → 401,
  missing signature → 400, checkout.session.completed flips tier +
  stores IDs, subscription.updated active grants paid,
  subscription.deleted drops to free with customer id preserved,
  replayed event id is no-op (one row in stripe_events),
  unknown event acked 200, checkout endpoint mocks the SDK and
  returns the hosted URL, checkout requires login.
- Full suite: 221 passed, 5 skipped.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 18:45:13 +02:00
..
partials news: clamp free + anonymous to last 6h; paid keeps 24h 2026-05-25 22:49:21 +02:00
about.html public: landing + pricing + legal pages, apex-ready, lawyer-reviewed 2026-05-24 00:08:02 +02:00
base.html beta: header chip flagged by BETA_MODE config (default on) 2026-05-25 22:42:19 +02:00
dashboard.html pricing: land £7/£70 paid tier and make behaviour match 2026-05-26 11:34:37 +02:00
disclaimer.html pricing: land £7/£70 paid tier and make behaviour match 2026-05-26 11:34:37 +02:00
landing.html pricing: land £7/£70 paid tier and make behaviour match 2026-05-26 11:34:37 +02:00
log.html pricing: land £7/£70 paid tier and make behaviour match 2026-05-26 11:34:37 +02:00
login.html public: landing + pricing + legal pages, apex-ready, lawyer-reviewed 2026-05-24 00:08:02 +02:00
news.html brand: rename product to "Read the Markets" (read.markets) 2026-05-22 19:39:38 +01:00
pricing.html stripe: wire checkout, customer portal, and webhook for read.markets 2026-05-26 18:45:13 +02:00
privacy.html public: landing + pricing + legal pages, apex-ready, lawyer-reviewed 2026-05-24 00:08:02 +02:00
public_base.html public: landing + pricing + legal pages, apex-ready, lawyer-reviewed 2026-05-24 00:08:02 +02:00
settings.html settings: digest opt-in + tone (PATCH /api/settings/digest + UI) 2026-05-25 23:23:03 +02:00
terms.html pricing: land £7/£70 paid tier and make behaviour match 2026-05-26 11:34:37 +02:00
upload.html sync: encrypted cloud backup for portfolios + settings UX rework 2026-05-23 16:15:54 +02:00
verify.html auth: subscribe-to-digests checkbox on verify (default on) 2026-05-25 23:27:33 +02:00