commit 5511145adf857c684382bef980f94fed457228a4 equwal <truex@equwal.com> 2026-09-20 16:17:27 -0700 Accounts, two tiers and Stripe checkout Free and paid are now split by output: the free tier is the .srt plus the .mkv with subtitles built in (one book per rolling 24h); the clean .mp4 for YouTube costs a credit or the unlimited plan. The mp4 is rendered for every job anyway - the mkv is a stream copy of it - so paying later unlocks a finished job instantly rather than re-running it. - billing.py: entitlements per tier, atomic credit spend, refund on a failed or cancelled run - payments.py: Stripe Checkout with inline prices, fulfilment that re-reads the session from Stripe and is idempotent on its id, subscription state from webhooks, customer portal - accounts.py / auth.py / mailer.py: an account is an email, attached by an emailed one-time link or by paying; anonymous work is folded in and the cookie re-pointed, including when the payment lands by webhook - db.py: additive column migration on startup, so a deploy needs no hand-run SQL - frontend: tier picker, locked-download button, price list, sign-in - first visit no longer mints two anonymous accounts - tests: 36, covering tiers, credits, webhook replay and forgery, account merging, sign-in links, and upgrading the previous release's database
.env.example | 25 +++- README.md | 66 ++++++++-- backend/accounts.py | 105 ++++++++++++++++ backend/api.py | 288 ++++++++++++++++++++++++++++++++++++++----- backend/auth.py | 86 +++++++++++++ backend/billing.py | 253 ++++++++++++++++++++++++-------------- backend/db.py | 122 ++++++++++++++++-- backend/mailer.py | 64 ++++++++++ backend/payments.py | 288 +++++++++++++++++++++++++++++++++++++++++++ backend/pricing.py | 26 +++- backend/runner.py | 7 +- backend/settings.py | 33 +++++ frontend/app.js | 290 ++++++++++++++++++++++++++++++++++++++----- frontend/index.html | 73 +++++++++-- frontend/style.css | 84 +++++++++++++ requirements-dev.txt | 3 + requirements.txt | 5 +- tests/__init__.py | 0 tests/conftest.py | 160 ++++++++++++++++++++++++ tests/test_auth.py | 134 ++++++++++++++++++++ tests/test_billing.py | 319 ++++++++++++++++++++++++++++++++++++++++++++++++ tests/test_migration.py | 81 ++++++++++++ tools/set-secret.sh | 28 +++++ 23 files changed, 2352 insertions(+), 188 deletions(-)
diff --git a/.env.example b/.env.example index d346cba..b164734 100644 --- a/.env.example +++ b/.env.example @@ -48,11 +48,34 @@ SUBPLZ_WEB_STORAGE_BACKEND=local # SUBPLZ_WEB_DATABASE_URL=postgresql+psycopg://user:pass@host/subplz # --- billing --------------------------------------------------------------- -# Off for localhost. Turning it on requires implementing billing.start_checkout. +# Off for localhost: nothing is locked and nothing is for sale. +# On: free tier = srt + mkv, one book per window; the YouTube mp4 costs a credit. SUBPLZ_WEB_BILLING_ENABLED=false # One free book per rolling 24 hours. SUBPLZ_WEB_FREE_CONVERSIONS=1 SUBPLZ_WEB_FREE_WINDOW_HOURS=24 + +# --- accounts & payments --------------------------------------------------- +# The origin users reach this on. Sign-in links and Stripe return URLs use it. +# SUBPLZ_WEB_PUBLIC_BASE_URL=https://subread.space +# Send the identity cookie over HTTPS only. Set true on any public deployment. +# SUBPLZ_WEB_COOKIE_SECURE=true +# Shown on the Stripe payment page. +# SUBPLZ_WEB_SITE_NAME=SubRead + +# Stripe. Do not type these into a shell - use tools/set-secret.sh, which +# prompts with input hidden and keeps them out of your history. +# SUBPLZ_WEB_STRIPE_SECRET_KEY= +# SUBPLZ_WEB_STRIPE_WEBHOOK_SECRET= + +# Sign-in links go out over SMTP, so any provider works. With no host set the +# link is written to the server log instead (and sign-in is refused outright +# when billing is on, since there would be no safe way to deliver it). +# SUBPLZ_WEB_SMTP_HOST=smtp.example.com +# SUBPLZ_WEB_SMTP_PORT=587 +# SUBPLZ_WEB_SMTP_USER= +# SUBPLZ_WEB_SMTP_PASSWORD= +# SUBPLZ_WEB_SMTP_FROM=SubRead <login@subread.space> # Override the plan catalogue (see backend/pricing.py for the shape). # SUBPLZ_WEB_PLANS_JSON=[...] diff --git a/README.md b/README.md index df7ba2e..f95907b 100644 --- a/README.md +++ b/README.md @@ -229,7 +229,10 @@ drop corrupt frames instead of letting a single bad chapter abort the whole run. | `backend/languages.py` | language registry and splitter routing | | `backend/queue.py` | in-process or Redis dispatch | | `backend/storage.py` | local disk or S3 | -| `backend/billing.py` | the 24-hour allowance | +| `backend/billing.py` | who may do what: the free window, credits, the two tiers | +| `backend/payments.py` | Stripe: checkout, idempotent fulfilment, webhooks | +| `backend/accounts.py` | one email, one account; folding anonymous work in | +| `backend/auth.py` | emailed one-time sign-in links | | `backend/pricing.py` | the plan catalogue, with the market it was set against | | `frontend/` | vanilla HTML/CSS/JS, no build step | | `tools/client.py` | CLI client, and a worked example of the API | @@ -318,14 +321,55 @@ the subscription just under Otter ($16.99) and Happy Scribe ($17). Every number is an env var — see `backend/pricing.py`. -**Identity is a cookie, and only a cookie.** Anyone who clears it gets another -free book. That is accepted: hard verification means accounts, email and a -signup wall in front of a tool whose pitch is "drop two files in". The real -protection against abuse is capacity, not identity. +**Free and paid are split by output, not by quality.** -What is **not** included is a payment provider. `billing.start_checkout` raises -`NotImplementedError` and is the single place Stripe plugs in. Credit -`Account.purchased_credits` from the webhook, never from the success redirect. +| Tier | You get | Costs | +|---|---|---| +| free | `.srt` (HoshiReader) + `.mkv` with the subtitles built in | one book per rolling 24 h | +| youtube | all of that + the clean `.mp4` for uploading | one credit, or the unlimited plan | + +The free tier is the whole product for someone reading along at home. The one +paid output is the one made for an audience. The mp4 is rendered for every job +regardless - the mkv is a stream copy of it - so paying later unlocks a finished +job instantly (`POST /api/jobs/{id}/unlock`) instead of re-running it. A credit +spent on a job that then fails or is cancelled is handed back. + +**The free tier's identity is a cookie, and only a cookie.** Anyone who clears +it gets another free book. That is accepted: a signup wall does not belong in +front of a tool whose pitch is "drop two files in". The real protection against +abuse is capacity, not identity. + +**An account is an email.** It gets attached either by following an emailed +one-time link (no passwords anywhere) or by paying, since Stripe collects one. +Whatever the device converted while anonymous is folded into the account, and +the cookie is re-pointed at it - including when the payment lands by webhook +with no browser attached (`Account.merged_into`). + +**Payments are Stripe Checkout**, with prices sent inline from +`backend/pricing.py`, so there is nothing to configure in the Stripe dashboard +beyond a webhook. Two rules: the browser returning from Stripe is never treated +as proof of payment (the session is re-read from Stripe), and fulfilment is +idempotent on the checkout session id, because Stripe reports one payment +several times. + +### Turning payments on + +```bash +# on the server, as root - each prompts for the value with input hidden +tools/set-secret.sh SUBPLZ_WEB_STRIPE_SECRET_KEY # sk_live_... (or sk_test_...) +tools/set-secret.sh SUBPLZ_WEB_STRIPE_WEBHOOK_SECRET # whsec_... +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. + +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 +once you are registered somewhere). --- @@ -339,8 +383,8 @@ Everything environment-specific is an env var with a localhost default. See | Queue | thread pool in the API process | Redis + `worker.py` | | Storage | `./data/artifacts` | S3, presigned download URLs | | Database | SQLite | Postgres | -| Identity | cookie | replace `api.get_account` | -| Billing | off | `SUBPLZ_WEB_BILLING_ENABLED=true` | +| Identity | cookie | cookie + emailed sign-in links (`SMTP_*`) | +| Billing | off, nothing locked | `SUBPLZ_WEB_BILLING_ENABLED=true` + Stripe keys | ```bash SUBPLZ_WEB_QUEUE_BACKEND=redis \ @@ -357,7 +401,7 @@ shared filesystem. ### Before going public -- Implement `billing.start_checkout` and its webhook. +- Set the Stripe keys, the webhook and SMTP (see *Turning payments on*). - Put a reverse proxy in front for TLS and upload limits. - Add a retention job — audiobooks are large and artifacts are kept forever. - Run workers on hardware that can take it. Alignment needs roughly 2–3 GB of diff --git a/backend/accounts.py b/backend/accounts.py new file mode 100644 index 0000000..7b31fe8 --- /dev/null +++ b/backend/accounts.py @@ -0,0 +1,105 @@ +"""Who an account is, and keeping one email to exactly one account. + +Every visitor starts anonymous, identified by a cookie. An email gets attached +in one of two ways - following a sign-in link, or paying (Stripe collects one at +checkout) - and from then on that email is the account. When the email already +belongs to another row, the anonymous row is folded into it, so whatever someone +converted before signing in is still there afterwards. +""" + +from __future__ import annotations + +import re +import secrets + +from sqlalchemy.orm import Session + +from .db import Account, Job, Purchase + +# Deliberately loose: the real test of an address is whether the link arrives. +_EMAIL = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") + + +class InvalidEmail(ValueError): + pass + + +def normalize_email(raw: str | None) -> str: + email = (raw or "").strip().lower() + if not email or len(email) > 320 or not _EMAIL.match(email): + raise InvalidEmail("That does not look like an email address.") + return email + + +def new_account(session: Session) -> Account: + account = Account(device_token=secrets.token_urlsafe(24)) + session.add(account) + session.flush() + return account + + +def by_email(session: Session, email: str) -> Account | None: + return session.query(Account).filter(Account.email == email).first() + + +def resolve(session: Session, account: Account) -> Account: + """Follow merged_into to the account that survived.""" + seen = {account.id} + while account.merged_into: + target = session.get(Account, account.merged_into) + if target is None or target.id in seen: + break + seen.add(target.id) + account = target + return account + + +def merge(session: Session, src: Account, dst: Account) -> None: + """Fold `src` into `dst`: its books, its purchases and anything it paid for.""" + if src.id == dst.id: + return + session.query(Job).filter(Job.account_id == src.id).update( + {Job.account_id: dst.id}, synchronize_session=False + ) + session.query(Purchase).filter(Purchase.account_id == src.id).update( + {Purchase.account_id: dst.id}, synchronize_session=False + ) + dst.purchased_credits += src.purchased_credits + src.purchased_credits = 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: + dst.subscription_id = src.subscription_id + dst.subscription_status = src.subscription_status + dst.subscription_period_end = src.subscription_period_end + src.stripe_customer_id = None + src.subscription_id = src.subscription_status = None + src.subscription_period_end = None + src.merged_into = dst.id + + +def adopt_email(session: Session, account: Account, email: str) -> Account: + """Attach `email` to `account`, returning whichever account ends up owning it. + + The caller must point the browser at the returned account - it is not + always the one passed in. + """ + account = resolve(session, account) + if account.email == email: + return account + + owner = by_email(session, email) + + if account.email is None: + if owner is None: + account.email = email + return account + # Known email, anonymous device: bring the device's work along. + merge(session, account, owner) + return owner + + # Signed in as someone else already. Switch users; never merge two people. + if owner is None: + owner = new_account(session) + owner.email = email + return owner diff --git a/backend/api.py b/backend/api.py index 59ca4ea..573e835 100644 --- a/backend/api.py +++ b/backend/api.py @@ -6,23 +6,29 @@ POST /api/jobs/{id}/start (entitlement check, enqueue) -> poll GET /api/jobs/{id Upload and start are separate so a wrong language guess costs a click rather than a re-upload and a wasted multi-hour run. + +Around that: /api/auth/* (email sign-in links) and /api/billing/* (Stripe +checkout, its return trip and its webhook). """ from __future__ import annotations -import secrets import shutil from pathlib import Path from typing import Annotated, Literal from fastapi import ( - APIRouter, Cookie, Depends, File, HTTPException, Response, UploadFile, + APIRouter, Cookie, Depends, File, HTTPException, Request, Response, + UploadFile, ) from fastapi.responses import FileResponse, RedirectResponse from pydantic import BaseModel, Field from sqlalchemy.orm import Session -from . import billing, convert, detect, languages, matching, pricing +from . import ( + accounts, auth, billing, convert, detect, languages, mailer, matching, + payments, pricing, +) from .aligner import aligner from .db import Account, Artifact, Job, JobStatus, SessionLocal, new_id, utcnow from .queue import queue @@ -55,30 +61,43 @@ def get_account( ) -> Account: """Identify the caller. - A cookie is the whole identity check, on purpose. Anyone who clears it gets - another free book, and that is an accepted cost: hard verification would - mean accounts, email and a signup wall in front of a tool whose pitch is - "drop two files in". The real protection against abuse is capacity - the - queue and per-worker limits - not identity. + A cookie is the whole identity check for the free tier, on purpose. Anyone + who clears it gets another free book, and that is an accepted cost: a + signup wall does not belong in front of a tool whose pitch is "drop two + files in". The real protection against abuse is capacity - the queue and + per-worker limits - not identity. - Swapping this for real auth later means changing this one function: - everything downstream just receives an Account. + Signing in does not replace the cookie, it re-points it: the cookie then + names the signed-in account, on every device that has signed in. """ - token = subplz_device account = None - if token: - account = session.query(Account).filter(Account.device_token == token).first() + if subplz_device: + account = ( + session.query(Account) + .filter(Account.device_token == subplz_device) + .first() + ) if account is None: - token = secrets.token_urlsafe(24) - account = Account(device_token=token) - session.add(account) + account = accounts.new_account(session) session.commit() - response.set_cookie( - DEVICE_COOKIE, token, - max_age=60 * 60 * 24 * 365, httponly=True, samesite="lax", - ) - return account + _set_identity(response, account) + return account + + # This device's anonymous row was folded into a real account while no + # browser was attached (a payment landing by webhook). Follow it. + survivor = accounts.resolve(session, account) + if survivor.id != account.id: + _set_identity(response, survivor) + return survivor + + +def _set_identity(response: Response, account: Account) -> None: + response.set_cookie( + DEVICE_COOKIE, account.device_token, + max_age=60 * 60 * 24 * 365, httponly=True, samesite="lax", + secure=settings.cookie_secure, + ) # -------------------------------------------------------------------------- @@ -105,6 +124,8 @@ class ArtifactOut(BaseModel): filename: str size_bytes: int url: str + # True when this file belongs to a tier the job has not paid for. + locked: bool = False class JobOut(BaseModel): @@ -124,6 +145,7 @@ class JobOut(BaseModel): audio_duration_seconds: float | None error: str | None created_at: str + tier: str = billing.FREE artifacts: list[ArtifactOut] = Field(default_factory=list) @@ -140,6 +162,7 @@ class UploadOut(BaseModel): class StartIn(BaseModel): language: str | None = None model: str | None = None + tier: Literal["free", "youtube"] = "free" class AccountOut(BaseModel): @@ -147,18 +170,39 @@ class AccountOut(BaseModel): signed_in: bool email: str | None billing_enabled: bool + # Whether the server can actually take money / send sign-in email yet. + payments_available: bool + email_sign_in_available: bool + # Free tier. free_allowance: int free_window_hours: int free_tier_summary: str - purchased_credits: int - used: int - remaining: int - allowed: bool + free_remaining: int + free_allowed: bool next_free_at: str | None reason: str + # Paid tier. + credits: int + subscribed: bool + subscription_ends: str | None + youtube_allowed: bool queue_depth: int +class EmailIn(BaseModel): + email: str + + +class TokenIn(BaseModel): + token: str + + +class CheckoutIn(BaseModel): + plan_id: str + # A finished free job to unlock with this purchase. + job_id: str | None = None + + def _job_out(job: Job, arts: list[Artifact]) -> JobOut: lang = languages.get(job.language) return JobOut( @@ -178,10 +222,12 @@ def _job_out(job: Job, arts: list[Artifact]) -> JobOut: audio_duration_seconds=job.audio_duration_seconds, error=job.error, created_at=job.created_at.isoformat(), + tier=job.tier or billing.FREE, artifacts=[ ArtifactOut( kind=a.kind, filename=a.filename, size_bytes=a.size_bytes, url=f"/api/jobs/{job.id}/files/{a.kind}", + locked=not billing.can_download(job, a.kind), ) for a in arts ], @@ -224,21 +270,33 @@ def get_account_info( account: Annotated[Account, Depends(get_account)], session: Annotated[Session, Depends(get_session)], ): + return _account_out(session, account) + + +def _account_out(session: Session, account: Account) -> AccountOut: ent = billing.check(session, account) return AccountOut( id=account.id, signed_in=account.signed_in, email=account.email, billing_enabled=settings.billing_enabled, + payments_available=settings.payments_configured, + email_sign_in_available=( + settings.email_configured or not settings.billing_enabled + ), free_allowance=ent.free_allowance, free_window_hours=ent.window_hours, free_tier_summary=pricing.free_tier_summary(), - purchased_credits=ent.purchased_credits, - used=ent.used, - remaining=ent.remaining, - allowed=ent.allowed, + free_remaining=ent.free_remaining, + free_allowed=ent.free_allowed, next_free_at=ent.next_free_at.isoformat() if ent.next_free_at else None, reason=ent.reason, + credits=ent.credits, + subscribed=ent.subscribed, + subscription_ends=( + ent.subscription_ends.isoformat() if ent.subscription_ends else None + ), + youtube_allowed=ent.youtube_allowed or not settings.billing_enabled, queue_depth=queue.depth(), ) @@ -249,10 +307,151 @@ def get_pricing(): return { "free_tier": pricing.free_tier_summary(), "billing_enabled": settings.billing_enabled, + "payments_available": settings.payments_configured, + "tiers": pricing.TIER_OUTPUTS, "plans": pricing.as_dicts(), } +# -------------------------------------------------------------------------- +# sign-in +# -------------------------------------------------------------------------- + +@router.post("/auth/request") +def request_sign_in( + body: EmailIn, + account: Annotated[Account, Depends(get_account)], + session: Annotated[Session, Depends(get_session)], +): + """Mail a one-time sign-in link.""" + try: + email = accounts.normalize_email(body.email) + except accounts.InvalidEmail as exc: + raise HTTPException(400, str(exc)) from exc + + # A public server that cannot send mail has no safe way to do this: the + # only fallback is showing the link, which would sign anyone in as anyone. + if settings.billing_enabled and not settings.email_configured: + raise HTTPException( + 503, "Email sign-in is not set up on this server yet." + ) + + try: + link = auth.issue(session, account, email) + except auth.TooManyRequests as exc: + raise HTTPException(429, str(exc)) from exc + + try: + sent = mailer.send_login_link(email, link) + except mailer.MailError as exc: + raise HTTPException(502, str(exc)) from exc + + out = {"sent": sent, "email": email} + if not sent: + # Localhost only (guarded above): there is no mailbox to check, so + # hand the link straight back. + out["dev_link"] = link + return out + + +@router.post("/auth/verify", response_model=AccountOut) +def verify_sign_in( + body: TokenIn, + response: Response, + account: Annotated[Account, Depends(get_account)], + session: Annotated[Session, Depends(get_session)], +): + owner = auth.redeem(session, body.token, account) + if owner is None: + raise HTTPException( + 400, "That sign-in link has expired or was already used. " + "Request a new one." + ) + _set_identity(response, owner) + return _account_out(session, owner) + + +@router.post("/auth/signout") +def sign_out(response: Response): + """Forget this browser. The account and everything in it stay put.""" + response.delete_cookie(DEVICE_COOKIE) + return {"signed_out": True} + + +# -------------------------------------------------------------------------- +# billing +# -------------------------------------------------------------------------- + +@router.post("/billing/checkout") +def create_checkout( + body: CheckoutIn, + account: Annotated[Account, Depends(get_account)], + session: Annotated[Session, Depends(get_session)], +): + plan = pricing.get(body.plan_id) + if plan is None: + raise HTTPException(404, "No such plan.") + if body.job_id: + _load(session, account, body.job_id) # 404s on someone else's job + try: + url = payments.start_checkout(session, account, plan, body.job_id) + except payments.PaymentsUnavailable as exc: + raise HTTPException(503, str(exc)) from exc + except payments.PaymentError as exc: + raise HTTPException(400, str(exc)) from exc + return {"url": url} + + +@router.get("/billing/return") +def checkout_return( + session_id: str, + session: Annotated[Session, Depends(get_session)], +): + """Where Stripe sends the browser after paying. + + Fulfils from here as well as from the webhook, so credits are there by the + time the page loads rather than whenever the webhook gets round to it. + Identity needs no handling: if paying folded this device into an existing + account, get_account re-points the cookie on the very next request. + """ + try: + paid = payments.fulfil_by_id(session, session_id) is not None + except payments.PaymentsUnavailable: + paid = False + state = "paid" if paid else "pending" + return RedirectResponse(f"/?checkout={state}", status_code=303) + + +@router.post("/billing/webhook") +async def stripe_webhook( + request: Request, + session: Annotated[Session, Depends(get_session)], +): + payload = await request.body() + try: + event = payments.verify_webhook( + payload, request.headers.get("stripe-signature") + ) + except payments.PaymentsUnavailable as exc: + raise HTTPException(503, str(exc)) from exc + except payments.PaymentError as exc: + raise HTTPException(400, str(exc)) from exc + payments.handle_event(session, event) + return {"received": True} + + +@router.post("/billing/portal") +def billing_portal( + account: Annotated[Account, Depends(get_account)], +): + try: + return {"url": payments.portal_url(account)} + except payments.PaymentsUnavailable as exc: + raise HTTPException(503, str(exc)) from exc + except payments.PaymentError as exc: + raise HTTPException(400, str(exc)) from exc + + @router.post("/uploads", response_model=UploadOut) async def create_upload( account: Annotated[Account, Depends(get_account)], @@ -425,9 +624,11 @@ def start_job( if body.model: job.model = body.model - ent = billing.check(session, account) - if not ent.allowed: - raise HTTPException(402, ent.reason) + try: + billing.authorize_start(session, account, job, body.tier) + except billing.PaymentRequired as exc: + session.rollback() + raise HTTPException(402, str(exc)) from exc # With an external queue the worker is probably not this machine, so the # staged inputs have to go somewhere both sides can reach before enqueuing. @@ -439,7 +640,6 @@ def start_job( rel = local.relative_to(paths.inp).as_posix() storage.put_file(f"{prefix}/{rel}", local) - billing.consume(session, job) job.status = JobStatus.queued job.stage = "Queued" job.progress = 0.0 @@ -493,6 +693,23 @@ def cancel_job( return _job_out(job, []) +@router.post("/jobs/{job_id}/unlock", response_model=JobOut) +def unlock_job( + job_id: str, + account: Annotated[Account, Depends(get_account)], + session: Annotated[Session, Depends(get_session)], +): + """Upgrade a free job to the YouTube tier, spending a credit.""" + job = _load(session, account, job_id) + try: + billing.unlock(session, account, job) + except billing.PaymentRequired as exc: + session.rollback() + raise HTTPException(402, str(exc)) from exc + session.commit() + return _job_out(job, _artifacts(session, job.id)) + + @router.delete("/jobs/{job_id}") def delete_job( job_id: str, @@ -517,6 +734,11 @@ def download( session: Annotated[Session, Depends(get_session)], ): job = _load(session, account, job_id) + if not billing.can_download(job, kind): + raise HTTPException( + 402, "The YouTube video is part of the paid tier. Unlock it with " + "a credit, or the unlimited plan." + ) art = ( session.query(Artifact) .filter(Artifact.job_id == job.id, Artifact.kind == kind) diff --git a/backend/auth.py b/backend/auth.py new file mode 100644 index 0000000..8534213 --- /dev/null +++ b/backend/auth.py @@ -0,0 +1,86 @@ +"""Email sign-in links. + +No passwords: a link is mailed, opening it signs the browser in. The link +carries a random token; only its hash is stored, it works once, and it expires. +""" + +from __future__ import annotations + +import hashlib +import secrets +from datetime import timedelta, timezone + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from . import accounts +from .db import Account, LoginToken, utcnow +from .settings import settings + +# Per address and per device, per hour. Enough for someone fumbling a typo, +# not enough to use us as a mail cannon. +_MAX_LINKS_PER_HOUR = 5 + + +class TooManyRequests(RuntimeError): + pass + + +def _hash(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def issue(session: Session, account: Account, email: str) -> str: + """Create a sign-in token for `email` and return the link to send.""" + since = utcnow() - timedelta(hours=1) + recent = ( + session.query(func.count(LoginToken.id)) + .filter( + LoginToken.created_at >= since, + (LoginToken.email == email) | (LoginToken.account_id == account.id), + ) + .scalar() + or 0 + ) + if recent >= _MAX_LINKS_PER_HOUR: + raise TooManyRequests( + "Too many sign-in links requested. Wait a little and try again." + ) + + token = secrets.token_urlsafe(32) + session.add( + LoginToken( + token_hash=_hash(token), + email=email, + account_id=account.id, + expires_at=utcnow() + timedelta(minutes=settings.login_link_minutes), + ) + ) + session.commit() + # Lands on the page, which then POSTs the token back. Mail scanners follow + # links but do not run scripts, so they cannot burn a one-time token. + return f"{settings.public_base_url.rstrip('/')}/?login={token}" + + +def redeem(session: Session, token: str, device: Account) -> Account | None: + """Spend a token. Returns the account to sign this browser into.""" + row = ( + session.query(LoginToken) + .filter(LoginToken.token_hash == _hash(token or "")) + .first() + ) + if row is None or row.used_at is not None: + return None + expires = row.expires_at + if expires.tzinfo is None: # SQLite hands back naive datetimes + expires = expires.replace(tzinfo=timezone.utc) + if expires < utcnow(): + return None + + row.used_at = utcnow() + # Fold in the device that opened the link. Usually that is the one that + # 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) + session.commit() + return owner diff --git a/backend/billing.py b/backend/billing.py index a3bd483..1af9775 100644 --- a/backend/billing.py +++ b/backend/billing.py @@ -1,16 +1,23 @@ -"""Entitlement: one free book per rolling 24 hours, pay for more. +"""Entitlement: what an account may do right now. + +Two tiers, split by what you walk away with: + + free The subtitles (.srt, for HoshiReader) and the video with the + subtitles built in (.mkv, for MPV/VLC). One book per rolling + 24 hours. + youtube All of that plus the clean .mp4 made for uploading to YouTube. + Costs one credit, or nothing on the unlimited plan, and starts + right away - paying customers do not queue behind the free window. Disabled on localhost (SUBPLZ_WEB_BILLING_ENABLED=false) so nothing gets in the -way while you use it yourself. The accounting still runs either way - every job -records whether it consumed an allowance - so turning billing on for the public -release does not need a backfill. +way while you use it yourself. The accounting still runs either way, so turning +billing on for the public release does not need a backfill. The window is rolling, not a calendar day: the allowance comes back 24 hours after the run that used it, which avoids a midnight stampede and is easier to explain than "resets at 00:00 in some timezone". -Deliberately not included: a payment provider. `start_checkout` is the single -seam where Stripe (or anything else) plugs in. +Taking the money lives in payments.py; this file only decides who may do what. """ from __future__ import annotations @@ -18,142 +25,208 @@ from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timedelta, timezone -from sqlalchemy import func +from sqlalchemy import func, update from sqlalchemy.orm import Session -from .db import Account, Job, JobStatus, utcnow +from .db import Account, Job, JobStatus, SessionLocal, utcnow from .settings import settings -# Jobs in these states hold an allowance. A failed or cancelled run releases it: -# charging for our own failure is not a business model. +FREE = "free" +YOUTUBE = "youtube" +TIERS = (FREE, YOUTUBE) + +# Jobs in these states hold a slot in the free window. A failed or cancelled +# run releases it: charging for our own failure is not a business model. _HOLDING = [JobStatus.queued, JobStatus.running, JobStatus.succeeded] +# Stripe keeps retrying a failed renewal for a while; do not lock someone out +# the second their card hiccups. +_SUBSCRIBED = {"active", "trialing", "past_due"} +_GRACE = timedelta(days=1) + + +class PaymentRequired(RuntimeError): + """The account cannot do this without paying. Carries the reason to show.""" + + +def _aware(when: datetime | None) -> datetime | None: + if when is not None and when.tzinfo is None: # SQLite hands back naive + return when.replace(tzinfo=timezone.utc) + return when + @dataclass(frozen=True) class Entitlement: - allowed: bool - used: int + # Free tier. + free_allowed: bool + free_used: int free_allowance: int - purchased_credits: int - remaining: int + free_remaining: int window_hours: int # When the next free conversion becomes available, if the window is full. - next_free_at: datetime | None = None + next_free_at: datetime | None + # Paid tier. + credits: int + subscribed: bool + subscription_ends: datetime | None + # Why a free start is blocked, ready to show. reason: str = "" @property - def needs_payment(self) -> bool: - return not self.allowed - + def youtube_allowed(self) -> bool: + return self.subscribed or self.credits > 0 -def _window_start() -> datetime: - return utcnow() - timedelta(hours=settings.free_window_hours) +def is_subscribed(account: Account) -> bool: + if account.subscription_status not in _SUBSCRIBED: + return False + ends = _aware(account.subscription_period_end) + return ends is None or ends + _GRACE > utcnow() -def _jobs_in_window(session: Session, account_id: str) -> int: - """Billable jobs started inside the current window.""" - return ( - session.query(func.count(Job.id)) - .filter( - Job.account_id == account_id, - Job.billed == 1, - Job.status.in_(_HOLDING), - Job.created_at >= _window_start(), - ) - .scalar() - or 0 - ) +def _window_start() -> datetime: + return utcnow() - timedelta(hours=settings.free_window_hours) -def _oldest_in_window(session: Session, account_id: str) -> datetime | None: - """The earliest billable job still inside the window. - Its age is what decides when the allowance frees up again. - """ +def _window_filter(account_id: str): return ( - session.query(func.min(Job.created_at)) - .filter( - Job.account_id == account_id, - Job.billed == 1, - Job.status.in_(_HOLDING), - Job.created_at >= _window_start(), - ) - .scalar() + Job.account_id == account_id, + Job.billed == 1, + Job.status.in_(_HOLDING), + Job.created_at >= _window_start(), ) def check(session: Session, account: Account) -> Entitlement: - used = _jobs_in_window(session, account.id) + used = ( + session.query(func.count(Job.id)).filter(*_window_filter(account.id)).scalar() + or 0 + ) free = settings.free_conversions - purchased = account.purchased_credits - remaining = max(0, free + purchased - used) window = settings.free_window_hours + subscribed = is_subscribed(account) + remaining = max(0, free - used) def build(allowed: bool, reason: str = "", when: datetime | None = None): return Entitlement( - allowed=allowed, used=used, free_allowance=free, - purchased_credits=purchased, remaining=remaining, - window_hours=window, next_free_at=when, reason=reason, + free_allowed=allowed, free_used=used, free_allowance=free, + free_remaining=remaining, window_hours=window, next_free_at=when, + credits=account.purchased_credits, subscribed=subscribed, + subscription_ends=_aware(account.subscription_period_end), + reason=reason, ) - if not settings.billing_enabled: - return build(True, "billing disabled") - - if remaining > 0: + if not settings.billing_enabled or subscribed or remaining > 0: return build(True) - oldest = _oldest_in_window(session, account.id) - when = None - if oldest is not None: - if oldest.tzinfo is None: # SQLite hands back naive datetimes - oldest = oldest.replace(tzinfo=timezone.utc) - when = oldest + timedelta(hours=window) - + # The earliest job still inside the window decides when a slot frees up. + oldest = _aware( + session.query(func.min(Job.created_at)) + .filter(*_window_filter(account.id)) + .scalar() + ) + when = oldest + timedelta(hours=window) if oldest else None + book = "book" if free == 1 else "books" return build( False, reason=( - f"You get {free} free book every {window} hours. " + f"The free tier is {free} {book} every {window} hours. " + ( - f"Your next free conversion unlocks at " - f"{when:%H:%M UTC on %d %b}." + f"Your next one unlocks at {when:%H:%M UTC on %d %b}. " if when - else "Try again later." + else "" ) - + " Add credit to convert one now." + + "A credit converts a book right now, YouTube video included." ), when=when, ) -def consume(session: Session, job: Job) -> None: - """Mark a job as having used an allowance. Called as the job is accepted.""" +def _spend_credit(session: Session, account: Account) -> bool: + """Take one credit, atomically. False if there was none to take.""" + taken = session.execute( + update(Account) + .where(Account.id == account.id, Account.purchased_credits > 0) + .values(purchased_credits=Account.purchased_credits - 1) + ).rowcount + session.refresh(account) + return bool(taken) + + +def authorize_start(session: Session, account: Account, job: Job, tier: str) -> None: + """Charge whatever starting `job` on `tier` costs, or raise PaymentRequired.""" + if tier not in TIERS: + raise ValueError(f"unknown tier {tier!r}") + + job.tier, job.billed, job.credit_spent = tier, 0, 0 + + if not settings.billing_enabled: + job.billed = 1 # accounting only; nothing is ever refused + return + + if is_subscribed(account): + return + + if tier == YOUTUBE: + if not _spend_credit(session, account): + raise PaymentRequired( + "The YouTube video is a paid extra: one credit per book, " + "or the unlimited plan." + ) + job.credit_spent = 1 + return + + ent = check(session, account) + if not ent.free_allowed: + raise PaymentRequired(ent.reason) job.billed = 1 - session.add(job) -def refund(session: Session, job: Job) -> None: - """Release the allowance a failed or cancelled job held.""" - job.billed = 0 - session.add(job) +def unlock(session: Session, account: Account, job: Job) -> None: + """Upgrade a free job to the YouTube tier after the fact. + + The mp4 already exists - the mkv is made from it - so this is only ever a + permission change, never a second run. + """ + if job.tier == YOUTUBE or not settings.billing_enabled: + job.tier = YOUTUBE + return + if not is_subscribed(account): + if not _spend_credit(session, account): + raise PaymentRequired( + "Unlocking the YouTube video takes one credit, " + "or the unlimited plan." + ) + job.credit_spent = 1 + job.tier = YOUTUBE -def start_checkout(account: Account, quantity: int = 1) -> str: - """Return a payment URL for `quantity` extra conversions. +def can_download(job: Job, kind: str) -> bool: + if kind != "video" or not settings.billing_enabled: + return True + return job.tier == YOUTUBE - Wire Stripe in here for the public release, e.g.: - session = stripe.checkout.Session.create( - customer=account.stripe_customer_id, - line_items=[{"price": PRICE_ID, "quantity": quantity}], - mode="payment", - success_url=..., cancel_url=..., +def refund(session: Session, job: Job) -> None: + """Hand back whatever a failed or cancelled job was holding.""" + job.billed = 0 + if job.credit_spent: + session.execute( + update(Account) + .where(Account.id == job.account_id) + .values(purchased_credits=Account.purchased_credits + 1) ) - return session.url + job.credit_spent = 0 + # Back to free, so a retry is priced again rather than riding on a + # credit that has just been returned. + job.tier = FREE + session.add(job) - and credit `Account.purchased_credits` from the webhook, not from the - success redirect - the redirect is not a payment confirmation. - """ - raise NotImplementedError( - "No payment provider configured. Implement billing.start_checkout " - "before enabling SUBPLZ_WEB_BILLING_ENABLED." - ) + +def refund_job(job_id: str) -> None: + """`refund`, for the runner, which works in job ids rather than sessions.""" + with SessionLocal() as session: + job = session.get(Job, job_id) + if job is not None: + refund(session, job) + session.commit() diff --git a/backend/db.py b/backend/db.py index 6167d4d..864bde4 100644 --- a/backend/db.py +++ b/backend/db.py @@ -14,6 +14,8 @@ from sqlalchemy import ( String, Text, create_engine, + inspect, + text, ) from sqlalchemy.orm import ( DeclarativeBase, @@ -52,9 +54,9 @@ class JobStatus(str, enum.Enum): class Account(Base): """One row per identified user. - On localhost everyone shares a single anonymous account. For the public - release this gains an auth provider id, an email and a Stripe customer id - - billing.py already reads its allowance from here. + Every visitor starts as an anonymous row keyed by a cookie. It becomes a + real account the moment an email is attached - by following a sign-in link + or by paying, since Stripe collects one at checkout. """ __tablename__ = "accounts" @@ -64,10 +66,28 @@ class Account(Base): ) # Opaque token the browser stores; becomes a real session subject later. device_token: Mapped[str] = mapped_column(String(64), unique=True, index=True) - email: Mapped[str | None] = mapped_column(String(320), nullable=True) - stripe_customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True) - # Conversions bought beyond the free allowance. + # Lowercased. Unique, so an email always resolves to exactly one account. + email: Mapped[str | None] = mapped_column( + String(320), nullable=True, unique=True, index=True + ) + stripe_customer_id: Mapped[str | None] = mapped_column( + String(64), nullable=True, index=True + ) + # Paid conversions in hand. One credit = one book with every output. purchased_credits: Mapped[int] = mapped_column(Integer, default=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. + subscription_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + subscription_status: Mapped[str | None] = mapped_column(String(32), nullable=True) + subscription_period_end: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + # Set when this anonymous row was folded into a signed-in account. A + # payment can finish without a browser attached (the webhook), so the + # cookie is re-pointed lazily, the next time this device shows up. + merged_into: Mapped[str | None] = mapped_column(String(64), nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=utcnow ) @@ -116,8 +136,17 @@ class Job(Base): stage: Mapped[str] = mapped_column(String(128), default="queued") error: Mapped[str | None] = mapped_column(Text, nullable=True) - # 1 once the job has consumed the free/paid allowance. + # 1 while the job holds a slot in the free window. billed: Mapped[int] = mapped_column(Integer, default=0) + # "free": subtitles + the video with subtitles built in. + # "youtube": also the clean mp4. Paid for with a credit or a subscription. + tier: Mapped[str] = mapped_column( + String(16), default="free", server_default=text("'free'") + ) + # 1 if a 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") + ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=utcnow @@ -156,6 +185,52 @@ class Artifact(Base): job: Mapped[Job] = relationship(back_populates="artifacts") +class LoginToken(Base): + """A one-time sign-in link. Only the hash is stored, so a leaked database + cannot be replayed into anyone's account.""" + + __tablename__ = "login_tokens" + + id: Mapped[str] = mapped_column( + String(64), primary_key=True, default=lambda: new_id("login") + ) + token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True) + email: Mapped[str] = mapped_column(String(320), index=True) + # The device that asked, so its anonymous jobs follow it into the account. + account_id: Mapped[str] = mapped_column(ForeignKey("accounts.id"), index=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=utcnow + ) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + used_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + +class Purchase(Base): + """Ledger of completed checkouts. + + The unique session id is what makes fulfilment idempotent: Stripe delivers + a payment twice (the webhook and the browser's return trip) and retries + webhooks freely, and each of those must credit the account exactly once. + """ + + __tablename__ = "purchases" + + id: Mapped[str] = mapped_column( + String(64), primary_key=True, default=lambda: new_id("buy") + ) + account_id: Mapped[str] = mapped_column(ForeignKey("accounts.id"), index=True) + plan_id: Mapped[str] = mapped_column(String(32)) + credits: Mapped[int] = mapped_column(Integer, default=0) + amount_cents: Mapped[int] = mapped_column(Integer, default=0) + currency: Mapped[str] = mapped_column(String(8), default="usd") + stripe_session_id: Mapped[str] = mapped_column(String(128), unique=True, index=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=utcnow + ) + + _is_sqlite = settings.resolved_database_url.startswith("sqlite") _engine = create_engine( @@ -167,5 +242,38 @@ _engine = create_engine( SessionLocal = sessionmaker(bind=_engine, expire_on_commit=False) +def _add_missing_columns() -> None: + """Bring an existing database up to the current models, additively. + + create_all() makes missing tables but never touches one that exists, so a + deploy that adds a column would otherwise need hand-run SQL on the server. + This only ever ADDs a column - anything destructive is still a deliberate, + manual migration. + """ + insp = inspect(_engine) + with _engine.begin() as conn: + for table in Base.metadata.sorted_tables: + if not insp.has_table(table.name): + continue + have = {c["name"] for c in insp.get_columns(table.name)} + for col in table.columns: + if col.name in have: + continue + ddl = ( + f"ALTER TABLE {table.name} ADD COLUMN {col.name} " + f"{col.type.compile(dialect=_engine.dialect)}" + ) + if col.server_default is not None: + ddl += f" DEFAULT {col.server_default.arg.text}" + conn.execute(text(ddl)) + + # A column added above cannot carry its index along with it. + existing = {i["name"] for i in insp.get_indexes(table.name)} + for index in table.indexes: + if index.name not in existing: + index.create(conn) + + def init_db() -> None: Base.metadata.create_all(_engine) + _add_missing_columns() diff --git a/backend/mailer.py b/backend/mailer.py new file mode 100644 index 0000000..a753569 --- /dev/null +++ b/backend/mailer.py @@ -0,0 +1,64 @@ +"""Outbound email. Plain SMTP, so any provider works; nothing else is sent.""" + +from __future__ import annotations + +import logging +import smtplib +from email.message import EmailMessage + +from .settings import settings + +log = logging.getLogger(__name__) + + +class MailError(RuntimeError): + pass + + +def send_login_link(to: str, url: str) -> bool: + """Email a sign-in link. Returns False when it was only logged. + + With no SMTP host configured the link goes to the server log, which keeps + sign-in usable on localhost without an email account to send from. + """ + if not settings.email_configured: + log.info("sign-in link for %s: %s", to, url) + return False + + minutes = settings.login_link_minutes + msg = EmailMessage() + msg["Subject"] = "Your sign-in link" + msg["From"] = settings.smtp_from + msg["To"] = to + msg.set_content( + f"Open this link to sign in:\n\n{url}\n\n" + f"It works once and expires in {minutes} minutes. " + "If you did not ask for it, ignore this email - nothing happens " + "unless the link is opened.\n" + ) + msg.add_alternative( + f"<p>Open this link to sign in:</p>" + f'<p><a href="{url}">Sign in</a></p>' + f'<p style="color:#666;font-size:13px">It works once and expires in ' + f"{minutes} minutes. If you did not ask for it, ignore this email - " + f"nothing happens unless the link is opened.</p>", + subtype="html", + ) + + try: + if settings.smtp_port == 465: + smtp = smtplib.SMTP_SSL(settings.smtp_host, settings.smtp_port, timeout=20) + else: + smtp = smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=20) + with smtp: + if settings.smtp_port != 465 and settings.smtp_starttls: + smtp.starttls() + if settings.smtp_user: + smtp.login(settings.smtp_user, settings.smtp_password) + smtp.send_message(msg) + except (smtplib.SMTPException, OSError) as exc: + log.warning("could not send sign-in link to %s: %s", to, exc) + raise MailError( + "The sign-in email could not be sent. Try again shortly." + ) from exc + return True diff --git a/backend/payments.py b/backend/payments.py new file mode 100644 index 0000000..1939baa --- /dev/null +++ b/backend/payments.py @@ -0,0 +1,288 @@ +"""Stripe: start a checkout, credit the account when it completes. + +Everything Stripe-specific is in this file. billing.py decides what an account +may do; this only moves money into `Account.purchased_credits` and keeps the +subscription fields honest. + +Two rules the rest follows from: + +* The browser coming back from Stripe is not proof of payment. Fulfilment + always re-reads the checkout session from Stripe, and only acts on "paid". +* Stripe tells us about one payment more than once - the webhook, the + browser's return trip, and any number of webhook retries - so fulfilment is + idempotent on the checkout session id (see db.Purchase). + +Prices are sent inline from pricing.py rather than configured in the Stripe +dashboard, so the catalogue has one source of truth and a new deployment needs +nothing but an API key. +""" + +from __future__ import annotations + +import json +import logging +from datetime import datetime, timezone + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from . import accounts, billing, pricing +from .db import Account, Job, Purchase +from .settings import settings + +log = logging.getLogger(__name__) + + +class PaymentsUnavailable(RuntimeError): + pass + + +class PaymentError(RuntimeError): + pass + + +def _stripe(): + if not settings.payments_configured: + raise PaymentsUnavailable( + "Payments are not set up on this server yet." + ) + import stripe # lazy: localhost never needs the package + + stripe.api_key = settings.stripe_secret_key + return stripe + + +def _plain(obj) -> dict: + """A Stripe object as plain data. They stopped being dicts in v13.""" + if isinstance(obj, dict): + return obj + return obj.to_dict() + + +def _base() -> str: + return settings.public_base_url.rstrip("/") + + +# --------------------------------------------------------------------------- +# checkout +# --------------------------------------------------------------------------- + +def start_checkout( + session: Session, account: Account, plan: pricing.Plan, job_id: str | None = None +) -> str: + """Return the Stripe-hosted payment page for `plan`. + + `job_id` names a finished free job to unlock as soon as the money lands, + so "pay to get this video" is one trip rather than two. + """ + stripe = _stripe() + + if plan.recurring and billing.is_subscribed(account): + raise PaymentError("You are already on the unlimited plan.") + + price = { + "currency": plan.currency, + "unit_amount": plan.price_cents, + "product_data": {"name": f"{settings.site_name} - {plan.name}"}, + } + if plan.recurring: + price["recurring"] = {"interval": "month"} + + meta = {"account_id": account.id, "plan_id": plan.id, "job_id": job_id or ""} + params: dict = { + "mode": "subscription" if plan.recurring else "payment", + "line_items": [{"quantity": 1, "price_data": price}], + "client_reference_id": account.id, + "metadata": meta, + # Stripe substitutes the real id; the return handler re-reads the + # session from it rather than trusting anything in the URL. + "success_url": f"{_base()}/api/billing/return" + "?session_id={CHECKOUT_SESSION_ID}", + "cancel_url": f"{_base()}/?checkout=cancelled", + "allow_promotion_codes": True, + } + if account.stripe_customer_id: + params["customer"] = account.stripe_customer_id + else: + if account.email: + params["customer_email"] = account.email + if not plan.recurring: + # One-off payments do not create a customer unless asked, and + # without one there is nothing to hang a later purchase on. + params["customer_creation"] = "always" + if plan.recurring: + params["subscription_data"] = {"metadata": meta} + + try: + checkout = _plain(stripe.checkout.Session.create(**params)) + except stripe.StripeError as exc: + log.warning("checkout create failed for %s: %s", account.id, exc) + raise PaymentError("Could not start the checkout. Try again shortly.") from exc + return checkout["url"] + + +def portal_url(account: Account) -> str: + """Stripe's own page for cancelling or updating the unlimited plan.""" + stripe = _stripe() + if not account.stripe_customer_id: + raise PaymentError("There is no payment history on this account yet.") + try: + portal = _plain( + stripe.billing_portal.Session.create( + customer=account.stripe_customer_id, return_url=f"{_base()}/" + ) + ) + except stripe.StripeError as exc: + log.warning("portal create failed for %s: %s", account.id, exc) + raise PaymentError("Could not open the billing page. Try again shortly.") from exc + return portal["url"] + + +# --------------------------------------------------------------------------- +# fulfilment +# --------------------------------------------------------------------------- + +def _period_end(sub: dict) -> datetime | None: + # Moved from the subscription onto its items in the 2025 API versions; + # which one arrives depends on the account's webhook API version. + stamp = sub.get("current_period_end") + if stamp is None: + items = (sub.get("items") or {}).get("data") or [] + stamp = items[0].get("current_period_end") if items else None + return datetime.fromtimestamp(stamp, tz=timezone.utc) if stamp else None + + +def _account_for(session: Session, account_id: str | None, customer: str | None): + account = session.get(Account, account_id) if account_id else None + if account is None and customer: + account = ( + session.query(Account) + .filter(Account.stripe_customer_id == customer) + .first() + ) + return accounts.resolve(session, account) if account else None + + +def apply_subscription(session: Session, sub: dict) -> None: + """Mirror a Stripe subscription onto its account.""" + meta = sub.get("metadata") or {} + account = _account_for(session, meta.get("account_id"), sub.get("customer")) + if account is None: + log.warning("subscription %s matches no account", sub.get("id")) + return + # An old subscription being deleted must not wipe out its replacement. + if account.subscription_id and account.subscription_id != sub.get("id") \ + and sub.get("status") in ("canceled", "incomplete_expired"): + return + account.subscription_id = sub.get("id") + account.subscription_status = sub.get("status") + account.subscription_period_end = _period_end(sub) + if sub.get("customer") and not account.stripe_customer_id: + account.stripe_customer_id = sub["customer"] + session.commit() + + +def fulfil(session: Session, checkout: dict) -> Account | None: + """Credit a completed checkout. Safe to call any number of times.""" + if checkout.get("payment_status") not in ("paid", "no_payment_required"): + return None # e.g. a bank debit still clearing; a later event follows + + meta = checkout.get("metadata") or {} + account = _account_for( + session, + checkout.get("client_reference_id") or meta.get("account_id"), + checkout.get("customer"), + ) + plan = pricing.get(meta.get("plan_id") or "") + if account is None or plan is None: + log.error("checkout %s: unknown account or plan %r", checkout.get("id"), meta) + return None + + # Paying is also how most people sign in: Stripe has verified nothing about + # the address, but it is where the receipt went, which is good enough to + # be the account's identity. + email = ((checkout.get("customer_details") or {}).get("email") or "").strip().lower() + if email and account.email is None: + account = accounts.adopt_email(session, account, email) + + session.add( + Purchase( + account_id=account.id, + plan_id=plan.id, + credits=plan.credits or 0, + amount_cents=checkout.get("amount_total") or plan.price_cents, + currency=checkout.get("currency") or plan.currency, + stripe_session_id=checkout["id"], + ) + ) + try: + session.flush() + except IntegrityError: + session.rollback() # already fulfilled by the other delivery path + return _account_for(session, account.id, None) + + if checkout.get("customer") and not account.stripe_customer_id: + account.stripe_customer_id = checkout["customer"] + if plan.credits: + account.purchased_credits += plan.credits + session.commit() + + if checkout.get("subscription"): + try: + sub = _plain(_stripe().Subscription.retrieve(checkout["subscription"])) + apply_subscription(session, sub) + except Exception as exc: # noqa: BLE001 - the subscription webhook will land too + log.warning("could not read subscription %s: %s", + checkout["subscription"], exc) + + # The purchase was made to get a particular video: hand it over. + job = session.get(Job, meta.get("job_id")) if meta.get("job_id") else None + if job is not None and job.account_id == account.id: + try: + billing.unlock(session, account, job) + session.commit() + except billing.PaymentRequired: + session.rollback() + + log.info("fulfilled %s: plan=%s account=%s", checkout["id"], plan.id, account.id) + return account + + +def fulfil_by_id(session: Session, session_id: str) -> Account | None: + """The browser's return trip: re-read the session from Stripe, then fulfil.""" + stripe = _stripe() + try: + checkout = _plain(stripe.checkout.Session.retrieve(session_id)) + except stripe.StripeError as exc: + log.warning("could not retrieve checkout %s: %s", session_id, exc) + return None + return fulfil(session, checkout) + + +# --------------------------------------------------------------------------- +# webhooks +# --------------------------------------------------------------------------- + +def verify_webhook(payload: bytes, signature: str | None) -> dict: + """Check Stripe's signature and return the event as plain data.""" + stripe = _stripe() + if not settings.stripe_webhook_secret: + raise PaymentsUnavailable("No webhook signing secret is configured.") + try: + stripe.Webhook.construct_event( + payload, signature or "", settings.stripe_webhook_secret + ) + except (ValueError, stripe.SignatureVerificationError) as exc: + raise PaymentError("Bad webhook signature.") from exc + return json.loads(payload) + + +def handle_event(session: Session, event: dict) -> None: + kind = event.get("type", "") + obj = (event.get("data") or {}).get("object") or {} + + if kind in ("checkout.session.completed", + "checkout.session.async_payment_succeeded"): + fulfil(session, obj) + elif kind.startswith("customer.subscription."): + apply_subscription(session, obj) diff --git a/backend/pricing.py b/backend/pricing.py index 53979e0..446868b 100644 --- a/backend/pricing.py +++ b/backend/pricing.py @@ -22,9 +22,16 @@ transcription - the model is tiny and its output is thrown away, since the subtitle text comes from the user's own book. So we price per book, cheap enough to be an impulse buy, and land the -subscription just under the general subtitling tools: +subscription just under the general subtitling tools. - free 1 book / 24h +What is free and what is paid is split by output, not by quality. The free tier +is the complete product for someone reading along at home: the .srt for +HoshiReader and an .mkv with the subtitles built in. What costs money is the +clean .mp4 made for publishing on YouTube - the one output whose whole point is +an audience, and so the one whose users can be asked to pay. A credit buys one +book with every output, and skips the free tier's 24-hour wait. + + free 1 book / 24h, srt + mkv single book $3.49 5-book pack $12.99 ($2.60/book) 20-book pack $39.99 ($2.00/book) @@ -71,21 +78,21 @@ DEFAULT_PLANS: list[Plan] = [ name="One book", credits=1, price_cents=349, - blurb="One more conversion, whenever you need it.", + blurb="One book with the YouTube video, no waiting.", ), Plan( id="pack5", name="5 books", credits=5, price_cents=1299, - blurb="$2.60 a book. Credits never expire.", + blurb="Credits never expire.", ), Plan( id="pack20", name="20 books", credits=20, price_cents=3999, - blurb="$2.00 a book. For working through a series.", + blurb="For working through a series.", ), Plan( id="unlimited", @@ -93,7 +100,7 @@ DEFAULT_PLANS: list[Plan] = [ credits=None, price_cents=1499, recurring=True, - blurb="As many books as you like. Cancel any time.", + blurb="Every book, YouTube video included. Cancel any time.", ), ] @@ -128,3 +135,10 @@ def free_tier_summary() -> str: hours = settings.free_window_hours book = "book" if n == 1 else "books" return f"{n} free {book} every {hours} hours" + + +# What each tier hands over, for the UI. Kinds match Artifact.kind. +TIER_OUTPUTS = { + "free": ["srt", "video_embedded"], + "youtube": ["srt", "video_embedded", "video"], +} diff --git a/backend/runner.py b/backend/runner.py index 2114220..4bf79e2 100644 --- a/backend/runner.py +++ b/backend/runner.py @@ -21,7 +21,7 @@ from dataclasses import dataclass from datetime import timezone from pathlib import Path -from . import languages, render +from . import billing, languages, render from .aligner import AlignRequest, aligner from .db import Artifact, Job, JobStatus, SessionLocal, utcnow from .settings import settings @@ -408,6 +408,11 @@ def run_job(job_id: str) -> None: pass _set(job_id, status=JobStatus.failed, error=str(exc)[:4000], stage="Failed", finished_at=utcnow()) + # Our failure, not theirs: a credit spent on this run goes back. + try: + billing.refund_job(job_id) + except Exception: # noqa: BLE001 + log.exception("job %s: could not refund after failure", job_id) def _stream_aligner(job_id: str, request: AlignRequest, log_path: Path) -> int: diff --git a/backend/settings.py b/backend/settings.py index ed9a9f8..8911388 100644 --- a/backend/settings.py +++ b/backend/settings.py @@ -64,6 +64,31 @@ class Settings(BaseSettings): # Off on localhost so nothing blocks you; flip on for the public release. billing_enabled: bool = False + # --- accounts & payments ----------------------------------------------- + # What customers see on the Stripe page and in the sign-in email. + site_name: str = "SubRead" + # The origin users reach this on. Sign-in links and Stripe's return URLs + # are built from it, so it must be right in production. + public_base_url: str = "http://127.0.0.1:8420" + # Send the identity cookie over HTTPS only. On for any public deployment. + cookie_secure: bool = False + + # Stripe. Leave empty and checkout reports itself unavailable rather than + # failing half way through a purchase. + stripe_secret_key: str = "" + stripe_webhook_secret: str = "" + + # Sign-in links go out over plain SMTP, so any provider works. With no + # host set the link is written to the server log instead - fine for + # localhost, and it keeps sign-in testable without an email account. + smtp_host: str = "" + smtp_port: int = 587 + smtp_user: str = "" + smtp_password: str = "" + smtp_from: str = "SubRead <login@subread.space>" + smtp_starttls: bool = True + login_link_minutes: int = 30 + # --- match check ------------------------------------------------------- # Transcribe a few short samples on upload and score them against the book, # so a mismatched pair fails in seconds instead of after a full run. @@ -86,6 +111,14 @@ class Settings(BaseSettings): # --- uploads ----------------------------------------------------------- max_upload_bytes: int = 2 * 1024 * 1024 * 1024 # 2 GiB + @property + def payments_configured(self) -> bool: + return bool(self.stripe_secret_key) + + @property + def email_configured(self) -> bool: + return bool(self.smtp_host) + @property def resolved_database_url(self) -> str: if self.database_url: diff --git a/frontend/app.js b/frontend/app.js index ffe70d7..3905319 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1,4 +1,4 @@ -/* SubPlz web UI. No framework, no build step - one file, served as-is. */ +/* SubRead web UI. No framework, no build step - one file, served as-is. */ const $ = (id) => document.getElementById(id); @@ -13,12 +13,23 @@ const el = { freetier: $('freetier'), staged: $('staged'), dzTitle: $('dz-title'), match: $('match'), matchBadge: $('match-badge'), matchSummary: $('match-summary'), matchWarnings: $('match-warnings'), + who: $('who'), buyBtn: $('buy-btn'), portalBtn: $('portal-btn'), + signinBtn: $('signin-btn'), signoutBtn: $('signout-btn'), + tiers: $('tiers'), freeNote: $('free-note'), ytNote: $('yt-note'), + ytCost: $('yt-cost'), paidTag: $('paid-tag'), + signinDialog: $('signin-dialog'), signinForm: $('signin-form'), + signinEmail: $('signin-email'), signinSend: $('signin-send'), + signinNote: $('signin-note'), + pricingDialog: $('pricing-dialog'), pricingWhy: $('pricing-why'), + plans: $('plans'), toast: $('toast'), }; let languages = []; let languagesReady = null; // resolves once the <select> is populated let draft = null; // the job awaiting confirmation let pollTimer = null; +let account = null; // last /api/account response +let unlockJobId = null; // the job a purchase is being made for /* ---------------- helpers ---------------- */ @@ -52,7 +63,9 @@ async function api(path, opts = {}) { if (body.detail) detail = typeof body.detail === 'string' ? body.detail : JSON.stringify(body.detail); } catch { /* non-JSON error body */ } - throw new Error(detail); + const err = new Error(detail); + err.status = res.status; + throw err; } return res.status === 204 ? null : res.json(); } @@ -85,30 +98,88 @@ function setLanguage(code) { updateSplitNote(); } -async function refreshQuota() { - try { - const a = await api('/api/account'); - signedIn = !!a.signed_in; - const hint = document.getElementById('cover-hint'); - if (hint) { - hint.innerHTML = signedIn - ? 'Optional: drop a jpg or png to use as the video background' - : 'Optional: drop a jpg or png to use as the video background — ' + - '<strong>sign in required</strong>'; - } - if (a.free_tier_summary && el.freetier) { - el.freetier.textContent = a.free_tier_summary.replace(/^./, (c) => c.toUpperCase()) + '.'; - } - if (!a.billing_enabled) { - el.quota.hidden = true; - return; - } - el.quota.hidden = false; - el.quota.classList.toggle('blocked', !a.allowed); - el.quota.textContent = a.allowed - ? `${a.remaining} conversion${a.remaining === 1 ? '' : 's'} left` - : 'Free conversion used — add credit to continue'; - } catch { /* quota is cosmetic; never block the UI on it */ } +async function refreshAccount() { + try { account = await api('/api/account'); } + catch { return; /* cosmetic; never block the UI on it */ } + renderAccount(); +} + +function renderAccount() { + const a = account; + if (!a) return; + signedIn = !!a.signed_in; + + const hint = document.getElementById('cover-hint'); + if (hint) { + hint.innerHTML = signedIn + ? 'Optional: drop a jpg or png to use as the video background' + : 'Optional: drop a jpg or png to use as the video background — ' + + '<strong>sign in required</strong>'; + } + if (a.free_tier_summary && el.freetier) { + el.freetier.textContent = + a.free_tier_summary.replace(/^./, (c) => c.toUpperCase()) + '.'; + } + + el.who.hidden = !signedIn; + el.who.textContent = signedIn ? a.email : ''; + el.signoutBtn.hidden = !signedIn; + el.signinBtn.hidden = signedIn || !a.email_sign_in_available; + + // With billing off (localhost) nothing is for sale and nothing is locked. + const selling = a.billing_enabled; + el.buyBtn.hidden = !selling || a.subscribed; + el.portalBtn.hidden = !(selling && a.subscribed); + el.tiers.hidden = !selling; + if (el.paidTag) el.paidTag.hidden = !selling; + + el.quota.hidden = !selling; + if (selling) { + el.quota.classList.toggle('blocked', !a.free_allowed && !a.youtube_allowed); + el.quota.textContent = a.subscribed ? 'Unlimited plan' + : a.credits > 0 ? `${a.credits} credit${a.credits === 1 ? '' : 's'}` + : a.free_allowed ? 'Free book available' + : 'Free book used'; + } + renderTiers(); +} + +/* Say what each choice will cost *this* account before they commit to it. */ +function renderTiers() { + const a = account; + if (!a || !a.billing_enabled) return; + + el.freeNote.textContent = a.subscribed || a.free_allowed ? '' + : `Used for now — next free book ${whenText(a.next_free_at)}.`; + el.ytCost.textContent = a.subscribed ? 'included' : '1 credit'; + el.ytNote.textContent = a.subscribed ? 'Included in your unlimited plan.' + : a.credits > 0 ? `You have ${a.credits} credit${a.credits === 1 ? '' : 's'}.` + : 'You have no credits yet — you can buy one on the next step.'; + + // Nothing free left: preselect the option that can actually start. + if (!a.free_allowed && !a.subscribed) { + const yt = document.querySelector('input[name=tier][value=youtube]'); + if (yt) yt.checked = true; + } +} + +function whenText(iso) { + if (!iso) return 'later'; + const d = new Date(iso); + return 'at ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + + (d.toDateString() === new Date().toDateString() ? '' : ' tomorrow'); +} + +const chosenTier = () => + (account && account.billing_enabled && + document.querySelector('input[name=tier]:checked')?.value) || 'free'; + +let toastTimer = null; +function toast(msg, ms = 6000) { + el.toast.textContent = msg; + el.toast.hidden = false; + clearTimeout(toastTimer); + toastTimer = setTimeout(() => { el.toast.hidden = true; }, ms); } /* ---------------- upload ---------------- */ @@ -377,13 +448,15 @@ el.start.addEventListener('click', async () => { await api(`/api/jobs/${draft.id}/start`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ language: el.language.value }), + body: JSON.stringify({ language: el.language.value, tier: chosenTier() }), }); resetToDrop(); refreshJobs(); - refreshQuota(); + refreshAccount(); } catch (e) { - showError(e.message); + // 402 is not an error to apologise for; it is the price list's cue. + if (e.status === 402) openPricing(e.message); + else showError(e.message); el.start.disabled = false; } }); @@ -451,6 +524,12 @@ function jobCard(j) { .map((a) => { const size = a.size_bytes ? ` <span class="dl-size">${fmtBytes(a.size_bytes)}</span>` : ''; const t = hint[a.kind] ? ` title="${escapeHtml(hint[a.kind])}"` : ''; + if (a.locked) { + // Already rendered, just not paid for: unlocking is instant. + return `<button type="button" class="dl locked" data-unlock="${j.id}" + title="Part of the YouTube tier — one credit, or the unlimited plan" + >🔒 Unlock the YouTube video (.mp4)${size}</button>`; + } return `<a class="dl ${primary.has(a.kind) ? '' : 'secondary'}"${t} href="${a.url}" download>${label[a.kind] || a.kind}${size}</a>`; }) @@ -459,6 +538,10 @@ function jobCard(j) { li.innerHTML = html; + li.querySelectorAll('[data-unlock]').forEach((btn) => { + btn.addEventListener('click', () => unlockJob(btn.dataset.unlock, btn)); + }); + if (active) { const cancel = document.createElement('button'); cancel.className = 'ghost'; @@ -468,7 +551,7 @@ function jobCard(j) { cancel.disabled = true; try { await api(`/api/jobs/${j.id}/cancel`, { method: 'POST' }); } catch (e) { showError(e.message); } - refreshJobs(); refreshQuota(); + refreshJobs(); refreshAccount(); }; li.appendChild(cancel); } @@ -580,12 +663,157 @@ el.picker.addEventListener('change', () => { el.picker.value = ''; // let the same file be chosen again after a removal }); +/* ---------------- unlock & checkout ---------------- */ + +async function unlockJob(jobId, btn) { + if (btn) btn.disabled = true; + try { + await api(`/api/jobs/${jobId}/unlock`, { method: 'POST' }); + toast('Unlocked — the YouTube video is ready to download.'); + refreshJobs(); + refreshAccount(); + } catch (e) { + if (e.status === 402) { + unlockJobId = jobId; + openPricing(e.message); + } else { + showError(e.message); + } + if (btn) btn.disabled = false; + } +} + +async function openPricing(why) { + el.pricingWhy.textContent = why || + 'The clean .mp4 for YouTube is the paid part. Everything else stays free.'; + el.plans.innerHTML = '<p class="muted">Loading…</p>'; + if (!el.pricingDialog.open) el.pricingDialog.showModal(); + + let catalogue; + try { catalogue = await api('/api/pricing'); } + catch (e) { el.plans.innerHTML = ''; showError(e.message); return; } + + el.plans.innerHTML = ''; + for (const p of catalogue.plans) { + const card = document.createElement('div'); + card.className = 'plan' + (p.id === 'pack5' ? ' featured' : ''); + const per = p.per_book_cents && p.credits > 1 + ? `<span class="plan-per">$${(p.per_book_cents / 100).toFixed(2)} a book</span>` : ''; + card.innerHTML = ` + <div class="plan-name">${escapeHtml(p.name)}</div> + <div class="plan-price">${escapeHtml(p.price_display)}${p.recurring ? '<small>/month</small>' : ''}</div> + ${per} + <p class="plan-blurb">${escapeHtml(p.blurb)}</p>`; + const buy = document.createElement('button'); + buy.className = 'primary'; + buy.textContent = catalogue.payments_available + ? (p.recurring ? 'Subscribe' : 'Buy') : 'Opening soon'; + buy.disabled = !catalogue.payments_available; + buy.onclick = () => checkout(p.id, buy); + card.appendChild(buy); + el.plans.appendChild(card); + } +} + +async function checkout(planId, btn) { + btn.disabled = true; + try { + const { url } = await api('/api/billing/checkout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ plan_id: planId, job_id: unlockJobId }), + }); + window.location.href = url; // Stripe's page; we come back via /api/billing/return + } catch (e) { + el.pricingDialog.close(); + showError(e.message); + btn.disabled = false; + } +} + +el.pricingDialog.addEventListener('close', () => { unlockJobId = null; }); +el.buyBtn.addEventListener('click', () => openPricing( + 'One credit is one book with every output, YouTube video included.')); + +el.portalBtn.addEventListener('click', async () => { + try { + const { url } = await api('/api/billing/portal', { method: 'POST' }); + window.location.href = url; + } catch (e) { showError(e.message); } +}); + +/* ---------------- sign in ---------------- */ + +el.signinBtn.addEventListener('click', () => { + el.signinNote.hidden = true; + el.signinDialog.showModal(); + el.signinEmail.focus(); +}); + +el.signinForm.addEventListener('submit', async (e) => { + e.preventDefault(); + el.signinSend.disabled = true; + try { + const r = await api('/api/auth/request', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: el.signinEmail.value }), + }); + el.signinNote.hidden = false; + if (r.dev_link) { + // Localhost has no mail server, so the server hands the link back. + el.signinNote.innerHTML = + `No mail server here — <a href="${escapeHtml(r.dev_link)}">open your sign-in link</a>.`; + } else { + el.signinNote.textContent = + `Link sent to ${r.email}. It works once and expires in 30 minutes.`; + } + } catch (err) { + el.signinNote.hidden = false; + el.signinNote.textContent = err.message; + } + el.signinSend.disabled = false; +}); + +el.signoutBtn.addEventListener('click', async () => { + try { await api('/api/auth/signout', { method: 'POST' }); } catch {} + window.location.replace('/'); +}); + +/* The sign-in link and Stripe's return trip both land here with a query + string. Act on it once, then clean the URL so a reload does not repeat it. */ +async function handleArrival() { + const q = new URLSearchParams(window.location.search); + const token = q.get('login'); + const paid = q.get('checkout'); + if (!token && !paid) return; + window.history.replaceState({}, '', '/'); + + if (token) { + try { + account = await api('/api/auth/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }); + toast(`Signed in as ${account.email}.`); + } catch (e) { showError(e.message); } + } + if (paid === 'paid') toast('Payment received — thank you. Your credits are ready.'); + if (paid === 'pending') { + toast('Payment is still being confirmed. Credits appear here as soon as it clears.', 9000); + } +} + /* ---------------- boot ---------------- */ (async function init() { languagesReady = loadLanguages(); try { await languagesReady; } catch (e) { showError(`Could not reach the server: ${e.message}`); } - refreshQuota(); + await handleArrival(); + // In sequence, not together: on a first visit each request without a cookie + // would mint its own anonymous account, and the browser keeps only one. + await refreshAccount(); refreshJobs(); })(); diff --git a/frontend/index.html b/frontend/index.html index 1f03cdf..e39d819 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,8 +3,8 @@ <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> -<title>SubPlz — Audiobook to Subtitles</title> -<link rel="stylesheet" href="/style.css?v=8"> +<title>SubRead — read along with your audiobook</title> +<link rel="stylesheet" href="/style.css?v=9"> <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> @@ -12,11 +12,18 @@ <div class="brand"> <span class="logo" aria-hidden="true">🎧</span> <div> - <h1>SubPlz</h1> + <h1>SubRead</h1> <p class="tagline">Line an audiobook up with its ebook, sentence by sentence.</p> </div> </div> - <div id="quota" class="quota" hidden></div> + <div class="account" id="account"> + <span id="quota" class="quota" hidden></span> + <span id="who" class="who" hidden></span> + <button type="button" id="buy-btn" class="ghost compact" hidden>Buy credits</button> + <button type="button" id="portal-btn" class="ghost compact" hidden>Manage plan</button> + <button type="button" id="signin-btn" class="ghost compact" hidden>Sign in</button> + <button type="button" id="signout-btn" class="ghost compact" hidden>Sign out</button> + </div> </header> <main> @@ -27,8 +34,12 @@ from a machine's guess at what it heard. </p> <ul class="uses"> - <li><strong>HoshiReader whispersync</strong> — a three-file package: audio, book, timings.</li> - <li><strong>Subtitled video</strong> — a YouTube-ready cut with the text on screen.</li> + <li><strong>HoshiReader read-along</strong> — the .srt timing file, to match + against your epub. <span class="tag free">free</span></li> + <li><strong>Local video</strong> — an .mkv with the subtitles built in, for + MPV or VLC. <span class="tag free">free</span></li> + <li><strong>YouTube video</strong> — a clean .mp4, plus the .srt to upload as + its captions. <span class="tag paid" id="paid-tag">paid</span></li> </ul> <p class="muted small" id="freetier">One free book every 24 hours.</p> </section> @@ -119,6 +130,28 @@ <ul id="match-warnings" class="match-warnings"></ul> </div> + <fieldset id="tiers" class="tiers" hidden> + <legend>What do you need?</legend> + <label class="tier"> + <input type="radio" name="tier" value="free" checked> + <span class="tier-body"> + <span class="tier-name">Read-along <span class="tag free">free</span></span> + <span class="tier-desc">Subtitles (.srt) for HoshiReader, and a video + with the subtitles built in (.mkv) for MPV or VLC.</span> + <span class="tier-note warn" id="free-note"></span> + </span> + </label> + <label class="tier"> + <input type="radio" name="tier" value="youtube"> + <span class="tier-body"> + <span class="tier-name">YouTube <span class="tag paid" id="yt-cost">1 credit</span></span> + <span class="tier-desc">Everything above, plus a clean .mp4 made for + uploading to YouTube. Starts right away — no 24-hour wait.</span> + <span class="tier-note" id="yt-note"></span> + </span> + </label> + </fieldset> + <div class="actions"> <button id="start" class="primary">Start alignment</button> <button id="discard" class="ghost">Discard</button> @@ -140,6 +173,32 @@ </p> </footer> -<script src="/app.js?v=8"></script> +<dialog id="signin-dialog" class="modal"> + <form method="dialog"><button class="modal-x" aria-label="Close">×</button></form> + <h2>Sign in</h2> + <p class="muted">We email you a one-time link. There is no password.</p> + <form id="signin-form" class="signin-form"> + <input type="email" id="signin-email" required autocomplete="email" + placeholder="you@example.com" aria-label="Email address"> + <button class="primary" id="signin-send">Email me a link</button> + </form> + <p id="signin-note" class="muted small" hidden></p> +</dialog> + +<dialog id="pricing-dialog" class="modal wide"> + <form method="dialog"><button class="modal-x" aria-label="Close">×</button></form> + <h2>Get the YouTube video</h2> + <p id="pricing-why" class="muted"></p> + <div id="plans" class="plans"></div> + <p class="muted small" id="pricing-foot"> + Paid through Stripe; we never see your card. One credit is one book with + every output, and credits never expire. Paying also creates your account, + under the email you give Stripe. + </p> +</dialog> + +<div id="toast" class="toast" hidden role="status"></div> + +<script src="/app.js?v=9"></script> </body> </html> diff --git a/frontend/style.css b/frontend/style.css index f7ddb2c..831fe42 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -277,6 +277,90 @@ button.ghost:hover { color: var(--ink); border-color: var(--muted); } .dl.secondary { background: transparent; color: var(--muted); border-color: var(--line); } .dl.secondary:hover { color: var(--ink); border-color: var(--muted); } +/* ---------- account bar ---------- */ +.account { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.who { color: var(--muted); font-size: 13px; max-width: 220px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +button.compact { padding: 6px 12px; font-size: 13px; } + +/* ---------- tiers ---------- */ +.tag { + font-size: 10.5px; font-weight: 700; letter-spacing: .06em; + text-transform: uppercase; padding: 2px 7px; border-radius: 999px; + vertical-align: 1px; white-space: nowrap; +} +.tag.free { background: var(--ok-soft); color: var(--ok); } +.tag.paid { background: var(--accent-soft); color: var(--accent); } + +.tiers { border: 0; padding: 0; margin: 20px 0 0; display: grid; gap: 10px; } +.tiers legend { padding: 0; margin-bottom: 8px; font-weight: 600; font-size: 14px; } +.tier { + display: flex; gap: 12px; align-items: flex-start; cursor: pointer; + border: 1px solid var(--line); border-radius: 11px; padding: 12px 14px; +} +.tier:hover { border-color: var(--muted); } +.tier:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); } +.tier input { margin-top: 4px; accent-color: var(--accent); } +.tier-body { display: grid; gap: 3px; min-width: 0; } +.tier-name { font-weight: 600; } +.tier-desc { color: var(--muted); font-size: 13.5px; } +.tier-note { font-size: 12.5px; color: var(--muted); } +.tier-note.warn { color: var(--warn); } +.tier-note:empty { display: none; } + +/* A download that exists but has not been paid for. */ +button.dl.locked { + background: transparent; color: var(--accent); border: 1px dashed var(--accent); +} +button.dl.locked:hover { background: var(--accent-soft); filter: none; } +button.dl.locked:disabled { opacity: .55; cursor: progress; } + +/* ---------- dialogs ---------- */ +dialog.modal { + border: 1px solid var(--line); border-radius: var(--radius); + background: var(--panel); color: var(--ink); box-shadow: var(--shadow); + padding: 26px 26px 22px; width: min(420px, calc(100vw - 32px)); +} +dialog.modal.wide { width: min(760px, calc(100vw - 32px)); } +dialog.modal::backdrop { background: rgba(10, 10, 20, .55); } +dialog.modal h2 { margin: 0 0 8px; font-size: 19px; } +.modal-x { + position: absolute; top: 10px; right: 12px; border: 0; background: none; + color: var(--muted); font-size: 24px; line-height: 1; cursor: pointer; +} +.modal-x:hover { color: var(--ink); } + +.signin-form { display: flex; gap: 8px; margin: 16px 0 10px; flex-wrap: wrap; } +.signin-form input { + flex: 1; min-width: 200px; font: inherit; padding: 9px 12px; + border-radius: 9px; border: 1px solid var(--line); + background: var(--bg); color: var(--ink); +} +.signin-form input:focus { outline: 2px solid var(--accent); outline-offset: 1px; } + +.plans { + display: grid; gap: 12px; margin: 18px 0 16px; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); +} +.plan { + border: 1px solid var(--line); border-radius: 12px; padding: 16px 14px; + display: flex; flex-direction: column; gap: 4px; +} +.plan.featured { border-color: var(--accent); } +.plan-name { font-weight: 600; } +.plan-price { font-size: 24px; font-weight: 700; letter-spacing: -.01em; } +.plan-price small { font-size: 13px; font-weight: 400; color: var(--muted); } +.plan-per { font-size: 12.5px; color: var(--ok); } +.plan-blurb { color: var(--muted); font-size: 13px; margin: 4px 0 12px; flex: 1; } +.plan button { width: 100%; } + +.toast { + position: fixed; left: 50%; bottom: 26px; transform: translateX(-50%); + background: var(--ink); color: var(--bg); padding: 11px 18px; + border-radius: 10px; box-shadow: var(--shadow); font-size: 14px; + max-width: calc(100vw - 32px); z-index: 10; +} + footer { padding-top: 34px; padding-bottom: 40px; } footer a { color: var(--accent); } .muted { color: var(--muted); } diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..e3fcd49 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest>=8 +httpx>=0.27 diff --git a/requirements.txt b/requirements.txt index 385f677..6f8778e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,9 +9,12 @@ pydantic-settings>=2.4 # fb2 is XML and epub is a zip, but mobi/azw3 need a real parser. mobi>=0.4 +# Payments. Imported lazily, so localhost runs without an API key - but it is +# small and pure Python, and leaving it optional only makes deploys fiddlier. +stripe>=13 + # --- public deployment extras (not needed on localhost) --- # redis>=5.0 # SUBPLZ_WEB_QUEUE_BACKEND=redis # rq>=1.16 # external worker processes # boto3>=1.34 # SUBPLZ_WEB_STORAGE_BACKEND=s3 # psycopg[binary]>=3 # SUBPLZ_WEB_DATABASE_URL=postgresql+psycopg://... -# stripe>=9 # billing.start_checkout diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..83be82a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,160 @@ +"""Test harness: a throwaway data dir, billing on, Stripe faked at the SDK edge. + +Settings are read once at import, so the environment has to be in place before +anything under `backend` is imported - hence doing it here, at module load. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import os +import tempfile +import time +from pathlib import Path + +_TMP = tempfile.mkdtemp(prefix="subplz-web-tests-") +os.environ.update( + SUBPLZ_WEB_DATA_DIR=_TMP, + SUBPLZ_WEB_BILLING_ENABLED="true", + SUBPLZ_WEB_STRIPE_SECRET_KEY="sk_test_dummy", + SUBPLZ_WEB_STRIPE_WEBHOOK_SECRET="whsec_test_secret", + SUBPLZ_WEB_PUBLIC_BASE_URL="https://example.test", + SUBPLZ_WEB_MATCH_CHECK="false", + SUBPLZ_WEB_SMTP_HOST="", +) + +import pytest # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + +from backend import api # noqa: E402 +from backend.db import ( # noqa: E402 + Account, Artifact, Job, JobStatus, SessionLocal, init_db, +) +from backend.main import app # noqa: E402 +from backend.storage import storage # noqa: E402 + +WEBHOOK_SECRET = "whsec_test_secret" + +init_db() + + +@pytest.fixture(autouse=True) +def _no_real_work(monkeypatch): + """Starting a job must not actually launch an alignment.""" + monkeypatch.setattr(api.queue, "enqueue", lambda job_id: None) + + +@pytest.fixture +def client(): + """A browser: keeps its cookie, so it stays one account across requests.""" + with TestClient(app, base_url="http://testserver") as c: + yield c + + +@pytest.fixture +def second_client(): + with TestClient(app, base_url="http://testserver") as c: + yield c + + +def account_id(client: TestClient) -> str: + return client.get("/api/account").json()["id"] + + +def make_job( + client: TestClient, status: JobStatus = JobStatus.draft, with_files: bool = False +) -> str: + """A job row owned by `client`, skipping the upload (which needs real audio).""" + acct = account_id(client) + with SessionLocal() as s: + job = Job( + account_id=acct, status=status, language="en", splitter="pysbd", + model="tiny", audio_filename="book.m4b", text_filename="book.epub", + ) + s.add(job) + s.commit() + job_id = job.id + + if with_files: + for kind, name in [("srt", "book.en.srt"), ("video", "book.en.mp4"), + ("video_embedded", "book.en.mkv")]: + src = Path(_TMP) / f"{job_id}-{name}" + src.write_bytes(b"x" * 10) + key = f"{job_id}/{name}" + storage.put_file(key, src) + s.add(Artifact(job_id=job_id, kind=kind, filename=name, + storage_key=key, size_bytes=10)) + s.commit() + return job_id + + +def get_account_row(account_id_: str) -> Account: + with SessionLocal() as s: + return s.get(Account, account_id_) + + +def get_job_row(job_id: str) -> Job: + with SessionLocal() as s: + return s.get(Job, job_id) + + +def signed(payload: dict, secret: str = WEBHOOK_SECRET) -> tuple[bytes, dict]: + """A webhook body plus the header Stripe would have sent with it.""" + body = json.dumps(payload).encode() + stamp = int(time.time()) + digest = hmac.new( + secret.encode(), f"{stamp}.".encode() + body, hashlib.sha256 + ).hexdigest() + return body, { + "stripe-signature": f"t={stamp},v1={digest}", + "content-type": "application/json", + } + + +def checkout_event( + session_id: str, account: str, plan: str, email: str | None = "buyer@example.com", + job_id: str = "", payment_status: str = "paid", subscription: str | None = None, + customer: str | None = "cus_test", +) -> dict: + return { + "id": f"evt_{session_id}", + "type": "checkout.session.completed", + "data": {"object": checkout_object( + session_id, account, plan, email, job_id, payment_status, + subscription, customer, + )}, + } + + +def checkout_object(session_id, account, plan, email="buyer@example.com", + job_id="", payment_status="paid", subscription=None, + customer="cus_test") -> dict: + return { + "id": session_id, + "object": "checkout.session", + "client_reference_id": account, + "customer": customer, + "customer_details": {"email": email} if email else None, + "payment_status": payment_status, + "amount_total": 1299, + "currency": "usd", + "subscription": subscription, + "metadata": {"account_id": account, "plan_id": plan, "job_id": job_id}, + } + + +def post_webhook(client: TestClient, event: dict, secret: str = WEBHOOK_SECRET): + body, headers = signed(event, secret) + return client.post("/api/billing/webhook", content=body, headers=headers) + + +class FakeStripeObject: + """Stands in for an SDK return value: not a dict, but has to_dict().""" + + def __init__(self, data: dict): + self._data = data + + def to_dict(self) -> dict: + return self._data diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..f8147d4 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,134 @@ +"""Email sign-in links.""" + +from __future__ import annotations + +from datetime import timedelta +from urllib.parse import parse_qs, urlparse + +import pytest + +from backend import mailer +from backend.db import JobStatus, LoginToken, SessionLocal, utcnow +from backend.settings import settings + +from .conftest import account_id, get_job_row, make_job + + +@pytest.fixture +def outbox(monkeypatch): + """Pretend SMTP is configured, and catch what would have been sent.""" + sent = [] + monkeypatch.setattr(settings, "smtp_host", "smtp.example.test") + monkeypatch.setattr(mailer, "send_login_link", + lambda to, url: sent.append((to, url)) or True) + return sent + + +def token_of(url: str) -> str: + return parse_qs(urlparse(url).query)["login"][0] + + +def sign_in(client, outbox, email): + assert client.post("/api/auth/request", json={"email": email}).status_code == 200 + return client.post("/api/auth/verify", json={"token": token_of(outbox[-1][1])}) + + +def test_link_signs_the_browser_in(client, outbox): + r = client.post("/api/auth/request", json={"email": " New@Example.com "}) + assert r.status_code == 200 + assert r.json() == {"sent": True, "email": "new@example.com"} # no link leaked + to, url = outbox[-1] + assert to == "new@example.com" + assert url.startswith("https://example.test/?login=") + + r = client.post("/api/auth/verify", json={"token": token_of(url)}) + assert r.status_code == 200 + assert r.json()["signed_in"] is True and r.json()["email"] == "new@example.com" + assert client.get("/api/account").json()["signed_in"] is True + + +def test_link_works_once(client, outbox): + assert sign_in(client, outbox, "once@example.com").status_code == 200 + again = client.post("/api/auth/verify", json={"token": token_of(outbox[-1][1])}) + assert again.status_code == 400 + + +def test_expired_link_is_refused(client, outbox): + client.post("/api/auth/request", json={"email": "late@example.com"}) + with SessionLocal() as s: + row = s.query(LoginToken).filter(LoginToken.email == "late@example.com").one() + row.expires_at = utcnow() - timedelta(minutes=1) + s.commit() + r = client.post("/api/auth/verify", json={"token": token_of(outbox[-1][1])}) + assert r.status_code == 400 + assert client.get("/api/account").json()["signed_in"] is False + + +def test_garbage_token_is_refused(client): + assert client.post("/api/auth/verify", json={"token": "nope"}).status_code == 400 + + +def test_bad_email_is_refused(client, outbox): + for bad in ["", "no-at-sign", "a@b", "two words@x.com"]: + assert client.post("/api/auth/request", json={"email": bad}).status_code == 400 + assert outbox == [] + + +def test_second_device_lands_in_the_same_account_and_keeps_its_work( + client, second_client, outbox +): + assert sign_in(client, outbox, "both@example.com").status_code == 200 + original = account_id(client) + + job_id = make_job(second_client, JobStatus.succeeded) + assert sign_in(second_client, outbox, "both@example.com").status_code == 200 + + assert account_id(second_client) == original + assert get_job_row(job_id).account_id == original + + +def test_sign_out_forgets_the_browser_but_not_the_account(client, outbox): + assert sign_in(client, outbox, "bye@example.com").status_code == 200 + original = account_id(client) + assert client.post("/api/auth/signout").status_code == 200 + + fresh = client.get("/api/account").json() + assert fresh["signed_in"] is False and fresh["id"] != original + + assert sign_in(client, outbox, "bye@example.com").status_code == 200 + assert account_id(client) == original + + +def test_signing_in_as_someone_else_switches_rather_than_merges(client, outbox): + assert sign_in(client, outbox, "first@example.com").status_code == 200 + first = account_id(client) + job_id = make_job(client, JobStatus.succeeded) + + assert sign_in(client, outbox, "second@example.com").status_code == 200 + assert account_id(client) != first + assert get_job_row(job_id).account_id == first # stayed with its owner + + +def test_requests_are_rate_limited(client, outbox): + codes = [ + client.post("/api/auth/request", json={"email": "flood@example.com"}).status_code + for _ in range(7) + ] + assert codes[:5] == [200] * 5 and set(codes[5:]) == {429} + + +def test_public_server_without_smtp_refuses_instead_of_leaking_the_link(client): + # billing on (public) + no SMTP host: showing the link would let anyone + # sign in as anyone. + r = client.post("/api/auth/request", json={"email": "x@example.com"}) + assert r.status_code == 503 + assert client.get("/api/account").json()["email_sign_in_available"] is False + + +def test_localhost_hands_the_link_back(client, monkeypatch): + monkeypatch.setattr(settings, "billing_enabled", False) + r = client.post("/api/auth/request", json={"email": "dev@example.com"}) + assert r.status_code == 200 and r.json()["sent"] is False + link = r.json()["dev_link"] + assert client.post("/api/auth/verify", + json={"token": token_of(link)}).json()["signed_in"] is True diff --git a/tests/test_billing.py b/tests/test_billing.py new file mode 100644 index 0000000..8e801d8 --- /dev/null +++ b/tests/test_billing.py @@ -0,0 +1,319 @@ +"""Tiers, credits, Stripe fulfilment. The rules under test: + + free srt + mkv, one book per window + youtube + the clean mp4; one credit, or the unlimited plan +""" + +from __future__ import annotations + +import time + +import stripe + +from backend import billing, runner +from backend.db import JobStatus + +from .conftest import ( + FakeStripeObject, account_id, checkout_event, checkout_object, + get_account_row, get_job_row, make_job, post_webhook, +) + + +def start(client, job_id, tier="free"): + return client.post(f"/api/jobs/{job_id}/start", json={"language": "en", "tier": tier}) + + +def buy(client, plan="pack5", session_id=None, **kw): + session_id = session_id or f"cs_{time.time_ns()}" + r = post_webhook(client, checkout_event(session_id, account_id(client), plan, **kw)) + assert r.status_code == 200, r.text + return session_id + + +# --- free tier --------------------------------------------------------------- + +def test_new_visitor_is_anonymous_with_a_free_book(client): + a = client.get("/api/account").json() + assert a["signed_in"] is False and a["email"] is None + assert a["free_allowed"] is True and a["free_remaining"] == 1 + assert a["credits"] == 0 and a["subscribed"] is False + assert a["youtube_allowed"] is False + + +def test_free_window_allows_one_book_then_asks_for_payment(client): + assert start(client, make_job(client)).status_code == 200 + + r = start(client, make_job(client)) + assert r.status_code == 402 + assert "every 24 hours" in r.json()["detail"] + + a = client.get("/api/account").json() + assert a["free_allowed"] is False and a["next_free_at"] + + +def test_failed_job_gives_the_free_slot_back(client): + job_id = make_job(client) + assert start(client, job_id).status_code == 200 + # No staged inputs, so the real runner fails it - which is the point. + runner.run_job(job_id) + assert get_job_row(job_id).status == JobStatus.failed + assert start(client, make_job(client)).status_code == 200 + + +def test_free_job_locks_only_the_youtube_video(client): + job_id = make_job(client, JobStatus.succeeded, with_files=True) + arts = {a["kind"]: a for a in client.get(f"/api/jobs/{job_id}").json()["artifacts"]} + assert arts["video"]["locked"] is True + assert arts["srt"]["locked"] is False + assert arts["video_embedded"]["locked"] is False + + assert client.get(f"/api/jobs/{job_id}/files/srt").status_code == 200 + assert client.get(f"/api/jobs/{job_id}/files/video_embedded").status_code == 200 + assert client.get(f"/api/jobs/{job_id}/files/video").status_code == 402 + + +def test_youtube_tier_needs_payment(client): + r = start(client, make_job(client), tier="youtube") + assert r.status_code == 402 + # Refused, so nothing was taken and the free book is still there. + assert client.get("/api/account").json()["free_remaining"] == 1 + + +# --- credits ----------------------------------------------------------------- + +def test_purchase_credits_the_account_and_signs_it_in(client): + buy(client, "pack5", email="Reader@Example.com") + a = client.get("/api/account").json() + assert a["credits"] == 5 + assert a["signed_in"] is True and a["email"] == "reader@example.com" + assert a["youtube_allowed"] is True + + +def test_webhook_redelivery_does_not_credit_twice(client): + sid = buy(client, "pack5", email="twice@example.com") + buy(client, "pack5", session_id=sid, email="twice@example.com") + assert client.get("/api/account").json()["credits"] == 5 + + +def test_unpaid_checkout_credits_nothing(client): + buy(client, "pack5", email="pending@example.com", payment_status="unpaid") + assert client.get("/api/account").json()["credits"] == 0 + + +def test_webhook_rejects_a_bad_signature(client): + event = checkout_event("cs_forged", account_id(client), "pack20") + assert post_webhook(client, event, secret="whsec_wrong").status_code == 400 + assert client.get("/api/account").json()["credits"] == 0 + + +def test_youtube_job_spends_a_credit_and_skips_the_free_window(client): + buy(client, "single", email="yt@example.com") + # Use up the free book first: a credit must not care. + assert start(client, make_job(client)).status_code == 200 + + job_id = make_job(client, with_files=True) + assert start(client, job_id, tier="youtube").status_code == 200 + assert client.get("/api/account").json()["credits"] == 0 + + job = client.get(f"/api/jobs/{job_id}").json() + assert job["tier"] == "youtube" + assert all(not a["locked"] for a in job["artifacts"]) + assert client.get(f"/api/jobs/{job_id}/files/video").status_code == 200 + + # And with the credit gone, the next one is refused again. + assert start(client, make_job(client), tier="youtube").status_code == 402 + + +def test_failed_youtube_job_returns_the_credit(client): + buy(client, "single", email="refund@example.com") + job_id = make_job(client) + assert start(client, job_id, tier="youtube").status_code == 200 + assert client.get("/api/account").json()["credits"] == 0 + + runner.run_job(job_id) # fails: nothing was staged + + assert client.get("/api/account").json()["credits"] == 1 + job = get_job_row(job_id) + assert job.credit_spent == 0 and job.tier == billing.FREE + + +def test_cancelled_youtube_job_returns_the_credit(client): + buy(client, "single", email="cancel@example.com") + job_id = make_job(client) + assert start(client, job_id, tier="youtube").status_code == 200 + assert client.post(f"/api/jobs/{job_id}/cancel").status_code == 200 + assert client.get("/api/account").json()["credits"] == 1 + + +def test_unlock_a_finished_free_job(client): + job_id = make_job(client, JobStatus.succeeded, with_files=True) + assert client.post(f"/api/jobs/{job_id}/unlock").status_code == 402 + + buy(client, "single", email="unlock@example.com") + r = client.post(f"/api/jobs/{job_id}/unlock") + assert r.status_code == 200 and r.json()["tier"] == "youtube" + assert client.get("/api/account").json()["credits"] == 0 + assert client.get(f"/api/jobs/{job_id}/files/video").status_code == 200 + + # Unlocking twice must not charge twice. + buy(client, "single", email="unlock@example.com") + assert client.post(f"/api/jobs/{job_id}/unlock").status_code == 200 + assert client.get("/api/account").json()["credits"] == 1 + + +def test_purchase_made_for_a_job_unlocks_it(client): + job_id = make_job(client, JobStatus.succeeded, with_files=True) + buy(client, "single", email="forjob@example.com", job_id=job_id) + assert get_job_row(job_id).tier == "youtube" + # The one credit bought was the one spent. + assert client.get("/api/account").json()["credits"] == 0 + + +def test_cannot_touch_someone_elses_job(client, second_client): + job_id = make_job(client, JobStatus.succeeded, with_files=True) + assert second_client.post(f"/api/jobs/{job_id}/unlock").status_code == 404 + assert second_client.get(f"/api/jobs/{job_id}/files/srt").status_code == 404 + r = second_client.post("/api/billing/checkout", + json={"plan_id": "single", "job_id": job_id}) + assert r.status_code == 404 + + +# --- checkout ---------------------------------------------------------------- + +def test_checkout_sends_stripe_the_right_order(client, monkeypatch): + seen = {} + + def create(**params): + seen.update(params) + return FakeStripeObject({"id": "cs_new", "url": "https://stripe.test/pay"}) + + monkeypatch.setattr(stripe.checkout.Session, "create", create) + r = client.post("/api/billing/checkout", json={"plan_id": "pack5"}) + assert r.status_code == 200 and r.json()["url"] == "https://stripe.test/pay" + + assert seen["mode"] == "payment" + assert seen["client_reference_id"] == account_id(client) + assert seen["metadata"]["plan_id"] == "pack5" + item = seen["line_items"][0]["price_data"] + assert item["unit_amount"] == 1299 and item["currency"] == "usd" + assert "recurring" not in item + assert seen["success_url"].startswith( + "https://example.test/api/billing/return?session_id={CHECKOUT_SESSION_ID}") + assert seen["customer_creation"] == "always" + + +def test_subscription_checkout_is_recurring(client, monkeypatch): + seen = {} + monkeypatch.setattr( + stripe.checkout.Session, "create", + lambda **p: seen.update(p) or FakeStripeObject({"url": "https://stripe.test/sub"}), + ) + assert client.post("/api/billing/checkout", + json={"plan_id": "unlimited"}).status_code == 200 + assert seen["mode"] == "subscription" + assert seen["line_items"][0]["price_data"]["recurring"] == {"interval": "month"} + assert seen["subscription_data"]["metadata"]["account_id"] == account_id(client) + assert "customer_creation" not in seen # not allowed in subscription mode + + +def test_unknown_plan_is_refused(client): + assert client.post("/api/billing/checkout", + json={"plan_id": "nope"}).status_code == 404 + + +def test_return_trip_fulfils_and_the_webhook_then_adds_nothing(client, monkeypatch): + acct = account_id(client) + paid = checkout_object("cs_return", acct, "pack20", email="return@example.com") + monkeypatch.setattr(stripe.checkout.Session, "retrieve", + lambda sid: FakeStripeObject(paid)) + + r = client.get("/api/billing/return?session_id=cs_return", follow_redirects=False) + assert r.status_code == 303 and r.headers["location"] == "/?checkout=paid" + assert client.get("/api/account").json()["credits"] == 20 + + assert post_webhook(client, checkout_event( + "cs_return", acct, "pack20", email="return@example.com")).status_code == 200 + assert client.get("/api/account").json()["credits"] == 20 + + +def test_return_trip_does_not_trust_an_unpaid_session(client, monkeypatch): + unpaid = checkout_object("cs_unpaid", account_id(client), "pack20", + payment_status="unpaid") + monkeypatch.setattr(stripe.checkout.Session, "retrieve", + lambda sid: FakeStripeObject(unpaid)) + r = client.get("/api/billing/return?session_id=cs_unpaid", follow_redirects=False) + assert r.headers["location"] == "/?checkout=pending" + assert client.get("/api/account").json()["credits"] == 0 + + +# --- subscription ------------------------------------------------------------ + +def subscription_event(kind, account, status, ends_in=30 * 86400, sub_id="sub_1"): + 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}, + # 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): + acct = account_id(client) + assert post_webhook(client, subscription_event("created", acct, "active")).status_code == 200 + + a = client.get("/api/account").json() + assert a["subscribed"] is True and a["youtube_allowed"] is True + assert a["subscription_ends"] + + # No window, no credits spent, mp4 unlocked. + for _ in range(3): + job_id = make_job(client, with_files=True) + assert start(client, job_id, tier="youtube").status_code == 200 + assert get_job_row(job_id).credit_spent == 0 + assert client.get(f"/api/jobs/{job_id}/files/video").status_code == 200 + + post_webhook(client, subscription_event("deleted", acct, "canceled")) + a = client.get("/api/account").json() + assert a["subscribed"] is False + assert start(client, make_job(client), tier="youtube").status_code == 402 + + +def test_subscription_past_its_paid_period_does_not_count(client): + acct = account_id(client) + post_webhook(client, subscription_event("updated", acct, "active", ends_in=-3 * 86400)) + assert client.get("/api/account").json()["subscribed"] is False + + +def test_cannot_subscribe_twice(client, monkeypatch): + post_webhook(client, subscription_event("created", account_id(client), "active")) + monkeypatch.setattr(stripe.checkout.Session, "create", + lambda **p: FakeStripeObject({"url": "x"})) + r = client.post("/api/billing/checkout", json={"plan_id": "unlimited"}) + assert r.status_code == 400 and "already" in r.json()["detail"] + + +# --- paying with an email that already has an account ------------------------ + +def test_paying_on_a_new_device_joins_the_existing_account(client, second_client): + buy(client, "pack5", email="same@example.com") + original = account_id(client) + + # Second device, anonymous, converts a book, then pays with the same email. + job_id = make_job(second_client, JobStatus.succeeded, with_files=True) + device = account_id(second_client) + assert device != original + buy(second_client, "single", email="same@example.com", job_id=job_id) + + # Its cookie now resolves to the original account, which holds everything. + a = second_client.get("/api/account").json() + assert a["id"] == original and a["email"] == "same@example.com" + assert a["credits"] == 5 # 5 + 1 bought - 1 spent unlocking the job + assert get_job_row(job_id).account_id == original + assert get_job_row(job_id).tier == "youtube" + assert get_account_row(device).merged_into == original + # ...and both browsers see the same jobs. + assert job_id in {j["id"] for j in client.get("/api/jobs").json()} diff --git a/tests/test_migration.py b/tests/test_migration.py new file mode 100644 index 0000000..d705860 --- /dev/null +++ b/tests/test_migration.py @@ -0,0 +1,81 @@ +"""A database created by the previous release must come up under this one. + +Runs in a subprocess: the engine is bound at import, and the point is to watch +a cold start against a pre-existing file. +""" + +from __future__ import annotations + +import os +import sqlite3 +import subprocess +import sys +from pathlib import Path + +# The schema as deployed before accounts and tiers existed. +OLD_SCHEMA = """ +CREATE TABLE accounts ( + id VARCHAR(64) PRIMARY KEY, device_token VARCHAR(64) NOT NULL UNIQUE, + email VARCHAR(320), stripe_customer_id VARCHAR(64), + purchased_credits INTEGER NOT NULL, created_at DATETIME NOT NULL +); +CREATE TABLE jobs ( + id VARCHAR(64) PRIMARY KEY, account_id VARCHAR(64) NOT NULL, + status VARCHAR(9) NOT NULL, language VARCHAR(16) NOT NULL, + splitter VARCHAR(16) NOT NULL, model VARCHAR(32) NOT NULL, + audio_filename VARCHAR(512) NOT NULL, text_filename VARCHAR(512) NOT NULL, + audio_parts INTEGER NOT NULL, cover_filename VARCHAR(512), + audio_bytes BIGINT NOT NULL, audio_duration_seconds FLOAT, + progress FLOAT NOT NULL, stage VARCHAR(128) NOT NULL, error TEXT, + billed INTEGER NOT NULL, created_at DATETIME NOT NULL, + started_at DATETIME, finished_at DATETIME +); +CREATE TABLE artifacts ( + id VARCHAR(64) PRIMARY KEY, job_id VARCHAR(64) NOT NULL, + kind VARCHAR(32) NOT NULL, filename VARCHAR(512) NOT NULL, + storage_key VARCHAR(1024) NOT NULL, size_bytes BIGINT NOT NULL, + created_at DATETIME NOT NULL +); +INSERT INTO accounts VALUES + ('acct_old', 'tok_old', NULL, NULL, 0, '2026-09-01 00:00:00'); +INSERT INTO jobs VALUES + ('job_old', 'acct_old', 'succeeded', 'ru', 'pysbd', 'tiny', 'a.m4b', 'a.epub', + 1, NULL, 10, 1.0, 1.0, 'Done', NULL, 1, '2026-09-01 00:00:00', NULL, NULL); +""" + +PROBE = """ +from backend.db import init_db, SessionLocal, Account, Job +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) +""" + + +def test_old_database_is_upgraded_in_place(tmp_path): + db = tmp_path / "subplz.db" + con = sqlite3.connect(db) + con.executescript(OLD_SCHEMA) + con.commit() + con.close() + + env = {**os.environ, "SUBPLZ_WEB_DATA_DIR": str(tmp_path)} + root = Path(__file__).resolve().parent.parent + out = subprocess.run([sys.executable, "-c", PROBE], cwd=root, env=env, + 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"] + + 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 {"merged_into", "subscription_id", "subscription_status", + "subscription_period_end"} <= 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. + indexes = {r[1]: r[2] for r in con.execute("PRAGMA index_list(accounts)")} + assert indexes.get("ix_accounts_email") == 1 + con.close() diff --git a/tools/set-secret.sh b/tools/set-secret.sh new file mode 100755 index 0000000..eea3589 --- /dev/null +++ b/tools/set-secret.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Put one secret into .env without it touching shell history, the process list +# or the screen. +# +# tools/set-secret.sh SUBPLZ_WEB_STRIPE_SECRET_KEY +set -euo pipefail + +key="${1:?usage: set-secret.sh VARIABLE_NAME}" +root="$(cd "$(dirname "$0")/.." && pwd)" +env_file="$root/.env" + +read -r -s -p "Value for $key (input hidden): " value +echo +[ -n "$value" ] || { echo "Empty value - nothing changed." >&2; exit 1; } + +touch "$env_file" +tmp="$(mktemp)" +grep -v "^${key}=" "$env_file" > "$tmp" || true +printf '%s=%s\n' "$key" "$value" >> "$tmp" +cat "$tmp" > "$env_file" +rm -f "$tmp" + +# The service does not run as root; keep the file readable by whoever owns the +# checkout, and by nobody else. +chown "$(stat -c %U:%G "$root")" "$env_file" 2>/dev/null || true +chmod 600 "$env_file" + +echo "$key saved. Apply it with: systemctl restart subplz-web"