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(