Recently Written · git

subplz-web

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

Log | Files | Refs


commit ed85502140ba70bba39bfb3ecfa76d68bd74aeb6
equwal <truex@equwal.com>
2026-09-22 16:51:20 -0700

Sell Subrep Pro with the accounts of this site

Subrep Pro is the paid plan of the Subrep desktop app. This site now sells
it with the same accounts and the same Stripe checkout as the book plans.

- Plans: $9.99 a year (first on the page) and $0.99 a month. The owner
  chose $0.99 a month. The financial advocate chose $9.99 a year and no
  free trial.
- A Stripe subscription of a Subrep plan goes to its own table
  (subrep_licences). It does not touch the book plan. An account can have
  both.
- /subrep.html shows a link command: the account id and a refresh key.
  The desktop app sends the two to POST /api/subrep/refresh and gets a
  licence token, signed with an Ed25519 key. The token has the format of
  subrep/licensing.py, so the desktop client works without a change. A
  token ends with the paid period, and after 31 days at most.
- tools/subrep_key.py makes the key on the server. It prints only the
  public key.
- POST /api/subrep/waitlist keeps the emails for the iPhone app (kind
  "ios").
- Where the server can send a sign-in email, only a verified account can
  pay. A $0.99 checkout draws card testing.
- A checkout can come back to /subrep.html. The return trip checks the
  page against a list.
- A recurring plan has an interval ("month" or "year").
- New dependency: cryptography, for Ed25519 signatures. The standard
  library has no Ed25519.

 .env.example         |   6 +
 backend/accounts.py  |   3 +-
 backend/api.py       |   9 +-
 backend/main.py      |   3 +-
 backend/payments.py  |  36 +++--
 backend/pricing.py   |   2 +
 backend/settings.py  |   7 +
 backend/subrep.py    | 408 +++++++++++++++++++++++++++++++++++++++++++++++++++
 frontend/subrep.html | 184 +++++++++++++++++++++++
 frontend/terms.html  |   9 ++
 requirements.txt     |   4 +
 tests/test_subrep.py | 370 ++++++++++++++++++++++++++++++++++++++++++++++
 tools/subrep_key.py  |  61 ++++++++
 13 files changed, 1088 insertions(+), 14 deletions(-)
diff --git a/.env.example b/.env.example
index e4c59a4..585b360 100644
--- a/.env.example
+++ b/.env.example
@@ -93,5 +93,11 @@ SUBPLZ_WEB_CONTACT_EMAIL=
 # Override the plan catalogue (see backend/pricing.py for the shape).
 # SUBPLZ_WEB_PLANS_JSON=[...]
 
+# --- Subrep Pro --------------------------------------------------------------
+# The key that signs the licences of the Subrep desktop app. Do not type it:
+# make it on the server with tools/subrep_key.py, which also prints the public
+# key for the desktop build. Without it, Subrep Pro is not for sale.
+# SUBPLZ_WEB_SUBREP_LICENSE_KEY=
+
 # --- uploads ---------------------------------------------------------------
 SUBPLZ_WEB_MAX_UPLOAD_BYTES=2147483648
diff --git a/backend/accounts.py b/backend/accounts.py
index 48b8acb..e58b124 100644
--- a/backend/accounts.py
+++ b/backend/accounts.py
@@ -64,9 +64,10 @@ def merge(session: Session, src: Account, dst: Account) -> None:
     session.query(Purchase).filter(Purchase.account_id == src.id).update(
         {Purchase.account_id: dst.id}, synchronize_session=False
     )
-    from . import captions  # captions imports api, which imports this module
+    from . import captions, subrep  # they import api, which imports this module
 
     captions.merge(session, src, dst)
+    subrep.merge(session, src, dst)
     dst.purchased_credits += src.purchased_credits
     src.purchased_credits = 0
     # Add the free credits used, not the free credits left: a second device
diff --git a/backend/api.py b/backend/api.py
index 8c93a2b..08c31e0 100644
--- a/backend/api.py
+++ b/backend/api.py
@@ -425,10 +425,16 @@ def create_checkout(
     return {"url": url}
 
 
+# The pages that a checkout can come back to. Any other page goes to "/", so
+# the return trip cannot send the browser off this site.
+RETURN_PAGES = {"/", "/subrep.html"}
+
+
 @router.get("/billing/return")
 def checkout_return(
     session_id: str,
     session: Annotated[Session, Depends(get_session)],
+    page: str = "/",
 ):
     """Where Stripe sends the browser after paying.
 
@@ -442,7 +448,8 @@ def checkout_return(
     except payments.PaymentsUnavailable:
         paid = False
     state = "paid" if paid else "pending"
-    return RedirectResponse(f"/?checkout={state}", status_code=303)
+    page = page if page in RETURN_PAGES else "/"
+    return RedirectResponse(f"{page}?checkout={state}", status_code=303)
 
 
 @router.post("/billing/webhook")
diff --git a/backend/main.py b/backend/main.py
index d50ca52..ffe9c0e 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -19,7 +19,7 @@ from fastapi import FastAPI
 from fastapi.responses import FileResponse
 from fastapi.staticfiles import StaticFiles
 
-from . import captions
+from . import captions, subrep
 from .api import router
 from .db import Job, JobStatus, SessionLocal, init_db
 from .languages import all_languages
@@ -128,6 +128,7 @@ app = FastAPI(
 )
 app.include_router(router)
 app.include_router(captions.router)
+app.include_router(subrep.router)
 
 
 @app.middleware("http")
diff --git a/backend/payments.py b/backend/payments.py
index 8cd2b61..0c62543 100644
--- a/backend/payments.py
+++ b/backend/payments.py
@@ -22,6 +22,7 @@ from __future__ import annotations
 import json
 import logging
 from datetime import datetime, timezone
+from urllib.parse import quote
 
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.orm import Session
@@ -68,7 +69,7 @@ def _base() -> str:
 # ---------------------------------------------------------------------------
 
 def _catalogue_price(stripe, plan: pricing.Plan) -> str | None:
-    """The id of the Stripe price of a monthly plan (lookup key = plan id).
+    """The id of the Stripe price of a recurring plan (lookup key = plan id).
 
     The customer portal switches a subscription only between prices of the
     Stripe catalogue, so a plan checks out with its catalogue price when one
@@ -83,7 +84,7 @@ def _catalogue_price(stripe, plan: pricing.Plan) -> str | None:
     for price in found.get("data") or []:
         if (price.get("unit_amount") == plan.price_cents
                 and price.get("currency") == plan.currency
-                and (price.get("recurring") or {}).get("interval") == "month"):
+                and (price.get("recurring") or {}).get("interval") == plan.interval):
             return price["id"]
         log.warning("Stripe price %s does not match plan %s", price.get("id"), plan.id)
     return None
@@ -107,7 +108,7 @@ def ensure_plan_prices() -> list[tuple[str, str, bool]]:
             price = _plain(stripe.Price.create(
                 unit_amount=plan.price_cents,
                 currency=plan.currency,
-                recurring={"interval": "month"},
+                recurring={"interval": plan.interval},
                 lookup_key=plan.id,
                 # A price that does not match keeps its product; move the key.
                 transfer_lookup_key=True,
@@ -118,11 +119,18 @@ def ensure_plan_prices() -> list[tuple[str, str, bool]]:
     return out
 
 
-def start_checkout(session: Session, account: Account, plan: pricing.Plan) -> str:
-    """Return the Stripe-hosted payment page for `plan`."""
+def start_checkout(session: Session, account: Account, plan: pricing.Plan,
+                   page: str = "/") -> str:
+    """Return the Stripe-hosted payment page for `plan`.
+
+    `page` is the page of this site that the browser comes back to. It must be
+    one of api.RETURN_PAGES.
+    """
     stripe = _stripe()
 
-    if plan.recurring and billing.is_subscribed(account):
+    # One book plan at a time. A Subrep plan (subrep.py) is not a book plan.
+    if plan.recurring and pricing.get(plan.id, retired=True) is not None \
+            and billing.is_subscribed(account):
         raise PaymentError(
             "You already have a monthly plan. Change or cancel it with Manage plan."
         )
@@ -133,7 +141,7 @@ def start_checkout(session: Session, account: Account, plan: pricing.Plan) -> st
         "product_data": {"name": f"{settings.site_name} - {plan.name}"},
     }
     if plan.recurring:
-        price["recurring"] = {"interval": "month"}
+        price["recurring"] = {"interval": plan.interval}
 
     line: dict = {"quantity": 1, "price_data": price}
     if plan.recurring:
@@ -142,6 +150,7 @@ def start_checkout(session: Session, account: Account, plan: pricing.Plan) -> st
             line = {"quantity": 1, "price": price_id}
 
     meta = {"account_id": account.id, "plan_id": plan.id}
+    back = "" if page == "/" else f"&page={quote(page, safe='')}"
     params: dict = {
         "mode": "subscription" if plan.recurring else "payment",
         "line_items": [line],
@@ -150,8 +159,8 @@ def start_checkout(session: Session, account: Account, plan: pricing.Plan) -> st
         # Stripe substitutes the real id; the return handler re-reads the
         # session from it rather than trusting anything in the URL.
         "success_url": f"{_base()}/api/billing/return"
-                       "?session_id={CHECKOUT_SESSION_ID}",
-        "cancel_url": f"{_base()}/?checkout=cancelled",
+                       "?session_id={CHECKOUT_SESSION_ID}" + back,
+        "cancel_url": f"{_base()}{page}?checkout=cancelled",
         "allow_promotion_codes": True,
     }
     if account.stripe_customer_id:
@@ -236,6 +245,10 @@ def _our_plan_id(sub: dict) -> str | None:
 
 def apply_subscription(session: Session, sub: dict) -> None:
     """Mirror a Stripe subscription onto its account."""
+    from . import subrep  # subrep imports api, which imports this module
+
+    if subrep.apply_subscription(session, sub):
+        return
     plan_id = _our_plan_id(sub)
     if plan_id is None:
         log.info("subscription %s is not for a plan of this site; ignored", sub.get("id"))
@@ -279,10 +292,11 @@ def fulfil(session: Session, checkout: dict) -> Account | None:
     )
     # A retired plan too: the buyer may have opened the payment page before
     # the plan was retired.
-    from . import captions  # captions imports api, which imports this module
+    from . import captions, subrep  # they import api, which imports this module
 
     plan = (pricing.get(meta.get("plan_id") or "", retired=True)
-            or captions.pack(meta.get("plan_id") or ""))
+            or captions.pack(meta.get("plan_id") or "")
+            or subrep.plan(meta.get("plan_id") or ""))
     if account is None or plan is None:
         log.error("checkout %s: unknown account or plan %r", checkout.get("id"), meta)
         return None
diff --git a/backend/pricing.py b/backend/pricing.py
index 329ff8a..b4772a1 100644
--- a/backend/pricing.py
+++ b/backend/pricing.py
@@ -74,6 +74,8 @@ class Plan:
     price_cents: int
     currency: str = "usd"
     recurring: bool = False
+    # How often a recurring plan renews: "month" or "year".
+    interval: str = "month"
     blurb: str = ""
 
     @property
diff --git a/backend/settings.py b/backend/settings.py
index 24eb257..a4f2758 100644
--- a/backend/settings.py
+++ b/backend/settings.py
@@ -141,6 +141,13 @@ class Settings(BaseSettings):
     # The longest piece of speech in one request. The app sends 11 s at most.
     caption_max_seconds: int = 30
 
+    # --- Subrep Pro -------------------------------------------------------
+    # The Ed25519 key that signs the licences of the Subrep desktop app
+    # (base64url of the 32-byte private key). Without it, Subrep Pro is not
+    # for sale. Make it on the server with tools/subrep_key.py: every licence
+    # of every customer depends on it, so never replace it.
+    subrep_license_key: str = ""
+
     # --- uploads -----------------------------------------------------------
     max_upload_bytes: int = 2 * 1024 * 1024 * 1024  # 2 GiB
 
diff --git a/backend/subrep.py b/backend/subrep.py
new file mode 100644
index 0000000..914feac
--- /dev/null
+++ b/backend/subrep.py
@@ -0,0 +1,408 @@
+"""Subrep Pro: the plan of the Subrep desktop app, sold with the accounts of this site.
+
+Subrep (https://honjimaku.com/subrep/) makes live captions of the sound on the
+customer's own computer. The free edition makes live captions. Pro unlocks the
+paid features of the desktop app (FEATURES in subrep/licensing.py of the
+Subrep repo). The owner chose the prices on 2026-09-22:
+
+  yearly   $9.99 a year    (first on the page: 16% less than 12 x $0.99)
+  monthly  $0.99 a month
+
+There is no free trial: the free edition is the trial.
+
+How the desktop app gets Pro:
+
+1. The customer pays here. A Stripe subscription makes the account Pro until
+   the end of the paid period (SubrepLicence).
+2. The Subrep page (/subrep.html) shows a link command with the account id
+   and a refresh key. The customer runs it once on each computer.
+3. The app sends the two to POST /api/subrep/refresh and gets a licence token:
+   JSON claims, signed with the Ed25519 key of this server
+   (SUBPLZ_WEB_SUBREP_LICENSE_KEY, made by tools/subrep_key.py). The app
+   checks the token offline with the public key in its build, and asks for a
+   new one before the token ends.
+
+The token format is that of sign_token in subrep/licensing.py:
+base64url(JSON with sorted keys) "." base64url(signature). A token ends with
+the paid period, and after TOKEN_DAYS at most, so a refund or a chargeback
+ends Pro within a month.
+
+A Subrep plan is not a book plan: billing.py does not see it, and an account
+can have both. payments.py sells the plans and gives each subscription event
+to apply_subscription first.
+
+The iOS waitlist is here too: the emails that ask to hear when an iPhone app
+ships. The owner counts them on 2026-11-30 (waitlist_count): 30 is one of the
+conditions to build the app.
+"""
+from __future__ import annotations
+
+import base64
+import hmac
+import importlib.util
+import json
+import logging
+import secrets
+from datetime import datetime, timedelta
+from typing import Annotated
+
+from fastapi import APIRouter, Depends, HTTPException
+from fastapi.responses import JSONResponse
+from pydantic import BaseModel
+from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.orm import Mapped, Session, mapped_column
+
+from . import accounts, billing, payments
+from .api import get_account, get_session
+from .db import Account, Base, utcnow
+from .pricing import Plan
+from .settings import settings
+
+log = logging.getLogger(__name__)
+
+# A plan that was sold must stay in this list: a subscription that still runs
+# names it. Change a price with a new plan id.
+PLANS: list[Plan] = [
+    Plan(id="subrep_year", name="Subrep Pro, yearly", credits=0, price_cents=999,
+         recurring=True, interval="year",
+         blurb="Renews each year until you cancel. 16% less than monthly."),
+    Plan(id="subrep_month", name="Subrep Pro, monthly", credits=0, price_cents=99,
+         recurring=True, interval="month",
+         blurb="Renews each month until you cancel."),
+]
+
+# The longest time that one licence token is good for.
+TOKEN_DAYS = 31
+
+WAITLISTS = {"ios"}
+
+
+class SubrepLicence(Base):
+    """The Subrep Pro subscription of an account, and its refresh key."""
+
+    __tablename__ = "subrep_licences"
+
+    account_id: Mapped[str] = mapped_column(
+        String(64), ForeignKey("accounts.id"), primary_key=True
+    )
+    # The desktop app sends it to get a licence. It is kept as it is, like the
+    # device token of an account: the page shows the same link command on
+    # each visit, so that each computer of the customer can use it.
+    refresh_key: Mapped[str | None] = mapped_column(String(64), nullable=True)
+    subscription_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
+    status: Mapped[str | None] = mapped_column(String(32), nullable=True)
+    plan_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
+    period_end: Mapped[datetime | None] = mapped_column(
+        DateTime(timezone=True), nullable=True
+    )
+
+
+class WaitlistEntry(Base):
+    """An email that asks to hear when an app ships (kind "ios")."""
+
+    __tablename__ = "waitlist"
+    __table_args__ = (UniqueConstraint("kind", "email"),)
+
+    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+    kind: Mapped[str] = mapped_column(String(16))
+    email: Mapped[str] = mapped_column(String(320))
+    account_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
+
+
+def plan(plan_id: str) -> Plan | None:
+    return next((p for p in PLANS if p.id == plan_id), None)
+
+
+def licence(session: Session, account_id: str) -> SubrepLicence | None:
+    return session.get(SubrepLicence, account_id)
+
+
+def is_pro(row: SubrepLicence | None, now: datetime | None = None) -> bool:
+    """Whether the subscription is paid for the current period."""
+    if row is None or row.status not in billing._SUBSCRIBED:
+        return False
+    end = billing._aware(row.period_end)
+    return end is None or end > (now or utcnow())
+
+
+def can_sign() -> bool:
+    """A key is set, and the package that signs with it is installed."""
+    return bool(settings.subrep_license_key) and (
+        importlib.util.find_spec("cryptography") is not None
+    )
+
+
+def available() -> bool:
+    """Subrep Pro is for sale: the server can take the money and sign a licence."""
+    return can_sign() and settings.payments_configured
+
+
+# ---------------------------------------------------------------------------
+# subscriptions
+# ---------------------------------------------------------------------------
+
+def _plan_of(sub: dict) -> Plan | None:
+    """The Subrep plan of a subscription, or None if it is not one.
+
+    As in payments._our_plan_id: the lookup key of the price names the plan
+    after a switch in the customer portal, and the checkout metadata names it
+    before that.
+    """
+    items = (sub.get("items") or {}).get("data") or []
+    price = (items[0].get("price") or {}) if items else {}
+    for plan_id in (price.get("lookup_key"), (sub.get("metadata") or {}).get("plan_id")):
+        found = plan(plan_id or "")
+        if found is not None:
+            return found
+    return None
+
+
+def apply_subscription(session: Session, sub: dict) -> bool:
+    """Mirror a Stripe subscription of a Subrep plan. False if it is not one."""
+    found = _plan_of(sub)
+    if found is None:
+        return False
+    meta = sub.get("metadata") or {}
+    account = payments._account_for(session, meta.get("account_id"), sub.get("customer"))
+    if account is None:
+        log.warning("subrep subscription %s matches no account", sub.get("id"))
+        return True
+    row = licence(session, account.id) or SubrepLicence(account_id=account.id)
+    # The end of an old subscription must not end its replacement.
+    if row.subscription_id and row.subscription_id != sub.get("id") \
+            and sub.get("status") in ("canceled", "incomplete_expired"):
+        return True
+    row.subscription_id = sub.get("id")
+    row.status = sub.get("status")
+    row.plan_id = found.id
+    row.period_end = payments._period_end(sub)
+    session.add(row)
+    if sub.get("customer") and not account.stripe_customer_id:
+        account.stripe_customer_id = sub["customer"]
+    session.commit()
+    return True
+
+
+def merge(session: Session, src: Account, dst: Account) -> None:
+    """Called by accounts.merge: the Subrep plan of `src` goes to `dst`."""
+    row = licence(session, src.id)
+    if row is None:
+        return
+    target = licence(session, dst.id)
+    if target is None or (is_pro(row) and not is_pro(target)):
+        target = target or SubrepLicence(account_id=dst.id)
+        target.subscription_id, target.status = row.subscription_id, row.status
+        target.plan_id, target.period_end = row.plan_id, row.period_end
+        session.add(target)
+    # The computers that were linked to `src` keep their link command.
+    target.refresh_key = target.refresh_key or row.refresh_key
+    session.delete(row)
+
+
+# ---------------------------------------------------------------------------
+# licence tokens
+# ---------------------------------------------------------------------------
+
+def _b64e(raw: bytes) -> str:
+    return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
+
+
+def _private_key():
+    # cryptography only here: a server without Subrep Pro does not need it.
+    from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+
+    text = settings.subrep_license_key
+    return Ed25519PrivateKey.from_private_bytes(
+        base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
+    )
+
+
+def sign(claims: dict) -> str:
+    """A licence token, in the format of sign_token in subrep/licensing.py."""
+    payload = json.dumps(claims, separators=(",", ":"), sort_keys=True).encode()
+    return _b64e(payload) + "." + _b64e(_private_key().sign(payload))
+
+
+def public_key() -> str:
+    """The key to build into the desktop app (SUBREP_LICENSE_PUBKEY)."""
+    return _b64e(_private_key().public_key().public_bytes_raw())
+
+
+class LicenceRefused(Exception):
+    def __init__(self, status: int, message: str):
+        super().__init__(message)
+        self.status = status
+        self.message = message
+
+
+def token(session: Session, account_id: str, refresh_key: str,
+          now: datetime | None = None) -> str:
+    """A licence token for the desktop app, or LicenceRefused."""
+    now = now or utcnow()
+    account = session.get(Account, account_id or "")
+    if account is not None:
+        account = accounts.resolve(session, account)
+    row = licence(session, account.id) if account is not None else None
+    if row is None or not row.refresh_key or not hmac.compare_digest(
+            row.refresh_key.encode(), (refresh_key or "").encode()):
+        raise LicenceRefused(
+            403, "This link command is not valid. "
+                 "Copy it again from the Subrep page of subread.space.")
+    if not is_pro(row, now):
+        raise LicenceRefused(402, "This account has no active Subrep Pro plan.")
+    latest = now + timedelta(days=TOKEN_DAYS)
+    end = min(billing._aware(row.period_end) or latest, latest)
+    return sign({"sub": account.email or account.id, "tier": "pro",
+                 "plan": row.plan_id, "iat": int(now.timestamp()),
+                 "exp": int(end.timestamp())})
+
+
+def link(session: Session, account: Account) -> dict:
+    """The link command of a Pro account. The first call makes its refresh key."""
+    row = licence(session, account.id)
+    if row is None:
+        raise LicenceRefused(402, "This account has no Subrep Pro plan.")
+    if not row.refresh_key:
+        row.refresh_key = secrets.token_urlsafe(24)
+        session.commit()
+    server = f"{settings.public_base_url.rstrip('/')}/api/subrep"
+    return {
+        "account": account.id,
+        "refresh_key": row.refresh_key,
+        "server": server,
+        "command": f'subrep license --link "{account.id}" "{row.refresh_key}" "{server}"',
+    }
+
+
+# ---------------------------------------------------------------------------
+# waitlist
+# ---------------------------------------------------------------------------
+
+def join(session: Session, kind: str, email: str, account_id: str | None) -> bool:
+    """Put `email` on a waitlist. False if it was on it already."""
+    session.add(WaitlistEntry(kind=kind, email=email, account_id=account_id))
+    try:
+        session.commit()
+    except IntegrityError:
+        session.rollback()
+        return False
+    return True
+
+
+def waitlist_count(session: Session, kind: str) -> int:
+    return session.query(WaitlistEntry).filter(WaitlistEntry.kind == kind).count()
+
+
+# ---------------------------------------------------------------------------
+# routes
+# ---------------------------------------------------------------------------
+
+router = APIRouter(prefix="/api/subrep")
+
+
+class PlanIn(BaseModel):
+    plan_id: str
+
+
+class RefreshIn(BaseModel):
+    account: str = ""
+    refresh_key: str = ""
+
+
+class WaitlistIn(BaseModel):
+    email: str
+    kind: str = "ios"
+
+
+def _needs_sign_in(account: Account) -> bool:
+    # A $0.99 checkout draws card testing. Where email sign-in exists, only a
+    # verified account may pay.
+    return settings.sign_in_available and not account.email_verified
+
+
+@router.get("")
+def subrep_state(
+    account: Annotated[Account, Depends(get_account)],
+    session: Annotated[Session, Depends(get_session)],
+):
+    row = licence(session, account.id)
+    pro = is_pro(row)
+    end = billing._aware(row.period_end) if pro and row is not None else None
+    return {
+        "available": available(),
+        "public_key": public_key() if can_sign() else None,
+        "plans": [
+            {"id": p.id, "name": p.name, "interval": p.interval,
+             "price_cents": p.price_cents, "price_display": p.price_display,
+             "blurb": p.blurb}
+            for p in PLANS
+        ],
+        "pro": pro,
+        "plan_id": row.plan_id if pro and row is not None else None,
+        "period_end": end.isoformat() if end else None,
+        "link": link(session, account) if pro else None,
+        "needs_sign_in": _needs_sign_in(account),
+        "email": account.email,
+        "can_manage": bool(account.stripe_customer_id),
+    }
+
+
+@router.post("/checkout")
+def subrep_checkout(
+    body: PlanIn,
+    account: Annotated[Account, Depends(get_account)],
+    session: Annotated[Session, Depends(get_session)],
+):
+    found = plan(body.plan_id)
+    if found is None:
+        raise HTTPException(404, "No such plan.")
+    if not available():
+        raise HTTPException(503, "Subrep Pro is not for sale on this server yet.")
+    if _needs_sign_in(account):
+        raise HTTPException(403, "Sign in with your email first.")
+    if is_pro(licence(session, account.id)):
+        raise HTTPException(
+            400, "You already have Subrep Pro. Change or cancel it with Manage plan.")
+    try:
+        return {"url": payments.start_checkout(session, account, found, page="/subrep.html")}
+    except payments.PaymentsUnavailable as exc:
+        raise HTTPException(503, str(exc)) from exc
+    except payments.PaymentError as exc:
+        raise HTTPException(400, str(exc)) from exc
+
+
+@router.post("/refresh")
+def subrep_refresh(
+    body: RefreshIn,
+    session: Annotated[Session, Depends(get_session)],
+):
+    """The desktop app asks for a licence (refresh in subrep/licensing.py).
+
+    No cookie: the app is not a browser. A refusal is {"error": ...}, because
+    the app shows that text to the customer.
+    """
+    if not can_sign():
+        return JSONResponse({"error": "Subrep Pro is not set up on this server."},
+                            status_code=503)
+    try:
+        return {"token": token(session, body.account, body.refresh_key)}
+    except LicenceRefused as exc:
+        return JSONResponse({"error": exc.message}, status_code=exc.status)
+
+
+@router.post("/waitlist")
+def subrep_waitlist(
+    body: WaitlistIn,
+    account: Annotated[Account, Depends(get_account)],
+    session: Annotated[Session, Depends(get_session)],
+):
+    if body.kind not in WAITLISTS:
+        raise HTTPException(404, "No such waitlist.")
+    try:
+        email = accounts.normalize_email(body.email)
+    except accounts.InvalidEmail as exc:
+        raise HTTPException(400, str(exc)) from exc
+    join(session, body.kind, email, account.id)
+    return {"joined": True, "kind": body.kind}
diff --git a/frontend/subrep.html b/frontend/subrep.html
new file mode 100644
index 0000000..980b9c0
--- /dev/null
+++ b/frontend/subrep.html
@@ -0,0 +1,184 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Subrep Pro — SubRead</title>
+<link rel="stylesheet" href="/style.css?v=13">
+<style>
+  main.pro { max-width: 720px; padding-top: 24px; padding-bottom: 60px; }
+  main.pro h1 { font-size: 26px; margin: 12px 0 6px; }
+  main.pro li { margin: 6px 0; }
+  main.pro a { color: var(--accent); }
+  .command { display: flex; gap: 8px; align-items: stretch; margin: 10px 0; }
+  .command code { flex: 1; min-width: 0; overflow-x: auto; white-space: nowrap;
+    padding: 9px 12px; border: 1px solid var(--line); border-radius: 9px;
+    background: var(--bg); font-size: 13px; }
+  .waitlist { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
+  .waitlist input { flex: 1; min-width: 200px; font: inherit; padding: 9px 12px;
+    border: 1px solid var(--line); border-radius: 9px; background: var(--bg); color: var(--ink); }
+  .note { margin-top: 12px; padding: 10px 14px; border-radius: 9px;
+    background: var(--accent-soft); }
+  .note:empty { display: none; }
+</style>
+</head>
+<body>
+<main class="pro">
+  <p><a href="https://honjimaku.com/subrep/">← Subrep</a> · <a href="/">SubRead</a></p>
+  <h1>Subrep Pro</h1>
+  <p class="muted">Subrep makes live captions of the sound on your computer, on your computer.
+    Live captions are free. Pro adds:</p>
+  <ul>
+    <li><strong>Replay clips:</strong> get back the sentence that you just heard, as a clip with
+      subtitles that you can mine with Yomitan.</li>
+    <li><strong>Anki cards</strong> from a clip.</li>
+    <li><strong>The large and medium speech models</strong>, for better text.</li>
+  </ul>
+  <p class="note" id="note" role="status"></p>
+
+  <section class="panel" id="buy">
+    <h2>Choose a plan</h2>
+    <div class="plans" id="plans"></div>
+    <p class="muted small" id="sign-in-note" hidden>Sign in with your email on
+      <a href="/">the SubRead page</a> first, then come back here.</p>
+    <p class="muted small">Stripe takes the payment. Cancel at any time with "Manage plan": the plan
+      then runs to the end of the period that you paid for. We refund a first payment on request
+      within 14 days. <a href="/terms.html">Terms</a></p>
+  </section>
+
+  <section class="panel" id="pro" hidden>
+    <h2>Your Subrep Pro</h2>
+    <p id="pro-status"></p>
+    <p>Run this command once on each computer that has Subrep. It links the computer to this
+      account, and the computer then renews its licence by itself:</p>
+    <div class="command">
+      <code id="command"></code>
+      <button class="ghost compact" id="copy" type="button">Copy</button>
+    </div>
+    <p class="muted small">Keep the command private: it gives Pro to any computer that runs it.</p>
+    <div class="actions">
+      <button class="ghost" id="manage" type="button">Manage plan</button>
+    </div>
+  </section>
+
+  <section class="panel" id="ios">
+    <h2>Subrep for iPhone</h2>
+    <p>There is no iPhone app yet. Leave your email, and we write to you once, when it ships.</p>
+    <form class="waitlist" id="waitlist">
+      <input id="waitlist-email" type="email" required autocomplete="email"
+        aria-label="Email" placeholder="you@example.com">
+      <button class="primary" type="submit">Tell me</button>
+    </form>
+    <p class="muted small">On an iPhone today, open the share link of a caption stream in Safari.</p>
+  </section>
+
+  <section class="panel">
+    <h2>Free editions</h2>
+    <ul>
+      <li><a href="https://honjimaku.com/subrep/">Subrep for Windows</a></li>
+      <li><a href="https://github.com/equwal/subrep-android/releases/latest">Subrep for Android</a></li>
+    </ul>
+  </section>
+</main>
+<script type="module">
+  const $ = (id) => document.getElementById(id);
+  const say = (text) => { $('note').textContent = text; };
+
+  async function post(path, body) {
+    const res = await fetch(path, {
+      method: 'POST', credentials: 'same-origin',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(body || {}),
+    });
+    const data = await res.json().catch(() => ({}));
+    if (!res.ok) throw new Error(data.detail || data.error || `Error ${res.status}`);
+    return data;
+  }
+
+  function planCard(plan, state, first) {
+    const card = document.createElement('div');
+    card.className = 'plan' + (first ? ' featured' : '');
+    const name = document.createElement('div');
+    name.className = 'plan-name';
+    name.textContent = plan.name;
+    const price = document.createElement('div');
+    price.className = 'plan-price';
+    price.textContent = plan.price_display + ' ';
+    const per = document.createElement('small');
+    per.textContent = plan.interval === 'year' ? 'billed yearly' : 'billed monthly';
+    price.append(per);
+    const blurb = document.createElement('p');
+    blurb.className = 'plan-blurb';
+    blurb.textContent = plan.blurb;
+    const buy = document.createElement('button');
+    buy.className = first ? 'primary' : 'ghost';
+    buy.type = 'button';
+    buy.textContent = `Pay ${plan.price_display}`;
+    buy.disabled = !state.available || state.needs_sign_in;
+    buy.addEventListener('click', async () => {
+      buy.disabled = true;
+      try {
+        location.href = (await post('/api/subrep/checkout', { plan_id: plan.id })).url;
+      } catch (err) {
+        say(err.message);
+        buy.disabled = false;
+      }
+    });
+    card.append(name, price, blurb, buy);
+    return card;
+  }
+
+  async function render() {
+    const state = await (await fetch('/api/subrep', { credentials: 'same-origin' })).json();
+    $('plans').replaceChildren(...state.plans.map((p, i) => planCard(p, state, i === 0)));
+    $('sign-in-note').hidden = !state.needs_sign_in;
+    $('buy').hidden = state.pro;
+    $('pro').hidden = !state.pro;
+    if (!state.available) say('Subrep Pro is not for sale yet. Check again soon.');
+    if (state.pro) {
+      const ends = new Date(state.period_end).toLocaleDateString();
+      const plan = state.plans.find((p) => p.id === state.plan_id);
+      $('pro-status').textContent = `${plan ? plan.name : 'Subrep Pro'}: paid until ${ends}.`;
+      $('command').textContent = state.link.command;
+    }
+    return state;
+  }
+
+  $('copy').addEventListener('click', async () => {
+    await navigator.clipboard.writeText($('command').textContent);
+    $('copy').textContent = 'Copied';
+  });
+
+  $('manage').addEventListener('click', async () => {
+    try {
+      location.href = (await post('/api/billing/portal')).url;
+    } catch (err) {
+      say(err.message);
+    }
+  });
+
+  $('waitlist').addEventListener('submit', async (event) => {
+    event.preventDefault();
+    try {
+      await post('/api/subrep/waitlist', { email: $('waitlist-email').value, kind: 'ios' });
+      $('waitlist').replaceChildren('Thank you. We write to you when the iPhone app ships.');
+    } catch (err) {
+      say(err.message);
+    }
+  });
+
+  const checkout = new URLSearchParams(location.search).get('checkout');
+  const state = await render();
+  if (checkout === 'cancelled') say('The payment was cancelled. No money was taken.');
+  if (checkout === 'paid' || checkout === 'pending') {
+    // The webhook can land a few seconds after the browser comes back.
+    say(state.pro ? 'Thank you. Subrep Pro is on.' : 'The payment is in progress…');
+    for (let i = 0; i < 10 && !(await render()).pro; i++) {
+      await new Promise((done) => setTimeout(done, 3000));
+    }
+    if ((await render()).pro) say('Thank you. Subrep Pro is on.');
+  }
+  history.replaceState(null, '', location.pathname + location.hash);
+</script>
+</body>
+</html>
diff --git a/frontend/terms.html b/frontend/terms.html
index 2b0353c..8dca898 100644
--- a/frontend/terms.html
+++ b/frontend/terms.html
@@ -26,6 +26,8 @@
       finished subtitles, so that this page can list your conversions.</li>
     <li><strong>On our server (credits):</strong> you upload the audio and the book, and the server
       does the work. One credit converts one book. New visitors get some free credits to try it.</li>
+    <li><strong>Subrep Pro:</strong> the paid plan of the Subrep desktop app, which makes live
+      captions on your computer. This site sells the plan and keeps its account.</li>
   </ul>
 
   <h2>Your files</h2>
@@ -55,6 +57,9 @@
       conversion in your browser first: it uses the same method, and it shows whether your
       book and your audio match.</li>
     <li>Unused credits are refunded on request within 14 days of the purchase.</li>
+    <li>Subrep Pro renews each month or each year, until you cancel it. Cancel at any time with
+      "Manage plan": the plan then runs to the end of the period that you paid for. We refund the
+      first payment of Subrep Pro on request within 14 days.</li>
   </ul>
 
   <h2>Accounts and privacy</h2>
@@ -66,6 +71,10 @@
       links and for answers to your messages, and for nothing else.</li>
     <li>We store: your email address (if you gave one), your credits and purchases, and the list of
       your conversions (file names, sizes, language, times). Write to us to have all of it deleted.</li>
+    <li>For Subrep Pro, we store your plan and a link key. The Subrep app on your computer sends
+      the key to get its licence. The app sends nothing else to this site.</li>
+    <li>If you ask to hear about the iPhone app, we use your email once, to tell you that it
+      ships.</li>
     <li>The server keeps ordinary web logs (IP address, time, page) for a short time, for security.</li>
   </ul>
 
diff --git a/requirements.txt b/requirements.txt
index 6f8778e..9957924 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -13,6 +13,10 @@ mobi>=0.4
 # small and pure Python, and leaving it optional only makes deploys fiddlier.
 stripe>=13
 
+# Subrep Pro licences (backend/subrep.py): Ed25519 signatures that the Subrep
+# desktop app checks. The standard library has no Ed25519.
+cryptography>=42
+
 # --- public deployment extras (not needed on localhost) ---
 # redis>=5.0          # SUBPLZ_WEB_QUEUE_BACKEND=redis
 # rq>=1.16            # external worker processes
diff --git a/tests/test_subrep.py b/tests/test_subrep.py
new file mode 100644
index 0000000..5205975
--- /dev/null
+++ b/tests/test_subrep.py
@@ -0,0 +1,370 @@
+"""Subrep Pro: the plan of the Subrep desktop app, sold with the accounts of this site.
+
+  yearly   $9.99 a year (first on the page)
+  monthly  $0.99 a month
+
+A Stripe subscription makes the account Pro. The desktop app does not have the
+cookie of the browser: the page gives a link command with the account id and a
+refresh key, and the app sends the two to /api/subrep/refresh. The answer is a
+licence token that the app checks offline with the public key in its build.
+A Subrep plan is not a book plan: an account can have both, or one of them.
+"""
+
+from __future__ import annotations
+
+import base64
+import json
+import time
+from urllib.parse import parse_qs, urlparse
+
+import pytest
+import stripe
+from cryptography.exceptions import InvalidSignature
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from hypothesis import given
+from hypothesis import settings as hsettings
+from hypothesis import strategies as st
+
+from backend import accounts, subrep
+from backend.db import Account, SessionLocal
+from backend.settings import settings
+
+from .conftest import FakeStripeObject, account_id, checkout_event, post_webhook
+
+SEED = bytes(range(32))  # a fixed key for the tests, never the key of a server
+PRIVATE_KEY = base64.urlsafe_b64encode(SEED).rstrip(b"=").decode()
+PUBLIC_KEY = Ed25519PrivateKey.from_private_bytes(SEED).public_key()
+DAY = 86400
+
+
+@pytest.fixture(autouse=True)
+def licence_key(monkeypatch):
+    monkeypatch.setattr(settings, "subrep_license_key", PRIVATE_KEY)
+
+
+def b64d(text: str) -> bytes:
+    return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
+
+
+def verify(token: str) -> dict:
+    """The check of the desktop app (verify_token in subrep/licensing.py)."""
+    payload, sig = token.split(".")
+    PUBLIC_KEY.verify(b64d(sig), b64d(payload))
+    return json.loads(b64d(payload))
+
+
+def sub_object(account, plan_id, status="active", period_end=None, sub_id=None,
+               price_key=None) -> dict:
+    item: dict = {"current_period_end": period_end or int(time.time()) + 30 * DAY}
+    if price_key:
+        item["price"] = {"id": f"price_{price_key}", "lookup_key": price_key}
+    return {
+        "id": sub_id or f"sub_subrep_{account}", "object": "subscription",
+        "status": status, "customer": f"cus_{account}",
+        "metadata": {"account_id": account, "plan_id": plan_id},
+        "items": {"data": [item]},
+    }
+
+
+def sub_event(kind: str, obj: dict) -> dict:
+    return {"id": f"evt_{kind}_{time.time_ns()}", "type": f"customer.subscription.{kind}",
+            "data": {"object": obj}}
+
+
+def buy(client, monkeypatch, plan_id="subrep_year", email=None) -> str:
+    """Pay for Subrep Pro, as the checkout webhook tells it. Returns the account id."""
+    acct = account_id(client)
+    sub = sub_object(acct, plan_id)
+    monkeypatch.setattr(stripe.Subscription, "retrieve", lambda sid: FakeStripeObject(sub))
+    event = checkout_event(f"cs_{time.time_ns()}", acct, plan_id,
+                           email=email or f"pro-{time.time_ns()}@example.com",
+                           subscription=sub["id"])
+    assert post_webhook(client, event).status_code == 200
+    return acct
+
+
+def state(client) -> dict:
+    return client.get("/api/subrep").json()
+
+
+def refresh(client, account: str, key: str):
+    return client.post("/api/subrep/refresh", json={"account": account, "refresh_key": key})
+
+
+def fake_checkout(monkeypatch) -> dict:
+    seen: dict = {}
+    monkeypatch.setattr(
+        stripe.checkout.Session, "create",
+        lambda **p: seen.update(p) or FakeStripeObject({"url": "https://stripe.test/pro"}),
+    )
+    return seen
+
+
+# --- plans and checkout ------------------------------------------------------
+
+def test_the_page_offers_the_yearly_plan_first_then_the_monthly_plan(client):
+    plans = state(client)["plans"]
+    assert [(p["id"], p["price_cents"], p["interval"]) for p in plans] == [
+        ("subrep_year", 999, "year"), ("subrep_month", 99, "month")]
+
+
+def test_the_book_page_does_not_show_subrep_pro(client):
+    ids = {p["id"] for p in client.get("/api/pricing").json()["plans"]}
+    assert not ids & {p.id for p in subrep.PLANS}
+
+
+def test_a_plan_checks_out_as_a_subscription_of_its_interval(client, monkeypatch):
+    seen = fake_checkout(monkeypatch)
+    r = client.post("/api/subrep/checkout", json={"plan_id": "subrep_year"})
+    assert r.status_code == 200 and r.json()["url"] == "https://stripe.test/pro"
+    assert seen["mode"] == "subscription"
+    price = seen["line_items"][0]["price_data"]
+    assert price["recurring"] == {"interval": "year"} and price["unit_amount"] == 999
+    assert seen["subscription_data"]["metadata"]["plan_id"] == "subrep_year"
+
+
+def test_the_checkout_comes_back_to_the_subrep_page(client, monkeypatch):
+    seen = fake_checkout(monkeypatch)
+    client.post("/api/subrep/checkout", json={"plan_id": "subrep_month"})
+    assert seen["cancel_url"] == "https://example.test/subrep.html?checkout=cancelled"
+    success = urlparse(seen["success_url"])
+    assert success.path == "/api/billing/return"
+    assert parse_qs(success.query)["page"] == ["/subrep.html"]
+
+
+def test_the_return_trip_goes_only_to_a_known_page(client, monkeypatch):
+    monkeypatch.setattr(stripe.checkout.Session, "retrieve",
+                        lambda sid: FakeStripeObject({"id": sid, "payment_status": "unpaid"}))
+    r = client.get("/api/billing/return?session_id=cs_x&page=/subrep.html",
+                   follow_redirects=False)
+    assert r.status_code == 303 and r.headers["location"] == "/subrep.html?checkout=pending"
+    r = client.get("/api/billing/return?session_id=cs_x&page=https://evil.test/",
+                   follow_redirects=False)
+    assert r.headers["location"] == "/?checkout=pending"
+
+
+def test_a_book_plan_does_not_stop_a_subrep_checkout(client, monkeypatch):
+    acct = account_id(client)
+    books = sub_object(acct, "month10", sub_id=f"sub_books_{acct}")
+    assert post_webhook(client, sub_event("created", books)).status_code == 200
+    assert client.get("/api/account").json()["subscribed"] is True
+    fake_checkout(monkeypatch)
+    assert client.post("/api/subrep/checkout", json={"plan_id": "subrep_month"}).status_code == 200
+
+
+def test_a_second_subrep_plan_is_refused(client, monkeypatch):
+    buy(client, monkeypatch)
+    fake_checkout(monkeypatch)
+    r = client.post("/api/subrep/checkout", json={"plan_id": "subrep_month"})
+    assert r.status_code == 400 and "Manage plan" in r.json()["detail"]
+
+
+def test_only_a_subrep_plan_is_sold_here(client):
+    assert client.post("/api/subrep/checkout", json={"plan_id": "month10"}).status_code == 404
+
+
+def test_checkout_needs_a_verified_email_when_the_server_can_send_one(client, monkeypatch):
+    # A $0.99 checkout draws card testing. With email sign-in, only a verified
+    # account may pay. Without it, the checks of Stripe are all there is.
+    monkeypatch.setattr(settings, "smtp_host", "smtp.example.test")
+    fake_checkout(monkeypatch)
+    assert state(client)["needs_sign_in"] is True
+    assert client.post("/api/subrep/checkout", json={"plan_id": "subrep_year"}).status_code == 403
+    with SessionLocal() as s:
+        row = s.get(Account, account_id(client))
+        row.email, row.email_verified = f"v-{time.time_ns()}@example.com", 1
+        s.commit()
+    assert client.post("/api/subrep/checkout", json={"plan_id": "subrep_year"}).status_code == 200
+
+
+def test_a_server_without_a_licence_key_sells_nothing(client, monkeypatch):
+    monkeypatch.setattr(settings, "subrep_license_key", "")
+    assert state(client)["available"] is False
+    assert client.post("/api/subrep/checkout", json={"plan_id": "subrep_year"}).status_code == 503
+
+
+# --- Pro, the link command and the licence -------------------------------------
+
+def test_paying_makes_the_account_pro_and_gives_a_link_command(client, monkeypatch):
+    acct = buy(client, monkeypatch)
+    s = state(client)
+    assert s["pro"] is True and s["plan_id"] == "subrep_year"
+    link = s["link"]
+    assert link["account"] == acct and link["server"] == "https://example.test/api/subrep"
+    assert link["command"] == (
+        f'subrep license --link "{acct}" "{link["refresh_key"]}" "{link["server"]}"')
+
+
+def test_the_page_shows_the_same_link_command_on_each_visit(client, monkeypatch):
+    # Each computer of the customer runs the same command.
+    buy(client, monkeypatch)
+    assert state(client)["link"] == state(client)["link"]
+
+
+def test_a_subrep_plan_does_not_touch_the_book_plan(client, monkeypatch):
+    buy(client, monkeypatch)
+    a = client.get("/api/account").json()
+    assert a["subscribed"] is False and a["plan_credits"] == 0
+
+
+def test_the_desktop_app_gets_a_licence_that_the_public_key_verifies(client, monkeypatch):
+    email = f"desk-{time.time_ns()}@example.com"
+    acct = buy(client, monkeypatch, email=email)
+    r = refresh(client, acct, state(client)["link"]["refresh_key"])
+    assert r.status_code == 200
+    claims = verify(r.json()["token"])
+    assert claims["tier"] == "pro" and claims["sub"] == email
+    assert time.time() < claims["exp"] <= time.time() + 31 * DAY
+
+
+def test_a_licence_ends_with_the_paid_period(client, monkeypatch):
+    acct = buy(client, monkeypatch, plan_id="subrep_month")
+    end = int(time.time()) + 3 * DAY
+    post_webhook(client, sub_event("updated", sub_object(acct, "subrep_month", period_end=end)))
+    token = refresh(client, acct, state(client)["link"]["refresh_key"]).json()["token"]
+    assert verify(token)["exp"] == end
+
+
+def test_a_yearly_licence_ends_within_31_days(client, monkeypatch):
+    # The app asks again before then, so a refund ends Pro within a month.
+    acct = buy(client, monkeypatch)
+    post_webhook(client, sub_event("updated", sub_object(
+        acct, "subrep_year", period_end=int(time.time()) + 365 * DAY)))
+    token = refresh(client, acct, state(client)["link"]["refresh_key"]).json()["token"]
+    assert verify(token)["exp"] <= time.time() + 31 * DAY
+
+
+def test_a_wrong_refresh_key_gets_no_licence(client, monkeypatch):
+    acct = buy(client, monkeypatch)
+    key = state(client)["link"]["refresh_key"]  # the account has a key now
+    r = refresh(client, acct, key[:-1] + ("A" if key[-1] != "A" else "B"))
+    assert r.status_code == 403 and "error" in r.json()
+    assert refresh(client, acct, key).status_code == 200
+
+
+def test_an_account_without_a_link_command_gets_no_licence(client, monkeypatch):
+    acct = buy(client, monkeypatch)  # the page was never opened: no key yet
+    assert refresh(client, acct, "").status_code == 403
+
+
+def test_a_cancelled_plan_gets_no_licence(client, monkeypatch):
+    acct = buy(client, monkeypatch, plan_id="subrep_month")
+    key = state(client)["link"]["refresh_key"]
+    ended = sub_object(acct, "subrep_month", status="canceled")
+    assert post_webhook(client, sub_event("deleted", ended)).status_code == 200
+    s = state(client)
+    assert s["pro"] is False and s["link"] is None
+    r = refresh(client, acct, key)
+    assert r.status_code == 402 and "error" in r.json()
+
+
+def test_a_switch_in_the_portal_changes_the_plan(client, monkeypatch):
+    acct = buy(client, monkeypatch, plan_id="subrep_month")
+    post_webhook(client, sub_event("updated", sub_object(
+        acct, "subrep_month", price_key="subrep_year")))
+    assert state(client)["plan_id"] == "subrep_year"
+
+
+def test_the_end_of_an_old_subscription_does_not_end_its_replacement(client, monkeypatch):
+    acct = buy(client, monkeypatch)
+    old = sub_object(acct, "subrep_month", status="canceled", sub_id="sub_old")
+    post_webhook(client, sub_event("deleted", old))
+    assert state(client)["pro"] is True
+
+
+def test_merging_accounts_keeps_the_licence(client, monkeypatch):
+    acct = buy(client, monkeypatch)
+    with SessionLocal() as s:
+        owner = accounts.new_account(s)
+        accounts.merge(s, s.get(Account, acct), owner)
+        s.commit()
+        assert subrep.is_pro(subrep.licence(s, owner.id))
+        assert subrep.licence(s, acct) is None
+
+
+def test_the_link_command_of_a_merged_account_still_works(client, monkeypatch):
+    acct = buy(client, monkeypatch)
+    key = state(client)["link"]["refresh_key"]
+    with SessionLocal() as s:
+        owner = accounts.new_account(s)
+        accounts.merge(s, s.get(Account, acct), owner)
+        s.commit()
+    assert refresh(client, acct, key).status_code == 200
+
+
+# --- the iOS waitlist ------------------------------------------------------------
+
+def test_the_ios_waitlist_keeps_one_entry_for_each_email(client):
+    email = f"Wait-{time.time_ns()}@Example.com"
+    for _ in range(2):
+        r = client.post("/api/subrep/waitlist", json={"email": email})
+        assert r.status_code == 200 and r.json()["joined"] is True
+    with SessionLocal() as s:
+        rows = s.query(subrep.WaitlistEntry).filter_by(email=email.lower()).all()
+        assert [row.kind for row in rows] == ["ios"]
+        assert subrep.waitlist_count(s, "ios") >= 1
+
+
+def test_the_waitlist_refuses_what_is_not_an_email(client):
+    assert client.post("/api/subrep/waitlist", json={"email": "nope"}).status_code == 400
+
+
+def test_there_is_no_waitlist_for_an_unknown_app(client):
+    r = client.post("/api/subrep/waitlist", json={"email": "a@example.com", "kind": "tv"})
+    assert r.status_code == 404
+
+
+# --- the token format ------------------------------------------------------------
+
+claims_st = st.dictionaries(
+    st.text(min_size=1, max_size=12),
+    st.one_of(st.integers(), st.text(max_size=20), st.booleans()),
+    max_size=6,
+)
+
+
+@hsettings(max_examples=60, deadline=None)
+@given(claims=claims_st)
+def test_a_token_carries_its_claims_and_its_signature_checks(claims):
+    assert verify(subrep.sign(claims)) == claims
+
+
+@hsettings(max_examples=60, deadline=None)
+@given(claims=claims_st, where=st.integers(min_value=0))
+def test_a_changed_token_fails_the_check(claims, where):
+    payload, sig = subrep.sign(claims).split(".")
+    raw = bytearray(b64d(payload))
+    raw[where % len(raw)] ^= 1
+    changed = base64.urlsafe_b64encode(bytes(raw)).rstrip(b"=").decode() + "." + sig
+    with pytest.raises(InvalidSignature):
+        verify(changed)
+
+
+def test_the_page_shows_the_public_key_for_the_desktop_build(client):
+    assert b64d(state(client)["public_key"]) == PUBLIC_KEY.public_bytes_raw()
+
+
+def test_the_key_tool_makes_one_key_and_never_replaces_it(tmp_path, monkeypatch, capsys):
+    import importlib.util
+    from pathlib import Path
+
+    spec = importlib.util.spec_from_file_location(
+        "subrep_key", Path(__file__).resolve().parent.parent / "tools" / "subrep_key.py")
+    tool = importlib.util.module_from_spec(spec)
+    spec.loader.exec_module(tool)
+    env = tmp_path / ".env"
+    env.write_text("SUBPLZ_WEB_OTHER=1", encoding="utf-8")  # no newline at the end
+    monkeypatch.setattr(tool, "ROOT", tmp_path)
+    monkeypatch.setattr(tool, "ENV", env)
+
+    assert tool.main() == 0
+    public = capsys.readouterr().out.strip()
+    text = env.read_text(encoding="utf-8")
+    assert text.startswith("SUBPLZ_WEB_OTHER=1\nSUBPLZ_WEB_SUBREP_LICENSE_KEY=")
+    saved = text.split("SUBPLZ_WEB_SUBREP_LICENSE_KEY=")[1].strip()
+    monkeypatch.setattr(settings, "subrep_license_key", saved)
+    assert subrep.public_key() == public  # the printed key belongs to the saved key
+
+    assert tool.main() == 0
+    assert capsys.readouterr().out.strip() == public
+    assert env.read_text(encoding="utf-8") == text
diff --git a/tools/subrep_key.py b/tools/subrep_key.py
new file mode 100644
index 0000000..6a999a2
--- /dev/null
+++ b/tools/subrep_key.py
@@ -0,0 +1,61 @@
+"""Make the licence key of Subrep Pro on the server, once.
+
+    python tools/subrep_key.py
+
+It writes SUBPLZ_WEB_SUBREP_LICENSE_KEY into .env and never shows it. It
+prints the public key: build that into the Subrep desktop app (PUBLIC_KEY_B64
+in subrep/licensing.py). When .env has the key already, it prints the public
+key of that key and changes nothing. Every licence of every customer depends
+on the key, so this tool never replaces it.
+
+After the first run: systemctl restart subplz-web
+"""
+from __future__ import annotations
+
+import base64
+import os
+import sys
+from pathlib import Path
+
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+
+NAME = "SUBPLZ_WEB_SUBREP_LICENSE_KEY"
+ROOT = Path(__file__).resolve().parent.parent
+ENV = ROOT / ".env"
+
+
+def b64e(raw: bytes) -> str:
+    return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
+
+
+def b64d(text: str) -> bytes:
+    return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
+
+
+def main() -> int:
+    text = ENV.read_text(encoding="utf-8") if ENV.exists() else ""
+    found = [line.split("=", 1)[1].strip() for line in text.splitlines()
+             if line.startswith(NAME + "=")]
+    if found and found[-1]:
+        key = Ed25519PrivateKey.from_private_bytes(b64d(found[-1]))
+        print(f"{NAME} is set already. It stays as it is.", file=sys.stderr)
+    else:
+        key = Ed25519PrivateKey.generate()
+        with ENV.open("a", encoding="utf-8") as f:
+            if text and not text.endswith("\n"):
+                f.write("\n")
+            f.write(f"{NAME}={b64e(key.private_bytes_raw())}\n")
+        # As tools/set-secret.sh: the service does not run as root. The file
+        # belongs to the owner of the checkout, and nobody else may read it.
+        if hasattr(os, "chown"):
+            owner = ROOT.stat()
+            os.chown(ENV, owner.st_uid, owner.st_gid)
+        os.chmod(ENV, 0o600)
+        print(f"{NAME} saved in {ENV}. Apply it with: systemctl restart subplz-web",
+              file=sys.stderr)
+    print(b64e(key.public_key().public_bytes_raw()))
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())