read.markets/app/routers/stripe_billing.py
Giorgio Gilestro ce36ce36fd referrals: close D.3 — both parties get 45 days credit on conversion
The referral feature was half-built: codes captured, banner shown,
counts displayed — but no money flowed when a referred user paid.
The Settings page hard-coded "— (D.3)" for Active credits and the
marketing copy promised "50% off for 3 months" with nothing behind it.

Closing the loop:

- New `convert_referral(session, user)` in referral_service.py looks
  up the user's Referral row, stamps `converted_at` + `credited_at`,
  and extends `credit_until` by 45 days on BOTH the buyer and the
  referrer. Idempotent — replayed webhooks and renewals are no-ops.
  Stacks correctly when the user already has a credit window running
  (anchors at max(now, current_credit_until) like cli.grant_credit).

- Stripe webhook wires this into `_grant_paid`. A captured
  `first_paid_transition = user.tier != "paid"` gate avoids the DB
  lookup on every renewal event; convert_referral's own idempotency
  is the second line of defence.

- `_grant_paid` now takes `session` as its first positional arg so
  the conversion runs inside the same transaction as the tier flip
  and audit-row write. A mid-flight failure rolls everything back
  together — no partial state.

- Settings page replaces the "— (D.3)" placeholder with the live
  count of conversions still inside their 45-day credit window, plus
  a "+N days on your account" hint when the user has any credit of
  their own (referrer bonus, admin grant, or future refund-as-credit).

- Marketing copy on pricing.html + settings.html switches from "50%
  off for 3 months" to "45 days of paid access" — same economic value,
  honest about the actual mechanism (full free access rather than
  discounted billing).

Credit-amount rationale: 50% × 3 months ≈ 1.5 months of free
service ≈ 45 days. Pure-credit delivery is processor-agnostic, needs
no Stripe coupon plumbing, and stacks cleanly across referrals.

7 new tests in test_referral_conversion.py cover the happy path,
idempotency, no-referral no-op, credit stacking, deleted-referrer
survival, end-to-end webhook → credit landing, and the renewal-event
no-double-credit guarantee.

Also bundled: the Restore-button class fix from earlier
(portfolio.js — the cloud-restore "Restore" submit was unstyled and
picked up browser defaults; now uses .settings-btn like the rest of
the action-button family).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 23:05:29 +02:00

431 lines
16 KiB
Python

"""Stripe billing endpoints — checkout, webhook, customer portal.
Stripe is the merchant-on-record for read.markets (after Polar/Paddle
both declined the financial-media category). We delegate payment UI to
Stripe-hosted Checkout and Customer Portal; the only state we keep on
our side is `users.stripe_customer_id` / `users.stripe_subscription_id`
so we can match incoming webhooks back to the right user.
The Stripe SDK is sync; we wrap calls in `asyncio.to_thread` so the
event loop doesn't block while Stripe answers. For our request volume
this is more reliable than the SDK's nascent async surface.
Routes
- POST /api/stripe/checkout — logged-in user upgrades. Body: {cadence}.
- POST /api/stripe/webhook — Stripe → us, signature-verified.
- POST /api/stripe/portal — logged-in user opens the customer portal.
"""
from __future__ import annotations
import asyncio
import json
from typing import Any, Literal
import stripe
from fastapi import APIRouter, Body, Depends, HTTPException, Request
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app import branding
from app.auth import CurrentUser, require_auth
from app.config import get_settings
from app.db import get_session, utcnow
from app.logging import get_logger
from app.models import StripeEvent, User
log = get_logger("stripe_billing")
router = APIRouter()
# Cap stored payload at 16 KiB so a hostile (or buggy) sender can't
# blow up a single row. Same pattern as polar_webhook.
_PAYLOAD_STORE_MAX = 16 * 1024
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _require_configured() -> None:
s = get_settings()
if not s.STRIPE_API_KEY:
raise HTTPException(status_code=503, detail="stripe not configured")
def _price_for(cadence: str) -> str:
s = get_settings()
if cadence == "monthly":
if not s.STRIPE_PRICE_MONTHLY:
raise HTTPException(status_code=503, detail="STRIPE_PRICE_MONTHLY not set")
return s.STRIPE_PRICE_MONTHLY
if cadence == "annual":
if not s.STRIPE_PRICE_ANNUAL:
raise HTTPException(status_code=503, detail="STRIPE_PRICE_ANNUAL not set")
return s.STRIPE_PRICE_ANNUAL
raise HTTPException(status_code=400, detail="cadence must be 'monthly' or 'annual'")
def _stripe_client() -> stripe.StripeClient:
"""Per-call client so we read the secret at request time (lets us
rotate the key by editing .env + reloading without rebuilding any
cached client)."""
return stripe.StripeClient(get_settings().STRIPE_API_KEY)
# ---------------------------------------------------------------------------
# POST /api/stripe/checkout
# ---------------------------------------------------------------------------
class CheckoutRequest(BaseModel):
cadence: Literal["monthly", "annual"]
class CheckoutResponse(BaseModel):
url: str
@router.post("/api/stripe/checkout", response_model=CheckoutResponse)
async def create_checkout(
body: CheckoutRequest,
session: AsyncSession = Depends(get_session),
cu: CurrentUser = Depends(require_auth),
) -> CheckoutResponse:
_require_configured()
if cu.user is None:
# Admin bearer token has no User row — they shouldn't be buying.
raise HTTPException(status_code=400, detail="admin token cannot purchase")
user = await session.get(User, cu.user.id)
if user is None:
raise HTTPException(status_code=404, detail="user_not_found")
price_id = _price_for(body.cadence)
client = _stripe_client()
# Pass `customer` if we already minted one for this user (avoids
# creating duplicate Stripe customers on repeat checkouts);
# otherwise let Stripe create it via `customer_email`.
create_kwargs: dict[str, Any] = {
"mode": "subscription",
"line_items": [{"price": price_id, "quantity": 1}],
"client_reference_id": str(user.id),
"success_url": f"{branding.SITE_URL}/settings?upgraded=1",
"cancel_url": f"{branding.SITE_URL}/pricing",
# Lets us paste in a referral coupon at checkout once the
# referral redemption flow ships.
"allow_promotion_codes": True,
}
# Per-cadence cooling-off treatment:
#
# - Annual gets a 14-day free trial. No money moves during the
# trial, so the Consumer Contracts Regulations 14-day refund
# question is moot (nothing paid = nothing to refund). Card is
# still required at checkout so Stripe can charge on day 15.
#
# - Monthly bills immediately (a 14-day trial on a £7/month plan
# would give away ~50% of cycle one). The Reg-36 waiver lives
# on our own /pricing page as a required tick-box (see
# pricing.html); we deliberately do NOT use Stripe's
# consent_collection.terms_of_service here because that's an
# account-wide setting and we want per-product control (and
# per-product Terms URLs) as we grow.
if body.cadence == "annual":
create_kwargs["subscription_data"] = {"trial_period_days": 14}
if user.stripe_customer_id:
create_kwargs["customer"] = user.stripe_customer_id
else:
create_kwargs["customer_email"] = user.email
try:
sess = await asyncio.to_thread(
client.checkout.sessions.create, params=create_kwargs,
)
except stripe.StripeError as e:
log.error("stripe.checkout.create_failed", user_id=user.id, error=str(e))
raise HTTPException(status_code=502, detail=f"stripe error: {e.user_message or str(e)}")
if not sess.url:
raise HTTPException(status_code=502, detail="stripe returned no checkout URL")
log.info("stripe.checkout.created", user_id=user.id, session_id=sess.id,
cadence=body.cadence)
return CheckoutResponse(url=sess.url)
# ---------------------------------------------------------------------------
# POST /api/stripe/portal
# ---------------------------------------------------------------------------
class PortalResponse(BaseModel):
url: str
@router.post("/api/stripe/portal", response_model=PortalResponse)
async def create_portal_session(
session: AsyncSession = Depends(get_session),
cu: CurrentUser = Depends(require_auth),
) -> PortalResponse:
_require_configured()
if cu.user is None:
raise HTTPException(status_code=400, detail="admin token has no portal")
user = await session.get(User, cu.user.id)
if user is None or not user.stripe_customer_id:
raise HTTPException(
status_code=404,
detail="no_stripe_customer — start a subscription first",
)
client = _stripe_client()
try:
portal = await asyncio.to_thread(
client.billing_portal.sessions.create,
params={
"customer": user.stripe_customer_id,
"return_url": f"{branding.SITE_URL}/settings",
},
)
except stripe.StripeError as e:
log.error("stripe.portal.create_failed", user_id=user.id, error=str(e))
raise HTTPException(status_code=502, detail=f"stripe error: {e.user_message or str(e)}")
return PortalResponse(url=portal.url)
# ---------------------------------------------------------------------------
# POST /api/stripe/webhook
# ---------------------------------------------------------------------------
async def _find_user(
session: AsyncSession,
*,
client_ref: str | None = None,
customer_id: str | None = None,
) -> User | None:
"""Find the User row this event belongs to.
`client_reference_id` is the most reliable join key — we set it
to `str(user.id)` at checkout creation. After the first event we
also know `stripe_customer_id`, which subsequent subscription /
invoice events arrive carrying."""
if client_ref:
try:
uid = int(client_ref)
except ValueError:
uid = None
if uid is not None:
u = await session.get(User, uid)
if u is not None:
return u
if customer_id:
row = (await session.execute(
select(User).where(User.stripe_customer_id == customer_id)
)).scalar_one_or_none()
return row
return None
async def _grant_paid(
session: AsyncSession,
user: User,
*,
customer_id: str | None,
subscription_id: str | None,
trial_end: int | None = None,
status: str | None = None,
) -> None:
# Capture "first paid transition" before mutating — drives the
# referral-conversion call below. Skipping the convert lookup on
# every renewal event saves a DB roundtrip per webhook.
first_paid_transition = user.tier != "paid"
user.tier = "paid"
if customer_id and user.stripe_customer_id != customer_id:
user.stripe_customer_id = customer_id
if subscription_id and user.stripe_subscription_id != subscription_id:
user.stripe_subscription_id = subscription_id
# Track trial_end so the settings page can show "N days remaining".
# Only populated when Stripe reports the sub as trialing — once the
# status flips to active (paid for real), we clear the trial marker.
if status == "trialing" and trial_end:
from datetime import datetime, timezone
user.stripe_trial_end_at = datetime.fromtimestamp(trial_end, tz=timezone.utc)
elif status == "active":
user.stripe_trial_end_at = None
# Apply referral credit on the FIRST paid transition only.
# convert_referral is itself idempotent (no-op on missing or
# already-converted rows), so this guard is purely a perf hint.
if first_paid_transition:
from app.services.referral_service import convert_referral
await convert_referral(session, user)
async def _revoke_paid(user: User) -> None:
user.tier = "free"
user.stripe_subscription_id = None
user.stripe_trial_end_at = None
# Keep stripe_customer_id so a re-subscription matches this row.
async def _handle_checkout_completed(
session: AsyncSession, event_type: str, obj: dict[str, Any],
) -> None:
user = await _find_user(
session,
client_ref=obj.get("client_reference_id"),
customer_id=obj.get("customer"),
)
if user is None:
log.warning("stripe.user_not_found", event_type=event_type)
return
# checkout.session.completed doesn't carry trial_end on the session
# object itself — the subscription.created event that fires right
# after will carry it. We grant paid here without trial info and
# let the subscription event fill in trial_end_at moments later.
await _grant_paid(
session,
user,
customer_id=obj.get("customer"),
subscription_id=obj.get("subscription"),
)
async def _handle_subscription_event(
session: AsyncSession, event_type: str, obj: dict[str, Any],
) -> None:
"""customer.subscription.created / .updated — flip to paid if the
Stripe-side status says the subscription is active/trialing; drop
to free if it's an end-state."""
user = await _find_user(session, customer_id=obj.get("customer"))
if user is None:
log.warning("stripe.user_not_found", event_type=event_type,
customer_id=obj.get("customer"))
return
status = obj.get("status")
# Stripe statuses: trialing, active, past_due, canceled, unpaid,
# incomplete, incomplete_expired, paused. Treat trialing/active as
# paid; everything else holds tier the same until we get an explicit
# subscription.deleted (which fires after the final state lands).
if status in ("trialing", "active"):
await _grant_paid(
session,
user,
customer_id=obj.get("customer"),
subscription_id=obj.get("id"),
trial_end=obj.get("trial_end"),
status=status,
)
async def _handle_subscription_deleted(
session: AsyncSession, event_type: str, obj: dict[str, Any],
) -> None:
user = await _find_user(session, customer_id=obj.get("customer"))
if user is None:
log.warning("stripe.user_not_found", event_type=event_type,
customer_id=obj.get("customer"))
return
await _revoke_paid(user)
async def _handle_audit_only(
session: AsyncSession, event_type: str, obj: dict[str, Any],
) -> None:
"""invoice.paid / invoice.payment_failed / charge.refunded — we
record these in stripe_events for the audit log but the tier doesn't
move until subscription.deleted fires."""
return None
_HANDLERS = {
"checkout.session.completed": _handle_checkout_completed,
"customer.subscription.created": _handle_subscription_event,
"customer.subscription.updated": _handle_subscription_event,
"customer.subscription.deleted": _handle_subscription_deleted,
"invoice.paid": _handle_audit_only,
"invoice.payment_failed": _handle_audit_only,
"charge.refunded": _handle_audit_only,
}
@router.post("/api/stripe/webhook")
async def stripe_webhook(
request: Request,
session: AsyncSession = Depends(get_session),
) -> dict[str, str]:
s = get_settings()
if not s.STRIPE_WEBHOOK_SECRET:
raise HTTPException(status_code=503, detail="stripe webhook not configured")
sig = request.headers.get("stripe-signature", "")
if not sig:
raise HTTPException(status_code=400, detail="missing stripe-signature header")
body = await request.body()
# construct_event handles HMAC verification + timestamp tolerance.
# We then re-parse the body as plain JSON for handler dispatch —
# the Stripe SDK's StripeObject doesn't expose dict.get(), and
# round-tripping through json gives us simple, typed-dict access.
try:
stripe.Webhook.construct_event(
payload=body, sig_header=sig, secret=s.STRIPE_WEBHOOK_SECRET,
)
except stripe.SignatureVerificationError:
raise HTTPException(status_code=401, detail="bad signature")
except ValueError:
raise HTTPException(status_code=400, detail="invalid payload")
envelope = json.loads(body)
event_id = envelope.get("id") or ""
event_type = envelope.get("type") or "unknown"
obj = (envelope.get("data") or {}).get("object") or {}
if not event_id:
raise HTTPException(status_code=400, detail="event missing id")
# Idempotency: insert audit row first. UNIQUE on event_id makes a
# replay of the same Stripe event id a no-op (Stripe retries on
# non-2xx, so always 2xx after first successful processing).
audit = StripeEvent(
event_id=event_id,
event_type=event_type,
received_at=utcnow(),
payload=body.decode("utf-8", errors="replace")[:_PAYLOAD_STORE_MAX],
)
session.add(audit)
try:
await session.flush()
except IntegrityError:
await session.rollback()
log.info("stripe.duplicate_delivery", event_id=event_id, type=event_type)
return {"status": "duplicate"}
handler = _HANDLERS.get(event_type)
if handler is None:
audit.processed_at = utcnow()
await session.commit()
log.info("stripe.event_unhandled", type=event_type, id=event_id)
return {"status": "ignored"}
try:
await handler(session, event_type, obj)
except Exception as e:
audit.error = str(e)[:1024]
await session.commit()
log.exception("stripe.handler_error", type=event_type, id=event_id)
# Ack 200 — we don't want Stripe retrying a handler that broke
# the same way on every delivery. An operator triages from the
# `error` column.
return {"status": "handler_error"}
audit.processed_at = utcnow()
await session.commit()
log.info("stripe.processed", type=event_type, id=event_id)
return {"status": "ok"}