backend/payments.py (10366 bytes)
1 """Stripe: start a checkout, credit the account when it completes. 2 3 Everything Stripe-specific is in this file. billing.py decides what an account 4 may do; this only moves money into `Account.purchased_credits` and keeps the 5 subscription fields honest. 6 7 Two rules the rest follows from: 8 9 * The browser coming back from Stripe is not proof of payment. Fulfilment 10 always re-reads the checkout session from Stripe, and only acts on "paid". 11 * Stripe tells us about one payment more than once - the webhook, the 12 browser's return trip, and any number of webhook retries - so fulfilment is 13 idempotent on the checkout session id (see db.Purchase). 14 15 Prices are sent inline from pricing.py rather than configured in the Stripe 16 dashboard, so the catalogue has one source of truth and a new deployment needs 17 nothing but an API key. 18 """ 19 20 from __future__ import annotations 21 22 import json 23 import logging 24 from datetime import datetime, timezone 25 26 from sqlalchemy.exc import IntegrityError 27 from sqlalchemy.orm import Session 28 29 from . import accounts, billing, pricing 30 from .db import Account, Purchase 31 from .settings import settings 32 33 log = logging.getLogger(__name__) 34 35 36 class PaymentsUnavailable(RuntimeError): 37 pass 38 39 40 class PaymentError(RuntimeError): 41 pass 42 43 44 def _stripe(): 45 if not settings.payments_configured: 46 raise PaymentsUnavailable( 47 "Payments are not set up on this server yet." 48 ) 49 import stripe # lazy: localhost never needs the package 50 51 stripe.api_key = settings.stripe_secret_key 52 return stripe 53 54 55 def _plain(obj) -> dict: 56 """A Stripe object as plain data. They stopped being dicts in v13.""" 57 if isinstance(obj, dict): 58 return obj 59 return obj.to_dict() 60 61 62 def _base() -> str: 63 return settings.public_base_url.rstrip("/") 64 65 66 # --------------------------------------------------------------------------- 67 # checkout 68 # --------------------------------------------------------------------------- 69 70 def start_checkout(session: Session, account: Account, plan: pricing.Plan) -> str: 71 """Return the Stripe-hosted payment page for `plan`.""" 72 stripe = _stripe() 73 74 if plan.recurring and billing.is_subscribed(account): 75 raise PaymentError("You are already on this plan.") 76 77 price = { 78 "currency": plan.currency, 79 "unit_amount": plan.price_cents, 80 "product_data": {"name": f"{settings.site_name} - {plan.name}"}, 81 } 82 if plan.recurring: 83 price["recurring"] = {"interval": "month"} 84 85 meta = {"account_id": account.id, "plan_id": plan.id} 86 params: dict = { 87 "mode": "subscription" if plan.recurring else "payment", 88 "line_items": [{"quantity": 1, "price_data": price}], 89 "client_reference_id": account.id, 90 "metadata": meta, 91 # Stripe substitutes the real id; the return handler re-reads the 92 # session from it rather than trusting anything in the URL. 93 "success_url": f"{_base()}/api/billing/return" 94 "?session_id={CHECKOUT_SESSION_ID}", 95 "cancel_url": f"{_base()}/?checkout=cancelled", 96 "allow_promotion_codes": True, 97 } 98 if account.stripe_customer_id: 99 params["customer"] = account.stripe_customer_id 100 else: 101 if account.email: 102 params["customer_email"] = account.email 103 if not plan.recurring: 104 # One-off payments do not create a customer unless asked, and 105 # without one there is nothing to hang a later purchase on. 106 params["customer_creation"] = "always" 107 if plan.recurring: 108 params["subscription_data"] = {"metadata": meta} 109 110 try: 111 checkout = _plain(stripe.checkout.Session.create(**params)) 112 except stripe.StripeError as exc: 113 log.warning("checkout create failed for %s: %s", account.id, exc) 114 raise PaymentError("Could not start the checkout. Try again shortly.") from exc 115 return checkout["url"] 116 117 118 def portal_url(account: Account) -> str: 119 """Stripe's own page for cancelling or updating the unlimited plan.""" 120 stripe = _stripe() 121 if not account.stripe_customer_id: 122 raise PaymentError("There is no payment history on this account yet.") 123 try: 124 portal = _plain( 125 stripe.billing_portal.Session.create( 126 customer=account.stripe_customer_id, return_url=f"{_base()}/" 127 ) 128 ) 129 except stripe.StripeError as exc: 130 log.warning("portal create failed for %s: %s", account.id, exc) 131 raise PaymentError("Could not open the billing page. Try again shortly.") from exc 132 return portal["url"] 133 134 135 # --------------------------------------------------------------------------- 136 # fulfilment 137 # --------------------------------------------------------------------------- 138 139 def _period_end(sub: dict) -> datetime | None: 140 # Moved from the subscription onto its items in the 2025 API versions; 141 # which one arrives depends on the account's webhook API version. 142 stamp = sub.get("current_period_end") 143 if stamp is None: 144 items = (sub.get("items") or {}).get("data") or [] 145 stamp = items[0].get("current_period_end") if items else None 146 return datetime.fromtimestamp(stamp, tz=timezone.utc) if stamp else None 147 148 149 def _account_for(session: Session, account_id: str | None, customer: str | None): 150 account = session.get(Account, account_id) if account_id else None 151 if account is None and customer: 152 account = ( 153 session.query(Account) 154 .filter(Account.stripe_customer_id == customer) 155 .first() 156 ) 157 return accounts.resolve(session, account) if account else None 158 159 160 def apply_subscription(session: Session, sub: dict) -> None: 161 """Mirror a Stripe subscription onto its account.""" 162 meta = sub.get("metadata") or {} 163 account = _account_for(session, meta.get("account_id"), sub.get("customer")) 164 if account is None: 165 log.warning("subscription %s matches no account", sub.get("id")) 166 return 167 # An old subscription being deleted must not wipe out its replacement. 168 if account.subscription_id and account.subscription_id != sub.get("id") \ 169 and sub.get("status") in ("canceled", "incomplete_expired"): 170 return 171 account.subscription_id = sub.get("id") 172 account.subscription_status = sub.get("status") 173 account.subscription_period_end = _period_end(sub) 174 if sub.get("customer") and not account.stripe_customer_id: 175 account.stripe_customer_id = sub["customer"] 176 session.commit() 177 178 179 def fulfil(session: Session, checkout: dict) -> Account | None: 180 """Credit a completed checkout. Safe to call any number of times.""" 181 if checkout.get("payment_status") not in ("paid", "no_payment_required"): 182 return None # e.g. a bank debit still clearing; a later event follows 183 184 meta = checkout.get("metadata") or {} 185 account = _account_for( 186 session, 187 checkout.get("client_reference_id") or meta.get("account_id"), 188 checkout.get("customer"), 189 ) 190 plan = pricing.get(meta.get("plan_id") or "") 191 if account is None or plan is None: 192 log.error("checkout %s: unknown account or plan %r", checkout.get("id"), meta) 193 return None 194 195 # Paying is also how most people sign in: Stripe has verified nothing about 196 # the address, but it is where the receipt went, which is good enough to 197 # be the account's identity. 198 email = ((checkout.get("customer_details") or {}).get("email") or "").strip().lower() 199 if email and account.email is None: 200 account = accounts.adopt_email(session, account, email) 201 202 session.add( 203 Purchase( 204 account_id=account.id, 205 plan_id=plan.id, 206 credits=plan.credits or 0, 207 amount_cents=checkout.get("amount_total") or plan.price_cents, 208 currency=checkout.get("currency") or plan.currency, 209 stripe_session_id=checkout["id"], 210 ) 211 ) 212 try: 213 session.flush() 214 except IntegrityError: 215 session.rollback() # already fulfilled by the other delivery path 216 return _account_for(session, account.id, None) 217 218 if checkout.get("customer") and not account.stripe_customer_id: 219 account.stripe_customer_id = checkout["customer"] 220 if plan.credits: 221 account.purchased_credits += plan.credits 222 session.commit() 223 224 if checkout.get("subscription"): 225 try: 226 sub = _plain(_stripe().Subscription.retrieve(checkout["subscription"])) 227 apply_subscription(session, sub) 228 except Exception as exc: # noqa: BLE001 - the subscription webhook will land too 229 log.warning("could not read subscription %s: %s", 230 checkout["subscription"], exc) 231 232 log.info("fulfilled %s: plan=%s account=%s", checkout["id"], plan.id, account.id) 233 return account 234 235 236 def fulfil_by_id(session: Session, session_id: str) -> Account | None: 237 """The browser's return trip: re-read the session from Stripe, then fulfil.""" 238 stripe = _stripe() 239 try: 240 checkout = _plain(stripe.checkout.Session.retrieve(session_id)) 241 except stripe.StripeError as exc: 242 log.warning("could not retrieve checkout %s: %s", session_id, exc) 243 return None 244 return fulfil(session, checkout) 245 246 247 # --------------------------------------------------------------------------- 248 # webhooks 249 # --------------------------------------------------------------------------- 250 251 def verify_webhook(payload: bytes, signature: str | None) -> dict: 252 """Check Stripe's signature and return the event as plain data.""" 253 stripe = _stripe() 254 if not settings.stripe_webhook_secret: 255 raise PaymentsUnavailable("No webhook signing secret is configured.") 256 try: 257 stripe.Webhook.construct_event( 258 payload, signature or "", settings.stripe_webhook_secret 259 ) 260 except (ValueError, stripe.SignatureVerificationError) as exc: 261 raise PaymentError("Bad webhook signature.") from exc 262 return json.loads(payload) 263 264 265 def handle_event(session: Session, event: dict) -> None: 266 kind = event.get("type", "") 267 obj = (event.get("data") or {}).get("object") or {} 268 269 if kind in ("checkout.session.completed", 270 "checkout.session.async_payment_succeeded"): 271 fulfil(session, obj) 272 elif kind.startswith("customer.subscription."): 273 apply_subscription(session, obj)