Recently Written · git

subplz-web

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

Log | Files | Refs


commit 6209eed8f458b114fb6ab89211868fef67371fe5
equwal <truex@equwal.com>
2026-09-22 15:51:18 -0700

Sell a 100-book pack, and state the real server speed

Add pack100: 100 credits for $39.99 ($0.40 a book, 20% below the
10-book pack). The financial advocate put this discount in the middle of
what credit packs give at ten times the volume. There is no 500-book
pack: one such sale holds the one CPU server for about six weeks.

The README and the pricing notes said that the server runs a large
speech model on a GPU, in minutes. It runs Whisper tiny on a CPU, one
book at a time: about 2 hours for a 10-hour book. The text now says so.

The tests check the catalogue, that a bigger pack costs less a book but
more in all, and that pack100 checks out at its price and credits 100
books.

 README.md             |  3 ++-
 backend/pricing.py    | 24 +++++++++++++++++++-----
 tests/test_billing.py | 34 +++++++++++++++++++++++++++++++++-
 3 files changed, 54 insertions(+), 7 deletions(-)
diff --git a/README.md b/README.md
index a4b60b9..733404e 100644
--- a/README.md
+++ b/README.md
@@ -325,11 +325,12 @@ use of its operator's machines, and nothing else.
 | Tier | Where it runs | You get | Costs |
 |---|---|---|---|
 | free | the visitor's browser (or the Android app) | each output: `.srt`, `.mkv`, `.mp4`, the read-along `.epub` | nothing, without limit |
-| cloud | this server's hardware: a large speech model on a GPU | the same, in minutes and not hours, from any device | one credit |
+| cloud | this server's hardware: Whisper tiny on a CPU, one book at a time | the same, from any device, with the tab closed: about 2 hours for a 10-hour book | one credit |
 
 | Plan | Price | Per book |
 |---|---|---|
 | 10 books | $4.99 | $0.50 |
+| 100 books | $39.99 | $0.40 |
 
 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/pricing.py b/backend/pricing.py
index d1be8e7..c828f1e 100644
--- a/backend/pricing.py
+++ b/backend/pricing.py
@@ -23,9 +23,10 @@ a 10-hour audiobook is 600 minutes, so it would cost ~$100 at Sonix's rate.
 
 What is free and what is paid is split by where the work is done. In the
 visitor's browser a conversion costs this server nothing: it is free, without
-limit, with each output. On this server's hardware (a large speech model on a
-GPU: minutes and not hours, from any device) a book takes one credit. The code
-is public, so what is sold is the use of these machines and nothing else.
+limit, with each output. On this server's hardware (Whisper tiny on a CPU, one
+book at a time: about 2 hours for a 10-hour book, from any device) a book takes
+one credit. The code is public, so what is sold is the use of these machines
+and nothing else.
 
 A book costs about $0.64 to convert on a rented GPU. Above about $5 a technical
 buyer wraps the ElevenLabs API; below $3 the fixed card fee takes too much.
@@ -34,9 +35,14 @@ one heavy user of an unlimited plan costs more than the plan brings in.
 
   free              in the browser: no limit, each output
   10-book pack      $4.99    ($0.50/book)
+  100-book pack     $39.99   ($0.40/book, 20% off: the middle of what credit
+                             packs give at 10x volume)
 
-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.
+The owner set $4.99 for ten books on 2026-09-22, and asked for bigger packs the
+same day. There is no 500-book pack: one such sale holds this one CPU server
+for about six weeks. A buyer who needs more books buys the 100-book pack again.
+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
@@ -80,6 +86,14 @@ DEFAULT_PLANS: list[Plan] = [
         price_cents=499,
         blurb="Ten books on our server, from any device. Credits never expire.",
     ),
+    Plan(
+        id="pack100",
+        name="100 books",
+        credits=100,
+        price_cents=3999,
+        blurb="For a whole library. Our server does one book at a time: about "
+              "2 hours for a 10-hour book. Credits never expire.",
+    ),
 ]
 
 # Not for sale. A checkout that started before a plan was retired can still
diff --git a/tests/test_billing.py b/tests/test_billing.py
index 1eb67fa..85d9583 100644
--- a/tests/test_billing.py
+++ b/tests/test_billing.py
@@ -167,10 +167,42 @@ def test_checkout_sends_stripe_the_right_order(client, monkeypatch):
 
 def test_ten_books_cost_4_99_and_no_unlimited_plan_is_sold(client):
     plans = client.get("/api/pricing").json()["plans"]
-    assert [(p["id"], p["credits"], p["price_cents"]) for p in plans] == [("pack10", 10, 499)]
+    assert [(p["id"], p["credits"], p["price_cents"]) for p in plans] == [
+        ("pack10", 10, 499), ("pack100", 100, 3999)]
     assert not any(p["recurring"] for p in plans)
 
 
+def test_bigger_packs_cost_less_a_book(client):
+    # A bigger pack must cost less a book, or nobody buys it. It must also cost
+    # more in all, or the smaller packs are pointless.
+    plans = client.get("/api/pricing").json()["plans"]
+    packs = sorted((p for p in plans if not p["recurring"]), key=lambda p: p["credits"])
+    assert [p["credits"] for p in packs] == [10, 100]
+    per_book = [p["price_cents"] / p["credits"] for p in packs]
+    assert all(small > big for small, big in zip(per_book, per_book[1:]))
+    totals = [p["price_cents"] for p in packs]
+    assert all(small < big for small, big in zip(totals, totals[1:]))
+
+
+@pytest.mark.parametrize("plan_id", ["pack100"])
+def test_a_big_pack_checks_out_at_its_price_and_credits_its_books(client, monkeypatch, plan_id):
+    plan = pricing.get(plan_id)
+    assert plan is not None
+    seen = {}
+
+    def create(**params):
+        seen.update(params)
+        return FakeStripeObject({"id": "cs_new", "url": "https://stripe.test/pay"})
+
+    monkeypatch.setattr(stripe.checkout.Session, "create", create)
+    assert client.post("/api/billing/checkout", json={"plan_id": plan_id}).status_code == 200
+    assert seen["metadata"]["plan_id"] == plan_id
+    assert seen["line_items"][0]["price_data"]["unit_amount"] == plan.price_cents
+
+    buy(client, plan_id, email=f"{plan_id}-{time.time_ns()}@example.com")
+    assert client.get("/api/account").json()["credits"] == plan.credits
+
+
 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.