backend/payments.py (15273 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 from urllib.parse import quote 26 27 from sqlalchemy.exc import IntegrityError 28 from sqlalchemy.orm import Session 29 30 from . import accounts, billing, pricing 31 from .db import Account, Purchase 32 from .settings import settings 33 34 log = logging.getLogger(__name__) 35 36 37 class PaymentsUnavailable(RuntimeError): 38 pass 39 40 41 class PaymentError(RuntimeError): 42 pass 43 44 45 def _stripe(): 46 if not settings.payments_configured: 47 raise PaymentsUnavailable( 48 "Payments are not set up on this server yet." 49 ) 50 import stripe # lazy: localhost never needs the package 51 52 stripe.api_key = settings.stripe_secret_key 53 return stripe 54 55 56 def _plain(obj) -> dict: 57 """A Stripe object as plain data. They stopped being dicts in v13.""" 58 if isinstance(obj, dict): 59 return obj 60 return obj.to_dict() 61 62 63 def _base() -> str: 64 return settings.public_base_url.rstrip("/") 65 66 67 # --------------------------------------------------------------------------- 68 # checkout 69 # --------------------------------------------------------------------------- 70 71 def _catalogue_price(stripe, plan: pricing.Plan) -> str | None: 72 """The id of the Stripe price of a recurring plan (lookup key = plan id). 73 74 The customer portal switches a subscription only between prices of the 75 Stripe catalogue, so a plan checks out with its catalogue price when one 76 exists. A price that does not match the plan is not used. 77 tools/stripe_plans.py makes the prices. 78 """ 79 try: 80 found = _plain(stripe.Price.list(lookup_keys=[plan.id], active=True, limit=1)) 81 except stripe.StripeError as exc: 82 log.warning("could not read the Stripe price of %s: %s", plan.id, exc) 83 return None 84 for price in found.get("data") or []: 85 if (price.get("unit_amount") == plan.price_cents 86 and price.get("currency") == plan.currency 87 and (price.get("recurring") or {}).get("interval") == plan.interval): 88 return price["id"] 89 log.warning("Stripe price %s does not match plan %s", price.get("id"), plan.id) 90 return None 91 92 93 def ensure_plan_prices() -> list[tuple[str, str, bool]]: 94 """Make a Stripe price for each monthly plan that has none. 95 96 Safe to run again: a plan with a matching price keeps it. Returns 97 (plan id, price id, made now) for each monthly plan. tools/stripe_plans.py 98 runs this. 99 """ 100 stripe = _stripe() 101 out = [] 102 for plan in pricing.plans(): 103 if not plan.recurring: 104 continue 105 price_id = _catalogue_price(stripe, plan) 106 made = price_id is None 107 if made: 108 price = _plain(stripe.Price.create( 109 unit_amount=plan.price_cents, 110 currency=plan.currency, 111 recurring={"interval": plan.interval}, 112 lookup_key=plan.id, 113 # A price that does not match keeps its product; move the key. 114 transfer_lookup_key=True, 115 product_data={"name": f"{settings.site_name} - {plan.name}"}, 116 )) 117 price_id = price["id"] 118 out.append((plan.id, price_id, made)) 119 return out 120 121 122 def start_checkout(session: Session, account: Account, plan: pricing.Plan, 123 page: str = "/") -> str: 124 """Return the Stripe-hosted payment page for `plan`. 125 126 `page` is the page of this site that the browser comes back to. It must be 127 one of api.RETURN_PAGES. 128 """ 129 stripe = _stripe() 130 131 # One book plan at a time. A Subrep plan (subrep.py) is not a book plan. 132 if plan.recurring and pricing.get(plan.id, retired=True) is not None \ 133 and billing.is_subscribed(account): 134 raise PaymentError( 135 "You already have a monthly plan. Change or cancel it with Manage plan." 136 ) 137 138 price = { 139 "currency": plan.currency, 140 "unit_amount": plan.price_cents, 141 "product_data": {"name": f"{settings.site_name} - {plan.name}"}, 142 } 143 if plan.recurring: 144 price["recurring"] = {"interval": plan.interval} 145 146 line: dict = {"quantity": 1, "price_data": price} 147 if plan.recurring: 148 price_id = _catalogue_price(stripe, plan) 149 if price_id: 150 line = {"quantity": 1, "price": price_id} 151 152 meta = {"account_id": account.id, "plan_id": plan.id} 153 back = "" if page == "/" else f"&page={quote(page, safe='')}" 154 params: dict = { 155 "mode": "subscription" if plan.recurring else "payment", 156 "line_items": [line], 157 "client_reference_id": account.id, 158 "metadata": meta, 159 # Stripe substitutes the real id; the return handler re-reads the 160 # session from it rather than trusting anything in the URL. 161 "success_url": f"{_base()}/api/billing/return" 162 "?session_id={CHECKOUT_SESSION_ID}" + back, 163 "cancel_url": f"{_base()}{page}?checkout=cancelled", 164 "allow_promotion_codes": True, 165 } 166 if account.stripe_customer_id: 167 params["customer"] = account.stripe_customer_id 168 else: 169 if account.email: 170 params["customer_email"] = account.email 171 if not plan.recurring: 172 # One-off payments do not create a customer unless asked, and 173 # without one there is nothing to hang a later purchase on. 174 params["customer_creation"] = "always" 175 if plan.recurring: 176 params["subscription_data"] = {"metadata": meta} 177 178 try: 179 checkout = _plain(stripe.checkout.Session.create(**params)) 180 except stripe.StripeError as exc: 181 log.warning("checkout create failed for %s: %s", account.id, exc) 182 raise PaymentError("Could not start the checkout. Try again shortly.") from exc 183 return checkout["url"] 184 185 186 def portal_url(account: Account) -> str: 187 """Stripe's own page for cancelling a monthly plan or changing the card.""" 188 stripe = _stripe() 189 if not account.stripe_customer_id: 190 raise PaymentError("There is no payment history on this account yet.") 191 try: 192 portal = _plain( 193 stripe.billing_portal.Session.create( 194 customer=account.stripe_customer_id, return_url=f"{_base()}/" 195 ) 196 ) 197 except stripe.StripeError as exc: 198 log.warning("portal create failed for %s: %s", account.id, exc) 199 raise PaymentError("Could not open the billing page. Try again shortly.") from exc 200 return portal["url"] 201 202 203 # --------------------------------------------------------------------------- 204 # fulfilment 205 # --------------------------------------------------------------------------- 206 207 def _period_end(sub: dict) -> datetime | None: 208 # Moved from the subscription onto its items in the 2025 API versions; 209 # which one arrives depends on the account's webhook API version. 210 stamp = sub.get("current_period_end") 211 if stamp is None: 212 items = (sub.get("items") or {}).get("data") or [] 213 stamp = items[0].get("current_period_end") if items else None 214 return datetime.fromtimestamp(stamp, tz=timezone.utc) if stamp else None 215 216 217 def _account_for(session: Session, account_id: str | None, customer: str | None): 218 account = session.get(Account, account_id) if account_id else None 219 if account is None and customer: 220 account = ( 221 session.query(Account) 222 .filter(Account.stripe_customer_id == customer) 223 .first() 224 ) 225 return accounts.resolve(session, account) if account else None 226 227 228 def _our_plan_id(sub: dict) -> str | None: 229 """The monthly plan of a subscription, or None if it is not one of ours. 230 231 The lookup key of the price names the plan, and it changes when the 232 customer switches plans in the portal. A subscription that started with 233 an inline price has no lookup key: its checkout metadata names the plan. 234 The Stripe account also sells other products, and their events come here 235 too. A subscription that names no monthly plan of this site is not ours. 236 """ 237 items = (sub.get("items") or {}).get("data") or [] 238 price = (items[0].get("price") or {}) if items else {} 239 for plan_id in (price.get("lookup_key"), (sub.get("metadata") or {}).get("plan_id")): 240 plan = pricing.get(plan_id or "", retired=True) 241 if plan is not None and plan.recurring: 242 return plan.id 243 return None 244 245 246 def apply_subscription(session: Session, sub: dict) -> None: 247 """Mirror a Stripe subscription onto its account.""" 248 from . import subrep # subrep imports api, which imports this module 249 250 if subrep.apply_subscription(session, sub): 251 return 252 plan_id = _our_plan_id(sub) 253 if plan_id is None: 254 log.info("subscription %s is not for a plan of this site; ignored", sub.get("id")) 255 return 256 meta = sub.get("metadata") or {} 257 account = _account_for(session, meta.get("account_id"), sub.get("customer")) 258 if account is None: 259 log.warning("subscription %s matches no account", sub.get("id")) 260 return 261 # An old subscription being deleted must not wipe out its replacement. 262 if account.subscription_id and account.subscription_id != sub.get("id") \ 263 and sub.get("status") in ("canceled", "incomplete_expired"): 264 return 265 period_end = _period_end(sub) 266 # A new subscription, or a new month of it, starts the count of its books 267 # again: the books of one month do not carry over. 268 if account.subscription_id != sub.get("id") \ 269 or billing._aware(account.subscription_period_end) != period_end: 270 account.subscription_credits_used = 0 271 account.subscription_id = sub.get("id") 272 account.subscription_status = sub.get("status") 273 account.subscription_period_end = period_end 274 # A switch in the portal changes the plan and keeps the month: the books 275 # used this month still count against the new plan. 276 account.subscription_plan_id = plan_id 277 if sub.get("customer") and not account.stripe_customer_id: 278 account.stripe_customer_id = sub["customer"] 279 session.commit() 280 281 282 def fulfil(session: Session, checkout: dict) -> Account | None: 283 """Credit a completed checkout. Safe to call any number of times.""" 284 if checkout.get("payment_status") not in ("paid", "no_payment_required"): 285 return None # e.g. a bank debit still clearing; a later event follows 286 287 meta = checkout.get("metadata") or {} 288 account = _account_for( 289 session, 290 checkout.get("client_reference_id") or meta.get("account_id"), 291 checkout.get("customer"), 292 ) 293 # A retired plan too: the buyer may have opened the payment page before 294 # the plan was retired. 295 from . import captions, subrep # they import api, which imports this module 296 297 plan = (pricing.get(meta.get("plan_id") or "", retired=True) 298 or captions.pack(meta.get("plan_id") or "") 299 or subrep.plan(meta.get("plan_id") or "")) 300 if account is None or plan is None: 301 log.error("checkout %s: unknown account or plan %r", checkout.get("id"), meta) 302 return None 303 304 # Paying is also how most people sign in: Stripe has verified nothing about 305 # the address, but it is where the receipt went, which is good enough to 306 # be the account's identity. 307 email = ((checkout.get("customer_details") or {}).get("email") or "").strip().lower() 308 if email and account.email is None: 309 account = accounts.adopt_email(session, account, email) 310 311 session.add( 312 Purchase( 313 account_id=account.id, 314 plan_id=plan.id, 315 credits=plan.credits or 0, 316 amount_cents=checkout.get("amount_total") or plan.price_cents, 317 currency=checkout.get("currency") or plan.currency, 318 stripe_session_id=checkout["id"], 319 ) 320 ) 321 try: 322 session.flush() 323 except IntegrityError: 324 session.rollback() # already fulfilled by the other delivery path 325 return _account_for(session, account.id, None) 326 327 if checkout.get("customer") and not account.stripe_customer_id: 328 account.stripe_customer_id = checkout["customer"] 329 # A pack adds credits that never expire. A monthly plan adds none here: 330 # its books come from the subscription, month by month. 331 if plan.credits and not plan.recurring: 332 account.purchased_credits += plan.credits 333 captions.fulfil(session, account, plan) 334 session.commit() 335 336 if checkout.get("subscription"): 337 try: 338 sub = _plain(_stripe().Subscription.retrieve(checkout["subscription"])) 339 apply_subscription(session, sub) 340 except Exception as exc: # noqa: BLE001 - the subscription webhook will land too 341 log.warning("could not read subscription %s: %s", 342 checkout["subscription"], exc) 343 344 log.info("fulfilled %s: plan=%s account=%s", checkout["id"], plan.id, account.id) 345 return account 346 347 348 def fulfil_by_id(session: Session, session_id: str) -> Account | None: 349 """The browser's return trip: re-read the session from Stripe, then fulfil.""" 350 stripe = _stripe() 351 try: 352 checkout = _plain(stripe.checkout.Session.retrieve(session_id)) 353 except stripe.StripeError as exc: 354 log.warning("could not retrieve checkout %s: %s", session_id, exc) 355 return None 356 return fulfil(session, checkout) 357 358 359 # --------------------------------------------------------------------------- 360 # webhooks 361 # --------------------------------------------------------------------------- 362 363 def verify_webhook(payload: bytes, signature: str | None) -> dict: 364 """Check Stripe's signature and return the event as plain data.""" 365 stripe = _stripe() 366 if not settings.stripe_webhook_secret: 367 raise PaymentsUnavailable("No webhook signing secret is configured.") 368 try: 369 stripe.Webhook.construct_event( 370 payload, signature or "", settings.stripe_webhook_secret 371 ) 372 except (ValueError, stripe.SignatureVerificationError) as exc: 373 raise PaymentError("Bad webhook signature.") from exc 374 return json.loads(payload) 375 376 377 def handle_event(session: Session, event: dict) -> None: 378 kind = event.get("type", "") 379 obj = (event.get("data") or {}).get("object") or {} 380 381 if kind in ("checkout.session.completed", 382 "checkout.session.async_payment_succeeded"): 383 fulfil(session, obj) 384 elif kind.startswith("customer.subscription."): 385 apply_subscription(session, obj)