commit ba4d643eca98ca3763b334f356d3bd12b085dd5a equwal <truex@equwal.com> 2026-09-21 01:40:56 -0700 Charge for where the work is done, not for what comes out A conversion in the visitor's browser costs the server nothing. It is now free without limit and gives each output, the mp4 for YouTube included. A conversion on the server's hardware takes one credit. The code is public, so the use of the operator's machines is the one thing for sale. - Remove the 24-hour free window, the YouTube tier, the unlock endpoint and the purchase made for a job. - New prices: $4.99, $16.99 for 5, $39 for 20. The default catalogue has no unlimited plan: use comes in bursts, and one heavy user costs more than the plan brings in. An operator can add a recurring plan with SUBPLZ_WEB_PLANS_JSON; the subscription code stays for that. - Add SUBPLZ_WEB_CLOUD_ENABLED. While it is false the page shows no way to buy credits, because there is nothing for them to buy yet.
.env.example | 9 +-- README.md | 61 +++++++---------- backend/api.py | 66 +++--------------- backend/billing.py | 175 +++++++++-------------------------------------- backend/db.py | 6 +- backend/payments.py | 25 ++----- backend/pricing.py | 75 +++++++++----------- backend/settings.py | 9 ++- frontend/app.js | 128 +++++----------------------------- frontend/index.html | 26 +------ frontend/style.css | 26 +------ tests/test_billing.py | 168 ++++++++++++++++++--------------------------- tests/test_local_jobs.py | 35 +++------- 13 files changed, 217 insertions(+), 592 deletions(-)
diff --git a/.env.example b/.env.example index 48b6822..7f9dae1 100644 --- a/.env.example +++ b/.env.example @@ -49,11 +49,12 @@ SUBPLZ_WEB_STORAGE_BACKEND=local # --- billing --------------------------------------------------------------- # 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. +# On: a conversion in the visitor's browser stays free, without limit. A +# conversion on this server's hardware takes one credit. SUBPLZ_WEB_BILLING_ENABLED=false -# One free book per rolling 24 hours. -SUBPLZ_WEB_FREE_CONVERSIONS=1 -SUBPLZ_WEB_FREE_WINDOW_HOURS=24 +# True when fast conversion on this server's hardware is on offer. While it is +# false the page sells nothing, whatever BILLING_ENABLED says. +SUBPLZ_WEB_CLOUD_ENABLED=false # --- accounts & payments --------------------------------------------------- # The origin users reach this on. Sign-in links and Stripe return URLs use it. diff --git a/README.md b/README.md index cbfa1dd..90f2ed7 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ from a machine's guess at what it heard. Two things to do with that: - **HoshiReader whispersync** — the `.srt` is the timing file. - **Subtitled video** — a YouTube-ready MP4 with a selectable caption track. -One free book per rolling 24 hours; see [Pricing](#pricing). +Free in the browser, without limit; see [Pricing](#pricing). --- @@ -229,7 +229,7 @@ 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` | who may do what: the free window, credits, the two tiers | +| `backend/billing.py` | who may do what: free in the browser, a credit for a server job | | `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 | @@ -310,45 +310,36 @@ mangles multipart filenames, and this client does not. ## Pricing -One free book per rolling 24 hours, then paid. The window is rolling rather than -a calendar day: the allowance returns 24 hours after the run that used it. +All of the code is public, and anyone may host it. What this site sells is the +use of its operator's machines, and nothing else. + +**Free and paid are split by where the work is done, not by what comes out.** + +| Tier | Where it runs | You get | Costs | +|---|---|---|---| +| free | the visitor's browser (or the Android app) | each output: `.srt`, `.mkv`, `.mp4`, the read-along `.epub` | nothing, without limit | +| cloud | this server's hardware: a large speech model on a GPU | the same, in minutes and not hours, from any device | one credit | | Plan | Price | Per book | |---|---|---| -| Free | — | 1 per 24h | -| One book | $3.49 | $3.49 | -| 5 books | $12.99 | $2.60 | -| 20 books | $39.99 | $2.00 | -| Unlimited monthly | $14.99 | — | - -Set against the market (2026): the direct competitors are cheap or free — -Voxlight $29.99/year (alignment runs on the user's own Mac), Storyteller and -syncabook free but self-hosted. The adjacent subtitling tools price for -*transcription* and do not transfer: Sonix is $10/hour, so a 10-hour audiobook -would be ~$100, and Happy Scribe's 120-minute $17 tier would not fit one book. -Forced alignment is far cheaper to run than transcription, because the model is -tiny and its output is thrown away. So: per book, priced as an impulse buy, with -the subscription just under Otter ($16.99) and Happy Scribe ($17). - +| One book | $4.99 | $4.99 | +| 5 books | $16.99 | $3.40 | +| 20 books | $39.00 | $1.95 | + +A book costs about $0.64 to convert on a rented GPU. Above about $5 a technical +buyer wraps a raw alignment API (ElevenLabs: $2.20 for a 10-hour book). Below +$3 the fixed card fee takes too much. There is no unlimited plan: use comes in +bursts, and one heavy user of such a plan costs more than the plan brings in. +An operator who wants a recurring plan adds one with `SUBPLZ_WEB_PLANS_JSON`. Every number is an env var — see `backend/pricing.py`. -**Free and paid are split by output, not by quality.** +A job in the browser costs the server nothing, so it is never counted and never +refused. A server job takes its credit at the start. A job that fails or is +cancelled gets the credit back. -| 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. +`SUBPLZ_WEB_CLOUD_ENABLED` says that fast conversion is on offer. While it is +false the page shows no way to buy credits, whatever `SUBPLZ_WEB_BILLING_ENABLED` +says: credits that buy nothing must not be for sale. **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. diff --git a/backend/api.py b/backend/api.py index 437e55e..60b1ee7 100644 --- a/backend/api.py +++ b/backend/api.py @@ -124,8 +124,6 @@ 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): @@ -163,7 +161,6 @@ class UploadOut(BaseModel): class StartIn(BaseModel): language: str | None = None model: str | None = None - tier: Literal["free", "youtube"] = "free" class AccountOut(BaseModel): @@ -174,19 +171,13 @@ class AccountOut(BaseModel): # 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 - free_remaining: int - free_allowed: bool - next_free_at: str | None - reason: str - # Paid tier. + # The cloud tier: conversions on this server's hardware. + cloud_available: bool credits: int subscribed: bool subscription_ends: str | None - youtube_allowed: bool + cloud_allowed: bool queue_depth: int @@ -198,7 +189,6 @@ class LocalJobIn(BaseModel): audio_duration_seconds: float | None = None text_filename: str = Field(max_length=512) language: str = Field(max_length=16) - tier: Literal["free", "youtube"] = "free" class LocalFinishIn(BaseModel): @@ -222,8 +212,6 @@ class TokenIn(BaseModel): 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: @@ -251,7 +239,6 @@ def _job_out(job: Job, arts: list[Artifact]) -> JobOut: 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 ], @@ -298,7 +285,7 @@ def get_account_info( def _account_out(session: Session, account: Account) -> AccountOut: - ent = billing.check(session, account) + ent = billing.check(account) return AccountOut( id=account.id, signed_in=account.signed_in, @@ -306,19 +293,14 @@ def _account_out(session: Session, account: Account) -> AccountOut: billing_enabled=settings.billing_enabled, payments_available=settings.payments_configured, email_sign_in_available=settings.sign_in_available, - free_allowance=ent.free_allowance, - free_window_hours=ent.window_hours, free_tier_summary=pricing.free_tier_summary(), - 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, + cloud_available=settings.cloud_enabled, + cloud_allowed=ent.cloud_allowed or not settings.billing_enabled, queue_depth=queue.depth(), ) @@ -411,10 +393,8 @@ def create_checkout( 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) + url = payments.start_checkout(session, account, plan) except payments.PaymentsUnavailable as exc: raise HTTPException(503, str(exc)) from exc except payments.PaymentError as exc: @@ -645,7 +625,7 @@ def start_job( job.model = body.model try: - billing.authorize_start(session, account, job, body.tier) + billing.authorize_start(session, account, job) except billing.PaymentRequired as exc: session.rollback() raise HTTPException(402, str(exc)) from exc @@ -702,12 +682,6 @@ def start_local_job( .first() ) if running is not None: - if body.tier == billing.YOUTUBE and running.tier != billing.YOUTUBE: - try: - billing.unlock(session, account, running) - except billing.PaymentRequired as exc: - session.rollback() - raise HTTPException(402, str(exc)) from exc running.language = body.language session.commit() return _job_out(running, []) @@ -722,7 +696,7 @@ def start_local_job( ) session.add(job) try: - billing.authorize_start(session, account, job, body.tier) + billing.authorize_start(session, account, job) except billing.PaymentRequired as exc: session.rollback() raise HTTPException(402, str(exc)) from exc @@ -864,23 +838,6 @@ 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, @@ -905,11 +862,6 @@ 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/billing.py b/backend/billing.py index 1af9775..1f4dcf7 100644 --- a/backend/billing.py +++ b/backend/billing.py @@ -1,43 +1,36 @@ """Entitlement: what an account may do right now. -Two tiers, split by what you walk away with: +The line between free and paid is where the work is done, not what comes out: - 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. + free The job runs in the visitor's browser, on the visitor's machine. It + costs this server nothing, so it has no price and no limit, and it + gives each output: subtitles, videos, the read-along book. + cloud The job runs on this server's hardware: a large speech model on a + GPU, minutes and not hours, from any device. One credit for a book, + or nothing on a recurring plan if the operator sells one. -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, so turning -billing on for the public release does not need a backfill. +All of the code is public and anyone may host it. What is sold is the use of +this operator's machines. -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". +Disabled on localhost (SUBPLZ_WEB_BILLING_ENABLED=false) so nothing gets in the +way while you use it yourself. Taking the money lives in payments.py; this file only decides who may do what. """ - from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timedelta, timezone -from sqlalchemy import func, update +from sqlalchemy import update from sqlalchemy.orm import Session -from .db import Account, Job, JobStatus, SessionLocal, utcnow +from .db import Account, Job, SessionLocal, utcnow from .settings import settings 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] +CLOUD = "cloud" +TIERS = (FREE, CLOUD) # Stripe keeps retrying a failed renewal for a while; do not lock someone out # the second their card hiccups. @@ -57,23 +50,12 @@ def _aware(when: datetime | None) -> datetime | None: @dataclass(frozen=True) class Entitlement: - # Free tier. - free_allowed: bool - free_used: int - free_allowance: int - free_remaining: int - window_hours: int - # When the next free conversion becomes available, if the window is full. - 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 youtube_allowed(self) -> bool: + def cloud_allowed(self) -> bool: return self.subscribed or self.credits > 0 @@ -84,61 +66,11 @@ def is_subscribed(account: Account) -> bool: return ends is None or ends + _GRACE > utcnow() -def _window_start() -> datetime: - return utcnow() - timedelta(hours=settings.free_window_hours) - - -def _window_filter(account_id: str): - return ( - 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 = ( - session.query(func.count(Job.id)).filter(*_window_filter(account.id)).scalar() - or 0 - ) - free = settings.free_conversions - 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( - 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 or subscribed or remaining > 0: - return build(True) - - # 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"The free tier is {free} {book} every {window} hours. " - + ( - f"Your next one unlocks at {when:%H:%M UTC on %d %b}. " - if when - else "" - ) - + "A credit converts a book right now, YouTube video included." - ), - when=when, +def check(account: Account) -> Entitlement: + return Entitlement( + credits=account.purchased_credits, + subscribed=is_subscribed(account), + subscription_ends=_aware(account.subscription_period_end), ) @@ -153,58 +85,22 @@ def _spend_credit(session: Session, account: Account) -> bool: 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}") +def authorize_start(session: Session, account: Account, job: Job) -> None: + """Charge what starting `job` costs, or raise PaymentRequired. - 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 - - -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. + Where the job runs sets its tier: a browser job is free, a server job is + a cloud job. """ - if job.tier == YOUTUBE or not settings.billing_enabled: - job.tier = YOUTUBE + job.tier = FREE if job.local else CLOUD + job.billed, job.credit_spent = 0, 0 + if job.local or not settings.billing_enabled or is_subscribed(account): 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 can_download(job: Job, kind: str) -> bool: - if kind != "video" or not settings.billing_enabled: - return True - return job.tier == YOUTUBE + if not _spend_credit(session, account): + raise PaymentRequired( + "A conversion on our servers takes one credit. " + "In your browser it is free, without limit." + ) + job.credit_spent = 1 def refund(session: Session, job: Job) -> None: @@ -217,9 +113,6 @@ def refund(session: Session, job: Job) -> None: .values(purchased_credits=Account.purchased_credits + 1) ) 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) diff --git a/backend/db.py b/backend/db.py index 245db7b..ada87d8 100644 --- a/backend/db.py +++ b/backend/db.py @@ -136,10 +136,10 @@ class Job(Base): stage: Mapped[str] = mapped_column(String(128), default="queued") error: Mapped[str | None] = mapped_column(Text, nullable=True) - # 1 while the job holds a slot in the free window. + # Not used from 2.2 on. It counted jobs in the free window of earlier versions. 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. + # "free": the job ran in the visitor's browser. "cloud": it ran on this + # server and took a credit. (Rows from before 2.2 can say "youtube".) tier: Mapped[str] = mapped_column( String(16), default="free", server_default=text("'free'") ) diff --git a/backend/payments.py b/backend/payments.py index 1939baa..b74ed93 100644 --- a/backend/payments.py +++ b/backend/payments.py @@ -27,7 +27,7 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from . import accounts, billing, pricing -from .db import Account, Job, Purchase +from .db import Account, Purchase from .settings import settings log = logging.getLogger(__name__) @@ -67,18 +67,12 @@ def _base() -> str: # 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. - """ +def start_checkout(session: Session, account: Account, plan: pricing.Plan) -> str: + """Return the Stripe-hosted payment page for `plan`.""" stripe = _stripe() if plan.recurring and billing.is_subscribed(account): - raise PaymentError("You are already on the unlimited plan.") + raise PaymentError("You are already on this plan.") price = { "currency": plan.currency, @@ -88,7 +82,7 @@ def start_checkout( if plan.recurring: price["recurring"] = {"interval": "month"} - meta = {"account_id": account.id, "plan_id": plan.id, "job_id": job_id or ""} + meta = {"account_id": account.id, "plan_id": plan.id} params: dict = { "mode": "subscription" if plan.recurring else "payment", "line_items": [{"quantity": 1, "price_data": price}], @@ -235,15 +229,6 @@ def fulfil(session: Session, checkout: dict) -> Account | None: 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 diff --git a/backend/pricing.py b/backend/pricing.py index 446868b..8f1ac90 100644 --- a/backend/pricing.py +++ b/backend/pricing.py @@ -15,39 +15,38 @@ Market as of 2026: Kapwing/Descript $24/mo Sonix $10/hour pay-as-you-go + Raw forced alignment as an API + ElevenLabs $0.22 per hour of audio: $2.20 for a 10-hour book + The adjacent tools price per *minute of transcription*, which does not transfer: -a 10-hour audiobook is 600 minutes, so it would cost ~$100 at Sonix's rate and -need Happy Scribe's $89 tier. Forced alignment is much cheaper to run than -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. - -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) - unlimited month $14.99 (under Otter/Happy Scribe, well under Veed/Kapwing) +a 10-hour audiobook is 600 minutes, so it would cost ~$100 at Sonix's rate. + +What is free and what is paid is split by where the work is done. In the +visitor's browser a conversion costs this server nothing: it is free, without +limit, with each output. On this server's hardware (a large speech model on a +GPU: minutes and not hours, from any device) a book takes one credit. The code +is public, so what is sold is the use of these machines and nothing else. + +A book costs about $0.64 to convert on a rented GPU. Above about $5 a technical +buyer wraps the ElevenLabs API; below $3 the fixed card fee takes too much. +There is no unlimited plan: use comes in bursts (a backlog, then nothing), and +one heavy user of an unlimited plan costs more than the plan brings in. + + free in the browser: no limit, each output + single book $4.99 + 5-book pack $16.99 ($3.40/book) + 20-book pack $39.00 ($1.95/book) Every number is overridable by env var; these are defaults, not decisions cast -in code. +in code. An operator who wants a recurring plan can add one with +SUBPLZ_WEB_PLANS_JSON ("recurring": true, "credits": null). """ - from __future__ import annotations import json import os from dataclasses import asdict, dataclass -from .settings import settings @dataclass(frozen=True) @@ -77,31 +76,23 @@ DEFAULT_PLANS: list[Plan] = [ id="single", name="One book", credits=1, - price_cents=349, - blurb="One book with the YouTube video, no waiting.", + price_cents=499, + blurb="One book on our GPU: minutes, from any device.", ), Plan( id="pack5", name="5 books", credits=5, - price_cents=1299, + price_cents=1699, blurb="Credits never expire.", ), Plan( id="pack20", name="20 books", credits=20, - price_cents=3999, + price_cents=3900, blurb="For working through a series.", ), - Plan( - id="unlimited", - name="Unlimited monthly", - credits=None, - price_cents=1499, - recurring=True, - blurb="Every book, YouTube video included. Cancel any time.", - ), ] @@ -131,14 +122,10 @@ def as_dicts() -> list[dict]: def free_tier_summary() -> str: - n = settings.free_conversions - hours = settings.free_window_hours - book = "book" if n == 1 else "books" - return f"{n} free {book} every {hours} hours" + return "free in your browser, without limit" -# What each tier hands over, for the UI. Kinds match Artifact.kind. -TIER_OUTPUTS = { - "free": ["srt", "video_embedded"], - "youtube": ["srt", "video_embedded", "video"], -} +# What each tier hands over, for the UI. Kinds match Artifact.kind. The tiers +# differ in where the work is done, not in what comes out. +_OUTPUTS = ["srt", "video_embedded", "video"] +TIER_OUTPUTS = {"free": _OUTPUTS, "cloud": _OUTPUTS} diff --git a/backend/settings.py b/backend/settings.py index 4583922..3fd6c78 100644 --- a/backend/settings.py +++ b/backend/settings.py @@ -58,9 +58,12 @@ class Settings(BaseSettings): database_url: str = "" # --- billing ----------------------------------------------------------- - # The public offer: one free book per rolling 24 hours, pay for more. - free_conversions: int = 1 - free_window_hours: int = 24 + # The public offer: free in the visitor's browser; a conversion on this + # server's hardware takes a credit. See billing.py. + # True when fast conversion on this server's hardware is on offer. While it + # is false the site sells nothing, whatever billing_enabled says: credits + # that buy nothing must not be for sale. + cloud_enabled: bool = False # Off on localhost so nothing blocks you; flip on for the public release. billing_enabled: bool = False diff --git a/frontend/app.js b/frontend/app.js index 7ae6a1d..77ad6f8 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -15,8 +15,6 @@ const el = { 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'), @@ -32,7 +30,6 @@ 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 ---------------- */ @@ -129,54 +126,18 @@ function renderAccount() { 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; + // What is sold is conversion on this server's hardware. Nothing is for sale + // until that is connected; in this tab each output is free. + const selling = a.billing_enabled && a.cloud_available; el.buyBtn.hidden = !selling || a.subscribed; 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; + el.quota.textContent = a.subscribed ? 'Cloud plan' + : `${a.credits} cloud credit${a.credits === 1 ? '' : 's'}`; } } -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; @@ -388,9 +349,8 @@ el.start.addEventListener('click', async () => { el.start.disabled = true; clearError(); const language = el.language.value; - const tier = chosenTier(); - // Ask first: the free window and credits are the server's to say. + // The server keeps the list of this visitor's books; a job in this tab costs nothing. let registered; try { registered = await api('/api/local/jobs', { @@ -400,19 +360,18 @@ el.start.addEventListener('click', async () => { audio_filename: draft.audio.length > 1 ? `${draft.audio[0].name} + ${draft.audio.length - 1} more` : draft.audio[0].name, audio_parts: draft.audio.length, audio_bytes: draft.audio.reduce((n, f) => n + f.size, 0), - text_filename: draft.book.name, language, tier, + text_filename: draft.book.name, language, }), }); } catch (e) { - // 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); + showError(e.message); el.start.disabled = false; return; } const { Job, Cancelled } = await import('/engine/job.js'); const job = new Job({ audio: draft.audio, book: draft.book, language, onStatus: renderWorking }); - running = { job, serverId: registered.id, tier: registered.tier, cover: draft.cover }; + running = { job, serverId: registered.id, cover: draft.cover }; el.confirm.hidden = true; el.working.hidden = false; el.results.hidden = true; @@ -485,7 +444,6 @@ function showResults(r) { function renderResultButtons() { const r = running.job.result; - const paid = !account?.billing_enabled || running.tier === 'youtube'; el.resultFiles.innerHTML = ''; const button = (label, hint, cls, onClick) => { const b = document.createElement('button'); @@ -501,10 +459,8 @@ function renderResultButtons() { } button('⬇ Video with subs built in (.mkv)', 'Subtitles inside the file, for MPV or VLC', '', (b) => makeVideo('mkv', b)); - button(paid ? '⬇ Video for YouTube (.mp4)' : '🔒 Unlock the YouTube video (.mp4)', - paid ? 'No subtitles baked in — add the .srt in YouTube Studio' - : 'Part of the YouTube tier — one credit, or the unlimited plan', - paid ? '' : 'locked', (b) => (paid ? makeVideo('mp4', b) : unlockRunning(b))); + button('⬇ Video for YouTube (.mp4)', 'No subtitles baked in — add the .srt in YouTube Studio', '', + (b) => makeVideo('mp4', b)); } async function makeEpub(btn) { @@ -534,20 +490,6 @@ async function makeVideo(kind, btn) { btn.disabled = false; } -async function unlockRunning(btn) { - btn.disabled = true; - try { - const j = await api(`/api/jobs/${running.serverId}/unlock`, { method: 'POST' }); - running.tier = j.tier; - toast('Unlocked.'); - renderResultButtons(); - refreshAccount(); - } catch (e) { - if (e.status === 402) { unlockJobId = running.serverId; openPricing(e.message); } else showError(e.message); - btn.disabled = false; - } -} - function save(file) { const a = document.createElement('a'); a.href = URL.createObjectURL(file); @@ -633,12 +575,6 @@ 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>`; }) @@ -647,10 +583,6 @@ 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'; @@ -772,29 +704,11 @@ 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; - } -} +/* ---------------- checkout ---------------- */ async function openPricing(why) { el.pricingWhy.textContent = why || - 'The clean .mp4 for YouTube is the paid part. Everything else stays free.'; + 'In this tab a conversion is free, without limit. A credit converts a book on our GPU: minutes and not hours, from any device.'; el.plans.innerHTML = '<p class="muted">Loading…</p>'; if (!el.pricingDialog.open) el.pricingDialog.showModal(); @@ -830,7 +744,7 @@ async function checkout(planId, btn) { const { url } = await api('/api/billing/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ plan_id: planId, job_id: unlockJobId }), + body: JSON.stringify({ plan_id: planId }), }); if (running) { // The finished job only exists in this tab. Pay in another one. @@ -848,9 +762,8 @@ async function checkout(planId, btn) { } } -el.pricingDialog.addEventListener('close', () => { unlockJobId = null; }); el.buyBtn.addEventListener('click', () => openPricing( - 'One credit is one book with every output, YouTube video included.')); + 'One credit converts one book on our GPU: minutes and not hours, from any device.')); el.portalBtn.addEventListener('click', async () => { try { @@ -922,15 +835,8 @@ async function handleArrival() { } } -// Back from paying in the other tab: the purchase unlocked this job server-side. -window.addEventListener('focus', async () => { - if (!running?.job.result || running.tier === 'youtube') return; - try { - const j = await api(`/api/jobs/${running.serverId}`); - if (j.tier === 'youtube') { running.tier = j.tier; renderResultButtons(); toast('Unlocked.'); } - refreshAccount(); - } catch { /* try again on the next focus */ } -}); +// Back from paying in the other tab. +window.addEventListener('focus', () => { if (running?.job.result) refreshAccount().catch(() => {}); }); /* ---------------- boot ---------------- */ diff --git a/frontend/index.html b/frontend/index.html index 71cd84a..2bc903d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -43,9 +43,9 @@ <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> + its captions. <span class="tag free">free</span></li> </ul> - <p class="muted small" id="freetier">One free book every 24 hours.</p> + <p class="muted small" id="freetier">Free in your browser, without limit.</p> </section> <!-- step 1: drop --> @@ -134,28 +134,6 @@ <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> diff --git a/frontend/style.css b/frontend/style.css index 7f362c2..101558b 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -292,37 +292,13 @@ button.dl:disabled { opacity: .6; cursor: progress; } overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } button.compact { padding: 6px 12px; font-size: 13px; } -/* ---------- tiers ---------- */ +/* ---------- tags ---------- */ .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 { diff --git a/tests/test_billing.py b/tests/test_billing.py index 8e801d8..af6585c 100644 --- a/tests/test_billing.py +++ b/tests/test_billing.py @@ -1,16 +1,19 @@ """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 + free a job in the visitor's browser: no price, no limit, each output + cloud a job on this server: one credit (or a recurring plan, if one is sold) """ from __future__ import annotations +import json import time +from dataclasses import asdict +import pytest import stripe -from backend import billing, runner +from backend import pricing, runner from backend.db import JobStatus from .conftest import ( @@ -19,8 +22,17 @@ from .conftest import ( ) -def start(client, job_id, tier="free"): - return client.post(f"/api/jobs/{job_id}/start", json={"language": "en", "tier": tier}) +def start(client, job_id): + """Start a job on the server: a cloud job.""" + return client.post(f"/api/jobs/{job_id}/start", json={"language": "en"}) + + +@pytest.fixture +def monthly_plan(monkeypatch): + """The default catalogue sells no recurring plan. An operator can add one.""" + plans = [asdict(p) for p in pricing.DEFAULT_PLANS] + plans.append({"id": "monthly", "name": "Monthly", "credits": None, "price_cents": 1500, "recurring": True}) + monkeypatch.setenv("SUBPLZ_WEB_PLANS_JSON", json.dumps(plans)) def buy(client, plan="pack5", session_id=None, **kw): @@ -30,53 +42,38 @@ def buy(client, plan="pack5", session_id=None, **kw): return session_id -# --- free tier --------------------------------------------------------------- +# --- the cloud tier ---------------------------------------------------------- -def test_new_visitor_is_anonymous_with_a_free_book(client): +def test_new_visitor_is_anonymous_and_has_no_credits(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 + assert a["cloud_allowed"] is False + assert "free in your browser" in a["free_tier_summary"] -def test_free_window_allows_one_book_then_asks_for_payment(client): - assert start(client, make_job(client)).status_code == 200 +def test_nothing_is_for_sale_until_the_cloud_is_connected(client, monkeypatch): + """Billing can be on while fast conversion is not yet on offer. The page + reads this flag, and shows no way to buy credits that would buy nothing.""" + from backend.settings import settings + assert client.get("/api/account").json()["cloud_available"] is False + monkeypatch.setattr(settings, "cloud_enabled", True) + assert client.get("/api/account").json()["cloud_available"] is True + +def test_a_server_job_needs_a_credit(client): 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 + assert "one credit" in r.json()["detail"] and "free" in r.json()["detail"] -def test_free_job_locks_only_the_youtube_video(client): +def test_each_output_of_a_job_can_be_downloaded(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 + arts = client.get(f"/api/jobs/{job_id}").json()["artifacts"] + assert {a["kind"] for a in arts} >= {"srt", "video", "video_embedded"} + assert all("locked" not in a for a in arts) + for kind in ("srt", "video_embedded", "video"): + assert client.get(f"/api/jobs/{job_id}/files/{kind}").status_code == 200 # --- credits ----------------------------------------------------------------- @@ -86,7 +83,7 @@ def test_purchase_credits_the_account_and_signs_it_in(client): 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 + assert a["cloud_allowed"] is True def test_webhook_redelivery_does_not_credit_twice(client): @@ -106,76 +103,42 @@ def test_webhook_rejects_a_bad_signature(client): 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 - +def test_a_server_job_spends_a_credit(client): + buy(client, "single", email="cloud@example.com") job_id = make_job(client, with_files=True) - assert start(client, job_id, tier="youtube").status_code == 200 + assert start(client, job_id).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 + assert client.get(f"/api/jobs/{job_id}").json()["tier"] == "cloud" + assert get_job_row(job_id).credit_spent == 1 # And with the credit gone, the next one is refused again. - assert start(client, make_job(client), tier="youtube").status_code == 402 + assert start(client, make_job(client)).status_code == 402 -def test_failed_youtube_job_returns_the_credit(client): +def test_failed_server_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 start(client, job_id).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 + assert get_job_row(job_id).credit_spent == 0 -def test_cancelled_youtube_job_returns_the_credit(client): +def test_cancelled_server_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 start(client, job_id).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 + assert second_client.post(f"/api/jobs/{job_id}/cancel").status_code == 404 # --- checkout ---------------------------------------------------------------- @@ -195,21 +158,28 @@ def test_checkout_sends_stripe_the_right_order(client, monkeypatch): 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 item["unit_amount"] == 1699 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): +def test_no_unlimited_plan_is_sold_by_default(client): + plans = client.get("/api/pricing").json()["plans"] + assert [p["id"] for p in plans] == ["single", "pack5", "pack20"] + assert [p["price_cents"] for p in plans] == [499, 1699, 3900] + assert not any(p["recurring"] for p in plans) + + +def test_subscription_checkout_is_recurring(client, monkeypatch, monthly_plan): 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 + json={"plan_id": "monthly"}).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) @@ -266,20 +236,19 @@ def test_subscription_lifts_every_limit_then_lapses(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["subscribed"] is True and a["cloud_allowed"] is True assert a["subscription_ends"] - # No window, no credits spent, mp4 unlocked. + # Server jobs start, and no credits are spent. for _ in range(3): job_id = make_job(client, with_files=True) - assert start(client, job_id, tier="youtube").status_code == 200 + assert start(client, job_id).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 + assert start(client, make_job(client)).status_code == 402 def test_subscription_past_its_paid_period_does_not_count(client): @@ -288,11 +257,11 @@ def test_subscription_past_its_paid_period_does_not_count(client): assert client.get("/api/account").json()["subscribed"] is False -def test_cannot_subscribe_twice(client, monkeypatch): +def test_cannot_subscribe_twice(client, monkeypatch, monthly_plan): 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"}) + r = client.post("/api/billing/checkout", json={"plan_id": "monthly"}) assert r.status_code == 400 and "already" in r.json()["detail"] @@ -306,14 +275,13 @@ def test_paying_on_a_new_device_joins_the_existing_account(client, second_client 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) + buy(second_client, "single", email="same@example.com") # 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 a["credits"] == 6 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_local_jobs.py b/tests/test_local_jobs.py index b5b3dae..6ca0daf 100644 --- a/tests/test_local_jobs.py +++ b/tests/test_local_jobs.py @@ -43,10 +43,13 @@ def test_free_job_runs_finishes_and_keeps_its_subtitles(client): assert job["id"] in {j["id"] for j in client.get("/api/jobs").json()} -def test_free_window_applies_to_browser_jobs_too(client): - assert begin(client).status_code == 200 - second = begin(client, audio_filename="another.m4b", audio_bytes=999) - assert second.status_code == 402 +def test_browser_jobs_have_no_limit_and_no_price(client): + buy(client, email="nolimit@example.com") + for n in range(4): + r = begin(client, audio_filename=f"book{n}.m4b", audio_bytes=1000 + n) + assert r.status_code == 200 and r.json()["tier"] == "free" + # The work was done on the visitor's machine: the credit is still there. + assert client.get("/api/account").json()["credits"] == 1 def test_reopening_the_same_book_does_not_charge_twice(client): @@ -57,28 +60,12 @@ def test_reopening_the_same_book_does_not_charge_twice(client): assert s.query(Job).filter(Job.account_id == account_id(client)).count() == 1 -def test_youtube_tier_costs_a_credit_and_a_failure_returns_it(client): - assert begin(client, tier="youtube").status_code == 402 - - buy(client) - r = begin(client, tier="youtube") - assert r.status_code == 200 and r.json()["tier"] == "youtube" - assert client.get("/api/account").json()["credits"] == 0 - +def test_a_failed_browser_job_takes_nothing(client): + buy(client, email="failed@example.com") # its own account: an email joins accounts across tests + r = begin(client) failed = client.post(f"/api/local/jobs/{r.json()['id']}/fail", json={"error": "GPU lost"}) assert failed.status_code == 200 and failed.json()["status"] == "failed" assert client.get("/api/account").json()["credits"] == 1 - # And the free book was never touched. - assert client.get("/api/account").json()["free_remaining"] == 1 - - -def test_upgrading_a_running_free_job_to_youtube(client): - job = begin(client).json() - assert begin(client, tier="youtube").status_code == 402 # no credit yet - buy(client, email="upgrade@example.com") - up = begin(client, tier="youtube") - assert up.status_code == 200 and up.json()["id"] == job["id"] and up.json()["tier"] == "youtube" - assert client.get("/api/account").json()["credits"] == 0 def test_finish_is_once_only_and_owner_only(client, second_client): @@ -101,14 +88,12 @@ def test_filename_cannot_escape_the_job_directory(client): def test_abandoned_jobs_release_what_they_held(client): job = begin(client).json() - assert begin(client, audio_filename="b.m4b", audio_bytes=2).status_code == 402 with SessionLocal() as s: row = s.get(Job, job["id"]) row.created_at = utcnow() - timedelta(hours=72) s.commit() assert api.expire_stale_local_jobs(s) >= 1 assert get_job_row(job["id"]).status == JobStatus.canceled - assert begin(client, audio_filename="b.m4b", audio_bytes=2).status_code == 200 def test_restart_does_not_queue_browser_jobs(client, monkeypatch):