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

Commit 97f26ab

Browse files
feat: Send email to admins on failed payment (#947)
1 parent 168d5f2 commit 97f26ab

File tree

3 files changed

+196
-4
lines changed

3 files changed

+196
-4
lines changed

billing/tests/test_views.py

Lines changed: 148 additions & 1 deletion
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

billing/views.py

Lines changed: 39 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",

services/task/task.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -391,15 +391,21 @@ def preprocess_upload(self, repoid, commitid, report_code):
391391
).apply_async()
392392

393393
def send_email(
394-
self, ownerid, template_name: str, from_addr: str, subject: str, **kwargs
394+
self,
395+
to_addr: str,
396+
subject: str,
397+
template_name: str,
398+
from_addr: str | None = None,
399+
**kwargs,
395400
):
401+
# Templates can be found in worker/templates
396402
self._create_signature(
397403
"app.tasks.send_email.SendEmail",
398404
kwargs=dict(
399-
ownerid=ownerid,
405+
to_addr=to_addr,
406+
subject=subject,
400407
template_name=template_name,
401408
from_addr=from_addr,
402-
subject=subject,
403409
**kwargs,
404410
),
405411
).apply_async()

0 commit comments

Comments
 (0)