Recently Written · git

subplz-web

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

Log | Files | Refs


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"]