tests/test_subscriptions.py (10542 bytes)
1 """Subscriptions: a plan renews each month until the customer cancels it. 2 3 capped a number of books a month. The books of one month do not carry 4 over to the next. A server job spends the daily free credit 5 first (it ends sooner), then the books of the plan, then free 6 and bought credits. 7 unlimited no credit is spent while the plan is active. 8 9 Stripe tells the server about a plan with customer.subscription.* events. A 10 new month (a new current_period_end) starts the count again. The lookup key 11 of the price names the plan, so a switch in the customer portal changes the 12 plan. The Stripe account also sells other products: a subscription that is 13 not for a plan of this site is ignored. 14 """ 15 16 from __future__ import annotations 17 18 import json 19 import time 20 21 import pytest 22 import stripe 23 24 from backend import runner 25 26 from .conftest import ( 27 FakeStripeObject, account_id, checkout_event, get_account_row, get_job_row, 28 make_job, post_webhook, 29 ) 30 31 # Its own catalogue, so that these tests do not depend on the plans for sale. 32 TEST_PLANS = [ 33 {"id": "pack10", "name": "10 books", "credits": 10, "price_cents": 499}, 34 {"id": "month10", "name": "10 books a month", "credits": 10, "price_cents": 499, 35 "recurring": True}, 36 {"id": "month30", "name": "30 books a month", "credits": 30, "price_cents": 999, 37 "recurring": True}, 38 {"id": "forever", "name": "Unlimited", "credits": None, "price_cents": 5000, 39 "recurring": True}, 40 ] 41 42 43 @pytest.fixture(autouse=True) 44 def catalogue(monkeypatch): 45 monkeypatch.setenv("SUBPLZ_WEB_PLANS_JSON", json.dumps(TEST_PLANS)) 46 47 48 def subscription_event(kind, account, plan_id, status="active", period_end=None, 49 price_key=None): 50 """`plan_id` goes in the checkout metadata; `price_key` is the lookup key 51 of the price, which the portal changes when the customer switches plans.""" 52 end = period_end or int(time.time()) + 30 * 86400 53 item = {"current_period_end": end} 54 if price_key is not None: 55 item["price"] = {"id": f"price_{price_key}", "lookup_key": price_key} 56 return { 57 "id": f"evt_{kind}_{time.time_ns()}", 58 "type": f"customer.subscription.{kind}", 59 "data": {"object": { 60 "id": f"sub_{account}", "object": "subscription", "status": status, 61 "customer": f"cus_{account}", 62 "metadata": {"account_id": account, "plan_id": plan_id}, 63 "items": {"data": [item]}, 64 }}, 65 } 66 67 68 def subscribe(client, plan_id, **kw) -> str: 69 acct = account_id(client) 70 event = subscription_event("created", acct, plan_id, **kw) 71 assert post_webhook(client, event).status_code == 200 72 return acct 73 74 75 def start(client, job_id: str): 76 return client.post(f"/api/jobs/{job_id}/start", json={"language": "en"}) 77 78 79 def account(client) -> dict: 80 return client.get("/api/account").json() 81 82 83 def test_a_capped_plan_checks_out_as_a_monthly_subscription(client, monkeypatch): 84 seen = {} 85 monkeypatch.setattr( 86 stripe.checkout.Session, "create", 87 lambda **p: seen.update(p) or FakeStripeObject({"url": "https://stripe.test/sub"}), 88 ) 89 assert client.post("/api/billing/checkout", json={"plan_id": "month10"}).status_code == 200 90 assert seen["mode"] == "subscription" 91 item = seen["line_items"][0]["price_data"] 92 assert item["recurring"] == {"interval": "month"} and item["unit_amount"] == 499 93 assert seen["subscription_data"]["metadata"]["plan_id"] == "month10" 94 95 96 def test_paying_for_a_plan_gives_the_books_of_the_month_not_a_pack(client, monkeypatch): 97 acct = account_id(client) 98 sub = subscription_event("created", acct, "month10")["data"]["object"] 99 monkeypatch.setattr(stripe.Subscription, "retrieve", lambda sid: FakeStripeObject(sub)) 100 event = checkout_event(f"cs_{time.time_ns()}", acct, "month10", 101 email=f"plan-{time.time_ns()}@example.com", subscription=sub["id"]) 102 assert post_webhook(client, event).status_code == 200 103 104 a = account(client) 105 assert a["subscribed"] is True and a["unlimited"] is False 106 assert a["plan_credits"] == 10 and a["credits"] == 10 107 assert get_account_row(acct).purchased_credits == 0 108 109 110 def test_a_capped_plan_is_spent_and_a_failed_job_gives_it_back(client): 111 subscribe(client, "month10") 112 job_id = make_job(client) 113 assert start(client, job_id).status_code == 200 114 job = get_job_row(job_id) 115 assert job.plan_credit_spent == 1 and job.credit_spent == 0 116 assert account(client)["plan_credits"] == 9 117 118 runner.run_job(job_id) # fails: nothing was staged 119 assert account(client)["plan_credits"] == 10 120 assert get_job_row(job_id).plan_credit_spent == 0 121 122 123 def test_the_books_of_a_month_do_not_carry_over(client): 124 acct = account_id(client) 125 end = int(time.time()) + 30 * 86400 126 post_webhook(client, subscription_event("created", acct, "month10", period_end=end)) 127 for _ in range(10): 128 assert start(client, make_job(client)).status_code == 200 129 assert start(client, make_job(client)).status_code == 402 130 131 # The same month, told again: nothing changes. 132 post_webhook(client, subscription_event("updated", acct, "month10", period_end=end)) 133 assert account(client)["plan_credits"] == 0 134 135 # A new month starts the count again. 136 post_webhook(client, subscription_event("updated", acct, "month10", 137 period_end=end + 30 * 86400)) 138 assert account(client)["plan_credits"] == 10 139 140 141 def test_an_unlimited_plan_spends_nothing(client): 142 subscribe(client, "forever") 143 a = account(client) 144 assert a["unlimited"] is True and a["cloud_allowed"] is True 145 for _ in range(3): 146 job_id = make_job(client) 147 assert start(client, job_id).status_code == 200 148 job = get_job_row(job_id) 149 assert job.plan_credit_spent == 0 and job.credit_spent == 0 150 151 152 def test_a_cancelled_plan_gives_nothing(client): 153 acct = subscribe(client, "month10") 154 post_webhook(client, subscription_event("deleted", acct, "month10", status="canceled")) 155 a = account(client) 156 assert a["subscribed"] is False and a["plan_credits"] == 0 157 assert start(client, make_job(client)).status_code == 402 158 159 160 def test_a_subscription_to_another_product_is_ignored(client): 161 # The same Stripe account sells other products (sbm Team). Their events 162 # reach this webhook too, and must not touch an account here. This is also 163 # why a sold plan must be retired (pricing.RETIRED_PLANS), not deleted. 164 acct = account_id(client) 165 event = subscription_event("created", acct, None, price_key="sbm-team") 166 assert post_webhook(client, event).status_code == 200 167 a = account(client) 168 assert a["subscribed"] is False and a["plan_credits"] == 0 169 170 171 def test_a_plan_checks_out_with_its_stripe_price_when_one_exists(client, monkeypatch): 172 # The portal can switch a subscription only between catalogue prices. 173 monkeypatch.setattr(stripe.Price, "list", lambda **kw: FakeStripeObject({"data": [ 174 {"id": "price_m10", "lookup_key": "month10", "unit_amount": 499, 175 "currency": "usd", "recurring": {"interval": "month"}}, 176 ]})) 177 seen = {} 178 monkeypatch.setattr( 179 stripe.checkout.Session, "create", 180 lambda **p: seen.update(p) or FakeStripeObject({"url": "https://stripe.test/sub"}), 181 ) 182 assert client.post("/api/billing/checkout", json={"plan_id": "month10"}).status_code == 200 183 assert seen["line_items"] == [{"quantity": 1, "price": "price_m10"}] 184 185 186 def test_a_stripe_price_that_does_not_match_the_plan_is_not_used(client, monkeypatch): 187 monkeypatch.setattr(stripe.Price, "list", lambda **kw: FakeStripeObject({"data": [ 188 {"id": "price_old", "lookup_key": "month10", "unit_amount": 999, 189 "currency": "usd", "recurring": {"interval": "month"}}, 190 ]})) 191 seen = {} 192 monkeypatch.setattr( 193 stripe.checkout.Session, "create", 194 lambda **p: seen.update(p) or FakeStripeObject({"url": "https://stripe.test/sub"}), 195 ) 196 assert client.post("/api/billing/checkout", json={"plan_id": "month10"}).status_code == 200 197 assert seen["line_items"][0]["price_data"]["unit_amount"] == 499 198 199 200 def test_the_plan_prices_are_made_once(monkeypatch): 201 from backend import payments 202 203 prices = [] # what Stripe has 204 205 def price_list(lookup_keys, **kw): 206 return FakeStripeObject({"data": [p for p in prices if p["lookup_key"] in lookup_keys]}) 207 208 def price_create(**kw): 209 price = {"id": f"price_{kw['lookup_key']}", "lookup_key": kw["lookup_key"], 210 "unit_amount": kw["unit_amount"], "currency": kw["currency"], 211 "recurring": kw["recurring"]} 212 prices.append(price) 213 return FakeStripeObject(price) 214 215 monkeypatch.setattr(stripe.Price, "list", price_list) 216 monkeypatch.setattr(stripe.Price, "create", price_create) 217 218 first = payments.ensure_plan_prices() 219 assert [(plan, made) for plan, _, made in first] == [ 220 ("month10", True), ("month30", True), ("forever", True)] 221 assert [p["unit_amount"] for p in prices] == [499, 999, 5000] 222 223 second = payments.ensure_plan_prices() 224 assert [made for _, _, made in second] == [False, False, False] 225 assert len(prices) == 3 226 227 228 def test_switching_plans_in_the_portal_changes_the_plan(client): 229 acct = account_id(client) 230 end = int(time.time()) + 30 * 86400 231 post_webhook(client, subscription_event("created", acct, "month10", 232 period_end=end, price_key="month10")) 233 for _ in range(8): 234 assert start(client, make_job(client)).status_code == 200 235 assert account(client)["plan_credits"] == 2 236 237 # The portal changes the price; the checkout metadata still says month10. 238 post_webhook(client, subscription_event("updated", acct, "month10", 239 period_end=end, price_key="month30")) 240 assert account(client)["plan_credits"] == 22 # 30 this month, 8 used 241 242 post_webhook(client, subscription_event("updated", acct, "month10", 243 period_end=end, price_key="forever")) 244 assert account(client)["unlimited"] is True 245 246 247 def test_a_second_plan_is_refused(client, monkeypatch): 248 subscribe(client, "month10") 249 monkeypatch.setattr(stripe.checkout.Session, "create", 250 lambda **p: FakeStripeObject({"url": "x"})) 251 r = client.post("/api/billing/checkout", json={"plan_id": "forever"}) 252 assert r.status_code == 400 and "Manage plan" in r.json()["detail"]