backend/pricing.py (4059 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 (a large speech model on a 27 GPU: minutes and not hours, from any device) a book takes one credit. The code 28 is public, so what is sold is the use of these machines and nothing else. 29 30 A book costs about $0.64 to convert on a rented GPU. Above about $5 a technical 31 buyer wraps the ElevenLabs API; below $3 the fixed card fee takes too much. 32 There is no unlimited plan: use comes in bursts (a backlog, then nothing), and 33 one heavy user of an unlimited plan costs more than the plan brings in. 34 35 free in the browser: no limit, each output 36 single book $4.99 37 5-book pack $16.99 ($3.40/book) 38 20-book pack $39.00 ($1.95/book) 39 40 Every number is overridable by env var; these are defaults, not decisions cast 41 in code. An operator who wants a recurring plan can add one with 42 SUBPLZ_WEB_PLANS_JSON ("recurring": true, "credits": null). 43 """ 44 from __future__ import annotations 45 46 import json 47 import os 48 from dataclasses import asdict, dataclass 49 50 51 52 @dataclass(frozen=True) 53 class Plan: 54 id: str 55 name: str 56 # None for the subscription, which is not a credit pack. 57 credits: int | None 58 price_cents: int 59 currency: str = "usd" 60 recurring: bool = False 61 blurb: str = "" 62 63 @property 64 def price_display(self) -> str: 65 return f"${self.price_cents / 100:,.2f}" 66 67 @property 68 def per_book_cents(self) -> int | None: 69 if not self.credits: 70 return None 71 return round(self.price_cents / self.credits) 72 73 74 DEFAULT_PLANS: list[Plan] = [ 75 Plan( 76 id="single", 77 name="One book", 78 credits=1, 79 price_cents=499, 80 blurb="One book on our server, from any device.", 81 ), 82 Plan( 83 id="pack5", 84 name="5 books", 85 credits=5, 86 price_cents=1699, 87 blurb="Credits never expire.", 88 ), 89 Plan( 90 id="pack20", 91 name="20 books", 92 credits=20, 93 price_cents=3900, 94 blurb="For working through a series.", 95 ), 96 ] 97 98 99 def plans() -> list[Plan]: 100 """The catalogue, overridable with SUBPLZ_WEB_PLANS_JSON.""" 101 raw = os.environ.get("SUBPLZ_WEB_PLANS_JSON") 102 if not raw: 103 return DEFAULT_PLANS 104 try: 105 return [Plan(**item) for item in json.loads(raw)] 106 except (json.JSONDecodeError, TypeError) as exc: 107 raise RuntimeError(f"SUBPLZ_WEB_PLANS_JSON is not valid: {exc}") from exc 108 109 110 def get(plan_id: str) -> Plan | None: 111 return next((p for p in plans() if p.id == plan_id), None) 112 113 114 def as_dicts() -> list[dict]: 115 out = [] 116 for p in plans(): 117 d = asdict(p) 118 d["price_display"] = p.price_display 119 d["per_book_cents"] = p.per_book_cents 120 out.append(d) 121 return out 122 123 124 def free_tier_summary() -> str: 125 return "free in your browser, without limit" 126 127 128 # What each tier hands over, for the UI. Kinds match Artifact.kind. The tiers 129 # differ in where the work is done, not in what comes out. 130 _OUTPUTS = ["srt", "video_embedded", "video"] 131 TIER_OUTPUTS = {"free": _OUTPUTS, "cloud": _OUTPUTS}