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.