Recently Written · git

subplz-web

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

Log | Files | Refs


commit ba4d643eca98ca3763b334f356d3bd12b085dd5a
equwal <truex@equwal.com>
2026-09-21 01:40:56 -0700

Charge for where the work is done, not for what comes out

A conversion in the visitor's browser costs the server nothing. It is
now free without limit and gives each output, the mp4 for YouTube
included. A conversion on the server's hardware takes one credit. The
code is public, so the use of the operator's machines is the one thing
for sale.

- Remove the 24-hour free window, the YouTube tier, the unlock endpoint
  and the purchase made for a job.
- New prices: $4.99, $16.99 for 5, $39 for 20. The default catalogue
  has no unlimited plan: use comes in bursts, and one heavy user costs
  more than the plan brings in. An operator can add a recurring plan
  with SUBPLZ_WEB_PLANS_JSON; the subscription code stays for that.
- Add SUBPLZ_WEB_CLOUD_ENABLED. While it is false the page shows no way
  to buy credits, because there is nothing for them to buy yet.

 .env.example             |   9 +--
 README.md                |  61 +++++++----------
 backend/api.py           |  66 +++---------------
 backend/billing.py       | 175 +++++++++--------------------------------------
 backend/db.py            |   6 +-
 backend/payments.py      |  25 ++-----
 backend/pricing.py       |  75 +++++++++-----------
 backend/settings.py      |   9 ++-
 frontend/app.js          | 128 +++++-----------------------------
 frontend/index.html      |  26 +------
 frontend/style.css       |  26 +------
 tests/test_billing.py    | 168 ++++++++++++++++++---------------------------
 tests/test_local_jobs.py |  35 +++-------
 13 files changed, 217 insertions(+), 592 deletions(-)
diff --git a/.env.example b/.env.example
index 48b6822..7f9dae1 100644
--- a/.env.example
+++ b/.env.example
@@ -49,11 +49,12 @@ SUBPLZ_WEB_STORAGE_BACKEND=local
 
 # --- billing ---------------------------------------------------------------
 # Off for localhost: nothing is locked and nothing is for sale.
-# On: free tier = srt + mkv, one book per window; the YouTube mp4 costs a credit.
+# On: a conversion in the visitor's browser stays free, without limit. A
+# conversion on this server's hardware takes one credit.
 SUBPLZ_WEB_BILLING_ENABLED=false
-# One free book per rolling 24 hours.
-SUBPLZ_WEB_FREE_CONVERSIONS=1
-SUBPLZ_WEB_FREE_WINDOW_HOURS=24
+# True when fast conversion on this server's hardware is on offer. While it is
+# false the page sells nothing, whatever BILLING_ENABLED says.
+SUBPLZ_WEB_CLOUD_ENABLED=false
 
 # --- accounts & payments ---------------------------------------------------
 # The origin users reach this on. Sign-in links and Stripe return URLs use it.
diff --git a/README.md b/README.md
index cbfa1dd..90f2ed7 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@ from a machine's guess at what it heard. Two things to do with that:
 - **HoshiReader whispersync** — the `.srt` is the timing file.
 - **Subtitled video** — a YouTube-ready MP4 with a selectable caption track.
 
-One free book per rolling 24 hours; see [Pricing](#pricing).
+Free in the browser, without limit; see [Pricing](#pricing).
 
 ---
 
@@ -229,7 +229,7 @@ drop corrupt frames instead of letting a single bad chapter abort the whole run.
 | `backend/languages.py` | language registry and splitter routing |
 | `backend/queue.py` | in-process or Redis dispatch |
 | `backend/storage.py` | local disk or S3 |
-| `backend/billing.py` | who may do what: the free window, credits, the two tiers |
+| `backend/billing.py` | who may do what: free in the browser, a credit for a server job |
 | `backend/payments.py` | Stripe: checkout, idempotent fulfilment, webhooks |
 | `backend/accounts.py` | one email, one account; folding anonymous work in |
 | `backend/auth.py` | emailed one-time sign-in links |
@@ -310,45 +310,36 @@ mangles multipart filenames, and this client does not.
 
 ## Pricing
 
-One free book per rolling 24 hours, then paid. The window is rolling rather than
-a calendar day: the allowance returns 24 hours after the run that used it.
+All of the code is public, and anyone may host it. What this site sells is the
+use of its operator's machines, and nothing else.
+
+**Free and paid are split by where the work is done, not by what comes out.**
+
+| 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 |
 
 | Plan | Price | Per book |
 |---|---|---|
-| Free | — | 1 per 24h |
-| One book | $3.49 | $3.49 |
-| 5 books | $12.99 | $2.60 |
-| 20 books | $39.99 | $2.00 |
-| Unlimited monthly | $14.99 | — |
-
-Set against the market (2026): the direct competitors are cheap or free —
-Voxlight $29.99/year (alignment runs on the user's own Mac), Storyteller and
-syncabook free but self-hosted. The adjacent subtitling tools price for
-*transcription* and do not transfer: Sonix is $10/hour, so a 10-hour audiobook
-would be ~$100, and Happy Scribe's 120-minute $17 tier would not fit one book.
-Forced alignment is far cheaper to run than transcription, because the model is
-tiny and its output is thrown away. So: per book, priced as an impulse buy, with
-the subscription just under Otter ($16.99) and Happy Scribe ($17).
-
+| One book | $4.99 | $4.99 |
+| 5 books | $16.99 | $3.40 |
+| 20 books | $39.00 | $1.95 |
+
+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
+$3 the fixed card fee takes too much. There is no unlimited plan: use comes in
+bursts, and one heavy user of such a plan costs more than the plan brings in.
+An operator who wants a recurring plan adds one with `SUBPLZ_WEB_PLANS_JSON`.
 Every number is an env var — see `backend/pricing.py`.
 
-**Free and paid are split by output, not by quality.**
+A job in the browser costs the server nothing, so it is never counted and never
+refused. A server job takes its credit at the start. A job that fails or is
+cancelled gets the credit back.
 
-| Tier | You get | Costs |
-|---|---|---|
-| free | `.srt` (HoshiReader) + `.mkv` with the subtitles built in | one book per rolling 24 h |
-| youtube | all of that + the clean `.mp4` for uploading | one credit, or the unlimited plan |
-
-The free tier is the whole product for someone reading along at home. The one
-paid output is the one made for an audience. The mp4 is rendered for every job
-regardless - the mkv is a stream copy of it - so paying later unlocks a finished
-job instantly (`POST /api/jobs/{id}/unlock`) instead of re-running it. A credit
-spent on a job that then fails or is cancelled is handed back.
-
-**The free tier's identity is a cookie, and only a cookie.** Anyone who clears
-it gets another free book. That is accepted: a signup wall does not belong in
-front of a tool whose pitch is "drop two files in". The real protection against
-abuse is capacity, not identity.
+`SUBPLZ_WEB_CLOUD_ENABLED` says that fast conversion is on offer. While it is
+false the page shows no way to buy credits, whatever `SUBPLZ_WEB_BILLING_ENABLED`
+says: credits that buy nothing must not be for sale.
 
 **An account is an email.** It gets attached either by following an emailed
 one-time link (no passwords anywhere) or by paying, since Stripe collects one.
diff --git a/backend/api.py b/backend/api.py
index 437e55e..60b1ee7 100644
--- a/backend/api.py
+++ b/backend/api.py
@@ -124,8 +124,6 @@ class ArtifactOut(BaseModel):
     filename: str
     size_bytes: int
     url: str
-    # True when this file belongs to a tier the job has not paid for.
-    locked: bool = False
 
 
 class JobOut(BaseModel):
@@ -163,7 +161,6 @@ class UploadOut(BaseModel):
 class StartIn(BaseModel):
     language: str | None = None
     model: str | None = None
-    tier: Literal["free", "youtube"] = "free"
 
 
 class AccountOut(BaseModel):
@@ -174,19 +171,13 @@ class AccountOut(BaseModel):
     # Whether the server can actually take money / send sign-in email yet.
     payments_available: bool
     email_sign_in_available: bool
-    # Free tier.
-    free_allowance: int
-    free_window_hours: int
     free_tier_summary: str
-    free_remaining: int
-    free_allowed: bool
-    next_free_at: str | None
-    reason: str
-    # Paid tier.
+    # The cloud tier: conversions on this server's hardware.
+    cloud_available: bool
     credits: int
     subscribed: bool
     subscription_ends: str | None
-    youtube_allowed: bool
+    cloud_allowed: bool
     queue_depth: int
 
 
@@ -198,7 +189,6 @@ class LocalJobIn(BaseModel):
     audio_duration_seconds: float | None = None
     text_filename: str = Field(max_length=512)
     language: str = Field(max_length=16)
-    tier: Literal["free", "youtube"] = "free"
 
 
 class LocalFinishIn(BaseModel):
@@ -222,8 +212,6 @@ class TokenIn(BaseModel):
 
 class CheckoutIn(BaseModel):
     plan_id: str
-    # A finished free job to unlock with this purchase.
-    job_id: str | None = None
 
 
 def _job_out(job: Job, arts: list[Artifact]) -> JobOut:
@@ -251,7 +239,6 @@ def _job_out(job: Job, arts: list[Artifact]) -> JobOut:
             ArtifactOut(
                 kind=a.kind, filename=a.filename, size_bytes=a.size_bytes,
                 url=f"/api/jobs/{job.id}/files/{a.kind}",
-                locked=not billing.can_download(job, a.kind),
             )
             for a in arts
         ],
@@ -298,7 +285,7 @@ def get_account_info(
 
 
 def _account_out(session: Session, account: Account) -> AccountOut:
-    ent = billing.check(session, account)
+    ent = billing.check(account)
     return AccountOut(
         id=account.id,
         signed_in=account.signed_in,
@@ -306,19 +293,14 @@ def _account_out(session: Session, account: Account) -> AccountOut:
         billing_enabled=settings.billing_enabled,
         payments_available=settings.payments_configured,
         email_sign_in_available=settings.sign_in_available,
-        free_allowance=ent.free_allowance,
-        free_window_hours=ent.window_hours,
         free_tier_summary=pricing.free_tier_summary(),
-        free_remaining=ent.free_remaining,
-        free_allowed=ent.free_allowed,
-        next_free_at=ent.next_free_at.isoformat() if ent.next_free_at else None,
-        reason=ent.reason,
         credits=ent.credits,
         subscribed=ent.subscribed,
         subscription_ends=(
             ent.subscription_ends.isoformat() if ent.subscription_ends else None
         ),
-        youtube_allowed=ent.youtube_allowed or not settings.billing_enabled,
+        cloud_available=settings.cloud_enabled,
+        cloud_allowed=ent.cloud_allowed or not settings.billing_enabled,
         queue_depth=queue.depth(),
     )
 
@@ -411,10 +393,8 @@ def create_checkout(
     plan = pricing.get(body.plan_id)
     if plan is None:
         raise HTTPException(404, "No such plan.")
-    if body.job_id:
-        _load(session, account, body.job_id)  # 404s on someone else's job
     try:
-        url = payments.start_checkout(session, account, plan, body.job_id)
+        url = payments.start_checkout(session, account, plan)
     except payments.PaymentsUnavailable as exc:
         raise HTTPException(503, str(exc)) from exc
     except payments.PaymentError as exc:
@@ -645,7 +625,7 @@ def start_job(
         job.model = body.model
 
     try:
-        billing.authorize_start(session, account, job, body.tier)
+        billing.authorize_start(session, account, job)
     except billing.PaymentRequired as exc:
         session.rollback()
         raise HTTPException(402, str(exc)) from exc
@@ -702,12 +682,6 @@ def start_local_job(
         .first()
     )
     if running is not None:
-        if body.tier == billing.YOUTUBE and running.tier != billing.YOUTUBE:
-            try:
-                billing.unlock(session, account, running)
-            except billing.PaymentRequired as exc:
-                session.rollback()
-                raise HTTPException(402, str(exc)) from exc
         running.language = body.language
         session.commit()
         return _job_out(running, [])
@@ -722,7 +696,7 @@ def start_local_job(
     )
     session.add(job)
     try:
-        billing.authorize_start(session, account, job, body.tier)
+        billing.authorize_start(session, account, job)
     except billing.PaymentRequired as exc:
         session.rollback()
         raise HTTPException(402, str(exc)) from exc
@@ -864,23 +838,6 @@ def cancel_job(
     return _job_out(job, [])
 
 
-@router.post("/jobs/{job_id}/unlock", response_model=JobOut)
-def unlock_job(
-    job_id: str,
-    account: Annotated[Account, Depends(get_account)],
-    session: Annotated[Session, Depends(get_session)],
-):
-    """Upgrade a free job to the YouTube tier, spending a credit."""
-    job = _load(session, account, job_id)
-    try:
-        billing.unlock(session, account, job)
-    except billing.PaymentRequired as exc:
-        session.rollback()
-        raise HTTPException(402, str(exc)) from exc
-    session.commit()
-    return _job_out(job, _artifacts(session, job.id))
-
-
 @router.delete("/jobs/{job_id}")
 def delete_job(
     job_id: str,
@@ -905,11 +862,6 @@ def download(
     session: Annotated[Session, Depends(get_session)],
 ):
     job = _load(session, account, job_id)
-    if not billing.can_download(job, kind):
-        raise HTTPException(
-            402, "The YouTube video is part of the paid tier. Unlock it with "
-                 "a credit, or the unlimited plan."
-        )
     art = (
         session.query(Artifact)
         .filter(Artifact.job_id == job.id, Artifact.kind == kind)
diff --git a/backend/billing.py b/backend/billing.py
index 1af9775..1f4dcf7 100644
--- a/backend/billing.py
+++ b/backend/billing.py
@@ -1,43 +1,36 @@
 """Entitlement: what an account may do right now.
 
-Two tiers, split by what you walk away with:
+The line between free and paid is where the work is done, not what comes out:
 
-  free      The subtitles (.srt, for HoshiReader) and the video with the
-            subtitles built in (.mkv, for MPV/VLC). One book per rolling
-            24 hours.
-  youtube   All of that plus the clean .mp4 made for uploading to YouTube.
-            Costs one credit, or nothing on the unlimited plan, and starts
-            right away - paying customers do not queue behind the free window.
+  free    The job runs in the visitor's browser, on the visitor's machine. It
+          costs this server nothing, so it has no price and no limit, and it
+          gives each output: subtitles, videos, the read-along book.
+  cloud   The job runs on this server's hardware: a large speech model on a
+          GPU, minutes and not hours, from any device. One credit for a book,
+          or nothing on a recurring plan if the operator sells one.
 
-Disabled on localhost (SUBPLZ_WEB_BILLING_ENABLED=false) so nothing gets in the
-way while you use it yourself. The accounting still runs either way, so turning
-billing on for the public release does not need a backfill.
+All of the code is public and anyone may host it. What is sold is the use of
+this operator's machines.
 
-The window is rolling, not a calendar day: the allowance comes back 24 hours
-after the run that used it, which avoids a midnight stampede and is easier to
-explain than "resets at 00:00 in some timezone".
+Disabled on localhost (SUBPLZ_WEB_BILLING_ENABLED=false) so nothing gets in the
+way while you use it yourself.
 
 Taking the money lives in payments.py; this file only decides who may do what.
 """
-
 from __future__ import annotations
 
 from dataclasses import dataclass
 from datetime import datetime, timedelta, timezone
 
-from sqlalchemy import func, update
+from sqlalchemy import update
 from sqlalchemy.orm import Session
 
-from .db import Account, Job, JobStatus, SessionLocal, utcnow
+from .db import Account, Job, SessionLocal, utcnow
 from .settings import settings
 
 FREE = "free"
-YOUTUBE = "youtube"
-TIERS = (FREE, YOUTUBE)
-
-# Jobs in these states hold a slot in the free window. A failed or cancelled
-# run releases it: charging for our own failure is not a business model.
-_HOLDING = [JobStatus.queued, JobStatus.running, JobStatus.succeeded]
+CLOUD = "cloud"
+TIERS = (FREE, CLOUD)
 
 # Stripe keeps retrying a failed renewal for a while; do not lock someone out
 # the second their card hiccups.
@@ -57,23 +50,12 @@ def _aware(when: datetime | None) -> datetime | None:
 
 @dataclass(frozen=True)
 class Entitlement:
-    # Free tier.
-    free_allowed: bool
-    free_used: int
-    free_allowance: int
-    free_remaining: int
-    window_hours: int
-    # When the next free conversion becomes available, if the window is full.
-    next_free_at: datetime | None
-    # Paid tier.
     credits: int
     subscribed: bool
     subscription_ends: datetime | None
-    # Why a free start is blocked, ready to show.
-    reason: str = ""
 
     @property
-    def youtube_allowed(self) -> bool:
+    def cloud_allowed(self) -> bool:
         return self.subscribed or self.credits > 0
 
 
@@ -84,61 +66,11 @@ def is_subscribed(account: Account) -> bool:
     return ends is None or ends + _GRACE > utcnow()
 
 
-def _window_start() -> datetime:
-    return utcnow() - timedelta(hours=settings.free_window_hours)
-
-
-def _window_filter(account_id: str):
-    return (
-        Job.account_id == account_id,
-        Job.billed == 1,
-        Job.status.in_(_HOLDING),
-        Job.created_at >= _window_start(),
-    )
-
-
-def check(session: Session, account: Account) -> Entitlement:
-    used = (
-        session.query(func.count(Job.id)).filter(*_window_filter(account.id)).scalar()
-        or 0
-    )
-    free = settings.free_conversions
-    window = settings.free_window_hours
-    subscribed = is_subscribed(account)
-    remaining = max(0, free - used)
-
-    def build(allowed: bool, reason: str = "", when: datetime | None = None):
-        return Entitlement(
-            free_allowed=allowed, free_used=used, free_allowance=free,
-            free_remaining=remaining, window_hours=window, next_free_at=when,
-            credits=account.purchased_credits, subscribed=subscribed,
-            subscription_ends=_aware(account.subscription_period_end),
-            reason=reason,
-        )
-
-    if not settings.billing_enabled or subscribed or remaining > 0:
-        return build(True)
-
-    # The earliest job still inside the window decides when a slot frees up.
-    oldest = _aware(
-        session.query(func.min(Job.created_at))
-        .filter(*_window_filter(account.id))
-        .scalar()
-    )
-    when = oldest + timedelta(hours=window) if oldest else None
-    book = "book" if free == 1 else "books"
-    return build(
-        False,
-        reason=(
-            f"The free tier is {free} {book} every {window} hours. "
-            + (
-                f"Your next one unlocks at {when:%H:%M UTC on %d %b}. "
-                if when
-                else ""
-            )
-            + "A credit converts a book right now, YouTube video included."
-        ),
-        when=when,
+def check(account: Account) -> Entitlement:
+    return Entitlement(
+        credits=account.purchased_credits,
+        subscribed=is_subscribed(account),
+        subscription_ends=_aware(account.subscription_period_end),
     )
 
 
@@ -153,58 +85,22 @@ def _spend_credit(session: Session, account: Account) -> bool:
     return bool(taken)
 
 
-def authorize_start(session: Session, account: Account, job: Job, tier: str) -> None:
-    """Charge whatever starting `job` on `tier` costs, or raise PaymentRequired."""
-    if tier not in TIERS:
-        raise ValueError(f"unknown tier {tier!r}")
+def authorize_start(session: Session, account: Account, job: Job) -> None:
+    """Charge what starting `job` costs, or raise PaymentRequired.
 
-    job.tier, job.billed, job.credit_spent = tier, 0, 0
-
-    if not settings.billing_enabled:
-        job.billed = 1  # accounting only; nothing is ever refused
-        return
-
-    if is_subscribed(account):
-        return
-
-    if tier == YOUTUBE:
-        if not _spend_credit(session, account):
-            raise PaymentRequired(
-                "The YouTube video is a paid extra: one credit per book, "
-                "or the unlimited plan."
-            )
-        job.credit_spent = 1
-        return
-
-    ent = check(session, account)
-    if not ent.free_allowed:
-        raise PaymentRequired(ent.reason)
-    job.billed = 1
-
-
-def unlock(session: Session, account: Account, job: Job) -> None:
-    """Upgrade a free job to the YouTube tier after the fact.
-
-    The mp4 already exists - the mkv is made from it - so this is only ever a
-    permission change, never a second run.
+    Where the job runs sets its tier: a browser job is free, a server job is
+    a cloud job.
     """
-    if job.tier == YOUTUBE or not settings.billing_enabled:
-        job.tier = YOUTUBE
+    job.tier = FREE if job.local else CLOUD
+    job.billed, job.credit_spent = 0, 0
+    if job.local or not settings.billing_enabled or is_subscribed(account):
         return
-    if not is_subscribed(account):
-        if not _spend_credit(session, account):
-            raise PaymentRequired(
-                "Unlocking the YouTube video takes one credit, "
-                "or the unlimited plan."
-            )
-        job.credit_spent = 1
-    job.tier = YOUTUBE
-
-
-def can_download(job: Job, kind: str) -> bool:
-    if kind != "video" or not settings.billing_enabled:
-        return True
-    return job.tier == YOUTUBE
+    if not _spend_credit(session, account):
+        raise PaymentRequired(
+            "A conversion on our servers takes one credit. "
+            "In your browser it is free, without limit."
+        )
+    job.credit_spent = 1
 
 
 def refund(session: Session, job: Job) -> None:
@@ -217,9 +113,6 @@ def refund(session: Session, job: Job) -> None:
             .values(purchased_credits=Account.purchased_credits + 1)
         )
         job.credit_spent = 0
-        # Back to free, so a retry is priced again rather than riding on a
-        # credit that has just been returned.
-        job.tier = FREE
     session.add(job)
 
 
diff --git a/backend/db.py b/backend/db.py
index 245db7b..ada87d8 100644
--- a/backend/db.py
+++ b/backend/db.py
@@ -136,10 +136,10 @@ class Job(Base):
     stage: Mapped[str] = mapped_column(String(128), default="queued")
     error: Mapped[str | None] = mapped_column(Text, nullable=True)
 
-    # 1 while the job holds a slot in the free window.
+    # Not used from 2.2 on. It counted jobs in the free window of earlier versions.
     billed: Mapped[int] = mapped_column(Integer, default=0)
-    # "free": subtitles + the video with subtitles built in.
-    # "youtube": also the clean mp4. Paid for with a credit or a subscription.
+    # "free": the job ran in the visitor's browser. "cloud": it ran on this
+    # server and took a credit. (Rows from before 2.2 can say "youtube".)
     tier: Mapped[str] = mapped_column(
         String(16), default="free", server_default=text("'free'")
     )
diff --git a/backend/payments.py b/backend/payments.py
index 1939baa..b74ed93 100644
--- a/backend/payments.py
+++ b/backend/payments.py
@@ -27,7 +27,7 @@ from sqlalchemy.exc import IntegrityError
 from sqlalchemy.orm import Session
 
 from . import accounts, billing, pricing
-from .db import Account, Job, Purchase
+from .db import Account, Purchase
 from .settings import settings
 
 log = logging.getLogger(__name__)
@@ -67,18 +67,12 @@ def _base() -> str:
 # checkout
 # ---------------------------------------------------------------------------
 
-def start_checkout(
-    session: Session, account: Account, plan: pricing.Plan, job_id: str | None = None
-) -> str:
-    """Return the Stripe-hosted payment page for `plan`.
-
-    `job_id` names a finished free job to unlock as soon as the money lands,
-    so "pay to get this video" is one trip rather than two.
-    """
+def start_checkout(session: Session, account: Account, plan: pricing.Plan) -> str:
+    """Return the Stripe-hosted payment page for `plan`."""
     stripe = _stripe()
 
     if plan.recurring and billing.is_subscribed(account):
-        raise PaymentError("You are already on the unlimited plan.")
+        raise PaymentError("You are already on this plan.")
 
     price = {
         "currency": plan.currency,
@@ -88,7 +82,7 @@ def start_checkout(
     if plan.recurring:
         price["recurring"] = {"interval": "month"}
 
-    meta = {"account_id": account.id, "plan_id": plan.id, "job_id": job_id or ""}
+    meta = {"account_id": account.id, "plan_id": plan.id}
     params: dict = {
         "mode": "subscription" if plan.recurring else "payment",
         "line_items": [{"quantity": 1, "price_data": price}],
@@ -235,15 +229,6 @@ def fulfil(session: Session, checkout: dict) -> Account | None:
             log.warning("could not read subscription %s: %s",
                         checkout["subscription"], exc)
 
-    # The purchase was made to get a particular video: hand it over.
-    job = session.get(Job, meta.get("job_id")) if meta.get("job_id") else None
-    if job is not None and job.account_id == account.id:
-        try:
-            billing.unlock(session, account, job)
-            session.commit()
-        except billing.PaymentRequired:
-            session.rollback()
-
     log.info("fulfilled %s: plan=%s account=%s", checkout["id"], plan.id, account.id)
     return account
 
diff --git a/backend/pricing.py b/backend/pricing.py
index 446868b..8f1ac90 100644
--- a/backend/pricing.py
+++ b/backend/pricing.py
@@ -15,39 +15,38 @@ Market as of 2026:
     Kapwing/Descript  $24/mo
     Sonix             $10/hour pay-as-you-go
 
+  Raw forced alignment as an API
+    ElevenLabs        $0.22 per hour of audio: $2.20 for a 10-hour book
+
 The adjacent tools price per *minute of transcription*, which does not transfer:
-a 10-hour audiobook is 600 minutes, so it would cost ~$100 at Sonix's rate and
-need Happy Scribe's $89 tier. Forced alignment is much cheaper to run than
-transcription - the model is tiny and its output is thrown away, since the
-subtitle text comes from the user's own book.
-
-So we price per book, cheap enough to be an impulse buy, and land the
-subscription just under the general subtitling tools.
-
-What is free and what is paid is split by output, not by quality. The free tier
-is the complete product for someone reading along at home: the .srt for
-HoshiReader and an .mkv with the subtitles built in. What costs money is the
-clean .mp4 made for publishing on YouTube - the one output whose whole point is
-an audience, and so the one whose users can be asked to pay. A credit buys one
-book with every output, and skips the free tier's 24-hour wait.
-
-  free              1 book / 24h, srt + mkv
-  single book       $3.49
-  5-book pack       $12.99   ($2.60/book)
-  20-book pack      $39.99   ($2.00/book)
-  unlimited month   $14.99   (under Otter/Happy Scribe, well under Veed/Kapwing)
+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.
+
+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.
+There is no unlimited plan: use comes in bursts (a backlog, then nothing), and
+one heavy user of an unlimited plan costs more than the plan brings in.
+
+  free              in the browser: no limit, each output
+  single book       $4.99
+  5-book pack       $16.99   ($3.40/book)
+  20-book pack      $39.00   ($1.95/book)
 
 Every number is overridable by env var; these are defaults, not decisions cast
-in code.
+in code. An operator who wants a recurring plan can add one with
+SUBPLZ_WEB_PLANS_JSON ("recurring": true, "credits": null).
 """
-
 from __future__ import annotations
 
 import json
 import os
 from dataclasses import asdict, dataclass
 
-from .settings import settings
 
 
 @dataclass(frozen=True)
@@ -77,31 +76,23 @@ DEFAULT_PLANS: list[Plan] = [
         id="single",
         name="One book",
         credits=1,
-        price_cents=349,
-        blurb="One book with the YouTube video, no waiting.",
+        price_cents=499,
+        blurb="One book on our GPU: minutes, from any device.",
     ),
     Plan(
         id="pack5",
         name="5 books",
         credits=5,
-        price_cents=1299,
+        price_cents=1699,
         blurb="Credits never expire.",
     ),
     Plan(
         id="pack20",
         name="20 books",
         credits=20,
-        price_cents=3999,
+        price_cents=3900,
         blurb="For working through a series.",
     ),
-    Plan(
-        id="unlimited",
-        name="Unlimited monthly",
-        credits=None,
-        price_cents=1499,
-        recurring=True,
-        blurb="Every book, YouTube video included. Cancel any time.",
-    ),
 ]
 
 
@@ -131,14 +122,10 @@ def as_dicts() -> list[dict]:
 
 
 def free_tier_summary() -> str:
-    n = settings.free_conversions
-    hours = settings.free_window_hours
-    book = "book" if n == 1 else "books"
-    return f"{n} free {book} every {hours} hours"
+    return "free in your browser, without limit"
 
 
-# What each tier hands over, for the UI. Kinds match Artifact.kind.
-TIER_OUTPUTS = {
-    "free": ["srt", "video_embedded"],
-    "youtube": ["srt", "video_embedded", "video"],
-}
+# What each tier hands over, for the UI. Kinds match Artifact.kind. The tiers
+# differ in where the work is done, not in what comes out.
+_OUTPUTS = ["srt", "video_embedded", "video"]
+TIER_OUTPUTS = {"free": _OUTPUTS, "cloud": _OUTPUTS}
diff --git a/backend/settings.py b/backend/settings.py
index 4583922..3fd6c78 100644
--- a/backend/settings.py
+++ b/backend/settings.py
@@ -58,9 +58,12 @@ class Settings(BaseSettings):
     database_url: str = ""
 
     # --- billing -----------------------------------------------------------
-    # The public offer: one free book per rolling 24 hours, pay for more.
-    free_conversions: int = 1
-    free_window_hours: int = 24
+    # The public offer: free in the visitor's browser; a conversion on this
+    # server's hardware takes a credit. See billing.py.
+    # True when fast conversion on this server's hardware is on offer. While it
+    # is false the site sells nothing, whatever billing_enabled says: credits
+    # that buy nothing must not be for sale.
+    cloud_enabled: bool = False
     # Off on localhost so nothing blocks you; flip on for the public release.
     billing_enabled: bool = False
 
diff --git a/frontend/app.js b/frontend/app.js
index 7ae6a1d..77ad6f8 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -15,8 +15,6 @@ const el = {
   matchSummary: $('match-summary'), matchWarnings: $('match-warnings'),
   who: $('who'), buyBtn: $('buy-btn'), portalBtn: $('portal-btn'),
   signinBtn: $('signin-btn'), signoutBtn: $('signout-btn'),
-  tiers: $('tiers'), freeNote: $('free-note'), ytNote: $('yt-note'),
-  ytCost: $('yt-cost'), paidTag: $('paid-tag'),
   signinDialog: $('signin-dialog'), signinForm: $('signin-form'),
   signinEmail: $('signin-email'), signinSend: $('signin-send'),
   signinNote: $('signin-note'),
@@ -32,7 +30,6 @@ let languagesReady = null;  // resolves once the <select> is populated
 let draft = null;           // the job awaiting confirmation
 let pollTimer = null;
 let account = null;         // last /api/account response
-let unlockJobId = null;     // the job a purchase is being made for
 
 /* ---------------- helpers ---------------- */
 
@@ -129,54 +126,18 @@ function renderAccount() {
   el.signoutBtn.hidden = !signedIn;
   el.signinBtn.hidden = signedIn || !a.email_sign_in_available;
 
-  // With billing off (localhost) nothing is for sale and nothing is locked.
-  const selling = a.billing_enabled;
+  // What is sold is conversion on this server's hardware. Nothing is for sale
+  // until that is connected; in this tab each output is free.
+  const selling = a.billing_enabled && a.cloud_available;
   el.buyBtn.hidden = !selling || a.subscribed;
   el.portalBtn.hidden = !(selling && a.subscribed);
-  el.tiers.hidden = !selling;
-  if (el.paidTag) el.paidTag.hidden = !selling;
-
   el.quota.hidden = !selling;
   if (selling) {
-    el.quota.classList.toggle('blocked', !a.free_allowed && !a.youtube_allowed);
-    el.quota.textContent = a.subscribed ? 'Unlimited plan'
-      : a.credits > 0 ? `${a.credits} credit${a.credits === 1 ? '' : 's'}`
-      : a.free_allowed ? 'Free book available'
-      : 'Free book used';
-  }
-  renderTiers();
-}
-
-/* Say what each choice will cost *this* account before they commit to it. */
-function renderTiers() {
-  const a = account;
-  if (!a || !a.billing_enabled) return;
-
-  el.freeNote.textContent = a.subscribed || a.free_allowed ? ''
-    : `Used for now — next free book ${whenText(a.next_free_at)}.`;
-  el.ytCost.textContent = a.subscribed ? 'included' : '1 credit';
-  el.ytNote.textContent = a.subscribed ? 'Included in your unlimited plan.'
-    : a.credits > 0 ? `You have ${a.credits} credit${a.credits === 1 ? '' : 's'}.`
-    : 'You have no credits yet — you can buy one on the next step.';
-
-  // Nothing free left: preselect the option that can actually start.
-  if (!a.free_allowed && !a.subscribed) {
-    const yt = document.querySelector('input[name=tier][value=youtube]');
-    if (yt) yt.checked = true;
+    el.quota.textContent = a.subscribed ? 'Cloud plan'
+      : `${a.credits} cloud credit${a.credits === 1 ? '' : 's'}`;
   }
 }
 
-function whenText(iso) {
-  if (!iso) return 'later';
-  const d = new Date(iso);
-  return 'at ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) +
-    (d.toDateString() === new Date().toDateString() ? '' : ' tomorrow');
-}
-
-const chosenTier = () =>
-  (account && account.billing_enabled &&
-   document.querySelector('input[name=tier]:checked')?.value) || 'free';
-
 let toastTimer = null;
 function toast(msg, ms = 6000) {
   el.toast.textContent = msg;
@@ -388,9 +349,8 @@ el.start.addEventListener('click', async () => {
   el.start.disabled = true;
   clearError();
   const language = el.language.value;
-  const tier = chosenTier();
 
-  // Ask first: the free window and credits are the server's to say.
+  // The server keeps the list of this visitor's books; a job in this tab costs nothing.
   let registered;
   try {
     registered = await api('/api/local/jobs', {
@@ -400,19 +360,18 @@ el.start.addEventListener('click', async () => {
         audio_filename: draft.audio.length > 1 ? `${draft.audio[0].name} + ${draft.audio.length - 1} more` : draft.audio[0].name,
         audio_parts: draft.audio.length,
         audio_bytes: draft.audio.reduce((n, f) => n + f.size, 0),
-        text_filename: draft.book.name, language, tier,
+        text_filename: draft.book.name, language,
       }),
     });
   } catch (e) {
-    // 402 is not an error to apologise for; it is the price list's cue.
-    if (e.status === 402) openPricing(e.message); else showError(e.message);
+    showError(e.message);
     el.start.disabled = false;
     return;
   }
 
   const { Job, Cancelled } = await import('/engine/job.js');
   const job = new Job({ audio: draft.audio, book: draft.book, language, onStatus: renderWorking });
-  running = { job, serverId: registered.id, tier: registered.tier, cover: draft.cover };
+  running = { job, serverId: registered.id, cover: draft.cover };
   el.confirm.hidden = true;
   el.working.hidden = false;
   el.results.hidden = true;
@@ -485,7 +444,6 @@ function showResults(r) {
 
 function renderResultButtons() {
   const r = running.job.result;
-  const paid = !account?.billing_enabled || running.tier === 'youtube';
   el.resultFiles.innerHTML = '';
   const button = (label, hint, cls, onClick) => {
     const b = document.createElement('button');
@@ -501,10 +459,8 @@ function renderResultButtons() {
   }
   button('⬇ Video with subs built in (.mkv)', 'Subtitles inside the file, for MPV or VLC', '',
     (b) => makeVideo('mkv', b));
-  button(paid ? '⬇ Video for YouTube (.mp4)' : '🔒 Unlock the YouTube video (.mp4)',
-    paid ? 'No subtitles baked in — add the .srt in YouTube Studio'
-      : 'Part of the YouTube tier — one credit, or the unlimited plan',
-    paid ? '' : 'locked', (b) => (paid ? makeVideo('mp4', b) : unlockRunning(b)));
+  button('⬇ Video for YouTube (.mp4)', 'No subtitles baked in — add the .srt in YouTube Studio', '',
+    (b) => makeVideo('mp4', b));
 }
 
 async function makeEpub(btn) {
@@ -534,20 +490,6 @@ async function makeVideo(kind, btn) {
   btn.disabled = false;
 }
 
-async function unlockRunning(btn) {
-  btn.disabled = true;
-  try {
-    const j = await api(`/api/jobs/${running.serverId}/unlock`, { method: 'POST' });
-    running.tier = j.tier;
-    toast('Unlocked.');
-    renderResultButtons();
-    refreshAccount();
-  } catch (e) {
-    if (e.status === 402) { unlockJobId = running.serverId; openPricing(e.message); } else showError(e.message);
-    btn.disabled = false;
-  }
-}
-
 function save(file) {
   const a = document.createElement('a');
   a.href = URL.createObjectURL(file);
@@ -633,12 +575,6 @@ function jobCard(j) {
       .map((a) => {
         const size = a.size_bytes ? ` <span class="dl-size">${fmtBytes(a.size_bytes)}</span>` : '';
         const t = hint[a.kind] ? ` title="${escapeHtml(hint[a.kind])}"` : '';
-        if (a.locked) {
-          // Already rendered, just not paid for: unlocking is instant.
-          return `<button type="button" class="dl locked" data-unlock="${j.id}"
-                    title="Part of the YouTube tier — one credit, or the unlimited plan"
-                    >🔒 Unlock the YouTube video (.mp4)${size}</button>`;
-        }
         return `<a class="dl ${primary.has(a.kind) ? '' : 'secondary'}"${t}
                    href="${a.url}" download>${label[a.kind] || a.kind}${size}</a>`;
       })
@@ -647,10 +583,6 @@ function jobCard(j) {
 
   li.innerHTML = html;
 
-  li.querySelectorAll('[data-unlock]').forEach((btn) => {
-    btn.addEventListener('click', () => unlockJob(btn.dataset.unlock, btn));
-  });
-
   if (active) {
     const cancel = document.createElement('button');
     cancel.className = 'ghost';
@@ -772,29 +704,11 @@ el.picker.addEventListener('change', () => {
   el.picker.value = '';  // let the same file be chosen again after a removal
 });
 
-/* ---------------- unlock & checkout ---------------- */
-
-async function unlockJob(jobId, btn) {
-  if (btn) btn.disabled = true;
-  try {
-    await api(`/api/jobs/${jobId}/unlock`, { method: 'POST' });
-    toast('Unlocked — the YouTube video is ready to download.');
-    refreshJobs();
-    refreshAccount();
-  } catch (e) {
-    if (e.status === 402) {
-      unlockJobId = jobId;
-      openPricing(e.message);
-    } else {
-      showError(e.message);
-    }
-    if (btn) btn.disabled = false;
-  }
-}
+/* ---------------- checkout ---------------- */
 
 async function openPricing(why) {
   el.pricingWhy.textContent = why ||
-    'The clean .mp4 for YouTube is the paid part. Everything else stays free.';
+    'In this tab a conversion is free, without limit. A credit converts a book on our GPU: minutes and not hours, from any device.';
   el.plans.innerHTML = '<p class="muted">Loading…</p>';
   if (!el.pricingDialog.open) el.pricingDialog.showModal();
 
@@ -830,7 +744,7 @@ async function checkout(planId, btn) {
     const { url } = await api('/api/billing/checkout', {
       method: 'POST',
       headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({ plan_id: planId, job_id: unlockJobId }),
+      body: JSON.stringify({ plan_id: planId }),
     });
     if (running) {
       // The finished job only exists in this tab. Pay in another one.
@@ -848,9 +762,8 @@ async function checkout(planId, btn) {
   }
 }
 
-el.pricingDialog.addEventListener('close', () => { unlockJobId = null; });
 el.buyBtn.addEventListener('click', () => openPricing(
-  'One credit is one book with every output, YouTube video included.'));
+  'One credit converts one book on our GPU: minutes and not hours, from any device.'));
 
 el.portalBtn.addEventListener('click', async () => {
   try {
@@ -922,15 +835,8 @@ async function handleArrival() {
   }
 }
 
-// Back from paying in the other tab: the purchase unlocked this job server-side.
-window.addEventListener('focus', async () => {
-  if (!running?.job.result || running.tier === 'youtube') return;
-  try {
-    const j = await api(`/api/jobs/${running.serverId}`);
-    if (j.tier === 'youtube') { running.tier = j.tier; renderResultButtons(); toast('Unlocked.'); }
-    refreshAccount();
-  } catch { /* try again on the next focus */ }
-});
+// Back from paying in the other tab.
+window.addEventListener('focus', () => { if (running?.job.result) refreshAccount().catch(() => {}); });
 
 /* ---------------- boot ---------------- */
 
diff --git a/frontend/index.html b/frontend/index.html
index 71cd84a..2bc903d 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -43,9 +43,9 @@
       <li><strong>Local video</strong> — an .mkv with the subtitles built in, for
         MPV or VLC. <span class="tag free">free</span></li>
       <li><strong>YouTube video</strong> — a clean .mp4, plus the .srt to upload as
-        its captions. <span class="tag paid" id="paid-tag">paid</span></li>
+        its captions. <span class="tag free">free</span></li>
     </ul>
-    <p class="muted small" id="freetier">One free book every 24 hours.</p>
+    <p class="muted small" id="freetier">Free in your browser, without limit.</p>
   </section>
 
   <!-- step 1: drop -->
@@ -134,28 +134,6 @@
       <ul id="match-warnings" class="match-warnings"></ul>
     </div>
 
-    <fieldset id="tiers" class="tiers" hidden>
-      <legend>What do you need?</legend>
-      <label class="tier">
-        <input type="radio" name="tier" value="free" checked>
-        <span class="tier-body">
-          <span class="tier-name">Read-along <span class="tag free">free</span></span>
-          <span class="tier-desc">Subtitles (.srt) for HoshiReader, and a video
-            with the subtitles built in (.mkv) for MPV or VLC.</span>
-          <span class="tier-note warn" id="free-note"></span>
-        </span>
-      </label>
-      <label class="tier">
-        <input type="radio" name="tier" value="youtube">
-        <span class="tier-body">
-          <span class="tier-name">YouTube <span class="tag paid" id="yt-cost">1 credit</span></span>
-          <span class="tier-desc">Everything above, plus a clean .mp4 made for
-            uploading to YouTube. Starts right away — no 24-hour wait.</span>
-          <span class="tier-note" id="yt-note"></span>
-        </span>
-      </label>
-    </fieldset>
-
     <div class="actions">
       <button id="start" class="primary">Start alignment</button>
       <button id="discard" class="ghost">Discard</button>
diff --git a/frontend/style.css b/frontend/style.css
index 7f362c2..101558b 100644
--- a/frontend/style.css
+++ b/frontend/style.css
@@ -292,37 +292,13 @@ button.dl:disabled { opacity: .6; cursor: progress; }
        overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
 button.compact { padding: 6px 12px; font-size: 13px; }
 
-/* ---------- tiers ---------- */
+/* ---------- tags ---------- */
 .tag {
   font-size: 10.5px; font-weight: 700; letter-spacing: .06em;
   text-transform: uppercase; padding: 2px 7px; border-radius: 999px;
   vertical-align: 1px; white-space: nowrap;
 }
 .tag.free { background: var(--ok-soft); color: var(--ok); }
-.tag.paid { background: var(--accent-soft); color: var(--accent); }
-
-.tiers { border: 0; padding: 0; margin: 20px 0 0; display: grid; gap: 10px; }
-.tiers legend { padding: 0; margin-bottom: 8px; font-weight: 600; font-size: 14px; }
-.tier {
-  display: flex; gap: 12px; align-items: flex-start; cursor: pointer;
-  border: 1px solid var(--line); border-radius: 11px; padding: 12px 14px;
-}
-.tier:hover { border-color: var(--muted); }
-.tier:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); }
-.tier input { margin-top: 4px; accent-color: var(--accent); }
-.tier-body { display: grid; gap: 3px; min-width: 0; }
-.tier-name { font-weight: 600; }
-.tier-desc { color: var(--muted); font-size: 13.5px; }
-.tier-note { font-size: 12.5px; color: var(--muted); }
-.tier-note.warn { color: var(--warn); }
-.tier-note:empty { display: none; }
-
-/* A download that exists but has not been paid for. */
-button.dl.locked {
-  background: transparent; color: var(--accent); border: 1px dashed var(--accent);
-}
-button.dl.locked:hover { background: var(--accent-soft); filter: none; }
-button.dl.locked:disabled { opacity: .55; cursor: progress; }
 
 /* ---------- dialogs ---------- */
 dialog.modal {
diff --git a/tests/test_billing.py b/tests/test_billing.py
index 8e801d8..af6585c 100644
--- a/tests/test_billing.py
+++ b/tests/test_billing.py
@@ -1,16 +1,19 @@
 """Tiers, credits, Stripe fulfilment. The rules under test:
 
-  free     srt + mkv, one book per window
-  youtube  + the clean mp4; one credit, or the unlimited plan
+  free   a job in the visitor's browser: no price, no limit, each output
+  cloud  a job on this server: one credit (or a recurring plan, if one is sold)
 """
 
 from __future__ import annotations
 
+import json
 import time
+from dataclasses import asdict
 
+import pytest
 import stripe
 
-from backend import billing, runner
+from backend import pricing, runner
 from backend.db import JobStatus
 
 from .conftest import (
@@ -19,8 +22,17 @@ from .conftest import (
 )
 
 
-def start(client, job_id, tier="free"):
-    return client.post(f"/api/jobs/{job_id}/start", json={"language": "en", "tier": tier})
+def start(client, job_id):
+    """Start a job on the server: a cloud job."""
+    return client.post(f"/api/jobs/{job_id}/start", json={"language": "en"})
+
+
+@pytest.fixture
+def monthly_plan(monkeypatch):
+    """The default catalogue sells no recurring plan. An operator can add one."""
+    plans = [asdict(p) for p in pricing.DEFAULT_PLANS]
+    plans.append({"id": "monthly", "name": "Monthly", "credits": None, "price_cents": 1500, "recurring": True})
+    monkeypatch.setenv("SUBPLZ_WEB_PLANS_JSON", json.dumps(plans))
 
 
 def buy(client, plan="pack5", session_id=None, **kw):
@@ -30,53 +42,38 @@ def buy(client, plan="pack5", session_id=None, **kw):
     return session_id
 
 
-# --- free tier ---------------------------------------------------------------
+# --- the cloud tier ----------------------------------------------------------
 
-def test_new_visitor_is_anonymous_with_a_free_book(client):
+def test_new_visitor_is_anonymous_and_has_no_credits(client):
     a = client.get("/api/account").json()
     assert a["signed_in"] is False and a["email"] is None
-    assert a["free_allowed"] is True and a["free_remaining"] == 1
     assert a["credits"] == 0 and a["subscribed"] is False
-    assert a["youtube_allowed"] is False
+    assert a["cloud_allowed"] is False
+    assert "free in your browser" in a["free_tier_summary"]
 
 
-def test_free_window_allows_one_book_then_asks_for_payment(client):
-    assert start(client, make_job(client)).status_code == 200
+def test_nothing_is_for_sale_until_the_cloud_is_connected(client, monkeypatch):
+    """Billing can be on while fast conversion is not yet on offer. The page
+    reads this flag, and shows no way to buy credits that would buy nothing."""
+    from backend.settings import settings
+    assert client.get("/api/account").json()["cloud_available"] is False
+    monkeypatch.setattr(settings, "cloud_enabled", True)
+    assert client.get("/api/account").json()["cloud_available"] is True
 
+
+def test_a_server_job_needs_a_credit(client):
     r = start(client, make_job(client))
     assert r.status_code == 402
-    assert "every 24 hours" in r.json()["detail"]
-
-    a = client.get("/api/account").json()
-    assert a["free_allowed"] is False and a["next_free_at"]
-
-
-def test_failed_job_gives_the_free_slot_back(client):
-    job_id = make_job(client)
-    assert start(client, job_id).status_code == 200
-    # No staged inputs, so the real runner fails it - which is the point.
-    runner.run_job(job_id)
-    assert get_job_row(job_id).status == JobStatus.failed
-    assert start(client, make_job(client)).status_code == 200
+    assert "one credit" in r.json()["detail"] and "free" in r.json()["detail"]
 
 
-def test_free_job_locks_only_the_youtube_video(client):
+def test_each_output_of_a_job_can_be_downloaded(client):
     job_id = make_job(client, JobStatus.succeeded, with_files=True)
-    arts = {a["kind"]: a for a in client.get(f"/api/jobs/{job_id}").json()["artifacts"]}
-    assert arts["video"]["locked"] is True
-    assert arts["srt"]["locked"] is False
-    assert arts["video_embedded"]["locked"] is False
-
-    assert client.get(f"/api/jobs/{job_id}/files/srt").status_code == 200
-    assert client.get(f"/api/jobs/{job_id}/files/video_embedded").status_code == 200
-    assert client.get(f"/api/jobs/{job_id}/files/video").status_code == 402
-
-
-def test_youtube_tier_needs_payment(client):
-    r = start(client, make_job(client), tier="youtube")
-    assert r.status_code == 402
-    # Refused, so nothing was taken and the free book is still there.
-    assert client.get("/api/account").json()["free_remaining"] == 1
+    arts = client.get(f"/api/jobs/{job_id}").json()["artifacts"]
+    assert {a["kind"] for a in arts} >= {"srt", "video", "video_embedded"}
+    assert all("locked" not in a for a in arts)
+    for kind in ("srt", "video_embedded", "video"):
+        assert client.get(f"/api/jobs/{job_id}/files/{kind}").status_code == 200
 
 
 # --- credits -----------------------------------------------------------------
@@ -86,7 +83,7 @@ def test_purchase_credits_the_account_and_signs_it_in(client):
     a = client.get("/api/account").json()
     assert a["credits"] == 5
     assert a["signed_in"] is True and a["email"] == "reader@example.com"
-    assert a["youtube_allowed"] is True
+    assert a["cloud_allowed"] is True
 
 
 def test_webhook_redelivery_does_not_credit_twice(client):
@@ -106,76 +103,42 @@ def test_webhook_rejects_a_bad_signature(client):
     assert client.get("/api/account").json()["credits"] == 0
 
 
-def test_youtube_job_spends_a_credit_and_skips_the_free_window(client):
-    buy(client, "single", email="yt@example.com")
-    # Use up the free book first: a credit must not care.
-    assert start(client, make_job(client)).status_code == 200
-
+def test_a_server_job_spends_a_credit(client):
+    buy(client, "single", email="cloud@example.com")
     job_id = make_job(client, with_files=True)
-    assert start(client, job_id, tier="youtube").status_code == 200
+    assert start(client, job_id).status_code == 200
     assert client.get("/api/account").json()["credits"] == 0
-
-    job = client.get(f"/api/jobs/{job_id}").json()
-    assert job["tier"] == "youtube"
-    assert all(not a["locked"] for a in job["artifacts"])
-    assert client.get(f"/api/jobs/{job_id}/files/video").status_code == 200
+    assert client.get(f"/api/jobs/{job_id}").json()["tier"] == "cloud"
+    assert get_job_row(job_id).credit_spent == 1
 
     # And with the credit gone, the next one is refused again.
-    assert start(client, make_job(client), tier="youtube").status_code == 402
+    assert start(client, make_job(client)).status_code == 402
 
 
-def test_failed_youtube_job_returns_the_credit(client):
+def test_failed_server_job_returns_the_credit(client):
     buy(client, "single", email="refund@example.com")
     job_id = make_job(client)
-    assert start(client, job_id, tier="youtube").status_code == 200
+    assert start(client, job_id).status_code == 200
     assert client.get("/api/account").json()["credits"] == 0
 
     runner.run_job(job_id)  # fails: nothing was staged
 
     assert client.get("/api/account").json()["credits"] == 1
-    job = get_job_row(job_id)
-    assert job.credit_spent == 0 and job.tier == billing.FREE
+    assert get_job_row(job_id).credit_spent == 0
 
 
-def test_cancelled_youtube_job_returns_the_credit(client):
+def test_cancelled_server_job_returns_the_credit(client):
     buy(client, "single", email="cancel@example.com")
     job_id = make_job(client)
-    assert start(client, job_id, tier="youtube").status_code == 200
+    assert start(client, job_id).status_code == 200
     assert client.post(f"/api/jobs/{job_id}/cancel").status_code == 200
     assert client.get("/api/account").json()["credits"] == 1
 
 
-def test_unlock_a_finished_free_job(client):
-    job_id = make_job(client, JobStatus.succeeded, with_files=True)
-    assert client.post(f"/api/jobs/{job_id}/unlock").status_code == 402
-
-    buy(client, "single", email="unlock@example.com")
-    r = client.post(f"/api/jobs/{job_id}/unlock")
-    assert r.status_code == 200 and r.json()["tier"] == "youtube"
-    assert client.get("/api/account").json()["credits"] == 0
-    assert client.get(f"/api/jobs/{job_id}/files/video").status_code == 200
-
-    # Unlocking twice must not charge twice.
-    buy(client, "single", email="unlock@example.com")
-    assert client.post(f"/api/jobs/{job_id}/unlock").status_code == 200
-    assert client.get("/api/account").json()["credits"] == 1
-
-
-def test_purchase_made_for_a_job_unlocks_it(client):
-    job_id = make_job(client, JobStatus.succeeded, with_files=True)
-    buy(client, "single", email="forjob@example.com", job_id=job_id)
-    assert get_job_row(job_id).tier == "youtube"
-    # The one credit bought was the one spent.
-    assert client.get("/api/account").json()["credits"] == 0
-
-
 def test_cannot_touch_someone_elses_job(client, second_client):
     job_id = make_job(client, JobStatus.succeeded, with_files=True)
-    assert second_client.post(f"/api/jobs/{job_id}/unlock").status_code == 404
     assert second_client.get(f"/api/jobs/{job_id}/files/srt").status_code == 404
-    r = second_client.post("/api/billing/checkout",
-                           json={"plan_id": "single", "job_id": job_id})
-    assert r.status_code == 404
+    assert second_client.post(f"/api/jobs/{job_id}/cancel").status_code == 404
 
 
 # --- checkout ----------------------------------------------------------------
@@ -195,21 +158,28 @@ def test_checkout_sends_stripe_the_right_order(client, monkeypatch):
     assert seen["client_reference_id"] == account_id(client)
     assert seen["metadata"]["plan_id"] == "pack5"
     item = seen["line_items"][0]["price_data"]
-    assert item["unit_amount"] == 1299 and item["currency"] == "usd"
+    assert item["unit_amount"] == 1699 and item["currency"] == "usd"
     assert "recurring" not in item
     assert seen["success_url"].startswith(
         "https://example.test/api/billing/return?session_id={CHECKOUT_SESSION_ID}")
     assert seen["customer_creation"] == "always"
 
 
-def test_subscription_checkout_is_recurring(client, monkeypatch):
+def test_no_unlimited_plan_is_sold_by_default(client):
+    plans = client.get("/api/pricing").json()["plans"]
+    assert [p["id"] for p in plans] == ["single", "pack5", "pack20"]
+    assert [p["price_cents"] for p in plans] == [499, 1699, 3900]
+    assert not any(p["recurring"] for p in plans)
+
+
+def test_subscription_checkout_is_recurring(client, monkeypatch, monthly_plan):
     seen = {}
     monkeypatch.setattr(
         stripe.checkout.Session, "create",
         lambda **p: seen.update(p) or FakeStripeObject({"url": "https://stripe.test/sub"}),
     )
     assert client.post("/api/billing/checkout",
-                       json={"plan_id": "unlimited"}).status_code == 200
+                       json={"plan_id": "monthly"}).status_code == 200
     assert seen["mode"] == "subscription"
     assert seen["line_items"][0]["price_data"]["recurring"] == {"interval": "month"}
     assert seen["subscription_data"]["metadata"]["account_id"] == account_id(client)
@@ -266,20 +236,19 @@ def test_subscription_lifts_every_limit_then_lapses(client):
     assert post_webhook(client, subscription_event("created", acct, "active")).status_code == 200
 
     a = client.get("/api/account").json()
-    assert a["subscribed"] is True and a["youtube_allowed"] is True
+    assert a["subscribed"] is True and a["cloud_allowed"] is True
     assert a["subscription_ends"]
 
-    # No window, no credits spent, mp4 unlocked.
+    # Server jobs start, and no credits are spent.
     for _ in range(3):
         job_id = make_job(client, with_files=True)
-        assert start(client, job_id, tier="youtube").status_code == 200
+        assert start(client, job_id).status_code == 200
     assert get_job_row(job_id).credit_spent == 0
-    assert client.get(f"/api/jobs/{job_id}/files/video").status_code == 200
 
     post_webhook(client, subscription_event("deleted", acct, "canceled"))
     a = client.get("/api/account").json()
     assert a["subscribed"] is False
-    assert start(client, make_job(client), tier="youtube").status_code == 402
+    assert start(client, make_job(client)).status_code == 402
 
 
 def test_subscription_past_its_paid_period_does_not_count(client):
@@ -288,11 +257,11 @@ def test_subscription_past_its_paid_period_does_not_count(client):
     assert client.get("/api/account").json()["subscribed"] is False
 
 
-def test_cannot_subscribe_twice(client, monkeypatch):
+def test_cannot_subscribe_twice(client, monkeypatch, monthly_plan):
     post_webhook(client, subscription_event("created", account_id(client), "active"))
     monkeypatch.setattr(stripe.checkout.Session, "create",
                         lambda **p: FakeStripeObject({"url": "x"}))
-    r = client.post("/api/billing/checkout", json={"plan_id": "unlimited"})
+    r = client.post("/api/billing/checkout", json={"plan_id": "monthly"})
     assert r.status_code == 400 and "already" in r.json()["detail"]
 
 
@@ -306,14 +275,13 @@ def test_paying_on_a_new_device_joins_the_existing_account(client, second_client
     job_id = make_job(second_client, JobStatus.succeeded, with_files=True)
     device = account_id(second_client)
     assert device != original
-    buy(second_client, "single", email="same@example.com", job_id=job_id)
+    buy(second_client, "single", email="same@example.com")
 
     # Its cookie now resolves to the original account, which holds everything.
     a = second_client.get("/api/account").json()
     assert a["id"] == original and a["email"] == "same@example.com"
-    assert a["credits"] == 5  # 5 + 1 bought - 1 spent unlocking the job
+    assert a["credits"] == 6
     assert get_job_row(job_id).account_id == original
-    assert get_job_row(job_id).tier == "youtube"
     assert get_account_row(device).merged_into == original
     # ...and both browsers see the same jobs.
     assert job_id in {j["id"] for j in client.get("/api/jobs").json()}
diff --git a/tests/test_local_jobs.py b/tests/test_local_jobs.py
index b5b3dae..6ca0daf 100644
--- a/tests/test_local_jobs.py
+++ b/tests/test_local_jobs.py
@@ -43,10 +43,13 @@ def test_free_job_runs_finishes_and_keeps_its_subtitles(client):
     assert job["id"] in {j["id"] for j in client.get("/api/jobs").json()}
 
 
-def test_free_window_applies_to_browser_jobs_too(client):
-    assert begin(client).status_code == 200
-    second = begin(client, audio_filename="another.m4b", audio_bytes=999)
-    assert second.status_code == 402
+def test_browser_jobs_have_no_limit_and_no_price(client):
+    buy(client, email="nolimit@example.com")
+    for n in range(4):
+        r = begin(client, audio_filename=f"book{n}.m4b", audio_bytes=1000 + n)
+        assert r.status_code == 200 and r.json()["tier"] == "free"
+    # The work was done on the visitor's machine: the credit is still there.
+    assert client.get("/api/account").json()["credits"] == 1
 
 
 def test_reopening_the_same_book_does_not_charge_twice(client):
@@ -57,28 +60,12 @@ def test_reopening_the_same_book_does_not_charge_twice(client):
         assert s.query(Job).filter(Job.account_id == account_id(client)).count() == 1
 
 
-def test_youtube_tier_costs_a_credit_and_a_failure_returns_it(client):
-    assert begin(client, tier="youtube").status_code == 402
-
-    buy(client)
-    r = begin(client, tier="youtube")
-    assert r.status_code == 200 and r.json()["tier"] == "youtube"
-    assert client.get("/api/account").json()["credits"] == 0
-
+def test_a_failed_browser_job_takes_nothing(client):
+    buy(client, email="failed@example.com")     # its own account: an email joins accounts across tests
+    r = begin(client)
     failed = client.post(f"/api/local/jobs/{r.json()['id']}/fail", json={"error": "GPU lost"})
     assert failed.status_code == 200 and failed.json()["status"] == "failed"
     assert client.get("/api/account").json()["credits"] == 1
-    # And the free book was never touched.
-    assert client.get("/api/account").json()["free_remaining"] == 1
-
-
-def test_upgrading_a_running_free_job_to_youtube(client):
-    job = begin(client).json()
-    assert begin(client, tier="youtube").status_code == 402     # no credit yet
-    buy(client, email="upgrade@example.com")
-    up = begin(client, tier="youtube")
-    assert up.status_code == 200 and up.json()["id"] == job["id"] and up.json()["tier"] == "youtube"
-    assert client.get("/api/account").json()["credits"] == 0
 
 
 def test_finish_is_once_only_and_owner_only(client, second_client):
@@ -101,14 +88,12 @@ def test_filename_cannot_escape_the_job_directory(client):
 
 def test_abandoned_jobs_release_what_they_held(client):
     job = begin(client).json()
-    assert begin(client, audio_filename="b.m4b", audio_bytes=2).status_code == 402
     with SessionLocal() as s:
         row = s.get(Job, job["id"])
         row.created_at = utcnow() - timedelta(hours=72)
         s.commit()
         assert api.expire_stale_local_jobs(s) >= 1
     assert get_job_row(job["id"]).status == JobStatus.canceled
-    assert begin(client, audio_filename="b.m4b", audio_bytes=2).status_code == 200
 
 
 def test_restart_does_not_queue_browser_jobs(client, monkeypatch):