backend/pricing.py (6454 bytes)
1 """What we charge, and why. 2 3 Market as of 2026: 4 5 Direct competitors (audiobook <-> ebook sync) 6 Voxlight $29.99/year, but alignment runs on the user's own Mac 7 Spokt freemium, metered sync minutes 8 Storyteller free, self-hosted; you administer a server 9 syncabook free CLI 10 11 Adjacent (AI subtitling SaaS, priced for transcription) 12 Otter $16.99/mo 13 Happy Scribe $17/mo for 120 AI minutes, $89/mo for 6,000 14 Veed $22/mo 15 Kapwing/Descript $24/mo 16 Sonix $10/hour pay-as-you-go 17 18 Raw forced alignment as an API 19 ElevenLabs $0.22 per hour of audio: $2.20 for a 10-hour book 20 21 The adjacent tools price per *minute of transcription*, which does not transfer: 22 a 10-hour audiobook is 600 minutes, so it would cost ~$100 at Sonix's rate. 23 24 What is free and what is paid is split by where the work is done. In the 25 visitor's browser a conversion costs this server nothing: it is free, without 26 limit, with each output. On this server's hardware (Whisper tiny on a CPU, one 27 book at a time: about 2 hours for a 10-hour book, from any device) a book takes 28 one credit. The code is public, so what is sold is the use of these machines 29 and nothing else. 30 31 A book costs about $0.64 to convert on a rented GPU. Above about $5 a technical 32 buyer wraps the ElevenLabs API; below $3 the fixed card fee takes too much. 33 34 free in the browser: no limit, each output 35 10-book pack $4.99 ($0.50/book) 36 100-book pack $39.99 ($0.40/book, 20% off: the middle of what credit 37 packs give at 10x volume) 38 500-book pack $174.99 ($0.35/book, 30% off) 39 40 The owner set $4.99 for ten books on 2026-09-22, and asked for bigger packs the 41 same day. The owner chose to sell the 500-book pack against the advice of the 42 financial advocate: one such sale can hold this one CPU server for about six 43 weeks, so the pack says how fast the server works. The packs sold before that 44 (one book $4.99, five $16.99, twenty $39) are in RETIRED_PLANS. 45 46 10 books a month $4.99/month 47 30 books a month $9.99/month 48 unlimited $50.00/month 49 50 The owner added these monthly plans on 2026-09-22. The books of a month do not 51 carry over. The unlimited plan goes against the earlier advice: use comes in 52 bursts, and one heavy user can hold this one-job-at-a-time server for all 53 other customers. 54 55 Every number is overridable by env var; these are defaults, not decisions cast 56 in code (SUBPLZ_WEB_PLANS_JSON). Retire a plan that was sold: do not delete it. 57 A late payment, or a running subscription, still finds it in RETIRED_PLANS. 58 """ 59 from __future__ import annotations 60 61 import json 62 import os 63 from dataclasses import asdict, dataclass 64 65 66 67 @dataclass(frozen=True) 68 class Plan: 69 id: str 70 name: str 71 # Books in a pack, or books each month for a recurring plan. None for an 72 # unlimited recurring plan. 73 credits: int | None 74 price_cents: int 75 currency: str = "usd" 76 recurring: bool = False 77 # How often a recurring plan renews: "month" or "year". 78 interval: str = "month" 79 blurb: str = "" 80 81 @property 82 def price_display(self) -> str: 83 return f"${self.price_cents / 100:,.2f}" 84 85 @property 86 def per_book_cents(self) -> int | None: 87 if not self.credits: 88 return None 89 return round(self.price_cents / self.credits) 90 91 92 DEFAULT_PLANS: list[Plan] = [ 93 Plan( 94 id="pack10", 95 name="10 books", 96 credits=10, 97 price_cents=499, 98 blurb="Ten books on our server, from any device. Credits never expire.", 99 ), 100 Plan( 101 id="pack100", 102 name="100 books", 103 credits=100, 104 price_cents=3999, 105 blurb="For a whole library. Our server does one book at a time: about " 106 "2 hours for a 10-hour book. Credits never expire.", 107 ), 108 Plan( 109 id="pack500", 110 name="500 books", 111 credits=500, 112 price_cents=17499, 113 blurb="For a very large library. Our server does one book at a time: " 114 "about 2 hours for a 10-hour book, so 500 long books take weeks. " 115 "Credits never expire.", 116 ), 117 Plan( 118 id="month10", 119 name="10 books a month", 120 credits=10, 121 price_cents=499, 122 recurring=True, 123 blurb="Renews each month until you cancel. Unused books do not carry over.", 124 ), 125 Plan( 126 id="month30", 127 name="30 books a month", 128 credits=30, 129 price_cents=999, 130 recurring=True, 131 blurb="Renews each month until you cancel. Unused books do not carry over.", 132 ), 133 Plan( 134 id="unlimited", 135 name="Unlimited", 136 credits=None, 137 price_cents=5000, 138 recurring=True, 139 blurb="Every book on our server, one at a time. Renews each month until you cancel.", 140 ), 141 ] 142 143 # Not for sale. A checkout that started before a plan was retired can still 144 # finish, and it must credit what the buyer saw on the payment page. 145 RETIRED_PLANS: list[Plan] = [ 146 Plan(id="single", name="One book", credits=1, price_cents=499), 147 Plan(id="pack5", name="5 books", credits=5, price_cents=1699), 148 Plan(id="pack20", name="20 books", credits=20, price_cents=3900), 149 ] 150 151 152 def plans() -> list[Plan]: 153 """The catalogue, overridable with SUBPLZ_WEB_PLANS_JSON.""" 154 raw = os.environ.get("SUBPLZ_WEB_PLANS_JSON") 155 if not raw: 156 return DEFAULT_PLANS 157 try: 158 return [Plan(**item) for item in json.loads(raw)] 159 except (json.JSONDecodeError, TypeError) as exc: 160 raise RuntimeError(f"SUBPLZ_WEB_PLANS_JSON is not valid: {exc}") from exc 161 162 163 def get(plan_id: str, retired: bool = False) -> Plan | None: 164 """A plan for sale. With `retired`, also a plan that is no longer sold.""" 165 pool = plans() + (RETIRED_PLANS if retired else []) 166 return next((p for p in pool if p.id == plan_id), None) 167 168 169 def as_dicts() -> list[dict]: 170 out = [] 171 for p in plans(): 172 d = asdict(p) 173 d["price_display"] = p.price_display 174 d["per_book_cents"] = p.per_book_cents 175 out.append(d) 176 return out 177 178 179 def free_tier_summary() -> str: 180 return "free in your browser, without limit" 181 182 183 # What each tier hands over, for the UI. Kinds match Artifact.kind. The tiers 184 # differ in where the work is done, not in what comes out. 185 _OUTPUTS = ["srt", "video_embedded", "video"] 186 TIER_OUTPUTS = {"free": _OUTPUTS, "cloud": _OUTPUTS}