Skip to content
This repository was archived by the owner on Jun 13, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 148 additions & 1 deletion billing/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import time
from unittest.mock import patch
from datetime import datetime
from unittest.mock import call, patch

import stripe
from django.conf import settings
Expand Down Expand Up @@ -142,6 +143,11 @@ def test_invoice_payment_failed_sets_owner_delinquent_true(self):
"object": {
"customer": self.owner.stripe_customer_id,
"subscription": self.owner.stripe_subscription_id,
"default_payment_method": {
"card": {"brand": "visa", "last4": 1234}
},
"total": 24000,
"hosted_invoice_url": "https://stripe.com",
}
},
}
Expand All @@ -165,6 +171,11 @@ def test_invoice_payment_failed_sets_multiple_owners_delinquent_true(self):
"object": {
"customer": self.owner.stripe_customer_id,
"subscription": self.owner.stripe_subscription_id,
"default_payment_method": {
"card": {"brand": "visa", "last4": 1234}
},
"total": 24000,
"hosted_invoice_url": "https://stripe.com",
}
},
}
Expand All @@ -176,6 +187,142 @@ def test_invoice_payment_failed_sets_multiple_owners_delinquent_true(self):
assert self.owner.delinquent is True
assert self.other_owner.delinquent is True

@patch("services.task.TaskService.send_email")
def test_invoice_payment_failed_sends_email_to_admins(self, mocked_send_email):
non_admin = OwnerFactory(email="[email protected]")
admin_1 = OwnerFactory(email="[email protected]")
admin_2 = OwnerFactory(email="[email protected]")
self.owner.admins = [admin_1.ownerid, admin_2.ownerid]
self.owner.plan_activated_users = [non_admin.ownerid]
self.owner.email = "[email protected]"
self.owner.save()

response = self._send_event(
payload={
"type": "invoice.payment_failed",
"data": {
"object": {
"customer": self.owner.stripe_customer_id,
"subscription": self.owner.stripe_subscription_id,
"default_payment_method": {
"card": {"brand": "visa", "last4": 1234}
},
"total": 24000,
"hosted_invoice_url": "https://stripe.com",
}
},
}
)

self.owner.refresh_from_db()
assert response.status_code == status.HTTP_204_NO_CONTENT
assert self.owner.delinquent is True

expected_calls = [
call(
to_addr=self.owner.email,
subject="Your Codecov payment failed",
template_name="failed-payment",
name=self.owner.username,
amount=240,
card_type="visa",
last_four=1234,
cta_link="https://stripe.com",
date=datetime.now().strftime("%B %-d, %Y"),
),
call(
to_addr=admin_1.email,
subject="Your Codecov payment failed",
template_name="failed-payment",
name=admin_1.username,
amount=240,
card_type="visa",
last_four=1234,
cta_link="https://stripe.com",
date=datetime.now().strftime("%B %-d, %Y"),
),
call(
to_addr=admin_2.email,
subject="Your Codecov payment failed",
template_name="failed-payment",
name=admin_2.username,
amount=240,
card_type="visa",
last_four=1234,
cta_link="https://stripe.com",
date=datetime.now().strftime("%B %-d, %Y"),
),
]
mocked_send_email.assert_has_calls(expected_calls)

@patch("services.task.TaskService.send_email")
def test_invoice_payment_failed_sends_email_to_admins_no_card(
self, mocked_send_email
):
non_admin = OwnerFactory(email="[email protected]")
admin_1 = OwnerFactory(email="[email protected]")
admin_2 = OwnerFactory(email="[email protected]")
self.owner.admins = [admin_1.ownerid, admin_2.ownerid]
self.owner.plan_activated_users = [non_admin.ownerid]
self.owner.email = "[email protected]"
self.owner.save()

response = self._send_event(
payload={
"type": "invoice.payment_failed",
"data": {
"object": {
"customer": self.owner.stripe_customer_id,
"subscription": self.owner.stripe_subscription_id,
"default_payment_method": None,
"total": 24000,
"hosted_invoice_url": "https://stripe.com",
}
},
}
)

self.owner.refresh_from_db()
assert response.status_code == status.HTTP_204_NO_CONTENT
assert self.owner.delinquent is True

expected_calls = [
call(
to_addr=self.owner.email,
subject="Your Codecov payment failed",
template_name="failed-payment",
name=self.owner.username,
amount=240,
card_type=None,
last_four=None,
cta_link="https://stripe.com",
date=datetime.now().strftime("%B %-d, %Y"),
),
call(
to_addr=admin_1.email,
subject="Your Codecov payment failed",
template_name="failed-payment",
name=admin_1.username,
amount=240,
card_type=None,
last_four=None,
cta_link="https://stripe.com",
date=datetime.now().strftime("%B %-d, %Y"),
),
call(
to_addr=admin_2.email,
subject="Your Codecov payment failed",
template_name="failed-payment",
name=admin_2.username,
amount=240,
card_type=None,
last_four=None,
cta_link="https://stripe.com",
date=datetime.now().strftime("%B %-d, %Y"),
),
]
mocked_send_email.assert_has_calls(expected_calls)

def test_customer_subscription_deleted_sets_plan_to_free(self):
self.owner.plan = "users-inappy"
self.owner.plan_user_count = 20
Expand Down
39 changes: 39 additions & 0 deletions billing/views.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
from datetime import datetime
from typing import List

import stripe
Expand All @@ -12,6 +13,7 @@

from codecov_auth.models import Owner
from plan.service import PlanService
from services.task.task import TaskService

from .constants import StripeHTTPHeaders, StripeWebhookEvents

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

# Send failed payment email to all owner admins

admin_ids = set()
for owner in owners:
if owner.admins:
admin_ids.update(owner.admins)

# Add the owner's email as well - for user owners, admins is empty.
if owner.email:
admin_ids.add(owner.ownerid)

admins: QuerySet[Owner] = Owner.objects.filter(pk__in=admin_ids)

task_service = TaskService()
card = (
invoice.default_payment_method.card
if invoice.default_payment_method
else None
)
template_vars = {
"amount": invoice.total / 100,
"card_type": card.brand if card else None,
"last_four": card.last4 if card else None,
"cta_link": invoice.hosted_invoice_url,
"date": datetime.now().strftime("%B %-d, %Y"),
}

for admin in admins:
if admin.email:
task_service.send_email(
to_addr=admin.email,
subject="Your Codecov payment failed",
template_name="failed-payment",
name=admin.username,
**template_vars,
)

def customer_subscription_deleted(self, subscription: stripe.Subscription) -> None:
log.info(
"Customer Subscription Deleted - Setting free plan and deactivating repos for stripe customer",
Expand Down
12 changes: 9 additions & 3 deletions services/task/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -391,15 +391,21 @@ def preprocess_upload(self, repoid, commitid, report_code):
).apply_async()

def send_email(
self, ownerid, template_name: str, from_addr: str, subject: str, **kwargs
self,
to_addr: str,
subject: str,
template_name: str,
from_addr: str | None = None,
**kwargs,
):
# Templates can be found in worker/templates
self._create_signature(
"app.tasks.send_email.SendEmail",
kwargs=dict(
ownerid=ownerid,
to_addr=to_addr,
subject=subject,
template_name=template_name,
from_addr=from_addr,
subject=subject,
**kwargs,
),
).apply_async()
Expand Down
Loading