Skip to content
This repository was archived by the owner on Jun 13, 2025. It is now read-only.

Commit 5553e16

Browse files
Merge branch 'main' into 828-repo-validation-throughout-upload-flow
2 parents 2ca0eb0 + 5495e13 commit 5553e16

File tree

11 files changed

+239
-17
lines changed

11 files changed

+239
-17
lines changed

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
24.10.1
1+
24.11.1

billing/tests/test_views.py

Lines changed: 174 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import time
2-
from unittest.mock import patch
2+
from datetime import datetime
3+
from unittest.mock import call, patch
34

45
import stripe
56
from django.conf import settings
@@ -142,6 +143,11 @@ def test_invoice_payment_failed_sets_owner_delinquent_true(self):
142143
"object": {
143144
"customer": self.owner.stripe_customer_id,
144145
"subscription": self.owner.stripe_subscription_id,
146+
"default_payment_method": {
147+
"card": {"brand": "visa", "last4": 1234}
148+
},
149+
"total": 24000,
150+
"hosted_invoice_url": "https://stripe.com",
145151
}
146152
},
147153
}
@@ -165,6 +171,11 @@ def test_invoice_payment_failed_sets_multiple_owners_delinquent_true(self):
165171
"object": {
166172
"customer": self.owner.stripe_customer_id,
167173
"subscription": self.owner.stripe_subscription_id,
174+
"default_payment_method": {
175+
"card": {"brand": "visa", "last4": 1234}
176+
},
177+
"total": 24000,
178+
"hosted_invoice_url": "https://stripe.com",
168179
}
169180
},
170181
}
@@ -176,6 +187,142 @@ def test_invoice_payment_failed_sets_multiple_owners_delinquent_true(self):
176187
assert self.owner.delinquent is True
177188
assert self.other_owner.delinquent is True
178189

190+
@patch("services.task.TaskService.send_email")
191+
def test_invoice_payment_failed_sends_email_to_admins(self, mocked_send_email):
192+
non_admin = OwnerFactory(email="[email protected]")
193+
admin_1 = OwnerFactory(email="[email protected]")
194+
admin_2 = OwnerFactory(email="[email protected]")
195+
self.owner.admins = [admin_1.ownerid, admin_2.ownerid]
196+
self.owner.plan_activated_users = [non_admin.ownerid]
197+
self.owner.email = "[email protected]"
198+
self.owner.save()
199+
200+
response = self._send_event(
201+
payload={
202+
"type": "invoice.payment_failed",
203+
"data": {
204+
"object": {
205+
"customer": self.owner.stripe_customer_id,
206+
"subscription": self.owner.stripe_subscription_id,
207+
"default_payment_method": {
208+
"card": {"brand": "visa", "last4": 1234}
209+
},
210+
"total": 24000,
211+
"hosted_invoice_url": "https://stripe.com",
212+
}
213+
},
214+
}
215+
)
216+
217+
self.owner.refresh_from_db()
218+
assert response.status_code == status.HTTP_204_NO_CONTENT
219+
assert self.owner.delinquent is True
220+
221+
expected_calls = [
222+
call(
223+
to_addr=self.owner.email,
224+
subject="Your Codecov payment failed",
225+
template_name="failed-payment",
226+
name=self.owner.username,
227+
amount=240,
228+
card_type="visa",
229+
last_four=1234,
230+
cta_link="https://stripe.com",
231+
date=datetime.now().strftime("%B %-d, %Y"),
232+
),
233+
call(
234+
to_addr=admin_1.email,
235+
subject="Your Codecov payment failed",
236+
template_name="failed-payment",
237+
name=admin_1.username,
238+
amount=240,
239+
card_type="visa",
240+
last_four=1234,
241+
cta_link="https://stripe.com",
242+
date=datetime.now().strftime("%B %-d, %Y"),
243+
),
244+
call(
245+
to_addr=admin_2.email,
246+
subject="Your Codecov payment failed",
247+
template_name="failed-payment",
248+
name=admin_2.username,
249+
amount=240,
250+
card_type="visa",
251+
last_four=1234,
252+
cta_link="https://stripe.com",
253+
date=datetime.now().strftime("%B %-d, %Y"),
254+
),
255+
]
256+
mocked_send_email.assert_has_calls(expected_calls)
257+
258+
@patch("services.task.TaskService.send_email")
259+
def test_invoice_payment_failed_sends_email_to_admins_no_card(
260+
self, mocked_send_email
261+
):
262+
non_admin = OwnerFactory(email="[email protected]")
263+
admin_1 = OwnerFactory(email="[email protected]")
264+
admin_2 = OwnerFactory(email="[email protected]")
265+
self.owner.admins = [admin_1.ownerid, admin_2.ownerid]
266+
self.owner.plan_activated_users = [non_admin.ownerid]
267+
self.owner.email = "[email protected]"
268+
self.owner.save()
269+
270+
response = self._send_event(
271+
payload={
272+
"type": "invoice.payment_failed",
273+
"data": {
274+
"object": {
275+
"customer": self.owner.stripe_customer_id,
276+
"subscription": self.owner.stripe_subscription_id,
277+
"default_payment_method": None,
278+
"total": 24000,
279+
"hosted_invoice_url": "https://stripe.com",
280+
}
281+
},
282+
}
283+
)
284+
285+
self.owner.refresh_from_db()
286+
assert response.status_code == status.HTTP_204_NO_CONTENT
287+
assert self.owner.delinquent is True
288+
289+
expected_calls = [
290+
call(
291+
to_addr=self.owner.email,
292+
subject="Your Codecov payment failed",
293+
template_name="failed-payment",
294+
name=self.owner.username,
295+
amount=240,
296+
card_type=None,
297+
last_four=None,
298+
cta_link="https://stripe.com",
299+
date=datetime.now().strftime("%B %-d, %Y"),
300+
),
301+
call(
302+
to_addr=admin_1.email,
303+
subject="Your Codecov payment failed",
304+
template_name="failed-payment",
305+
name=admin_1.username,
306+
amount=240,
307+
card_type=None,
308+
last_four=None,
309+
cta_link="https://stripe.com",
310+
date=datetime.now().strftime("%B %-d, %Y"),
311+
),
312+
call(
313+
to_addr=admin_2.email,
314+
subject="Your Codecov payment failed",
315+
template_name="failed-payment",
316+
name=admin_2.username,
317+
amount=240,
318+
card_type=None,
319+
last_four=None,
320+
cta_link="https://stripe.com",
321+
date=datetime.now().strftime("%B %-d, %Y"),
322+
),
323+
]
324+
mocked_send_email.assert_has_calls(expected_calls)
325+
179326
def test_customer_subscription_deleted_sets_plan_to_free(self):
180327
self.owner.plan = "users-inappy"
181328
self.owner.plan_user_count = 20
@@ -1003,29 +1150,51 @@ def test_subscription_schedule_updated_logs_changes_to_schedule(
10031150
assert self.owner.plan == original_plan
10041151
assert self.owner.plan_user_count == original_quantity
10051152

1006-
def test_checkout_session_completed_sets_stripe_customer_id(self):
1153+
def test_checkout_session_completed_sets_stripe_ids(self):
10071154
self.owner.stripe_customer_id = None
10081155
self.owner.save()
10091156

1010-
expected_id = "fhjtwoo40"
1157+
expected_customer_id = "cus_1234"
1158+
expected_subscription_id = "sub_7890"
10111159

10121160
self._send_event(
10131161
payload={
10141162
"type": "checkout.session.completed",
10151163
"data": {
10161164
"object": {
1017-
"customer": expected_id,
1165+
"customer": expected_customer_id,
10181166
"client_reference_id": str(self.owner.ownerid),
1167+
"subscription": expected_subscription_id,
10191168
}
10201169
},
10211170
}
10221171
)
10231172

10241173
self.owner.refresh_from_db()
1025-
assert self.owner.stripe_customer_id == expected_id
1174+
assert self.owner.stripe_customer_id == expected_customer_id
1175+
assert self.owner.stripe_subscription_id == expected_subscription_id
10261176

10271177
@patch("billing.views.stripe.Subscription.modify")
10281178
def test_customer_update_but_not_payment_method(self, subscription_modify_mock):
1179+
payment_method = "pm_123"
1180+
self._send_event(
1181+
payload={
1182+
"type": "customer.updated",
1183+
"data": {
1184+
"object": {
1185+
"invoice_settings": {"default_payment_method": None},
1186+
"subscriptions": {
1187+
"data": [{"default_payment_method": payment_method}]
1188+
},
1189+
}
1190+
},
1191+
}
1192+
)
1193+
1194+
subscription_modify_mock.assert_not_called()
1195+
1196+
@patch("billing.views.stripe.Subscription.modify")
1197+
def test_customer_update_but_payment_method_is_same(self, subscription_modify_mock):
10291198
payment_method = "pm_123"
10301199
self._send_event(
10311200
payload={

billing/views.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
from datetime import datetime
23
from typing import List
34

45
import stripe
@@ -12,6 +13,7 @@
1213

1314
from codecov_auth.models import Owner
1415
from plan.service import PlanService
16+
from services.task.task import TaskService
1517

1618
from .constants import StripeHTTPHeaders, StripeWebhookEvents
1719

@@ -63,6 +65,43 @@ def invoice_payment_failed(self, invoice: stripe.Invoice) -> None:
6365
owners.update(delinquent=True)
6466
self._log_updated(list(owners))
6567

68+
# Send failed payment email to all owner admins
69+
70+
admin_ids = set()
71+
for owner in owners:
72+
if owner.admins:
73+
admin_ids.update(owner.admins)
74+
75+
# Add the owner's email as well - for user owners, admins is empty.
76+
if owner.email:
77+
admin_ids.add(owner.ownerid)
78+
79+
admins: QuerySet[Owner] = Owner.objects.filter(pk__in=admin_ids)
80+
81+
task_service = TaskService()
82+
card = (
83+
invoice.default_payment_method.card
84+
if invoice.default_payment_method
85+
else None
86+
)
87+
template_vars = {
88+
"amount": invoice.total / 100,
89+
"card_type": card.brand if card else None,
90+
"last_four": card.last4 if card else None,
91+
"cta_link": invoice.hosted_invoice_url,
92+
"date": datetime.now().strftime("%B %-d, %Y"),
93+
}
94+
95+
for admin in admins:
96+
if admin.email:
97+
task_service.send_email(
98+
to_addr=admin.email,
99+
subject="Your Codecov payment failed",
100+
template_name="failed-payment",
101+
name=admin.username,
102+
**template_vars,
103+
)
104+
66105
def customer_subscription_deleted(self, subscription: stripe.Subscription) -> None:
67106
log.info(
68107
"Customer Subscription Deleted - Setting free plan and deactivating repos for stripe customer",
@@ -337,6 +376,10 @@ def customer_updated(self, customer: stripe.Customer) -> None:
337376
new_default_payment_method = customer["invoice_settings"][
338377
"default_payment_method"
339378
]
379+
380+
if new_default_payment_method is None:
381+
return
382+
340383
for subscription in customer.get("subscriptions", {}).get("data", []):
341384
if new_default_payment_method == subscription["default_payment_method"]:
342385
continue
@@ -362,6 +405,7 @@ def checkout_session_completed(
362405
)
363406
owner = Owner.objects.get(ownerid=checkout_session.client_reference_id)
364407
owner.stripe_customer_id = checkout_session.customer
408+
owner.stripe_subscription_id = checkout_session.subscription
365409
owner.save()
366410

367411
self._log_updated([owner])

codecov/settings_base.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,6 @@
9292

9393
WSGI_APPLICATION = "codecov.wsgi.application"
9494

95-
9695
# GraphQL
9796

9897
GRAPHQL_QUERY_COST_THRESHOLD = get_config(
@@ -105,6 +104,8 @@
105104

106105
GRAPHQL_RATE_LIMIT_RPM = get_config("setup", "graphql", "rate_limit_rpm", default=300)
107106

107+
GRAPHQL_INTROSPECTION_ENABLED = False
108+
108109
# Database
109110
# https://docs.djangoproject.com/en/2.1/ref/settings/#databases
110111

@@ -184,7 +185,6 @@
184185

185186
USE_TZ = True
186187

187-
188188
# Static files (CSS, JavaScript, Images)
189189
# https://docs.djangoproject.com/en/2.1/howto/static-files/
190190

@@ -308,7 +308,6 @@
308308
"gitlab", "bots", "tokenless", "key", default=GITLAB_BOT_KEY
309309
)
310310

311-
312311
GITLAB_ENTERPRISE_CLIENT_ID = get_config("gitlab_enterprise", "client_id")
313312
GITLAB_ENTERPRISE_CLIENT_SECRET = get_config("gitlab_enterprise", "client_secret")
314313
GITLAB_ENTERPRISE_REDIRECT_URI = get_config(
@@ -323,7 +322,6 @@
323322
GITLAB_ENTERPRISE_URL = get_config("gitlab_enterprise", "url")
324323
GITLAB_ENTERPRISE_API_URL = get_config("gitlab_enterprise", "api_url")
325324

326-
327325
CORS_ALLOW_HEADERS = (
328326
list(default_headers)
329327
+ ["token-type"]
@@ -344,7 +342,6 @@
344342
"setup", "http", "file_upload_max_memory_size", default=2621440
345343
)
346344

347-
348345
CORS_ALLOWED_ORIGIN_REGEXES = get_config(
349346
"setup", "api_cors_allowed_origin_regexes", default=[]
350347
)
@@ -362,7 +359,6 @@
362359

363360
HIDE_ALL_CODECOV_TOKENS = get_config("setup", "hide_all_codecov_tokens", default=False)
364361

365-
366362
SENTRY_JWT_SHARED_SECRET = get_config(
367363
"sentry", "jwt_shared_secret", default=None
368364
) or get_config("setup", "sentry", "jwt_shared_secret", default=None)

codecov/settings_dev.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,5 @@
5555
# SHELTER_SHARED_SECRET = "test-supertoken"
5656

5757
GUEST_ACCESS = True
58+
59+
GRAPHQL_INTROSPECTION_ENABLED = True

codecov/settings_staging.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,5 @@
7575
CSRF_TRUSTED_ORIGINS = [
7676
get_config("setup", "trusted_origin", default="https://*.codecov.dev")
7777
]
78+
79+
GRAPHQL_INTROSPECTION_ENABLED = True

codecov/settings_test.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,5 @@
1010
# Mock the Pub/Sub host for testing
1111
# this prevents the pubsub SDK from trying to load credentials
1212
os.environ["PUBSUB_EMULATOR_HOST"] = "localhost"
13+
14+
GRAPHQL_INTROSPECTION_ENABLED = True

graphql_api/views.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ def __exit__(self, exc_type, exc_value, exc_traceback):
188188
class AsyncGraphqlView(GraphQLAsyncView):
189189
schema = schema
190190
extensions = [QueryMetricsExtension]
191+
introspection = getattr(settings, "GRAPHQL_INTROSPECTION_ENABLED", False)
191192

192193
def get_validation_rules(
193194
self,

0 commit comments

Comments
 (0)