Recently Written · git

subplz-web

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

Log | Files | Refs


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}