Recently Written · git

subplz-web

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

Log | Files | Refs


commit cf33e15ac342a2d997fc5517ba9d8fd46942cf23
equwal <truex@equwal.com>
2026-09-22 15:51:46 -0700

Give free credits to verified emails: 2, then 1 a day, and 1 per 2 books

The owner set new free credits for the server:

- A visitor without a verified email gets one free credit, as before. A
  buyer whose email came only from Stripe is in this group, so paying no
  longer unlocks the account credits.
- An email is verified when its owner opens a sign-in link that the
  server sent to it (Account.email_verified). A verified account gets two
  free credits in all, one more each day (not carried over), and one more
  for every two books that the server converted for it. 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. A failed job gives the daily credit back on the same day.

The page offers "Sign in for 2 free credits + 1 a day", and "Verify your
email" to a buyer. New settings: SUBPLZ_WEB_FREE_CREDITS_VERIFIED,
SUBPLZ_WEB_DAILY_FREE_CREDIT and SUBPLZ_WEB_BOOKS_PER_BONUS_CREDIT.
SUBPLZ_WEB_FREE_CREDITS_SIGNED_IN is gone.

 .env.example               |  10 ++--
 README.md                  |  25 ++++++---
 backend/api.py             |  15 ++++--
 backend/auth.py            |   2 +
 backend/billing.py         |  99 ++++++++++++++++++++++++++++-------
 backend/db.py              |  12 ++++-
 backend/settings.py        |  18 +++++--
 frontend/app.js            |  28 ++++++----
 frontend/terms.html        |   2 +
 tests/test_free_credits.py | 127 +++++++++++++++++++++++++++++++++++++--------
 tests/test_migration.py    |  12 +++--
 11 files changed, 274 insertions(+), 76 deletions(-)
diff --git a/.env.example b/.env.example
index 28fd5f1..e4c59a4 100644
--- a/.env.example
+++ b/.env.example
@@ -56,10 +56,14 @@ SUBPLZ_WEB_BILLING_ENABLED=false
 # 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.
+# A visitor without a verified email gets FREE_CREDITS_ANONYMOUS. An account
+# with a verified email gets FREE_CREDITS_VERIFIED in all, one more each day
+# (DAILY_FREE_CREDIT, not carried over), and one more for every
+# BOOKS_PER_BONUS_CREDIT books that the server converted for it (0 = off).
 SUBPLZ_WEB_FREE_CREDITS_ANONYMOUS=1
-SUBPLZ_WEB_FREE_CREDITS_SIGNED_IN=10
+SUBPLZ_WEB_FREE_CREDITS_VERIFIED=2
+SUBPLZ_WEB_DAILY_FREE_CREDIT=true
+SUBPLZ_WEB_BOOKS_PER_BONUS_CREDIT=2
 # Where customers write to. Shown on /terms.html.
 SUBPLZ_WEB_CONTACT_EMAIL=
 
diff --git a/README.md b/README.md
index f34d931..73f1eca 100644
--- a/README.md
+++ b/README.md
@@ -344,13 +344,24 @@ 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.
 
-**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.
+**Free credits.** They exist only while `SUBPLZ_WEB_CLOUD_ENABLED` is true.
+
+- A visitor without a verified email gets one free credit
+  (`SUBPLZ_WEB_FREE_CREDITS_ANONYMOUS`). A buyer whose email came only from
+  Stripe is in this group.
+- An account whose email is verified gets two in all
+  (`SUBPLZ_WEB_FREE_CREDITS_VERIFIED`). The email is verified when its owner
+  opens a sign-in link that the server sent to it. A free credit that a device
+  used before counts toward the two, and a second device adds no free credits.
+- A verified account also gets one free credit each day
+  (`SUBPLZ_WEB_DAILY_FREE_CREDIT`). It does not carry over to the next day.
+- A verified account gets one more free credit for every two books that the
+  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
+(`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.
diff --git a/backend/api.py b/backend/api.py
index f5f1871..6b5a399 100644
--- a/backend/api.py
+++ b/backend/api.py
@@ -166,6 +166,8 @@ class AccountOut(BaseModel):
     id: str
     signed_in: bool
     email: str | None
+    # True once the owner opened a sign-in link sent to `email`.
+    email_verified: bool
     billing_enabled: bool
     # Whether the server can actually take money / send sign-in email yet.
     payments_available: bool
@@ -177,8 +179,11 @@ class AccountOut(BaseModel):
     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.
+    # The free credits (not the daily one) that this visitor has after the
+    # email is verified. Zero when it is verified.
     free_credits_with_account: int
+    # Whether a verified account gets one free credit each day.
+    daily_free_credit: bool
     subscribed: bool
     subscription_ends: str | None
     cloud_allowed: bool
@@ -289,11 +294,12 @@ def get_account_info(
 
 
 def _account_out(session: Session, account: Account) -> AccountOut:
-    ent = billing.check(account)
+    ent = billing.check(session, account)
     return AccountOut(
         id=account.id,
         signed_in=account.signed_in,
         email=account.email,
+        email_verified=bool(account.email_verified),
         billing_enabled=settings.billing_enabled,
         payments_available=settings.payments_configured,
         email_sign_in_available=settings.sign_in_available,
@@ -301,9 +307,10 @@ def _account_out(session: Session, account: Account) -> AccountOut:
         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)
+            0 if account.email_verified
+            else billing.free_credits_left(session, account, verified=True)
         ),
+        daily_free_credit=settings.cloud_enabled and settings.daily_free_credit,
         subscribed=ent.subscribed,
         subscription_ends=(
             ent.subscription_ends.isoformat() if ent.subscription_ends else None
diff --git a/backend/auth.py b/backend/auth.py
index 8534213..4d66295 100644
--- a/backend/auth.py
+++ b/backend/auth.py
@@ -82,5 +82,7 @@ def redeem(session: Session, token: str, device: Account) -> Account | None:
     # asked for it; when it is not (link opened on a phone), this still does
     # the right thing - the phone's anonymous work joins the account.
     owner = accounts.adopt_email(session, device, row.email)
+    # The link went to this address and came back: the address is verified.
+    owner.email_verified = 1
     session.commit()
     return owner
diff --git a/backend/billing.py b/backend/billing.py
index 088e1f3..0b52213 100644
--- a/backend/billing.py
+++ b/backend/billing.py
@@ -22,12 +22,12 @@ Taking the money lives in payments.py; this file only decides who may do what.
 from __future__ import annotations
 
 from dataclasses import dataclass
-from datetime import datetime, timedelta, timezone
+from datetime import date, datetime, timedelta, timezone
 
-from sqlalchemy import update
+from sqlalchemy import func, or_, update
 from sqlalchemy.orm import Session
 
-from .db import Account, Job, SessionLocal, utcnow
+from .db import Account, Job, JobStatus, SessionLocal, utcnow
 from .settings import settings
 
 FREE = "free"
@@ -71,31 +71,58 @@ 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.
+def _today() -> date:
+    """The day of the daily free credit (UTC). Tests replace this."""
+    return utcnow().date()
+
+
+def _books_converted(session: Session, account: Account) -> int:
+    """Books that the server converted for `account`.
+
+    Books converted in the browser do not count: the server cannot check
+    that a browser did the work.
+    """
+    return (
+        session.query(func.count(Job.id))
+        .filter(Job.account_id == account.id, Job.local == 0,
+                Job.status == JobStatus.succeeded)
+        .scalar()
+        or 0
+    )
+
+
+def free_allowance(session: Session, account: Account, verified: bool | None = None) -> int:
+    """The free credits (not the daily one) that `account` gets in all.
 
     Zero while this server takes no conversions: a free credit must not
-    promise a job that cannot run.
+    promise a job that cannot run. Give `verified=True` to get the number
+    after the email is verified.
     """
     if not settings.cloud_enabled:
         return 0
-    if signed_in:
-        return settings.free_credits_signed_in
-    return settings.free_credits_anonymous
+    if verified is None:
+        verified = bool(account.email_verified)
+    if not verified:
+        return settings.free_credits_anonymous
+    bonus = 0
+    if settings.books_per_bonus_credit > 0:
+        bonus = _books_converted(session, account) // settings.books_per_bonus_credit
+    return settings.free_credits_verified + bonus
 
 
-def free_credits_left(account: Account, signed_in: bool | None = None) -> int:
-    """The free credits that `account` can spend now.
+def free_credits_left(session: Session, account: Account, verified: bool | None = None) -> int:
+    return max(0, free_allowance(session, account, verified) - account.free_credits_used)
 
-    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 daily_credit_left(account: Account) -> int:
+    """1 while a verified account has not spent the free credit of today."""
+    if not (settings.cloud_enabled and settings.daily_free_credit and account.email_verified):
+        return 0
+    return 0 if account.daily_credit_on == _today() else 1
 
 
-def check(account: Account) -> Entitlement:
-    free = free_credits_left(account)
+def check(session: Session, account: Account) -> Entitlement:
+    free = free_credits_left(session, account) + daily_credit_left(account)
     return Entitlement(
         credits=account.purchased_credits + free,
         free_credits=free,
@@ -104,13 +131,30 @@ def check(account: Account) -> Entitlement:
     )
 
 
+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):
+        return None
+    today = _today()
+    taken = session.execute(
+        update(Account)
+        .where(
+            Account.id == account.id,
+            or_(Account.daily_credit_on.is_(None), Account.daily_credit_on != today),
+        )
+        .values(daily_credit_on=today)
+    ).rowcount
+    session.refresh(account)
+    return today if taken else None
+
+
 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),
+            Account.free_credits_used < free_allowance(session, account),
         )
         .values(free_credits_used=Account.free_credits_used + 1)
     ).rowcount
@@ -133,12 +177,18 @@ 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 a free credit first, then a bought one.
+    a cloud job. A cloud job spends the daily credit first (it does not
+    carry over), 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):
         return
+    day = _spend_daily_credit(session, account)
+    if day is not None:
+        job.daily_credit_on = day
+        return
     if _spend_free_credit(session, account):
         job.free_credit_spent = 1
         return
@@ -167,6 +217,15 @@ def refund(session: Session, job: Job) -> None:
             .values(free_credits_used=Account.free_credits_used - 1)
         )
         job.free_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(
+            update(Account)
+            .where(Account.id == job.account_id,
+                   Account.daily_credit_on == job.daily_credit_on)
+            .values(daily_credit_on=None)
+        )
+        job.daily_credit_on = None
     session.add(job)
 
 
diff --git a/backend/db.py b/backend/db.py
index 6d94f64..414ef45 100644
--- a/backend/db.py
+++ b/backend/db.py
@@ -2,10 +2,11 @@
 
 import enum
 import secrets
-from datetime import datetime, timezone
+from datetime import date, datetime, timezone
 
 from sqlalchemy import (
     BigInteger,
+    Date,
     DateTime,
     Enum,
     Float,
@@ -81,6 +82,13 @@ class Account(Base):
     free_credits_used: Mapped[int] = mapped_column(
         Integer, default=0, server_default=text("0")
     )
+    # 1 once the owner of the email opened a sign-in link that we sent to it.
+    # An email that came only from a payment is not verified.
+    email_verified: Mapped[int] = mapped_column(
+        Integer, default=0, server_default=text("0")
+    )
+    # The last day (UTC) on which the account spent its daily free credit.
+    daily_credit_on: Mapped[date | None] = mapped_column(Date, nullable=True)
 
     # 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.
@@ -161,6 +169,8 @@ class Job(Base):
     free_credit_spent: Mapped[int] = mapped_column(
         Integer, default=0, server_default=text("0")
     )
+    # 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)
 
     created_at: Mapped[datetime] = mapped_column(
         DateTime(timezone=True), default=utcnow
diff --git a/backend/settings.py b/backend/settings.py
index 9b5de23..55512c1 100644
--- a/backend/settings.py
+++ b/backend/settings.py
@@ -70,12 +70,20 @@ class Settings(BaseSettings):
     # 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.
+    # cloud_enabled is on. See billing.py.
+    # A visitor without a verified email gets free_credits_anonymous. A buyer
+    # whose email came only from Stripe is in this group.
     free_credits_anonymous: int = 1
-    free_credits_signed_in: int = 10
+    # An account whose email is verified (by an emailed sign-in link) gets
+    # free_credits_verified in all: a free credit that one of its devices
+    # used before counts toward it.
+    free_credits_verified: int = 2
+    # A verified account also gets one free credit each day. It does not
+    # carry over to the next day.
+    daily_free_credit: bool = True
+    # A verified account gets one more free credit for each this many books
+    # that the server converted for it. 0 turns this off.
+    books_per_bonus_credit: int = 2
 
     # --- 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 59bd27b..cb9a0c5 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -126,10 +126,12 @@ function renderAccount() {
   el.who.hidden = !signedIn;
   el.who.textContent = signedIn ? a.email : '';
   el.signoutBtn.hidden = !signedIn;
-  el.signinBtn.hidden = signedIn || !a.email_sign_in_available;
+  // A buyer has an email from Stripe that is not verified yet. The same
+  // sign-in link verifies it.
+  el.signinBtn.hidden = !a.email_sign_in_available || (signedIn && a.email_verified);
   const offer = signInOffer();
-  el.signinBtn.textContent = offer
-    ? `Sign in for ${offer} free credit${offer === 1 ? '' : 's'}` : 'Sign in';
+  const verb = signedIn ? 'Verify your email' : 'Sign in';
+  el.signinBtn.textContent = offer ? `${verb} for ${offer}` : verb;
 
   // 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.
@@ -146,12 +148,17 @@ function renderAccount() {
   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. */
+/* What a verified email gives this visitor, in words: "2 free credits + 1 a
+   day". Empty when there is nothing to offer: the email is verified, the
+   server sends no sign-in email, or billing is 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;
+  if (!a || a.email_verified || !a.email_sign_in_available || !a.billing_enabled) return '';
+  const n = a.free_credits_with_account || 0;
+  const parts = [];
+  if (n) parts.push(`${n} free credit${n === 1 ? '' : 's'}`);
+  if (a.daily_free_credit) parts.push(n ? '1 a day' : '1 free credit a day');
+  return parts.join(' + ');
 }
 
 let toastTimer = null;
@@ -888,8 +895,9 @@ async function openPricing(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.pricingSigninBtn.textContent = signedIn
+    ? `Or verify your email and get ${offer}.`
+    : `Or sign in with your email and get ${offer}.`;
   el.plans.innerHTML = '<p class="muted">Loading…</p>';
   if (!el.pricingDialog.open) el.pricingDialog.showModal();
 
@@ -957,6 +965,8 @@ el.portalBtn.addEventListener('click', async () => {
 
 function openSignIn() {
   el.signinNote.hidden = true;
+  // A buyer verifies the address that Stripe has.
+  if (account?.email && !el.signinEmail.value) el.signinEmail.value = account.email;
   el.signinDialog.showModal();
   el.signinEmail.focus();
 }
diff --git a/frontend/terms.html b/frontend/terms.html
index 0f1874b..f1cfc35 100644
--- a/frontend/terms.html
+++ b/frontend/terms.html
@@ -43,6 +43,8 @@
     <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>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>
     <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
index c5431bf..03594ea 100644
--- a/tests/test_free_credits.py
+++ b/tests/test_free_credits.py
@@ -2,22 +2,30 @@
 
 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
+  anonymous  one free credit, while the server takes conversions. A buyer
+             whose email came from Stripe is in this group until the email
+             is verified.
+  verified   an account that signed in with an emailed link gets two free
+             credits in all (a free credit that a device used before counts
+             toward the two), one more each day, and one more for every two
+             books that the server converted for it
+  daily      the daily credit does not carry over to the next day
+  order      a server job spends the daily credit, then a free credit, then
+             a bought one
+  refund     a failed job gives its credit back to the same kind
   merge      a second device that signs in adds no free credits
 """
 
 from __future__ import annotations
 
 import time
+from datetime import timedelta
 from urllib.parse import parse_qs, urlparse
 
 import pytest
 
-from backend import runner
+from backend import billing, runner
+from backend.db import JobStatus
 from backend.settings import Settings, settings
 
 from .conftest import (
@@ -29,6 +37,7 @@ BOOK = {
     "audio_filename": "free.m4b", "audio_bytes": 4321,
     "text_filename": "free.epub", "language": "en",
 }
+SRT = "1\n00:00:00,000 --> 00:00:01,000\nhello\n\n"
 
 
 @pytest.fixture
@@ -62,17 +71,32 @@ 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 days_later(monkeypatch, days: int) -> None:
+    later = billing._today() + timedelta(days=days)
+    monkeypatch.setattr(billing, "_today", lambda: later)
+
+
+def convert_in_browser(client, n: int) -> None:
+    job = client.post("/api/local/jobs", json={**BOOK, "audio_bytes": 100 + n}).json()
+    r = client.post(f"/api/local/jobs/{job['id']}/finish",
+                    json={"srt": SRT, "filename": "free.srt"})
+    assert r.status_code == 200
+
+
+def test_defaults():
+    fields = Settings.model_fields
+    assert fields["free_credits_anonymous"].default == 1
+    assert fields["free_credits_verified"].default == 2
+    assert fields["daily_free_credit"].default is True
+    assert fields["books_per_bonus_credit"].default == 2
 
 
 def test_a_new_visitor_has_one_free_credit(client, cloud):
     a = account(client)
-    assert a["signed_in"] is False
+    assert a["signed_in"] is False and a["email_verified"] is False
     assert a["credits"] == 1 and a["free_credits"] == 1
     assert a["cloud_allowed"] is True
-    assert a["free_credits_with_account"] == 10
+    assert a["free_credits_with_account"] == 2 and a["daily_free_credit"] is True
 
 
 def test_the_free_credit_pays_for_one_server_job(client, cloud):
@@ -86,20 +110,66 @@ def test_the_free_credit_pays_for_one_server_job(client, cloud):
     assert start(client, make_job(client)).status_code == 402
 
 
-def test_signing_in_raises_the_free_credits_to_ten_in_all(client, cloud):
+def test_a_verified_email_gets_two_free_credits_in_all_and_one_a_day(client, cloud):
     assert start(client, make_job(client)).status_code == 200  # the anonymous credit
-    assert account(client)["free_credits_with_account"] == 9
+    assert account(client)["free_credits_with_account"] == 1
 
-    sign_in(client, unique("topup"))
+    sign_in(client, unique("verify"))
     a = account(client)
-    assert a["signed_in"] is True
-    assert a["credits"] == 9 and a["free_credits"] == 9
+    assert a["signed_in"] is True and a["email_verified"] is True
+    assert a["free_credits"] == 2  # one of the two, and the daily credit
     assert a["free_credits_with_account"] == 0
 
 
+def test_a_buyer_gets_account_credits_only_after_verifying_the_email(client, cloud):
+    email = unique("buyer")
+    buy(client, "pack10", email)
+    a = account(client)
+    assert a["signed_in"] is True and a["email_verified"] is False
+    assert a["credits"] == 11 and a["free_credits"] == 1  # 10 bought, the anonymous one
+    assert a["free_credits_with_account"] == 2
+
+    sign_in(client, email)  # the same address: this verifies the account
+    a = account(client)
+    assert a["email_verified"] is True
+    assert a["free_credits"] == 3 and a["credits"] == 13
+
+
+def test_the_daily_credit_does_not_carry_over(client, cloud, monkeypatch):
+    sign_in(client, unique("daily"))
+    assert account(client)["free_credits"] == 3  # two, and today's
+
+    days_later(monkeypatch, 1)
+    assert account(client)["free_credits"] == 3  # not 4: yesterday's credit is gone
+    for _ in range(3):
+        assert start(client, make_job(client)).status_code == 200
+    assert start(client, make_job(client)).status_code == 402
+
+    days_later(monkeypatch, 1)
+    assert account(client)["free_credits"] == 1  # the credit of the new day
+    job_id = make_job(client)
+    assert start(client, job_id).status_code == 200
+    assert get_job_row(job_id).daily_credit_on == billing._today()
+
+
+def test_every_two_books_converted_on_the_server_earn_a_free_credit(client, cloud):
+    sign_in(client, unique("bonus"))
+    before = account(client)["free_credits"]
+
+    make_job(client, JobStatus.succeeded)
+    assert account(client)["free_credits"] == before
+    make_job(client, JobStatus.succeeded)
+    assert account(client)["free_credits"] == before + 1
+
+    # Books converted in the browser do not count: the server cannot check them.
+    convert_in_browser(client, 1)
+    convert_in_browser(client, 2)
+    assert account(client)["free_credits"] == before + 1
+
+
 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
+    buy(client, "single", unique("order"))  # a retired plan: one credit
+    assert account(client)["credits"] == 2  # one bought, the anonymous one
 
     job_id = make_job(client)
     assert start(client, job_id).status_code == 200
@@ -118,29 +188,40 @@ def test_a_failed_job_gives_the_free_credit_back(client, cloud):
     assert get_account_row(account_id(client)).purchased_credits == 0
 
 
+def test_a_failed_job_gives_the_daily_credit_back(client, cloud):
+    sign_in(client, unique("refund"))
+    job_id = make_job(client)
+    assert start(client, job_id).status_code == 200
+    assert get_job_row(job_id).daily_credit_on == billing._today()
+
+    runner.run_job(job_id)  # fails: nothing was staged
+    assert get_job_row(job_id).daily_credit_on is None
+    assert account(client)["free_credits"] == 3
+
+
 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
+    assert account(client)["free_credits"] == 3
 
     # 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
+    assert account(second_client)["free_credits"] == 2
+    assert account(client)["free_credits"] == 2
 
     # 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
+    assert account(client)["free_credits"] == 2
 
 
 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 a["free_credits_with_account"] == 0 and a["daily_free_credit"] is False
     assert start(client, make_job(client)).status_code == 402
 
 
diff --git a/tests/test_migration.py b/tests/test_migration.py
index 150e4ad..43b3a08 100644
--- a/tests/test_migration.py
+++ b/tests/test_migration.py
@@ -50,7 +50,8 @@ 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,
-          job.free_credit_spent, acct.free_credits_used)
+          job.free_credit_spent, acct.free_credits_used, acct.email_verified,
+          acct.daily_credit_on, job.daily_credit_on)
 """
 
 
@@ -67,13 +68,16 @@ 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", "0", "0"]
+    assert out.stdout.split() == [
+        "free", "0", "succeeded", "None", "False", "0", "0", "0", "None", "None",
+    ]
 
     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"} <= cols("jobs")
+    assert {"tier", "credit_spent", "free_credit_spent", "daily_credit_on"} <= cols("jobs")
     assert {"merged_into", "subscription_id", "subscription_status",
-            "subscription_period_end", "free_credits_used"} <= cols("accounts")
+            "subscription_period_end", "free_credits_used", "email_verified",
+            "daily_credit_on"} <= 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.