Recently Written · git

subplz-web

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

Log | Files | Refs


commit 80bc6fac71fb5aa28604e436cc63484f132b69dc
equwal <truex@equwal.com>
2026-09-22 15:04:20 -0700

Give free server credits, and show the browser switch first

A visitor without an account gets one free credit for a job on the
server. An account gets ten in all. A free credit that a device used
before sign-in counts toward the ten, and a second device adds no free
credits. There are no free credits while SUBPLZ_WEB_CLOUD_ENABLED is
false. SUBPLZ_WEB_FREE_CREDITS_ANONYMOUS and
SUBPLZ_WEB_FREE_CREDITS_SIGNED_IN set the numbers.

A server job spends a free credit first, then a bought one. A failed or
cancelled job gives its credit back to the same kind. 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.

The switch "Convert in this browser (JavaScript)" is now above the drop
zone, so it shows before a file is chosen. Each visit starts with the
switch on: the page no longer keeps an "off" from an earlier visit.

The page shows the free credits in the credit badge, on the sign-in
button and in the pricing dialog. The Ko-fi request counts bought
credits only. The terms say that free credits have no cash value.

 .env.example               |   5 ++
 README.md                  |  17 +++--
 backend/accounts.py        |   4 ++
 backend/api.py             |  10 +++
 backend/billing.py         |  62 +++++++++++++++++--
 backend/db.py              |  13 +++-
 backend/settings.py        |   7 +++
 frontend/app.js            |  69 +++++++++++++++------
 frontend/index.html        |  22 ++++---
 frontend/style.css         |  14 ++++-
 frontend/terms.html        |   5 +-
 tests/test_free_credits.py | 150 +++++++++++++++++++++++++++++++++++++++++++++
 tests/test_migration.py    |   9 +--
 tests/test_page.py         |  20 ++++++
 14 files changed, 361 insertions(+), 46 deletions(-)
diff --git a/.env.example b/.env.example
index a492708..ab3b5d2 100644
--- a/.env.example
+++ b/.env.example
@@ -55,6 +55,11 @@ SUBPLZ_WEB_BILLING_ENABLED=false
 # 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
+# Free credits for conversions on this server, while CLOUD_ENABLED is true.
+# A visitor without an account gets FREE_CREDITS_ANONYMOUS. An account gets
+# FREE_CREDITS_SIGNED_IN in all. Set both to 0 to give none.
+SUBPLZ_WEB_FREE_CREDITS_ANONYMOUS=1
+SUBPLZ_WEB_FREE_CREDITS_SIGNED_IN=10
 # Where customers write to. Shown on /terms.html.
 SUBPLZ_WEB_CONTACT_EMAIL=
 # How long the server keeps a visitor's files (see backend/retention.py).
diff --git a/README.md b/README.md
index e53249a..5a817d0 100644
--- a/README.md
+++ b/README.md
@@ -342,10 +342,19 @@ 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.
 
-The page offers the server job on the confirm screen ("Convert on our server"):
-the files are uploaded, the job runs on the server, and the result waits in the
-list of conversions, so the visitor can close the tab. The page asks for the
-credit before the upload, not after it.
+**Free credits.** While `SUBPLZ_WEB_CLOUD_ENABLED` is true, a visitor without
+an account gets one free credit (`SUBPLZ_WEB_FREE_CREDITS_ANONYMOUS`), and an
+account gets ten in all (`SUBPLZ_WEB_FREE_CREDITS_SIGNED_IN`). A free credit
+that a device used before sign-in counts toward the ten, and a second device
+adds no free credits. A server job spends the free credits first. 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
+with it on ("Convert in this browser (JavaScript)"), before a file is chosen.
+Off: the files are uploaded, the job runs on the server, and the result waits
+in the list of conversions, so the visitor can close the tab. The page asks
+for the credit before the upload, not after it.
 
 The server does not keep a visitor's files past their use
 (`backend/retention.py`, once an hour): uploads go when the job succeeds, or
diff --git a/backend/accounts.py b/backend/accounts.py
index 7b31fe8..e3e389c 100644
--- a/backend/accounts.py
+++ b/backend/accounts.py
@@ -66,6 +66,10 @@ def merge(session: Session, src: Account, dst: Account) -> None:
     )
     dst.purchased_credits += src.purchased_credits
     src.purchased_credits = 0
+    # Add the free credits used, not the free credits left: a second device
+    # must not bring a second set of free credits into one account.
+    dst.free_credits_used += src.free_credits_used
+    src.free_credits_used = 0
     if src.stripe_customer_id and not dst.stripe_customer_id:
         dst.stripe_customer_id = src.stripe_customer_id
     if src.subscription_id and not dst.subscription_id:
diff --git a/backend/api.py b/backend/api.py
index 553ac1e..8d31b72 100644
--- a/backend/api.py
+++ b/backend/api.py
@@ -174,7 +174,12 @@ class AccountOut(BaseModel):
     free_tier_summary: str
     # The cloud tier: conversions on this server's hardware.
     cloud_available: bool
+    # Bought and free credits together.
     credits: int
+    # The part of `credits` that is free.
+    free_credits: int
+    # The free credits this visitor has after a sign-in. Zero when signed in.
+    free_credits_with_account: int
     subscribed: bool
     subscription_ends: str | None
     cloud_allowed: bool
@@ -295,6 +300,11 @@ def _account_out(session: Session, account: Account) -> AccountOut:
         email_sign_in_available=settings.sign_in_available,
         free_tier_summary=pricing.free_tier_summary(),
         credits=ent.credits,
+        free_credits=ent.free_credits,
+        free_credits_with_account=(
+            0 if account.signed_in
+            else billing.free_credits_left(account, signed_in=True)
+        ),
         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 1f4dcf7..088e1f3 100644
--- a/backend/billing.py
+++ b/backend/billing.py
@@ -7,7 +7,9 @@ The line between free and paid is where the work is done, not what comes out:
           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.
+          or nothing on a recurring plan if the operator sells one. Each
+          visitor gets some free credits (settings.free_credits_*); a job
+          spends those before the bought ones.
 
 All of the code is public and anyone may host it. What is sold is the use of
 this operator's machines.
@@ -50,7 +52,10 @@ 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.
     credits: int
+    # The part of `credits` that is free.
+    free_credits: int
     subscribed: bool
     subscription_ends: datetime | None
 
@@ -66,14 +71,53 @@ def is_subscribed(account: Account) -> bool:
     return ends is None or ends + _GRACE > utcnow()
 
 
+def free_allowance(signed_in: bool) -> int:
+    """The free credits that an account gets in all.
+
+    Zero while this server takes no conversions: a free credit must not
+    promise a job that cannot run.
+    """
+    if not settings.cloud_enabled:
+        return 0
+    if signed_in:
+        return settings.free_credits_signed_in
+    return settings.free_credits_anonymous
+
+
+def free_credits_left(account: Account, signed_in: bool | None = None) -> int:
+    """The free credits that `account` can spend now.
+
+    Give `signed_in=True` to get the number after a sign-in.
+    """
+    if signed_in is None:
+        signed_in = account.signed_in
+    return max(0, free_allowance(signed_in) - account.free_credits_used)
+
+
 def check(account: Account) -> Entitlement:
+    free = free_credits_left(account)
     return Entitlement(
-        credits=account.purchased_credits,
+        credits=account.purchased_credits + free,
+        free_credits=free,
         subscribed=is_subscribed(account),
         subscription_ends=_aware(account.subscription_period_end),
     )
 
 
+def _spend_free_credit(session: Session, account: Account) -> bool:
+    """Take one free credit, atomically. False if there was none to take."""
+    taken = session.execute(
+        update(Account)
+        .where(
+            Account.id == account.id,
+            Account.free_credits_used < free_allowance(account.signed_in),
+        )
+        .values(free_credits_used=Account.free_credits_used + 1)
+    ).rowcount
+    session.refresh(account)
+    return bool(taken)
+
+
 def _spend_credit(session: Session, account: Account) -> bool:
     """Take one credit, atomically. False if there was none to take."""
     taken = session.execute(
@@ -89,12 +133,15 @@ 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. A cloud job spends a free credit first, then a bought one.
     """
     job.tier = FREE if job.local else CLOUD
-    job.billed, job.credit_spent = 0, 0
+    job.billed, job.credit_spent, job.free_credit_spent = 0, 0, 0
     if job.local or not settings.billing_enabled or is_subscribed(account):
         return
+    if _spend_free_credit(session, account):
+        job.free_credit_spent = 1
+        return
     if not _spend_credit(session, account):
         raise PaymentRequired(
             "A conversion on our servers takes one credit. "
@@ -113,6 +160,13 @@ def refund(session: Session, job: Job) -> None:
             .values(purchased_credits=Account.purchased_credits + 1)
         )
         job.credit_spent = 0
+    if job.free_credit_spent:
+        session.execute(
+            update(Account)
+            .where(Account.id == job.account_id, Account.free_credits_used > 0)
+            .values(free_credits_used=Account.free_credits_used - 1)
+        )
+        job.free_credit_spent = 0
     session.add(job)
 
 
diff --git a/backend/db.py b/backend/db.py
index ada87d8..6d94f64 100644
--- a/backend/db.py
+++ b/backend/db.py
@@ -75,6 +75,12 @@ class Account(Base):
     )
     # Paid conversions in hand. One credit = one book with every output.
     purchased_credits: Mapped[int] = mapped_column(Integer, default=0)
+    # Free credits spent so far. The free credits left are the allowance
+    # (billing.free_allowance) minus this count. Thus a sign-in raises the
+    # allowance, and a merge adds the two counts together.
+    free_credits_used: Mapped[int] = mapped_column(
+        Integer, default=0, server_default=text("0")
+    )
 
     # The unlimited plan. Status is Stripe's own word for it (active, past_due,
     # canceled...); period_end is when the paid-for time runs out.
@@ -146,10 +152,15 @@ class Job(Base):
     # 1 when the work happens in the visitor's browser and the server only
     # keeps the books: nothing to queue, nothing to resume after a restart.
     local: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0"))
-    # 1 if a credit was spent on this job, so a failed run can hand it back.
+    # 1 if a bought credit was spent on this job, so a failed run can hand it back.
     credit_spent: Mapped[int] = mapped_column(
         Integer, default=0, server_default=text("0")
     )
+    # 1 if a free credit was spent on this job. A failed run gives it back to
+    # the free credits, not to the bought ones.
+    free_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/settings.py b/backend/settings.py
index 9ba471c..d41129f 100644
--- a/backend/settings.py
+++ b/backend/settings.py
@@ -73,6 +73,13 @@ class Settings(BaseSettings):
     contact_email: str = ""
     # Off on localhost so nothing blocks you; flip on for the public release.
     billing_enabled: bool = False
+    # Free credits for conversions on this server. They exist only while
+    # cloud_enabled is on. A visitor without an account gets
+    # free_credits_anonymous. An account gets free_credits_signed_in in all:
+    # a free credit that one of its devices used before sign-in counts toward
+    # it. See billing.py.
+    free_credits_anonymous: int = 1
+    free_credits_signed_in: int = 10
 
     # --- accounts & payments -----------------------------------------------
     # What customers see on the Stripe page and in the sign-in email.
diff --git a/frontend/app.js b/frontend/app.js
index 36d2418..553b39d 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -9,7 +9,7 @@ const el = {
   confirm: $('confirm'), cAudio: $('c-audio'), cText: $('c-text'),
   language: $('language'), detected: $('detected'), splitnote: $('splitnote'),
   start: $('start'), discard: $('discard'), eta: $('eta'),
-  inBrowser: $('in-browser'),
+  inBrowser: $('in-browser'), modeNote: $('mode-note'),
   jobsSection: $('jobs-section'), jobs: $('jobs'), quota: $('quota'),
   freetier: $('freetier'), staged: $('staged'), dzTitle: $('dz-title'),
   match: $('match'), matchBadge: $('match-badge'),
@@ -20,6 +20,7 @@ const el = {
   signinEmail: $('signin-email'), signinSend: $('signin-send'),
   signinNote: $('signin-note'),
   pricingDialog: $('pricing-dialog'), pricingWhy: $('pricing-why'),
+  pricingSignin: $('pricing-signin'), pricingSigninBtn: $('pricing-signin-btn'),
   plans: $('plans'), toast: $('toast'),
   working: $('working'), workTitle: $('work-title'), workBar: $('work-bar'),
   workMeta: $('work-meta'), stop: $('stop'), results: $('results'),
@@ -126,6 +127,9 @@ function renderAccount() {
   el.who.textContent = signedIn ? a.email : '';
   el.signoutBtn.hidden = !signedIn;
   el.signinBtn.hidden = signedIn || !a.email_sign_in_available;
+  const offer = signInOffer();
+  el.signinBtn.textContent = offer
+    ? `Sign in for ${offer} free credit${offer === 1 ? '' : 's'}` : 'Sign in';
 
   // 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.
@@ -134,9 +138,20 @@ function renderAccount() {
   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'
-      : `${a.credits} cloud credit${a.credits === 1 ? '' : 's'}`;
+      : `${a.credits} ${free}cloud credit${a.credits === 1 ? '' : 's'}`;
   }
+  renderMode();
+}
+
+/* The free credits that a sign-in gives this visitor. Zero when there is
+   nothing to offer: signed in already, no sign-in email, or billing off. */
+function signInOffer() {
+  const a = account;
+  if (!a || signedIn || !a.email_sign_in_available || !a.billing_enabled) return 0;
+  return a.free_credits_with_account || 0;
 }
 
 let toastTimer = null;
@@ -334,40 +349,47 @@ el.discard.addEventListener('click', () => { clearError(); resetToDrop(); });
 /* ---------------- where the work is done ---------------- */
 
 /* In this browser (JavaScript, free, nothing uploaded) is the default. The
-   switch turns that off and sends the job to the server instead: for a phone,
-   an old computer, or a tab that has to close. The choice is kept. */
-const MODE_KEY = 'subread-mode';
+   switch is above the drop zone, so the choice shows before a file is chosen,
+   and each visit starts with the switch on. Off sends the job to the server:
+   for a phone, an old computer, or a tab that has to close. */
 let browserPlan = { note: '', label: 'Start' };
 
 function serverOffered() { return !!account?.cloud_available; }
 
 function inBrowser() { return !serverOffered() || el.inBrowser.checked; }
 
+/* What a job on the server costs this visitor, as a sentence. */
+function serverPrice() {
+  const a = account;
+  if (!a?.billing_enabled) return '';
+  if (a.subscribed) 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}.`;
+  }
+  return ' One credit a book.';
+}
+
 function renderMode() {
   const offered = serverOffered();
   el.inBrowser.disabled = !offered;
-  el.inBrowser.checked = !offered || localStorage.getItem(MODE_KEY) !== 'server';
+  if (!offered) el.inBrowser.checked = true;
   if (inBrowser()) {
+    el.modeNote.textContent = 'Free, without limit, and your files are not uploaded.' +
+      (offered ? ' Turn this off to have our server do the work.' + serverPrice() : '');
     el.start.textContent = browserPlan.label;
     el.eta.textContent = browserPlan.note +
       (offered ? '' : ' Our server is not taking conversions at the moment, so this is the one way.');
     return;
   }
-  const a = account;
-  const price = !a.billing_enabled ? ''
-    : a.subscribed ? ' Included in your plan.'
-    : a.credits > 0 ? ` One credit; you have ${a.credits}.`
-    : ' One credit.';
+  el.modeNote.textContent = 'Off: your files are uploaded, and our server does the work.' + serverPrice();
   el.start.textContent = 'Upload and convert on our server';
   el.eta.textContent =
     'The files are uploaded, the work is done on our server, and the result waits for you on this page, ' +
-    'so you can close this tab. The files are deleted after the job.' + price;
+    'so you can close this tab. The files are deleted after the job.' + serverPrice();
 }
 
-el.inBrowser.addEventListener('change', () => {
-  try { localStorage.setItem(MODE_KEY, el.inBrowser.checked ? 'browser' : 'server'); } catch { /* private window */ }
-  renderMode();
-});
+el.inBrowser.addEventListener('change', renderMode);
 
 function startOnServer() {
   if (!draft || running) return;
@@ -516,8 +538,10 @@ function renderWorking(st) {
 function showResults(r) {
   // The ask goes to people who got a book for nothing, at the moment it worked.
   // 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');
-  if (chipIn) chipIn.hidden = Boolean(account?.credits > 0 || account?.subscribed);
+  const bought = (account?.credits ?? 0) - (account?.free_credits ?? 0);
+  if (chipIn) chipIn.hidden = Boolean(bought > 0 || account?.subscribed);
   el.stop.hidden = true;
   el.stop.disabled = false;
   el.results.hidden = false;
@@ -861,6 +885,10 @@ el.picker.addEventListener('change', () => {
 async function openPricing(why) {
   el.pricingWhy.textContent = why ||
     'In this tab a conversion is free, without limit. A credit converts a book on our server: from any device, and you can close the tab.';
+  const offer = signInOffer();
+  el.pricingSignin.hidden = !offer;
+  el.pricingSigninBtn.textContent =
+    `Or sign in with your email and get ${offer} free credit${offer === 1 ? '' : 's'}.`;
   el.plans.innerHTML = '<p class="muted">Loading…</p>';
   if (!el.pricingDialog.open) el.pricingDialog.showModal();
 
@@ -926,11 +954,14 @@ el.portalBtn.addEventListener('click', async () => {
 
 /* ---------------- sign in ---------------- */
 
-el.signinBtn.addEventListener('click', () => {
+function openSignIn() {
   el.signinNote.hidden = true;
   el.signinDialog.showModal();
   el.signinEmail.focus();
-});
+}
+
+el.signinBtn.addEventListener('click', openSignIn);
+el.pricingSigninBtn.addEventListener('click', () => { el.pricingDialog.close(); openSignIn(); });
 
 el.signinForm.addEventListener('submit', async (e) => {
   e.preventDefault();
diff --git a/frontend/index.html b/frontend/index.html
index 0ce1cff..88f7d20 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -12,7 +12,7 @@
 <meta property="og:url" content="https://subread.space/">
 <meta name="twitter:card" content="summary">
 <meta name="theme-color" content="#4f46e5">
-<link rel="stylesheet" href="/style.css?v=12">
+<link rel="stylesheet" href="/style.css?v=13">
 <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎧</text></svg>">
 </head>
 <body>
@@ -56,6 +56,16 @@
     <p class="muted small" id="freetier">Free in your browser, without limit.</p>
   </section>
 
+  <!-- where the work is done: in this tab unless the visitor turns it off -->
+  <section id="mode" class="mode">
+    <label class="switch">
+      <input type="checkbox" id="in-browser" checked autocomplete="off">
+      <span><strong>Convert in this browser (JavaScript)</strong>
+        <span class="tag free">free</span></span>
+    </label>
+    <p class="muted small" id="mode-note">Free, without limit, and your files are not uploaded.</p>
+  </section>
+
   <!-- step 1: drop -->
   <section id="dropzone" class="dropzone" tabindex="0" role="button"
            aria-label="Drop an audio file and an ebook, or click to choose files">
@@ -142,13 +152,6 @@
       <ul id="match-warnings" class="match-warnings"></ul>
     </div>
 
-    <div class="mode">
-      <label class="switch">
-        <input type="checkbox" id="in-browser" checked>
-        <span>Convert in this browser (JavaScript). Off: our server does it.</span>
-      </label>
-    </div>
-
     <div class="actions">
       <button id="start" class="primary">Start alignment</button>
       <button id="discard" class="ghost">Discard</button>
@@ -232,6 +235,7 @@
   <form method="dialog"><button class="modal-x" aria-label="Close">×</button></form>
   <h2>Credits for our server</h2>
   <p id="pricing-why" class="muted"></p>
+  <p id="pricing-signin" hidden><button type="button" id="pricing-signin-btn" class="linkbtn"></button></p>
   <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
@@ -243,6 +247,6 @@
 
 <div id="toast" class="toast" hidden role="status"></div>
 
-<script type="module" src="/app.js?v=12"></script>
+<script type="module" src="/app.js?v=13"></script>
 </body>
 </html>
diff --git a/frontend/style.css b/frontend/style.css
index 78f482a..0180dbb 100644
--- a/frontend/style.css
+++ b/frontend/style.css
@@ -300,10 +300,18 @@ button.compact { padding: 6px 12px; font-size: 13px; }
 }
 .tag.free { background: var(--ok-soft); color: var(--ok); }
 
-.mode { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--line); font-size: 14px; }
-.switch { display: flex; align-items: center; gap: 10px; cursor: pointer; }
+/* ---------- where the work is done ---------- */
+.mode {
+  margin: 0 0 14px; padding: 14px 18px;
+  border: 1px solid var(--line); border-radius: var(--radius);
+  background: var(--panel);
+}
+/* Green while the work stays in this tab: the free path must be easy to see. */
+.mode:has(input:checked) { border-color: var(--ok); background: var(--ok-soft); }
+.mode p { margin: 4px 0 0 30px; }
+.switch { display: flex; align-items: center; gap: 12px; cursor: pointer; font-size: 16px; }
+.switch:has(input:disabled) { cursor: default; }
 .switch input { width: 18px; height: 18px; margin: 0; accent-color: var(--ok); flex: none; }
-.switch input:disabled + span { color: var(--muted); }
 
 .share-form { display: grid; gap: 6px; margin: 14px 0 8px; }
 .share-form label { font-size: 13px; font-weight: 600; margin-top: 6px; }
diff --git a/frontend/terms.html b/frontend/terms.html
index 3dec461..a6054f7 100644
--- a/frontend/terms.html
+++ b/frontend/terms.html
@@ -24,8 +24,8 @@
     <li><strong>In your browser (free):</strong> the work is done on your machine. Your audio and
       your book are not uploaded. The server gets the names and sizes of the files and the
       finished subtitles, so that this page can list your conversions.</li>
-    <li><strong>On our server (paid):</strong> you upload the audio and the book, and the server
-      does the work. One credit converts one book.</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>
   </ul>
 
   <h2>Your files</h2>
@@ -44,6 +44,7 @@
   <ul>
     <li>Payments are taken by Stripe. We never see your card.</li>
     <li>Credits do not expire.</li>
+    <li>Free credits have no cash value. We do not refund them.</li>
     <li>A job that fails or that you cancel gives its credit back by itself.</li>
     <li>If a result is not usable (for example the subtitles do not match the book), write to us
       within 14 days and we return the credit or the money, as you choose. Try the free
diff --git a/tests/test_free_credits.py b/tests/test_free_credits.py
new file mode 100644
index 0000000..c5431bf
--- /dev/null
+++ b/tests/test_free_credits.py
@@ -0,0 +1,150 @@
+"""Free credits for conversions on the server.
+
+The rules under test:
+
+  anonymous  one free credit, while the server takes conversions
+  account    ten free credits in all: a free credit that a device used before
+             sign-in counts toward the ten
+  order      a server job spends a free credit first, then a bought one
+  refund     a failed job gives its free credit back to the free credits
+  merge      a second device that signs in adds no free credits
+"""
+
+from __future__ import annotations
+
+import time
+from urllib.parse import parse_qs, urlparse
+
+import pytest
+
+from backend import runner
+from backend.settings import Settings, settings
+
+from .conftest import (
+    account_id, checkout_event, get_account_row, get_job_row, make_job,
+    post_webhook,
+)
+
+BOOK = {
+    "audio_filename": "free.m4b", "audio_bytes": 4321,
+    "text_filename": "free.epub", "language": "en",
+}
+
+
+@pytest.fixture
+def cloud(monkeypatch):
+    """The server takes conversions, and sign-in links come back in the response."""
+    monkeypatch.setattr(settings, "cloud_enabled", True)
+    monkeypatch.setattr(settings, "dev_login_links", True)
+
+
+def unique(name: str) -> str:
+    """An email for one test only: an email joins accounts across tests."""
+    return f"{name}-{time.time_ns()}@example.com"
+
+
+def sign_in(client, email: str) -> None:
+    link = client.post("/api/auth/request", json={"email": email}).json()["dev_link"]
+    token = parse_qs(urlparse(link).query)["login"][0]
+    assert client.post("/api/auth/verify", json={"token": token}).status_code == 200
+
+
+def buy(client, plan: str, email: str) -> None:
+    event = checkout_event(f"cs_{time.time_ns()}", account_id(client), plan, email=email)
+    assert post_webhook(client, event).status_code == 200
+
+
+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_defaults_are_one_free_credit_and_ten_with_an_account():
+    assert Settings.model_fields["free_credits_anonymous"].default == 1
+    assert Settings.model_fields["free_credits_signed_in"].default == 10
+
+
+def test_a_new_visitor_has_one_free_credit(client, cloud):
+    a = account(client)
+    assert a["signed_in"] is False
+    assert a["credits"] == 1 and a["free_credits"] == 1
+    assert a["cloud_allowed"] is True
+    assert a["free_credits_with_account"] == 10
+
+
+def test_the_free_credit_pays_for_one_server_job(client, cloud):
+    job_id = make_job(client)
+    assert start(client, job_id).status_code == 200
+    job = get_job_row(job_id)
+    assert job.free_credit_spent == 1 and job.credit_spent == 0
+
+    a = account(client)
+    assert a["credits"] == 0 and a["cloud_allowed"] is False
+    assert start(client, make_job(client)).status_code == 402
+
+
+def test_signing_in_raises_the_free_credits_to_ten_in_all(client, cloud):
+    assert start(client, make_job(client)).status_code == 200  # the anonymous credit
+    assert account(client)["free_credits_with_account"] == 9
+
+    sign_in(client, unique("topup"))
+    a = account(client)
+    assert a["signed_in"] is True
+    assert a["credits"] == 9 and a["free_credits"] == 9
+    assert a["free_credits_with_account"] == 0
+
+
+def test_free_credits_are_spent_before_bought_ones(client, cloud):
+    buy(client, "single", unique("order"))
+    assert account(client)["credits"] == 11  # 1 bought, 10 free
+
+    job_id = make_job(client)
+    assert start(client, job_id).status_code == 200
+    assert get_job_row(job_id).free_credit_spent == 1
+    row = get_account_row(account_id(client))
+    assert row.purchased_credits == 1 and row.free_credits_used == 1
+
+
+def test_a_failed_job_gives_the_free_credit_back(client, cloud):
+    job_id = make_job(client)
+    assert start(client, job_id).status_code == 200
+    runner.run_job(job_id)  # fails: nothing was staged
+
+    assert account(client)["free_credits"] == 1
+    assert get_job_row(job_id).free_credit_spent == 0
+    assert get_account_row(account_id(client)).purchased_credits == 0
+
+
+def test_a_second_device_adds_no_free_credits(client, second_client, cloud):
+    email = unique("merge")
+    sign_in(client, email)
+    assert account(client)["free_credits"] == 10
+
+    # The second device spends its anonymous credit, then signs in as the same person.
+    assert start(second_client, make_job(second_client)).status_code == 200
+    sign_in(second_client, email)
+    assert account(second_client)["free_credits"] == 9
+    assert account(client)["free_credits"] == 9
+
+    # A new cookie that signs in again brings no free credit along either.
+    client.post("/api/auth/signout")
+    assert account(client)["free_credits"] == 1
+    sign_in(client, email)
+    assert account(client)["free_credits"] == 9
+
+
+def test_no_free_credits_while_the_server_takes_no_conversions(client):
+    # conftest leaves SUBPLZ_WEB_CLOUD_ENABLED off.
+    a = account(client)
+    assert a["credits"] == 0 and a["free_credits"] == 0
+    assert a["free_credits_with_account"] == 0
+    assert start(client, make_job(client)).status_code == 402
+
+
+def test_browser_jobs_leave_the_free_credit_alone(client, cloud):
+    r = client.post("/api/local/jobs", json=BOOK)
+    assert r.status_code == 200 and r.json()["tier"] == "free"
+    assert account(client)["free_credits"] == 1
diff --git a/tests/test_migration.py b/tests/test_migration.py
index d705860..150e4ad 100644
--- a/tests/test_migration.py
+++ b/tests/test_migration.py
@@ -49,7 +49,8 @@ init_db(); init_db()  # twice: a restart must be a no-op
 with SessionLocal() as s:
     job = s.get(Job, "job_old")
     acct = s.get(Account, "acct_old")
-    print(job.tier, job.credit_spent, job.status.value, acct.merged_into, acct.signed_in)
+    print(job.tier, job.credit_spent, job.status.value, acct.merged_into, acct.signed_in,
+          job.free_credit_spent, acct.free_credits_used)
 """
 
 
@@ -66,13 +67,13 @@ def test_old_database_is_upgraded_in_place(tmp_path):
                          capture_output=True, text=True, timeout=120)
     assert out.returncode == 0, out.stderr
     # Old rows read back with the new columns defaulted, nothing lost.
-    assert out.stdout.split() == ["free", "0", "succeeded", "None", "False"]
+    assert out.stdout.split() == ["free", "0", "succeeded", "None", "False", "0", "0"]
 
     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"} <= cols("jobs")
+    assert {"tier", "credit_spent", "free_credit_spent"} <= cols("jobs")
     assert {"merged_into", "subscription_id", "subscription_status",
-            "subscription_period_end"} <= cols("accounts")
+            "subscription_period_end", "free_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_page.py b/tests/test_page.py
new file mode 100644
index 0000000..16d77b1
--- /dev/null
+++ b/tests/test_page.py
@@ -0,0 +1,20 @@
+"""The first screen: what a visitor sees before choosing a file."""
+
+from __future__ import annotations
+
+import re
+
+
+def test_the_javascript_switch_is_on_the_first_screen_and_on(client):
+    page = client.get("/").text
+    switch = re.search(r'<input[^>]*\bid="in-browser"[^>]*>', page)
+    assert switch, "the page has no in-browser switch"
+    tag = switch.group(0)
+    # On when the page opens. autocomplete="off" stops a browser from
+    # bringing back an "off" from an earlier visit.
+    assert re.search(r"\schecked\b", tag) and 'autocomplete="off"' in tag
+
+    # Above the drop zone, so that it shows before a file is chosen.
+    dropzone = page.index('id="dropzone"')
+    assert switch.start() < dropzone
+    assert "JavaScript" in page[switch.start():dropzone]