Skip to content

Commit e951ec1

Browse files
authored
orgs: stripe data hygiene (#17380)
* ensure we don't try to process checkout events unrelated to us * create _new_ subscription when existing subscriptions are cancelled Cancelled is a terminal state, we cannot "return" to re-enable a cancelled subscription. Must create a new one * translations * lint * implement Organization.manageable_subscription This is distinct from "active_subscription" as it determines if a user can self-service updating the subscription payment. Basically all states excepted Cancelled can be self-serviced.
1 parent dedcee4 commit e951ec1

File tree

11 files changed

+157
-20
lines changed

11 files changed

+157
-20
lines changed

dev/environment

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ SESSION_SECRET="an insecure development secret"
2020
# Use Stripe billing service with test keys for development.
2121
# See Stripe Dashboard for keys. https://dashboard.stripe.com/apikeys
2222
# See Stripe Docs for more information. https://stripe.com/docs/keys
23-
BILLING_BACKEND=warehouse.subscriptions.services.MockStripeBillingService api_base=http://stripe:12111 api_version=2020-08-27
24-
# BILLING_BACKEND=warehouse.subscriptions.services.StripeBillingService api_version=2020-08-27 publishable_key=pk_test_123 secret_key=sk_test_123 webhook_key=whsec_123
23+
BILLING_BACKEND=warehouse.subscriptions.services.MockStripeBillingService api_base=http://stripe:12111 api_version=2020-08-27 domain=localhost
24+
# BILLING_BACKEND=warehouse.subscriptions.services.StripeBillingService api_version=2020-08-27 publishable_key=pk_test_123 secret_key=sk_test_123 webhook_key=whsec_123 domain=localhost
2525

2626
CAMO_URL={request.scheme}://{request.domain}:9000/
2727
CAMO_KEY=insecurecamokey

tests/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,7 @@ def billing_service(app_config):
590590
api=stripe,
591591
publishable_key="pk_test_123",
592592
webhook_secret="whsec_123",
593+
domain="localhost",
593594
)
594595

595596

tests/unit/api/test_billing.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,59 @@
3030

3131
class TestHandleBillingWebhookEvent:
3232
# checkout.session.completed
33+
def test_handle_billing_webhook_event_checkout_complete_not_us(
34+
self, db_request, subscription_service, monkeypatch, billing_service
35+
):
36+
organization = OrganizationFactory.create()
37+
stripe_customer = StripeCustomerFactory.create()
38+
OrganizationStripeCustomerFactory.create(
39+
organization=organization, customer=stripe_customer
40+
)
41+
subscription = StripeSubscriptionFactory.create(customer=stripe_customer)
42+
OrganizationStripeSubscriptionFactory.create(
43+
organization=organization, subscription=subscription
44+
)
45+
46+
event = {
47+
"type": "checkout.session.completed",
48+
"data": {
49+
"object": {
50+
"id": "cs_test_12345",
51+
"customer": stripe_customer.customer_id,
52+
"status": "complete",
53+
"subscription": subscription.subscription_id,
54+
# Missing expected metadata tags (billing_service, domain)
55+
"metadata": {},
56+
},
57+
},
58+
}
59+
60+
checkout_session = {
61+
"id": "cs_test_12345",
62+
"customer": {
63+
"id": stripe_customer.customer_id,
64+
"email": "[email protected]",
65+
},
66+
"status": "complete",
67+
"subscription": {
68+
"id": subscription.subscription_id,
69+
"items": {
70+
"data": [{"id": "si_12345"}],
71+
},
72+
},
73+
}
74+
75+
get_checkout_session = pretend.call_recorder(lambda *a, **kw: checkout_session)
76+
monkeypatch.setattr(
77+
billing_service, "get_checkout_session", get_checkout_session
78+
)
79+
80+
billing.handle_billing_webhook_event(db_request, event)
81+
82+
assert (
83+
get_checkout_session.calls == []
84+
) # Should have stopped immediately before this call
85+
3386
def test_handle_billing_webhook_event_checkout_complete_update(
3487
self, db_request, subscription_service, monkeypatch, billing_service
3588
):
@@ -51,6 +104,10 @@ def test_handle_billing_webhook_event_checkout_complete_update(
51104
"customer": stripe_customer.customer_id,
52105
"status": "complete",
53106
"subscription": subscription.subscription_id,
107+
"metadata": {
108+
"billing_service": "pypi",
109+
"domain": "localhost",
110+
},
54111
},
55112
},
56113
}
@@ -94,6 +151,10 @@ def test_handle_billing_webhook_event_checkout_complete_add(
94151
"customer": stripe_customer.customer_id,
95152
"status": "complete",
96153
"subscription": "sub_12345",
154+
"metadata": {
155+
"billing_service": "pypi",
156+
"domain": "localhost",
157+
},
97158
},
98159
},
99160
}
@@ -131,6 +192,10 @@ def test_handle_billing_webhook_event_checkout_complete_invalid_status(
131192
"customer": "cus_1234",
132193
"status": "invalid_status",
133194
"subscription": "sub_12345",
195+
"metadata": {
196+
"billing_service": "pypi",
197+
"domain": "localhost",
198+
},
134199
},
135200
},
136201
}
@@ -149,6 +214,10 @@ def test_handle_billing_webhook_event_checkout_complete_invalid_customer(
149214
"customer": "",
150215
"status": "complete",
151216
"subscription": "sub_12345",
217+
"metadata": {
218+
"billing_service": "pypi",
219+
"domain": "localhost",
220+
},
152221
},
153222
},
154223
}
@@ -187,6 +256,10 @@ def test_handle_billing_webhook_event_checkout_complete_invalid_subscription(
187256
"customer": "cus_1234",
188257
"status": "complete",
189258
"subscription": "",
259+
"metadata": {
260+
"billing_service": "pypi",
261+
"domain": "localhost",
262+
},
190263
},
191264
},
192265
}

tests/unit/organizations/test_models.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,8 +445,38 @@ def test_active_subscription(self, db_session):
445445
organization=organization, subscription=subscription
446446
)
447447
assert organization.active_subscription is not None
448+
assert organization.manageable_subscription is not None
448449

449450
def test_active_subscription_none(self, db_session):
451+
organization = DBOrganizationFactory.create()
452+
stripe_customer = DBStripeCustomerFactory.create()
453+
DBOrganizationStripeCustomerFactory.create(
454+
organization=organization, customer=stripe_customer
455+
)
456+
subscription = DBStripeSubscriptionFactory.create(
457+
customer=stripe_customer,
458+
status="unpaid",
459+
)
460+
DBOrganizationStripeSubscriptionFactory.create(
461+
organization=organization, subscription=subscription
462+
)
463+
assert organization.active_subscription is None
464+
assert organization.manageable_subscription is not None
465+
466+
def test_manageable_subscription(self, db_session):
467+
organization = DBOrganizationFactory.create()
468+
stripe_customer = DBStripeCustomerFactory.create()
469+
DBOrganizationStripeCustomerFactory.create(
470+
organization=organization, customer=stripe_customer
471+
)
472+
subscription = DBStripeSubscriptionFactory.create(customer=stripe_customer)
473+
DBOrganizationStripeSubscriptionFactory.create(
474+
organization=organization, subscription=subscription
475+
)
476+
assert organization.active_subscription is not None
477+
assert organization.manageable_subscription is not None
478+
479+
def test_manageable_subscription_none(self, db_session):
450480
organization = DBOrganizationFactory.create()
451481
stripe_customer = DBStripeCustomerFactory.create()
452482
DBOrganizationStripeCustomerFactory.create(
@@ -460,3 +490,4 @@ def test_active_subscription_none(self, db_session):
460490
organization=organization, subscription=subscription
461491
)
462492
assert organization.active_subscription is None
493+
assert organization.manageable_subscription is None

tests/unit/subscriptions/test_services.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,13 @@ def test_basic_init(self):
5959
api=api,
6060
publishable_key="secret_to_everybody",
6161
webhook_secret="keep_it_secret_keep_it_safe",
62+
domain="tests",
6263
)
6364

6465
assert billing_service.api is api
6566
assert billing_service.publishable_key == "secret_to_everybody"
6667
assert billing_service.webhook_secret == "keep_it_secret_keep_it_safe"
68+
assert billing_service.domain == "tests"
6769

6870
def test_create_service(self):
6971
# Reload stripe to reset the global stripe.api_key to default.
@@ -77,6 +79,7 @@ def test_create_service(self):
7779
"billing.secret_key": "sk_test_123",
7880
"billing.publishable_key": "pk_test_123",
7981
"billing.webhook_key": "whsec_123",
82+
"billing.domain": "tests",
8083
}
8184
)
8285
)
@@ -87,6 +90,7 @@ def test_create_service(self):
8790
assert billing_service.api.api_key == "sk_test_123"
8891
assert billing_service.publishable_key == "pk_test_123"
8992
assert billing_service.webhook_secret == "whsec_123"
93+
assert billing_service.domain == "tests"
9094

9195

9296
class TestMockStripeBillingService:
@@ -100,11 +104,13 @@ def test_basic_init(self):
100104
api=api,
101105
publishable_key="secret_to_everybody",
102106
webhook_secret="keep_it_secret_keep_it_safe",
107+
domain="tests",
103108
)
104109

105110
assert billing_service.api is api
106111
assert billing_service.publishable_key == "secret_to_everybody"
107112
assert billing_service.webhook_secret == "keep_it_secret_keep_it_safe"
113+
assert billing_service.domain == "tests"
108114

109115
def test_create_service(self):
110116
request = pretend.stub(
@@ -420,11 +426,13 @@ def test_basic_init(self):
420426
api=api,
421427
publishable_key="secret_to_everybody",
422428
webhook_secret="keep_it_secret_keep_it_safe",
429+
domain="tests",
423430
)
424431

425432
assert billing_service.api is api
426433
assert billing_service.publishable_key == "secret_to_everybody"
427434
assert billing_service.webhook_secret == "keep_it_secret_keep_it_safe"
435+
assert billing_service.domain == "tests"
428436

429437
def test_notimplementederror(self):
430438
with pytest.raises(NotImplementedError):

warehouse/api/billing.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ def handle_billing_webhook_event(request, event):
2727
# Occurs when a Checkout Session has been successfully completed.
2828
case "checkout.session.completed":
2929
checkout_session = event["data"]["object"]
30+
if not (
31+
checkout_session["metadata"].get("billing_service") == "pypi"
32+
and checkout_session["metadata"].get("domain") == billing_service.domain
33+
):
34+
return
3035
# Get expanded checkout session object
3136
checkout_session = billing_service.get_checkout_session(
3237
checkout_session["id"],

warehouse/locale/messages.pot

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -579,12 +579,12 @@ msgid ""
579579
msgstr ""
580580

581581
#: warehouse/manage/views/__init__.py:2817
582-
#: warehouse/manage/views/organizations.py:887
582+
#: warehouse/manage/views/organizations.py:889
583583
msgid "User '${username}' already has an active invite. Please try again later."
584584
msgstr ""
585585

586586
#: warehouse/manage/views/__init__.py:2882
587-
#: warehouse/manage/views/organizations.py:952
587+
#: warehouse/manage/views/organizations.py:954
588588
msgid "Invitation sent to '${username}'"
589589
msgstr ""
590590

@@ -597,30 +597,30 @@ msgid "Invitation already expired."
597597
msgstr ""
598598

599599
#: warehouse/manage/views/__init__.py:2958
600-
#: warehouse/manage/views/organizations.py:1139
600+
#: warehouse/manage/views/organizations.py:1141
601601
msgid "Invitation revoked from '${username}'."
602602
msgstr ""
603603

604-
#: warehouse/manage/views/organizations.py:863
604+
#: warehouse/manage/views/organizations.py:865
605605
msgid "User '${username}' already has ${role_name} role for organization"
606606
msgstr ""
607607

608-
#: warehouse/manage/views/organizations.py:874
608+
#: warehouse/manage/views/organizations.py:876
609609
msgid ""
610610
"User '${username}' does not have a verified primary email address and "
611611
"cannot be added as a ${role_name} for organization"
612612
msgstr ""
613613

614-
#: warehouse/manage/views/organizations.py:1035
615-
#: warehouse/manage/views/organizations.py:1077
614+
#: warehouse/manage/views/organizations.py:1037
615+
#: warehouse/manage/views/organizations.py:1079
616616
msgid "Could not find organization invitation."
617617
msgstr ""
618618

619-
#: warehouse/manage/views/organizations.py:1045
619+
#: warehouse/manage/views/organizations.py:1047
620620
msgid "Organization invitation could not be re-sent."
621621
msgstr ""
622622

623-
#: warehouse/manage/views/organizations.py:1092
623+
#: warehouse/manage/views/organizations.py:1094
624624
msgid "Expired invitation for '${username}' deleted."
625625
msgstr ""
626626

warehouse/manage/views/organizations.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -573,8 +573,10 @@ def create_or_manage_subscription(self):
573573
if not self.request.organization_access:
574574
raise HTTPNotFound()
575575

576-
if not self.organization.subscriptions:
577-
# Create subscription if there are no existing subscription.
576+
if not self.organization.manageable_subscription:
577+
# Create subscription if there are no manageable subscription.
578+
# This occurs if no subscription exists, or all subscriptions have reached
579+
# a terminal state of Canceled.
578580
return self.create_subscription()
579581
else:
580582
# Manage subscription if there is an existing subscription.

warehouse/organizations/models.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,14 @@ def active_subscription(self):
490490
else:
491491
return None
492492

493+
@property
494+
def manageable_subscription(self):
495+
for subscription in self.subscriptions:
496+
if subscription.is_manageable:
497+
return subscription
498+
else:
499+
return None
500+
493501
def customer_name(self, site_name="PyPI"):
494502
return f"{site_name} Organization - {self.display_name} ({self.name})"
495503

warehouse/subscriptions/models.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,12 @@ def is_restricted(self):
125125
StripeSubscriptionStatus.Trialing.value,
126126
]
127127

128+
@property
129+
def is_manageable(self):
130+
return self.status not in [
131+
StripeSubscriptionStatus.Canceled.value,
132+
]
133+
128134

129135
class StripeSubscriptionProduct(db.Model):
130136
__tablename__ = "stripe_subscription_products"

0 commit comments

Comments
 (0)