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