commit 6fd2375ae1a5a995e2d087b626cd6a91399fdab9 equwal <truex@equwal.com> 2026-09-22 16:10:14 -0700 Let customers switch monthly plans, and ignore other products The owner wants customers to upgrade their plans in the Stripe customer portal. The portal switches a subscription only between prices of the Stripe catalogue: - tools/stripe_plans.py makes one monthly price for each plan, with the plan id as lookup key (payments.ensure_plan_prices). It is safe to run again. - A monthly plan checks out with its catalogue price when one exists and matches the plan. Else it uses the inline price, as before. - The lookup key of the price names the plan, so a switch in the portal changes the plan at once. The books used this month still count. The Stripe account also sells sbm Team, and its events reach this webhook. A subscription whose price or checkout metadata names no monthly plan of this site is now ignored, so it cannot change an account here. No test reaches the real Stripe API: conftest stubs stripe.Price.list.
README.md | 9 ++++ backend/payments.py | 86 ++++++++++++++++++++++++++++++++++-- tests/conftest.py | 8 ++++ tests/test_billing.py | 2 +- tests/test_subscriptions.py | 105 +++++++++++++++++++++++++++++++++++++++++--- tools/stripe_plans.py | 37 ++++++++++++++++ 6 files changed, 236 insertions(+), 11 deletions(-)
diff --git a/README.md b/README.md index d59e6cc..33c5b09 100644 --- a/README.md +++ b/README.md @@ -372,6 +372,15 @@ The books of a month do not carry over: a new `current_period_end` from Stripe sets `subscription_credits_used` back to 0. Retire a plan that was sold (`pricing.RETIRED_PLANS`); do not delete it, or its subscribers get nothing. +To let customers switch plans in the portal, make the Stripe prices once: +`runuser -u subplz -- .venv/bin/python tools/stripe_plans.py` on the server. +It makes one monthly price for each plan, with the plan id as lookup key, and +it is safe to run again. Then, in Stripe (Settings -> Billing -> Customer +portal), let customers switch plans between these three products. The lookup +key of the price names the plan, so a switch changes the plan at once, and the +books already used this month still count. A subscription whose price names +no plan of this site (the Stripe account also sells other products) is ignored. + A server job spends the credit that ends first: the daily credit, then a book of this month's plan, then a free credit, then a bought one. An unlimited plan spends nothing. The account counts the free credits apart from the bought ones diff --git a/backend/payments.py b/backend/payments.py index a4790db..8a2f1b4 100644 --- a/backend/payments.py +++ b/backend/payments.py @@ -67,6 +67,57 @@ def _base() -> str: # checkout # --------------------------------------------------------------------------- +def _catalogue_price(stripe, plan: pricing.Plan) -> str | None: + """The id of the Stripe price of a monthly plan (lookup key = plan id). + + The customer portal switches a subscription only between prices of the + Stripe catalogue, so a plan checks out with its catalogue price when one + exists. A price that does not match the plan is not used. + tools/stripe_plans.py makes the prices. + """ + try: + found = _plain(stripe.Price.list(lookup_keys=[plan.id], active=True, limit=1)) + except stripe.StripeError as exc: + log.warning("could not read the Stripe price of %s: %s", plan.id, exc) + return None + for price in found.get("data") or []: + if (price.get("unit_amount") == plan.price_cents + and price.get("currency") == plan.currency + and (price.get("recurring") or {}).get("interval") == "month"): + return price["id"] + log.warning("Stripe price %s does not match plan %s", price.get("id"), plan.id) + return None + + +def ensure_plan_prices() -> list[tuple[str, str, bool]]: + """Make a Stripe price for each monthly plan that has none. + + Safe to run again: a plan with a matching price keeps it. Returns + (plan id, price id, made now) for each monthly plan. tools/stripe_plans.py + runs this. + """ + stripe = _stripe() + out = [] + for plan in pricing.plans(): + if not plan.recurring: + continue + price_id = _catalogue_price(stripe, plan) + made = price_id is None + if made: + price = _plain(stripe.Price.create( + unit_amount=plan.price_cents, + currency=plan.currency, + recurring={"interval": "month"}, + lookup_key=plan.id, + # A price that does not match keeps its product; move the key. + transfer_lookup_key=True, + product_data={"name": f"{settings.site_name} - {plan.name}"}, + )) + price_id = price["id"] + out.append((plan.id, price_id, made)) + return out + + def start_checkout(session: Session, account: Account, plan: pricing.Plan) -> str: """Return the Stripe-hosted payment page for `plan`.""" stripe = _stripe() @@ -84,10 +135,16 @@ def start_checkout(session: Session, account: Account, plan: pricing.Plan) -> st if plan.recurring: price["recurring"] = {"interval": "month"} + line: dict = {"quantity": 1, "price_data": price} + if plan.recurring: + price_id = _catalogue_price(stripe, plan) + if price_id: + line = {"quantity": 1, "price": price_id} + meta = {"account_id": account.id, "plan_id": plan.id} params: dict = { "mode": "subscription" if plan.recurring else "payment", - "line_items": [{"quantity": 1, "price_data": price}], + "line_items": [line], "client_reference_id": account.id, "metadata": meta, # Stripe substitutes the real id; the return handler re-reads the @@ -159,8 +216,30 @@ def _account_for(session: Session, account_id: str | None, customer: str | None) return accounts.resolve(session, account) if account else None +def _our_plan_id(sub: dict) -> str | None: + """The monthly plan of a subscription, or None if it is not one of ours. + + The lookup key of the price names the plan, and it changes when the + customer switches plans in the portal. A subscription that started with + an inline price has no lookup key: its checkout metadata names the plan. + The Stripe account also sells other products, and their events come here + too. A subscription that names no monthly plan of this site is not ours. + """ + items = (sub.get("items") or {}).get("data") or [] + price = (items[0].get("price") or {}) if items else {} + for plan_id in (price.get("lookup_key"), (sub.get("metadata") or {}).get("plan_id")): + plan = pricing.get(plan_id or "", retired=True) + if plan is not None and plan.recurring: + return plan.id + return None + + def apply_subscription(session: Session, sub: dict) -> None: """Mirror a Stripe subscription onto its account.""" + plan_id = _our_plan_id(sub) + if plan_id is None: + log.info("subscription %s is not for a plan of this site; ignored", sub.get("id")) + return meta = sub.get("metadata") or {} account = _account_for(session, meta.get("account_id"), sub.get("customer")) if account is None: @@ -179,8 +258,9 @@ def apply_subscription(session: Session, sub: dict) -> None: account.subscription_id = sub.get("id") account.subscription_status = sub.get("status") account.subscription_period_end = period_end - if meta.get("plan_id"): - account.subscription_plan_id = meta["plan_id"] + # A switch in the portal changes the plan and keeps the month: the books + # used this month still count against the new plan. + account.subscription_plan_id = plan_id if sub.get("customer") and not account.stripe_customer_id: account.stripe_customer_id = sub["customer"] session.commit() diff --git a/tests/conftest.py b/tests/conftest.py index 83be82a..8ed33c7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -46,6 +46,14 @@ def _no_real_work(monkeypatch): monkeypatch.setattr(api.queue, "enqueue", lambda job_id: None) +@pytest.fixture(autouse=True) +def _no_stripe_prices(monkeypatch): + """Stripe has no prices for the plans, unless a test says so. No test may + reach the real Stripe API.""" + import stripe + monkeypatch.setattr(stripe.Price, "list", lambda **kw: FakeStripeObject({"data": []})) + + @pytest.fixture def client(): """A browser: keeps its cookie, so it stays one account across requests.""" diff --git a/tests/test_billing.py b/tests/test_billing.py index 51b0fa1..5225721 100644 --- a/tests/test_billing.py +++ b/tests/test_billing.py @@ -294,7 +294,7 @@ def test_subscription_lifts_every_limit_then_lapses(client, monthly_plan): assert start(client, make_job(client)).status_code == 402 -def test_subscription_past_its_paid_period_does_not_count(client): +def test_subscription_past_its_paid_period_does_not_count(client, monthly_plan): acct = account_id(client) post_webhook(client, subscription_event("updated", acct, "active", ends_in=-3 * 86400)) assert client.get("/api/account").json()["subscribed"] is False diff --git a/tests/test_subscriptions.py b/tests/test_subscriptions.py index 586e9ba..750f0d3 100644 --- a/tests/test_subscriptions.py +++ b/tests/test_subscriptions.py @@ -7,7 +7,10 @@ unlimited no credit is spent while the plan is active. Stripe tells the server about a plan with customer.subscription.* events. A -new month (a new current_period_end) starts the count again. +new month (a new current_period_end) starts the count again. The lookup key +of the price names the plan, so a switch in the customer portal changes the +plan. The Stripe account also sells other products: a subscription that is +not for a plan of this site is ignored. """ from __future__ import annotations @@ -30,6 +33,8 @@ TEST_PLANS = [ {"id": "pack10", "name": "10 books", "credits": 10, "price_cents": 499}, {"id": "month10", "name": "10 books a month", "credits": 10, "price_cents": 499, "recurring": True}, + {"id": "month30", "name": "30 books a month", "credits": 30, "price_cents": 999, + "recurring": True}, {"id": "forever", "name": "Unlimited", "credits": None, "price_cents": 5000, "recurring": True}, ] @@ -40,8 +45,14 @@ def catalogue(monkeypatch): monkeypatch.setenv("SUBPLZ_WEB_PLANS_JSON", json.dumps(TEST_PLANS)) -def subscription_event(kind, account, plan_id, status="active", period_end=None): +def subscription_event(kind, account, plan_id, status="active", period_end=None, + price_key=None): + """`plan_id` goes in the checkout metadata; `price_key` is the lookup key + of the price, which the portal changes when the customer switches plans.""" end = period_end or int(time.time()) + 30 * 86400 + item = {"current_period_end": end} + if price_key is not None: + item["price"] = {"id": f"price_{price_key}", "lookup_key": price_key} return { "id": f"evt_{kind}_{time.time_ns()}", "type": f"customer.subscription.{kind}", @@ -49,7 +60,7 @@ def subscription_event(kind, account, plan_id, status="active", period_end=None) "id": f"sub_{account}", "object": "subscription", "status": status, "customer": f"cus_{account}", "metadata": {"account_id": account, "plan_id": plan_id}, - "items": {"data": [{"current_period_end": end}]}, + "items": {"data": [item]}, }}, } @@ -146,11 +157,91 @@ def test_a_cancelled_plan_gives_nothing(client): assert start(client, make_job(client)).status_code == 402 -def test_a_plan_that_is_not_in_the_catalogue_gives_nothing(client): - # Retire a sold plan (pricing.RETIRED_PLANS); do not delete it. - subscribe(client, "gone") +def test_a_subscription_to_another_product_is_ignored(client): + # The same Stripe account sells other products (sbm Team). Their events + # reach this webhook too, and must not touch an account here. This is also + # why a sold plan must be retired (pricing.RETIRED_PLANS), not deleted. + acct = account_id(client) + event = subscription_event("created", acct, None, price_key="sbm-team") + assert post_webhook(client, event).status_code == 200 a = account(client) - assert a["unlimited"] is False and a["plan_credits"] == 0 + assert a["subscribed"] is False and a["plan_credits"] == 0 + + +def test_a_plan_checks_out_with_its_stripe_price_when_one_exists(client, monkeypatch): + # The portal can switch a subscription only between catalogue prices. + monkeypatch.setattr(stripe.Price, "list", lambda **kw: FakeStripeObject({"data": [ + {"id": "price_m10", "lookup_key": "month10", "unit_amount": 499, + "currency": "usd", "recurring": {"interval": "month"}}, + ]})) + seen = {} + monkeypatch.setattr( + stripe.checkout.Session, "create", + lambda **p: seen.update(p) or FakeStripeObject({"url": "https://stripe.test/sub"}), + ) + assert client.post("/api/billing/checkout", json={"plan_id": "month10"}).status_code == 200 + assert seen["line_items"] == [{"quantity": 1, "price": "price_m10"}] + + +def test_a_stripe_price_that_does_not_match_the_plan_is_not_used(client, monkeypatch): + monkeypatch.setattr(stripe.Price, "list", lambda **kw: FakeStripeObject({"data": [ + {"id": "price_old", "lookup_key": "month10", "unit_amount": 999, + "currency": "usd", "recurring": {"interval": "month"}}, + ]})) + seen = {} + monkeypatch.setattr( + stripe.checkout.Session, "create", + lambda **p: seen.update(p) or FakeStripeObject({"url": "https://stripe.test/sub"}), + ) + assert client.post("/api/billing/checkout", json={"plan_id": "month10"}).status_code == 200 + assert seen["line_items"][0]["price_data"]["unit_amount"] == 499 + + +def test_the_plan_prices_are_made_once(monkeypatch): + from backend import payments + + prices = [] # what Stripe has + + def price_list(lookup_keys, **kw): + return FakeStripeObject({"data": [p for p in prices if p["lookup_key"] in lookup_keys]}) + + def price_create(**kw): + price = {"id": f"price_{kw['lookup_key']}", "lookup_key": kw["lookup_key"], + "unit_amount": kw["unit_amount"], "currency": kw["currency"], + "recurring": kw["recurring"]} + prices.append(price) + return FakeStripeObject(price) + + monkeypatch.setattr(stripe.Price, "list", price_list) + monkeypatch.setattr(stripe.Price, "create", price_create) + + first = payments.ensure_plan_prices() + assert [(plan, made) for plan, _, made in first] == [ + ("month10", True), ("month30", True), ("forever", True)] + assert [p["unit_amount"] for p in prices] == [499, 999, 5000] + + second = payments.ensure_plan_prices() + assert [made for _, _, made in second] == [False, False, False] + assert len(prices) == 3 + + +def test_switching_plans_in_the_portal_changes_the_plan(client): + acct = account_id(client) + end = int(time.time()) + 30 * 86400 + post_webhook(client, subscription_event("created", acct, "month10", + period_end=end, price_key="month10")) + for _ in range(8): + assert start(client, make_job(client)).status_code == 200 + assert account(client)["plan_credits"] == 2 + + # The portal changes the price; the checkout metadata still says month10. + post_webhook(client, subscription_event("updated", acct, "month10", + period_end=end, price_key="month30")) + assert account(client)["plan_credits"] == 22 # 30 this month, 8 used + + post_webhook(client, subscription_event("updated", acct, "month10", + period_end=end, price_key="forever")) + assert account(client)["unlimited"] is True def test_a_second_plan_is_refused(client, monkeypatch): diff --git a/tools/stripe_plans.py b/tools/stripe_plans.py new file mode 100644 index 0000000..f2aafa3 --- /dev/null +++ b/tools/stripe_plans.py @@ -0,0 +1,37 @@ +"""Make the Stripe prices of the monthly plans. + +The Stripe customer portal switches a subscription only between prices of the +Stripe catalogue. This makes one monthly price, with its product, for each +monthly plan in backend/pricing.py. The plan id is the lookup key of the price. +It is safe to run again: a plan that has a matching price keeps it. + + # on the server, from the checkout, with the server's settings + runuser -u subplz -- .venv/bin/python tools/stripe_plans.py + +Then let the customer portal switch between these prices (Stripe: Settings -> +Billing -> Customer portal -> customers can switch plans). +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from backend import payments # noqa: E402 + + +def main() -> int: + try: + rows = payments.ensure_plan_prices() + except payments.PaymentsUnavailable as exc: + print(exc) + return 1 + for plan_id, price_id, made in rows: + print(f"{plan_id}: {price_id} ({'made now' if made else 'already there'})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())