Recently Written · git

subplz-web

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

Log | Files | Refs


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()}