Recently Written · git

subplz-web

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

Log | Files | Refs


commit 63f3c22cea0e132d823e1e775dd334bc376e8404
equwal <truex@equwal.com>
2026-09-22 15:56:40 -0700

Sell monthly plans: 10 or 30 books a month, or unlimited

The owner added three monthly plans: 10 books a month for $4.99, 30 books
a month for $9.99, and unlimited for $50 a month.

- The subscription carries its plan id (subscription_plan_id). The books
  of a month do not carry over: a new current_period_end from Stripe sets
  subscription_credits_used back to 0.
- A server job spends the credit that ends first: the daily credit, then
  a book of this month's plan, then a free credit, then a bought one. An
  unlimited plan spends nothing. A failed job gives the book back.
- Paying for a plan adds no pack credits. A second plan is refused: the
  customer changes or cancels the plan with Manage plan.
- A plan that is not in the catalogue gives nothing, so a sold plan must
  be retired (RETIRED_PLANS), not deleted.

The page, the terms and the README say that pack credits never expire,
that a plan renews until cancelled, and that its books do not carry over.
The plans need the customer.subscription.* webhook events and the Stripe
customer portal.

 README.md                   |  42 ++++++++++--
 backend/api.py              |   6 ++
 backend/billing.py          |  68 +++++++++++++++++--
 backend/db.py               |  10 +++
 backend/payments.py         |  20 ++++--
 backend/pricing.py          |  42 ++++++++++--
 frontend/app.js             |  10 +--
 frontend/index.html         |   7 +-
 frontend/terms.html         |   6 +-
 tests/test_billing.py       |  15 +++--
 tests/test_migration.py     |   6 +-
 tests/test_subscriptions.py | 161 ++++++++++++++++++++++++++++++++++++++++++++
 12 files changed, 356 insertions(+), 37 deletions(-)
diff --git a/README.md b/README.md
index 73f1eca..d59e6cc 100644
--- a/README.md
+++ b/README.md
@@ -359,8 +359,22 @@ cancelled gets the credit back.
   server converted for it (`SUBPLZ_WEB_BOOKS_PER_BONUS_CREDIT`). Books
   converted in the browser do not count, because the server cannot check them.
 
-A server job spends the daily credit first, then a free credit, then a bought
-one. The account counts the free credits apart from the bought ones
+**Monthly plans.** A plan renews each month until the customer cancels it in
+Stripe's customer portal ("Manage plan" on the page).
+
+| Plan | Price | Books |
+|---|---|---|
+| `month10` | $4.99 a month | 10 a month |
+| `month30` | $9.99 a month | 30 a month |
+| `unlimited` | $50.00 a month | no limit, one book at a time |
+
+The books of a month do not carry over: a new `current_period_end` from Stripe
+sets `subscription_credits_used` back to 0. Retire a plan that was sold
+(`pricing.RETIRED_PLANS`); do not delete it, or its subscribers get nothing.
+
+A server job spends the credit that ends first: the daily credit, then a book
+of this month's plan, then a free credit, then a bought one. An unlimited plan
+spends nothing. The account counts the free credits apart from the bought ones
 (`free_credits_used`), so a refund of bought credits never pays out a free one.
 
 A switch above the drop zone says where the work is done. Each visit starts
@@ -401,9 +415,27 @@ tools/set-secret.sh SUBPLZ_WEB_SMTP_PASSWORD           # for sign-in emails
 
 In Stripe: Developers -> Webhooks -> add `https://<host>/api/billing/webhook`
 with `checkout.session.completed`, `checkout.session.async_payment_succeeded`,
-`customer.subscription.created`, `.updated` and `.deleted`. Then set
-`SUBPLZ_WEB_BILLING_ENABLED=true`, `SUBPLZ_WEB_PUBLIC_BASE_URL`,
-`SUBPLZ_WEB_COOKIE_SECURE=true` and the `SMTP_*` values in `.env` and restart.
+`customer.subscription.created`, `.updated` and `.deleted`. The monthly plans
+need the three subscription events, and the customer portal (Settings ->
+Billing -> Customer portal: let customers cancel at the end of the period and
+update the card, then save). Then set `SUBPLZ_WEB_BILLING_ENABLED=true`,
+`SUBPLZ_WEB_PUBLIC_BASE_URL`, `SUBPLZ_WEB_COOKIE_SECURE=true` and the `SMTP_*`
+values in `.env` and restart.
+
+Sign-in email uses plain SMTP, so any provider works. With Resend: add the
+domain in Resend, add the DNS records that it shows (they sit on a `send`
+subdomain and `resend._domainkey`, so existing mail forwarding stays), create
+an API key with sending access, and set:
+
+```bash
+SUBPLZ_WEB_SMTP_HOST=smtp.resend.com
+SUBPLZ_WEB_SMTP_PORT=587
+SUBPLZ_WEB_SMTP_USER=resend
+tools/set-secret.sh SUBPLZ_WEB_SMTP_PASSWORD   # the Resend API key
+```
+
+Opening the emailed link verifies the address. A verified address gets the
+free credits of an account (see *Free credits*).
 
 Not handled: refunds and disputes (do them in the Stripe dashboard and adjust
 `purchased_credits` by hand), and tax (Stripe Tax is one checkout parameter away
diff --git a/backend/api.py b/backend/api.py
index 6b5a399..8c93a2b 100644
--- a/backend/api.py
+++ b/backend/api.py
@@ -184,6 +184,10 @@ class AccountOut(BaseModel):
     free_credits_with_account: int
     # Whether a verified account gets one free credit each day.
     daily_free_credit: bool
+    # The part of `credits` that is left of this month's plan.
+    plan_credits: int
+    # A monthly plan with no limit: no server job spends a credit.
+    unlimited: bool
     subscribed: bool
     subscription_ends: str | None
     cloud_allowed: bool
@@ -311,6 +315,8 @@ def _account_out(session: Session, account: Account) -> AccountOut:
             else billing.free_credits_left(session, account, verified=True)
         ),
         daily_free_credit=settings.cloud_enabled and settings.daily_free_credit,
+        plan_credits=ent.plan_credits,
+        unlimited=ent.unlimited,
         subscribed=ent.subscribed,
         subscription_ends=(
             ent.subscription_ends.isoformat() if ent.subscription_ends else None
diff --git a/backend/billing.py b/backend/billing.py
index 0b52213..e3b7bab 100644
--- a/backend/billing.py
+++ b/backend/billing.py
@@ -27,6 +27,7 @@ from datetime import date, datetime, timedelta, timezone
 from sqlalchemy import func, or_, update
 from sqlalchemy.orm import Session
 
+from . import pricing
 from .db import Account, Job, JobStatus, SessionLocal, utcnow
 from .settings import settings
 
@@ -52,16 +53,20 @@ def _aware(when: datetime | None) -> datetime | None:
 
 @dataclass(frozen=True)
 class Entitlement:
-    # Every credit the account can spend now: the bought and the free ones.
+    # Every credit the account can spend now: bought, free and monthly ones.
     credits: int
     # The part of `credits` that is free.
     free_credits: int
+    # The part of `credits` that is left of this month's plan.
+    plan_credits: int
     subscribed: bool
+    # A subscription with no monthly limit: no job spends a credit.
+    unlimited: bool
     subscription_ends: datetime | None
 
     @property
     def cloud_allowed(self) -> bool:
-        return self.subscribed or self.credits > 0
+        return self.unlimited or self.credits > 0
 
 
 def is_subscribed(account: Account) -> bool:
@@ -71,6 +76,26 @@ def is_subscribed(account: Account) -> bool:
     return ends is None or ends + _GRACE > utcnow()
 
 
+def subscription_plan(account: Account) -> pricing.Plan | None:
+    """The plan of an active subscription. None without one, or when the plan
+    is not in the catalogue (a sold plan must be retired, not deleted)."""
+    if not is_subscribed(account):
+        return None
+    return pricing.get(account.subscription_plan_id or "", retired=True)
+
+
+def is_unlimited(account: Account) -> bool:
+    plan = subscription_plan(account)
+    return plan is not None and plan.recurring and plan.credits is None
+
+
+def plan_credits_left(account: Account) -> int:
+    plan = subscription_plan(account)
+    if plan is None or not plan.recurring or plan.credits is None:
+        return 0
+    return max(0, plan.credits - account.subscription_credits_used)
+
+
 def _today() -> date:
     """The day of the daily free credit (UTC). Tests replace this."""
     return utcnow().date()
@@ -123,14 +148,32 @@ def daily_credit_left(account: Account) -> int:
 
 def check(session: Session, account: Account) -> Entitlement:
     free = free_credits_left(session, account) + daily_credit_left(account)
+    monthly = plan_credits_left(account)
     return Entitlement(
-        credits=account.purchased_credits + free,
+        credits=account.purchased_credits + free + monthly,
         free_credits=free,
+        plan_credits=monthly,
         subscribed=is_subscribed(account),
+        unlimited=is_unlimited(account),
         subscription_ends=_aware(account.subscription_period_end),
     )
 
 
+def _spend_plan_credit(session: Session, account: Account) -> bool:
+    """Take one book of this month's plan, atomically. False if none is left."""
+    plan = subscription_plan(account)
+    if plan is None or not plan.recurring or plan.credits is None:
+        return False
+    taken = session.execute(
+        update(Account)
+        .where(Account.id == account.id,
+               Account.subscription_credits_used < plan.credits)
+        .values(subscription_credits_used=Account.subscription_credits_used + 1)
+    ).rowcount
+    session.refresh(account)
+    return bool(taken)
+
+
 def _spend_daily_credit(session: Session, account: Account) -> date | None:
     """Take the free credit of today, atomically. Its day, or None."""
     if not daily_credit_left(account):
@@ -177,18 +220,22 @@ def authorize_start(session: Session, account: Account, job: Job) -> None:
     """Charge what starting `job` costs, or raise PaymentRequired.
 
     Where the job runs sets its tier: a browser job is free, a server job is
-    a cloud job. A cloud job spends the daily credit first (it does not
-    carry over), then a free credit, then a bought one.
+    a cloud job. An unlimited plan pays for a cloud job. Else the job spends
+    the credit that ends first: the daily credit, then a book of this month's
+    plan, then a free credit, then a bought one.
     """
     job.tier = FREE if job.local else CLOUD
     job.billed, job.credit_spent, job.free_credit_spent = 0, 0, 0
-    job.daily_credit_on = None
-    if job.local or not settings.billing_enabled or is_subscribed(account):
+    job.plan_credit_spent, job.daily_credit_on = 0, None
+    if job.local or not settings.billing_enabled or is_unlimited(account):
         return
     day = _spend_daily_credit(session, account)
     if day is not None:
         job.daily_credit_on = day
         return
+    if _spend_plan_credit(session, account):
+        job.plan_credit_spent = 1
+        return
     if _spend_free_credit(session, account):
         job.free_credit_spent = 1
         return
@@ -217,6 +264,13 @@ def refund(session: Session, job: Job) -> None:
             .values(free_credits_used=Account.free_credits_used - 1)
         )
         job.free_credit_spent = 0
+    if job.plan_credit_spent:
+        session.execute(
+            update(Account)
+            .where(Account.id == job.account_id, Account.subscription_credits_used > 0)
+            .values(subscription_credits_used=Account.subscription_credits_used - 1)
+        )
+        job.plan_credit_spent = 0
     if job.daily_credit_on is not None:
         # Only the credit of the same day comes back: a later day has its own.
         session.execute(
diff --git a/backend/db.py b/backend/db.py
index 414ef45..52f1874 100644
--- a/backend/db.py
+++ b/backend/db.py
@@ -97,6 +97,12 @@ class Account(Base):
     subscription_period_end: Mapped[datetime | None] = mapped_column(
         DateTime(timezone=True), nullable=True
     )
+    # The plan of the subscription (pricing.Plan.id), and how many of its
+    # books this month's jobs used. A new month sets the count back to 0.
+    subscription_plan_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
+    subscription_credits_used: Mapped[int] = mapped_column(
+        Integer, default=0, server_default=text("0")
+    )
 
     # Set when this anonymous row was folded into a signed-in account. A
     # payment can finish without a browser attached (the webhook), so the
@@ -171,6 +177,10 @@ class Job(Base):
     )
     # Set if this job spent a daily free credit: the day of that credit.
     daily_credit_on: Mapped[date | None] = mapped_column(Date, nullable=True)
+    # 1 if this job spent one of the monthly books of a subscription.
+    plan_credit_spent: Mapped[int] = mapped_column(
+        Integer, default=0, server_default=text("0")
+    )
 
     created_at: Mapped[datetime] = mapped_column(
         DateTime(timezone=True), default=utcnow
diff --git a/backend/payments.py b/backend/payments.py
index 166d270..a4790db 100644
--- a/backend/payments.py
+++ b/backend/payments.py
@@ -72,7 +72,9 @@ def start_checkout(session: Session, account: Account, plan: pricing.Plan) -> st
     stripe = _stripe()
 
     if plan.recurring and billing.is_subscribed(account):
-        raise PaymentError("You are already on this plan.")
+        raise PaymentError(
+            "You already have a monthly plan. Change or cancel it with Manage plan."
+        )
 
     price = {
         "currency": plan.currency,
@@ -116,7 +118,7 @@ def start_checkout(session: Session, account: Account, plan: pricing.Plan) -> st
 
 
 def portal_url(account: Account) -> str:
-    """Stripe's own page for cancelling or updating the unlimited plan."""
+    """Stripe's own page for cancelling a monthly plan or changing the card."""
     stripe = _stripe()
     if not account.stripe_customer_id:
         raise PaymentError("There is no payment history on this account yet.")
@@ -168,9 +170,17 @@ def apply_subscription(session: Session, sub: dict) -> None:
     if account.subscription_id and account.subscription_id != sub.get("id") \
             and sub.get("status") in ("canceled", "incomplete_expired"):
         return
+    period_end = _period_end(sub)
+    # A new subscription, or a new month of it, starts the count of its books
+    # again: the books of one month do not carry over.
+    if account.subscription_id != sub.get("id") \
+            or billing._aware(account.subscription_period_end) != period_end:
+        account.subscription_credits_used = 0
     account.subscription_id = sub.get("id")
     account.subscription_status = sub.get("status")
-    account.subscription_period_end = _period_end(sub)
+    account.subscription_period_end = period_end
+    if meta.get("plan_id"):
+        account.subscription_plan_id = meta["plan_id"]
     if sub.get("customer") and not account.stripe_customer_id:
         account.stripe_customer_id = sub["customer"]
     session.commit()
@@ -219,7 +229,9 @@ def fulfil(session: Session, checkout: dict) -> Account | None:
 
     if checkout.get("customer") and not account.stripe_customer_id:
         account.stripe_customer_id = checkout["customer"]
-    if plan.credits:
+    # A pack adds credits that never expire. A monthly plan adds none here:
+    # its books come from the subscription, month by month.
+    if plan.credits and not plan.recurring:
         account.purchased_credits += plan.credits
     session.commit()
 
diff --git a/backend/pricing.py b/backend/pricing.py
index e50fc68..329ff8a 100644
--- a/backend/pricing.py
+++ b/backend/pricing.py
@@ -30,8 +30,6 @@ 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
   10-book pack      $4.99    ($0.50/book)
@@ -45,9 +43,18 @@ financial advocate: one such sale can hold this one CPU server for about six
 weeks, so the pack says how fast the server works. The packs sold before that
 (one book $4.99, five $16.99, twenty $39) are in RETIRED_PLANS.
 
+  10 books a month  $4.99/month
+  30 books a month  $9.99/month
+  unlimited         $50.00/month
+
+The owner added these monthly plans on 2026-09-22. The books of a month do not
+carry over. The unlimited plan goes against the earlier advice: use comes in
+bursts, and one heavy user can hold this one-job-at-a-time server for all
+other customers.
+
 Every number is overridable by env var; these are defaults, not decisions cast
-in code. An operator who wants a recurring plan can add one with
-SUBPLZ_WEB_PLANS_JSON ("recurring": true, "credits": null).
+in code (SUBPLZ_WEB_PLANS_JSON). Retire a plan that was sold: do not delete it.
+A late payment, or a running subscription, still finds it in RETIRED_PLANS.
 """
 from __future__ import annotations
 
@@ -61,7 +68,8 @@ from dataclasses import asdict, dataclass
 class Plan:
     id: str
     name: str
-    # None for the subscription, which is not a credit pack.
+    # Books in a pack, or books each month for a recurring plan. None for an
+    # unlimited recurring plan.
     credits: int | None
     price_cents: int
     currency: str = "usd"
@@ -104,6 +112,30 @@ DEFAULT_PLANS: list[Plan] = [
               "about 2 hours for a 10-hour book, so 500 long books take weeks. "
               "Credits never expire.",
     ),
+    Plan(
+        id="month10",
+        name="10 books a month",
+        credits=10,
+        price_cents=499,
+        recurring=True,
+        blurb="Renews each month until you cancel. Unused books do not carry over.",
+    ),
+    Plan(
+        id="month30",
+        name="30 books a month",
+        credits=30,
+        price_cents=999,
+        recurring=True,
+        blurb="Renews each month until you cancel. Unused books do not carry over.",
+    ),
+    Plan(
+        id="unlimited",
+        name="Unlimited",
+        credits=None,
+        price_cents=5000,
+        recurring=True,
+        blurb="Every book on our server, one at a time. Renews each month until you cancel.",
+    ),
 ]
 
 # Not for sale. A checkout that started before a plan was retired can still
diff --git a/frontend/app.js b/frontend/app.js
index cb9a0c5..5f6fdd7 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -136,13 +136,14 @@ function renderAccount() {
   // 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;
+  // A monthly plan with a limit can still add a pack; an unlimited one needs none.
+  el.buyBtn.hidden = !selling || a.unlimited;
   el.portalBtn.hidden = !(selling && a.subscribed);
   el.quota.hidden = !selling;
   if (selling) {
     // Say "free" while each credit in hand is a free one.
     const free = a.credits > 0 && a.free_credits === a.credits ? 'free ' : '';
-    el.quota.textContent = a.subscribed ? 'Cloud plan'
+    el.quota.textContent = a.unlimited ? 'Unlimited plan'
       : `${a.credits} ${free}cloud credit${a.credits === 1 ? '' : 's'}`;
   }
   renderMode();
@@ -369,7 +370,7 @@ function inBrowser() { return !serverOffered() || el.inBrowser.checked; }
 function serverPrice() {
   const a = account;
   if (!a?.billing_enabled) return '';
-  if (a.subscribed) return ' Included in your plan.';
+  if (a.unlimited) return ' Included in your plan.';
   if (a.credits > 0) {
     const free = a.free_credits === a.credits ? ' free' : '';
     return ` One credit a book; you have ${a.credits}${free}.`;
@@ -548,7 +549,8 @@ function showResults(r) {
   // Not to a customer: someone who pays for credits has done their part.
   // Free credits do not make a customer, so count the bought ones only.
   const chipIn = document.getElementById('chip-in');
-  const bought = (account?.credits ?? 0) - (account?.free_credits ?? 0);
+  const bought = (account?.credits ?? 0) - (account?.free_credits ?? 0)
+    - (account?.plan_credits ?? 0);
   if (chipIn) chipIn.hidden = Boolean(bought > 0 || account?.subscribed);
   el.stop.hidden = true;
   el.stop.disabled = false;
diff --git a/frontend/index.html b/frontend/index.html
index 88f7d20..adf8ee0 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -239,9 +239,10 @@
   <div id="plans" class="plans"></div>
   <p class="muted small" id="pricing-foot">
     Paid through Stripe; we never see your card. One credit converts one book
-    on our server, with every output. Credits never expire, and a job that
-    fails gives its credit back. In this tab a conversion is always free. Paying also creates your account,
-    under the email you give Stripe.
+    on our server, with every output. Credits in a pack never expire, and a job that
+    fails gives its credit back. A monthly plan renews each month until you cancel it
+    with "Manage plan"; its books do not carry over to the next month. In this tab a
+    conversion is always free. Paying also creates your account, under the email you give Stripe.
   </p>
 </dialog>
 
diff --git a/frontend/terms.html b/frontend/terms.html
index f1cfc35..2b0353c 100644
--- a/frontend/terms.html
+++ b/frontend/terms.html
@@ -41,7 +41,11 @@
   <h2>Payments and refunds</h2>
   <ul>
     <li>Payments are taken by Stripe. We never see your card.</li>
-    <li>Credits do not expire.</li>
+    <li>Credits in a pack do not expire.</li>
+    <li>A monthly plan renews each month until you cancel it. Cancel at any time with "Manage plan"
+      on the page: the plan then runs to the end of the month that you paid for.</li>
+    <li>The books of a monthly plan are for that month. They do not carry over to the next month.
+      Every plan, the unlimited one too, converts one book at a time.</li>
     <li>Free credits have no cash value. We do not refund them.</li>
     <li>An account gets its free credits after you open a sign-in link that we email to you. The
       daily free credit does not carry over to the next day.</li>
diff --git a/tests/test_billing.py b/tests/test_billing.py
index 051690d..51b0fa1 100644
--- a/tests/test_billing.py
+++ b/tests/test_billing.py
@@ -165,11 +165,13 @@ def test_checkout_sends_stripe_the_right_order(client, monkeypatch):
     assert seen["customer_creation"] == "always"
 
 
-def test_ten_books_cost_4_99_and_no_unlimited_plan_is_sold(client):
+def test_the_packs_and_the_monthly_plans_for_sale(client):
     plans = client.get("/api/pricing").json()["plans"]
-    assert [(p["id"], p["credits"], p["price_cents"]) for p in plans] == [
+    row = lambda p: (p["id"], p["credits"], p["price_cents"])  # noqa: E731
+    assert [row(p) for p in plans if not p["recurring"]] == [
         ("pack10", 10, 499), ("pack100", 100, 3999), ("pack500", 500, 17499)]
-    assert not any(p["recurring"] for p in plans)
+    assert [row(p) for p in plans if p["recurring"]] == [
+        ("month10", 10, 499), ("month30", 30, 999), ("unlimited", None, 5000)]
 
 
 def test_bigger_packs_cost_less_a_book(client):
@@ -258,20 +260,21 @@ def test_return_trip_does_not_trust_an_unpaid_session(client, monkeypatch):
 
 # --- subscription ------------------------------------------------------------
 
-def subscription_event(kind, account, status, ends_in=30 * 86400, sub_id="sub_1"):
+def subscription_event(kind, account, status, ends_in=30 * 86400, sub_id="sub_1",
+                       plan_id="monthly"):
     return {
         "id": f"evt_{kind}_{time.time_ns()}",
         "type": f"customer.subscription.{kind}",
         "data": {"object": {
             "id": sub_id, "object": "subscription", "status": status,
-            "customer": "cus_sub", "metadata": {"account_id": account},
+            "customer": "cus_sub", "metadata": {"account_id": account, "plan_id": plan_id},
             # Where newer API versions put it; _period_end reads both places.
             "items": {"data": [{"current_period_end": int(time.time()) + ends_in}]},
         }},
     }
 
 
-def test_subscription_lifts_every_limit_then_lapses(client):
+def test_subscription_lifts_every_limit_then_lapses(client, monthly_plan):
     acct = account_id(client)
     assert post_webhook(client, subscription_event("created", acct, "active")).status_code == 200
 
diff --git a/tests/test_migration.py b/tests/test_migration.py
index 43b3a08..354dc59 100644
--- a/tests/test_migration.py
+++ b/tests/test_migration.py
@@ -74,10 +74,12 @@ def test_old_database_is_upgraded_in_place(tmp_path):
 
     con = sqlite3.connect(db)
     cols = lambda t: {r[1] for r in con.execute(f"PRAGMA table_info({t})")}  # noqa: E731
-    assert {"tier", "credit_spent", "free_credit_spent", "daily_credit_on"} <= cols("jobs")
+    assert {"tier", "credit_spent", "free_credit_spent", "daily_credit_on",
+            "plan_credit_spent"} <= cols("jobs")
     assert {"merged_into", "subscription_id", "subscription_status",
             "subscription_period_end", "free_credits_used", "email_verified",
-            "daily_credit_on"} <= cols("accounts")
+            "daily_credit_on", "subscription_plan_id",
+            "subscription_credits_used"} <= cols("accounts")
     tables = {r[0] for r in con.execute("SELECT name FROM sqlite_master WHERE type='table'")}
     assert {"login_tokens", "purchases"} <= tables
     # The unique email index has to exist, or two accounts could share one.
diff --git a/tests/test_subscriptions.py b/tests/test_subscriptions.py
new file mode 100644
index 0000000..586e9ba
--- /dev/null
+++ b/tests/test_subscriptions.py
@@ -0,0 +1,161 @@
+"""Subscriptions: a plan renews each month until the customer cancels it.
+
+  capped     a number of books a month. The books of one month do not carry
+             over to the next. A server job spends the daily free credit
+             first (it ends sooner), then the books of the plan, then free
+             and bought credits.
+  unlimited  no credit is spent while the plan is active.
+
+Stripe tells the server about a plan with customer.subscription.* events. A
+new month (a new current_period_end) starts the count again.
+"""
+
+from __future__ import annotations
+
+import json
+import time
+
+import pytest
+import stripe
+
+from backend import runner
+
+from .conftest import (
+    FakeStripeObject, account_id, checkout_event, get_account_row, get_job_row,
+    make_job, post_webhook,
+)
+
+# Its own catalogue, so that these tests do not depend on the plans for sale.
+TEST_PLANS = [
+    {"id": "pack10", "name": "10 books", "credits": 10, "price_cents": 499},
+    {"id": "month10", "name": "10 books a month", "credits": 10, "price_cents": 499,
+     "recurring": True},
+    {"id": "forever", "name": "Unlimited", "credits": None, "price_cents": 5000,
+     "recurring": True},
+]
+
+
+@pytest.fixture(autouse=True)
+def catalogue(monkeypatch):
+    monkeypatch.setenv("SUBPLZ_WEB_PLANS_JSON", json.dumps(TEST_PLANS))
+
+
+def subscription_event(kind, account, plan_id, status="active", period_end=None):
+    end = period_end or int(time.time()) + 30 * 86400
+    return {
+        "id": f"evt_{kind}_{time.time_ns()}",
+        "type": f"customer.subscription.{kind}",
+        "data": {"object": {
+            "id": f"sub_{account}", "object": "subscription", "status": status,
+            "customer": f"cus_{account}",
+            "metadata": {"account_id": account, "plan_id": plan_id},
+            "items": {"data": [{"current_period_end": end}]},
+        }},
+    }
+
+
+def subscribe(client, plan_id, **kw) -> str:
+    acct = account_id(client)
+    event = subscription_event("created", acct, plan_id, **kw)
+    assert post_webhook(client, event).status_code == 200
+    return acct
+
+
+def start(client, job_id: str):
+    return client.post(f"/api/jobs/{job_id}/start", json={"language": "en"})
+
+
+def account(client) -> dict:
+    return client.get("/api/account").json()
+
+
+def test_a_capped_plan_checks_out_as_a_monthly_subscription(client, monkeypatch):
+    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": "month10"}).status_code == 200
+    assert seen["mode"] == "subscription"
+    item = seen["line_items"][0]["price_data"]
+    assert item["recurring"] == {"interval": "month"} and item["unit_amount"] == 499
+    assert seen["subscription_data"]["metadata"]["plan_id"] == "month10"
+
+
+def test_paying_for_a_plan_gives_the_books_of_the_month_not_a_pack(client, monkeypatch):
+    acct = account_id(client)
+    sub = subscription_event("created", acct, "month10")["data"]["object"]
+    monkeypatch.setattr(stripe.Subscription, "retrieve", lambda sid: FakeStripeObject(sub))
+    event = checkout_event(f"cs_{time.time_ns()}", acct, "month10",
+                           email=f"plan-{time.time_ns()}@example.com", subscription=sub["id"])
+    assert post_webhook(client, event).status_code == 200
+
+    a = account(client)
+    assert a["subscribed"] is True and a["unlimited"] is False
+    assert a["plan_credits"] == 10 and a["credits"] == 10
+    assert get_account_row(acct).purchased_credits == 0
+
+
+def test_a_capped_plan_is_spent_and_a_failed_job_gives_it_back(client):
+    subscribe(client, "month10")
+    job_id = make_job(client)
+    assert start(client, job_id).status_code == 200
+    job = get_job_row(job_id)
+    assert job.plan_credit_spent == 1 and job.credit_spent == 0
+    assert account(client)["plan_credits"] == 9
+
+    runner.run_job(job_id)  # fails: nothing was staged
+    assert account(client)["plan_credits"] == 10
+    assert get_job_row(job_id).plan_credit_spent == 0
+
+
+def test_the_books_of_a_month_do_not_carry_over(client):
+    acct = account_id(client)
+    end = int(time.time()) + 30 * 86400
+    post_webhook(client, subscription_event("created", acct, "month10", period_end=end))
+    for _ in range(10):
+        assert start(client, make_job(client)).status_code == 200
+    assert start(client, make_job(client)).status_code == 402
+
+    # The same month, told again: nothing changes.
+    post_webhook(client, subscription_event("updated", acct, "month10", period_end=end))
+    assert account(client)["plan_credits"] == 0
+
+    # A new month starts the count again.
+    post_webhook(client, subscription_event("updated", acct, "month10",
+                                            period_end=end + 30 * 86400))
+    assert account(client)["plan_credits"] == 10
+
+
+def test_an_unlimited_plan_spends_nothing(client):
+    subscribe(client, "forever")
+    a = account(client)
+    assert a["unlimited"] is True and a["cloud_allowed"] is True
+    for _ in range(3):
+        job_id = make_job(client)
+        assert start(client, job_id).status_code == 200
+        job = get_job_row(job_id)
+        assert job.plan_credit_spent == 0 and job.credit_spent == 0
+
+
+def test_a_cancelled_plan_gives_nothing(client):
+    acct = subscribe(client, "month10")
+    post_webhook(client, subscription_event("deleted", acct, "month10", status="canceled"))
+    a = account(client)
+    assert a["subscribed"] is False and a["plan_credits"] == 0
+    assert start(client, make_job(client)).status_code == 402
+
+
+def test_a_plan_that_is_not_in_the_catalogue_gives_nothing(client):
+    # Retire a sold plan (pricing.RETIRED_PLANS); do not delete it.
+    subscribe(client, "gone")
+    a = account(client)
+    assert a["unlimited"] is False and a["plan_credits"] == 0
+
+
+def test_a_second_plan_is_refused(client, monkeypatch):
+    subscribe(client, "month10")
+    monkeypatch.setattr(stripe.checkout.Session, "create",
+                        lambda **p: FakeStripeObject({"url": "x"}))
+    r = client.post("/api/billing/checkout", json={"plan_id": "forever"})
+    assert r.status_code == 400 and "Manage plan" in r.json()["detail"]