Recently Written · git

subplz-web

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

Log | Files | Refs


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)