Recently Written · git

subplz-web

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

Log | Files | Refs


commit b933680598e87fc6a65495bfe259585d72af3c58
equwal <truex@equwal.com>
2026-09-22 15:20:21 -0700

Sell ten books for $4.99

The owner changed the price: $4.99 now buys ten books on the server, not
one. The pack of ten is the only plan for sale.

The earlier plans (one book $4.99, five $16.99, twenty $39) are not sold
now. They stay in RETIRED_PLANS, and fulfilment still finds them: a buyer
who opened the payment page before the change gets what that page showed.

 README.md             |  4 +---
 backend/payments.py   |  4 +++-
 backend/pricing.py    | 43 ++++++++++++++++++++-----------------------
 frontend/app.js       |  2 +-
 tests/test_billing.py | 20 ++++++++++++++------
 5 files changed, 39 insertions(+), 34 deletions(-)
diff --git a/README.md b/README.md
index 5a817d0..77c19c7 100644
--- a/README.md
+++ b/README.md
@@ -327,9 +327,7 @@ use of its operator's machines, and nothing else.
 
 | Plan | Price | Per book |
 |---|---|---|
-| One book | $4.99 | $4.99 |
-| 5 books | $16.99 | $3.40 |
-| 20 books | $39.00 | $1.95 |
+| 10 books | $4.99 | $0.50 |
 
 A book costs about $0.64 to convert on a rented GPU. Above about $5 a technical
 buyer wraps a raw alignment API (ElevenLabs: $2.20 for a 10-hour book). Below
diff --git a/backend/payments.py b/backend/payments.py
index b74ed93..166d270 100644
--- a/backend/payments.py
+++ b/backend/payments.py
@@ -187,7 +187,9 @@ def fulfil(session: Session, checkout: dict) -> Account | None:
         checkout.get("client_reference_id") or meta.get("account_id"),
         checkout.get("customer"),
     )
-    plan = pricing.get(meta.get("plan_id") or "")
+    # A retired plan too: the buyer may have opened the payment page before
+    # the plan was retired.
+    plan = pricing.get(meta.get("plan_id") or "", retired=True)
     if account is None or plan is None:
         log.error("checkout %s: unknown account or plan %r", checkout.get("id"), meta)
         return None
diff --git a/backend/pricing.py b/backend/pricing.py
index 4e7535d..d1be8e7 100644
--- a/backend/pricing.py
+++ b/backend/pricing.py
@@ -33,9 +33,10 @@ There is no unlimited plan: use comes in bursts (a backlog, then nothing), and
 one heavy user of an unlimited plan costs more than the plan brings in.
 
   free              in the browser: no limit, each output
-  single book       $4.99
-  5-book pack       $16.99   ($3.40/book)
-  20-book pack      $39.00   ($1.95/book)
+  10-book pack      $4.99    ($0.50/book)
+
+The owner set $4.99 for ten books on 2026-09-22. The packs sold before that
+(one book $4.99, five $16.99, twenty $39) are in RETIRED_PLANS.
 
 Every number is overridable by env var; these are defaults, not decisions cast
 in code. An operator who wants a recurring plan can add one with
@@ -73,28 +74,22 @@ class Plan:
 
 DEFAULT_PLANS: list[Plan] = [
     Plan(
-        id="single",
-        name="One book",
-        credits=1,
+        id="pack10",
+        name="10 books",
+        credits=10,
         price_cents=499,
-        blurb="One book on our server, from any device.",
-    ),
-    Plan(
-        id="pack5",
-        name="5 books",
-        credits=5,
-        price_cents=1699,
-        blurb="Credits never expire.",
-    ),
-    Plan(
-        id="pack20",
-        name="20 books",
-        credits=20,
-        price_cents=3900,
-        blurb="For working through a series.",
+        blurb="Ten books on our server, from any device. Credits never expire.",
     ),
 ]
 
+# Not for sale. A checkout that started before a plan was retired can still
+# finish, and it must credit what the buyer saw on the payment page.
+RETIRED_PLANS: list[Plan] = [
+    Plan(id="single", name="One book", credits=1, price_cents=499),
+    Plan(id="pack5", name="5 books", credits=5, price_cents=1699),
+    Plan(id="pack20", name="20 books", credits=20, price_cents=3900),
+]
+
 
 def plans() -> list[Plan]:
     """The catalogue, overridable with SUBPLZ_WEB_PLANS_JSON."""
@@ -107,8 +102,10 @@ def plans() -> list[Plan]:
         raise RuntimeError(f"SUBPLZ_WEB_PLANS_JSON is not valid: {exc}") from exc
 
 
-def get(plan_id: str) -> Plan | None:
-    return next((p for p in plans() if p.id == plan_id), None)
+def get(plan_id: str, retired: bool = False) -> Plan | None:
+    """A plan for sale. With `retired`, also a plan that is no longer sold."""
+    pool = plans() + (RETIRED_PLANS if retired else [])
+    return next((p for p in pool if p.id == plan_id), None)
 
 
 def as_dicts() -> list[dict]:
diff --git a/frontend/app.js b/frontend/app.js
index 553b39d..2a0c9ab 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -899,7 +899,7 @@ async function openPricing(why) {
   el.plans.innerHTML = '';
   for (const p of catalogue.plans) {
     const card = document.createElement('div');
-    card.className = 'plan' + (p.id === 'pack5' ? ' featured' : '');
+    card.className = 'plan' + (p.id === 'pack10' ? ' featured' : '');
     const per = p.per_book_cents && p.credits > 1
       ? `<span class="plan-per">$${(p.per_book_cents / 100).toFixed(2)} a book</span>` : '';
     card.innerHTML = `
diff --git a/tests/test_billing.py b/tests/test_billing.py
index af6585c..1eb67fa 100644
--- a/tests/test_billing.py
+++ b/tests/test_billing.py
@@ -151,27 +151,35 @@ def test_checkout_sends_stripe_the_right_order(client, monkeypatch):
         return FakeStripeObject({"id": "cs_new", "url": "https://stripe.test/pay"})
 
     monkeypatch.setattr(stripe.checkout.Session, "create", create)
-    r = client.post("/api/billing/checkout", json={"plan_id": "pack5"})
+    r = client.post("/api/billing/checkout", json={"plan_id": "pack10"})
     assert r.status_code == 200 and r.json()["url"] == "https://stripe.test/pay"
 
     assert seen["mode"] == "payment"
     assert seen["client_reference_id"] == account_id(client)
-    assert seen["metadata"]["plan_id"] == "pack5"
+    assert seen["metadata"]["plan_id"] == "pack10"
     item = seen["line_items"][0]["price_data"]
-    assert item["unit_amount"] == 1699 and item["currency"] == "usd"
+    assert item["unit_amount"] == 499 and item["currency"] == "usd"
     assert "recurring" not in item
     assert seen["success_url"].startswith(
         "https://example.test/api/billing/return?session_id={CHECKOUT_SESSION_ID}")
     assert seen["customer_creation"] == "always"
 
 
-def test_no_unlimited_plan_is_sold_by_default(client):
+def test_ten_books_cost_4_99_and_no_unlimited_plan_is_sold(client):
     plans = client.get("/api/pricing").json()["plans"]
-    assert [p["id"] for p in plans] == ["single", "pack5", "pack20"]
-    assert [p["price_cents"] for p in plans] == [499, 1699, 3900]
+    assert [(p["id"], p["credits"], p["price_cents"]) for p in plans] == [("pack10", 10, 499)]
     assert not any(p["recurring"] for p in plans)
 
 
+def test_a_retired_plan_is_not_sold_but_a_late_payment_still_credits(client):
+    # A checkout that started before the price change can finish after it.
+    # It must credit what the buyer saw on the payment page.
+    assert client.post("/api/billing/checkout",
+                       json={"plan_id": "single"}).status_code == 404
+    buy(client, "single", email=f"late-{time.time_ns()}@example.com")
+    assert client.get("/api/account").json()["credits"] == 1
+
+
 def test_subscription_checkout_is_recurring(client, monkeypatch, monthly_plan):
     seen = {}
     monkeypatch.setattr(