tests/test_billing.py (14187 bytes)
1 """Tiers, credits, Stripe fulfilment. The rules under test: 2 3 free a job in the visitor's browser: no price, no limit, each output 4 cloud a job on this server: one credit (or a recurring plan, if one is sold) 5 """ 6 7 from __future__ import annotations 8 9 import json 10 import time 11 from dataclasses import asdict 12 13 import pytest 14 import stripe 15 16 from backend import pricing, runner 17 from backend.db import JobStatus 18 19 from .conftest import ( 20 FakeStripeObject, account_id, checkout_event, checkout_object, 21 get_account_row, get_job_row, make_job, post_webhook, 22 ) 23 24 25 def start(client, job_id): 26 """Start a job on the server: a cloud job.""" 27 return client.post(f"/api/jobs/{job_id}/start", json={"language": "en"}) 28 29 30 @pytest.fixture 31 def monthly_plan(monkeypatch): 32 """The default catalogue sells no recurring plan. An operator can add one.""" 33 plans = [asdict(p) for p in pricing.DEFAULT_PLANS] 34 plans.append({"id": "monthly", "name": "Monthly", "credits": None, "price_cents": 1500, "recurring": True}) 35 monkeypatch.setenv("SUBPLZ_WEB_PLANS_JSON", json.dumps(plans)) 36 37 38 def buy(client, plan="pack5", session_id=None, **kw): 39 session_id = session_id or f"cs_{time.time_ns()}" 40 r = post_webhook(client, checkout_event(session_id, account_id(client), plan, **kw)) 41 assert r.status_code == 200, r.text 42 return session_id 43 44 45 # --- the cloud tier ---------------------------------------------------------- 46 47 def test_new_visitor_is_anonymous_and_has_no_credits(client): 48 a = client.get("/api/account").json() 49 assert a["signed_in"] is False and a["email"] is None 50 assert a["credits"] == 0 and a["subscribed"] is False 51 assert a["cloud_allowed"] is False 52 assert "free in your browser" in a["free_tier_summary"] 53 54 55 def test_nothing_is_for_sale_until_the_cloud_is_connected(client, monkeypatch): 56 """Billing can be on while fast conversion is not yet on offer. The page 57 reads this flag, and shows no way to buy credits that would buy nothing.""" 58 from backend.settings import settings 59 assert client.get("/api/account").json()["cloud_available"] is False 60 monkeypatch.setattr(settings, "cloud_enabled", True) 61 assert client.get("/api/account").json()["cloud_available"] is True 62 63 64 def test_a_server_job_needs_a_credit(client): 65 r = start(client, make_job(client)) 66 assert r.status_code == 402 67 assert "one credit" in r.json()["detail"] and "free" in r.json()["detail"] 68 69 70 def test_each_output_of_a_job_can_be_downloaded(client): 71 job_id = make_job(client, JobStatus.succeeded, with_files=True) 72 arts = client.get(f"/api/jobs/{job_id}").json()["artifacts"] 73 assert {a["kind"] for a in arts} >= {"srt", "video", "video_embedded"} 74 assert all("locked" not in a for a in arts) 75 for kind in ("srt", "video_embedded", "video"): 76 assert client.get(f"/api/jobs/{job_id}/files/{kind}").status_code == 200 77 78 79 # --- credits ----------------------------------------------------------------- 80 81 def test_purchase_credits_the_account_and_signs_it_in(client): 82 buy(client, "pack5", email="Reader@Example.com") 83 a = client.get("/api/account").json() 84 assert a["credits"] == 5 85 assert a["signed_in"] is True and a["email"] == "reader@example.com" 86 assert a["cloud_allowed"] is True 87 88 89 def test_webhook_redelivery_does_not_credit_twice(client): 90 sid = buy(client, "pack5", email="twice@example.com") 91 buy(client, "pack5", session_id=sid, email="twice@example.com") 92 assert client.get("/api/account").json()["credits"] == 5 93 94 95 def test_unpaid_checkout_credits_nothing(client): 96 buy(client, "pack5", email="pending@example.com", payment_status="unpaid") 97 assert client.get("/api/account").json()["credits"] == 0 98 99 100 def test_webhook_rejects_a_bad_signature(client): 101 event = checkout_event("cs_forged", account_id(client), "pack20") 102 assert post_webhook(client, event, secret="whsec_wrong").status_code == 400 103 assert client.get("/api/account").json()["credits"] == 0 104 105 106 def test_a_server_job_spends_a_credit(client): 107 buy(client, "single", email="cloud@example.com") 108 job_id = make_job(client, with_files=True) 109 assert start(client, job_id).status_code == 200 110 assert client.get("/api/account").json()["credits"] == 0 111 assert client.get(f"/api/jobs/{job_id}").json()["tier"] == "cloud" 112 assert get_job_row(job_id).credit_spent == 1 113 114 # And with the credit gone, the next one is refused again. 115 assert start(client, make_job(client)).status_code == 402 116 117 118 def test_failed_server_job_returns_the_credit(client): 119 buy(client, "single", email="refund@example.com") 120 job_id = make_job(client) 121 assert start(client, job_id).status_code == 200 122 assert client.get("/api/account").json()["credits"] == 0 123 124 runner.run_job(job_id) # fails: nothing was staged 125 126 assert client.get("/api/account").json()["credits"] == 1 127 assert get_job_row(job_id).credit_spent == 0 128 129 130 def test_cancelled_server_job_returns_the_credit(client): 131 buy(client, "single", email="cancel@example.com") 132 job_id = make_job(client) 133 assert start(client, job_id).status_code == 200 134 assert client.post(f"/api/jobs/{job_id}/cancel").status_code == 200 135 assert client.get("/api/account").json()["credits"] == 1 136 137 138 def test_cannot_touch_someone_elses_job(client, second_client): 139 job_id = make_job(client, JobStatus.succeeded, with_files=True) 140 assert second_client.get(f"/api/jobs/{job_id}/files/srt").status_code == 404 141 assert second_client.post(f"/api/jobs/{job_id}/cancel").status_code == 404 142 143 144 # --- checkout ---------------------------------------------------------------- 145 146 def test_checkout_sends_stripe_the_right_order(client, monkeypatch): 147 seen = {} 148 149 def create(**params): 150 seen.update(params) 151 return FakeStripeObject({"id": "cs_new", "url": "https://stripe.test/pay"}) 152 153 monkeypatch.setattr(stripe.checkout.Session, "create", create) 154 r = client.post("/api/billing/checkout", json={"plan_id": "pack10"}) 155 assert r.status_code == 200 and r.json()["url"] == "https://stripe.test/pay" 156 157 assert seen["mode"] == "payment" 158 assert seen["client_reference_id"] == account_id(client) 159 assert seen["metadata"]["plan_id"] == "pack10" 160 item = seen["line_items"][0]["price_data"] 161 assert item["unit_amount"] == 499 and item["currency"] == "usd" 162 assert "recurring" not in item 163 assert seen["success_url"].startswith( 164 "https://example.test/api/billing/return?session_id={CHECKOUT_SESSION_ID}") 165 assert seen["customer_creation"] == "always" 166 167 168 def test_the_packs_and_the_monthly_plans_for_sale(client): 169 plans = client.get("/api/pricing").json()["plans"] 170 row = lambda p: (p["id"], p["credits"], p["price_cents"]) # noqa: E731 171 assert [row(p) for p in plans if not p["recurring"]] == [ 172 ("pack10", 10, 499), ("pack100", 100, 3999), ("pack500", 500, 17499)] 173 assert [row(p) for p in plans if p["recurring"]] == [ 174 ("month10", 10, 499), ("month30", 30, 999), ("unlimited", None, 5000)] 175 176 177 def test_bigger_packs_cost_less_a_book(client): 178 # A bigger pack must cost less a book, or nobody buys it. It must also cost 179 # more in all, or the smaller packs are pointless. 180 plans = client.get("/api/pricing").json()["plans"] 181 packs = sorted((p for p in plans if not p["recurring"]), key=lambda p: p["credits"]) 182 assert [p["credits"] for p in packs] == [10, 100, 500] 183 per_book = [p["price_cents"] / p["credits"] for p in packs] 184 assert all(small > big for small, big in zip(per_book, per_book[1:])) 185 totals = [p["price_cents"] for p in packs] 186 assert all(small < big for small, big in zip(totals, totals[1:])) 187 188 189 @pytest.mark.parametrize("plan_id", ["pack100", "pack500"]) 190 def test_a_big_pack_checks_out_at_its_price_and_credits_its_books(client, monkeypatch, plan_id): 191 plan = pricing.get(plan_id) 192 assert plan is not None 193 seen = {} 194 195 def create(**params): 196 seen.update(params) 197 return FakeStripeObject({"id": "cs_new", "url": "https://stripe.test/pay"}) 198 199 monkeypatch.setattr(stripe.checkout.Session, "create", create) 200 assert client.post("/api/billing/checkout", json={"plan_id": plan_id}).status_code == 200 201 assert seen["metadata"]["plan_id"] == plan_id 202 assert seen["line_items"][0]["price_data"]["unit_amount"] == plan.price_cents 203 204 buy(client, plan_id, email=f"{plan_id}-{time.time_ns()}@example.com") 205 assert client.get("/api/account").json()["credits"] == plan.credits 206 207 208 def test_a_retired_plan_is_not_sold_but_a_late_payment_still_credits(client): 209 # A checkout that started before the price change can finish after it. 210 # It must credit what the buyer saw on the payment page. 211 assert client.post("/api/billing/checkout", 212 json={"plan_id": "single"}).status_code == 404 213 buy(client, "single", email=f"late-{time.time_ns()}@example.com") 214 assert client.get("/api/account").json()["credits"] == 1 215 216 217 def test_subscription_checkout_is_recurring(client, monkeypatch, monthly_plan): 218 seen = {} 219 monkeypatch.setattr( 220 stripe.checkout.Session, "create", 221 lambda **p: seen.update(p) or FakeStripeObject({"url": "https://stripe.test/sub"}), 222 ) 223 assert client.post("/api/billing/checkout", 224 json={"plan_id": "monthly"}).status_code == 200 225 assert seen["mode"] == "subscription" 226 assert seen["line_items"][0]["price_data"]["recurring"] == {"interval": "month"} 227 assert seen["subscription_data"]["metadata"]["account_id"] == account_id(client) 228 assert "customer_creation" not in seen # not allowed in subscription mode 229 230 231 def test_unknown_plan_is_refused(client): 232 assert client.post("/api/billing/checkout", 233 json={"plan_id": "nope"}).status_code == 404 234 235 236 def test_return_trip_fulfils_and_the_webhook_then_adds_nothing(client, monkeypatch): 237 acct = account_id(client) 238 paid = checkout_object("cs_return", acct, "pack20", email="return@example.com") 239 monkeypatch.setattr(stripe.checkout.Session, "retrieve", 240 lambda sid: FakeStripeObject(paid)) 241 242 r = client.get("/api/billing/return?session_id=cs_return", follow_redirects=False) 243 assert r.status_code == 303 and r.headers["location"] == "/?checkout=paid" 244 assert client.get("/api/account").json()["credits"] == 20 245 246 assert post_webhook(client, checkout_event( 247 "cs_return", acct, "pack20", email="return@example.com")).status_code == 200 248 assert client.get("/api/account").json()["credits"] == 20 249 250 251 def test_return_trip_does_not_trust_an_unpaid_session(client, monkeypatch): 252 unpaid = checkout_object("cs_unpaid", account_id(client), "pack20", 253 payment_status="unpaid") 254 monkeypatch.setattr(stripe.checkout.Session, "retrieve", 255 lambda sid: FakeStripeObject(unpaid)) 256 r = client.get("/api/billing/return?session_id=cs_unpaid", follow_redirects=False) 257 assert r.headers["location"] == "/?checkout=pending" 258 assert client.get("/api/account").json()["credits"] == 0 259 260 261 # --- subscription ------------------------------------------------------------ 262 263 def subscription_event(kind, account, status, ends_in=30 * 86400, sub_id="sub_1", 264 plan_id="monthly"): 265 return { 266 "id": f"evt_{kind}_{time.time_ns()}", 267 "type": f"customer.subscription.{kind}", 268 "data": {"object": { 269 "id": sub_id, "object": "subscription", "status": status, 270 "customer": "cus_sub", "metadata": {"account_id": account, "plan_id": plan_id}, 271 # Where newer API versions put it; _period_end reads both places. 272 "items": {"data": [{"current_period_end": int(time.time()) + ends_in}]}, 273 }}, 274 } 275 276 277 def test_subscription_lifts_every_limit_then_lapses(client, monthly_plan): 278 acct = account_id(client) 279 assert post_webhook(client, subscription_event("created", acct, "active")).status_code == 200 280 281 a = client.get("/api/account").json() 282 assert a["subscribed"] is True and a["cloud_allowed"] is True 283 assert a["subscription_ends"] 284 285 # Server jobs start, and no credits are spent. 286 for _ in range(3): 287 job_id = make_job(client, with_files=True) 288 assert start(client, job_id).status_code == 200 289 assert get_job_row(job_id).credit_spent == 0 290 291 post_webhook(client, subscription_event("deleted", acct, "canceled")) 292 a = client.get("/api/account").json() 293 assert a["subscribed"] is False 294 assert start(client, make_job(client)).status_code == 402 295 296 297 def test_subscription_past_its_paid_period_does_not_count(client, monthly_plan): 298 acct = account_id(client) 299 post_webhook(client, subscription_event("updated", acct, "active", ends_in=-3 * 86400)) 300 assert client.get("/api/account").json()["subscribed"] is False 301 302 303 def test_cannot_subscribe_twice(client, monkeypatch, monthly_plan): 304 post_webhook(client, subscription_event("created", account_id(client), "active")) 305 monkeypatch.setattr(stripe.checkout.Session, "create", 306 lambda **p: FakeStripeObject({"url": "x"})) 307 r = client.post("/api/billing/checkout", json={"plan_id": "monthly"}) 308 assert r.status_code == 400 and "already" in r.json()["detail"] 309 310 311 # --- paying with an email that already has an account ------------------------ 312 313 def test_paying_on_a_new_device_joins_the_existing_account(client, second_client): 314 buy(client, "pack5", email="same@example.com") 315 original = account_id(client) 316 317 # Second device, anonymous, converts a book, then pays with the same email. 318 job_id = make_job(second_client, JobStatus.succeeded, with_files=True) 319 device = account_id(second_client) 320 assert device != original 321 buy(second_client, "single", email="same@example.com") 322 323 # Its cookie now resolves to the original account, which holds everything. 324 a = second_client.get("/api/account").json() 325 assert a["id"] == original and a["email"] == "same@example.com" 326 assert a["credits"] == 6 327 assert get_job_row(job_id).account_id == original 328 assert get_account_row(device).merged_into == original 329 # ...and both browsers see the same jobs. 330 assert job_id in {j["id"] for j in client.get("/api/jobs").json()}