Recently Written · git

subplz-web

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

Log | Files | Refs


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())