Recently Written · git

subplz-web

git clone https://github.com/equwal/subplz-web

Log | Files | Refs


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)