From f99d736a0b64beeec189bfa48ca86e103bcbc4f3 Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Wed, 18 Dec 2024 21:11:29 -0500 Subject: [PATCH] Add success-after-payment-failed email and fix card in failed-payment email --- billing/tests/test_views.py | 176 ++++++++++++++++++++++++++++++++---- billing/views.py | 41 ++++++++- 2 files changed, 198 insertions(+), 19 deletions(-) diff --git a/billing/tests/test_views.py b/billing/tests/test_views.py index 293f3d4048..d1fbcc8da8 100644 --- a/billing/tests/test_views.py +++ b/billing/tests/test_views.py @@ -39,6 +39,35 @@ def __getitem__(self, key): return getattr(self, key) +class MockCard(object): + def __init__(self): + self.brand = "visa" + self.last4 = "1234" + + def __getitem__(self, key): + return getattr(self, key) + + +class MockPaymentMethod(object): + def __init__(self, noCard=False): + if noCard: + self.card = None + return + + self.card = MockCard() + + def __getitem__(self, key): + return getattr(self, key) + + +class MockPaymentIntent(object): + def __init__(self, noCard=False): + self.payment_method = MockPaymentMethod(noCard) + + def __getitem__(self, key): + return getattr(self, key) + + class StripeWebhookHandlerTests(APITestCase): def setUp(self): self.owner = OwnerFactory( @@ -97,6 +126,8 @@ def test_invoice_payment_succeeded_sets_owner_delinquent_false(self): "object": { "customer": self.owner.stripe_customer_id, "subscription": self.owner.stripe_subscription_id, + "total": 24000, + "hosted_invoice_url": "https://stripe.com", } }, } @@ -120,6 +151,72 @@ def test_invoice_payment_succeeded_sets_multiple_owners_delinquent_false(self): "object": { "customer": self.owner.stripe_customer_id, "subscription": self.owner.stripe_subscription_id, + "total": 24000, + "hosted_invoice_url": "https://stripe.com", + } + }, + } + ) + + self.owner.refresh_from_db() + self.other_owner.refresh_from_db() + assert response.status_code == status.HTTP_204_NO_CONTENT + assert self.owner.delinquent is False + assert self.other_owner.delinquent is False + + @patch("services.task.TaskService.send_email") + def test_invoice_payment_succeeded_emails_only_emails_delinquents( + self, + mocked_send_email, + ): + self.add_second_owner() + self.owner.delinquent = False + self.owner.save() + + response = self._send_event( + payload={ + "type": "invoice.payment_succeeded", + "data": { + "object": { + "customer": self.owner.stripe_customer_id, + "subscription": self.owner.stripe_subscription_id, + "total": 24000, + "hosted_invoice_url": "https://stripe.com", + } + }, + } + ) + + self.owner.refresh_from_db() + self.other_owner.refresh_from_db() + assert response.status_code == status.HTTP_204_NO_CONTENT + assert self.owner.delinquent is False + + mocked_send_email.assert_not_called() + + @patch("services.task.TaskService.send_email") + def test_invoice_payment_succeeded_emails_delinquents(self, mocked_send_email): + non_admin = OwnerFactory(email="non-admin@codecov.io") + admin_1 = OwnerFactory(email="admin1@codecov.io") + admin_2 = OwnerFactory(email="admin2@codecov.io") + self.owner.admins = [admin_1.ownerid, admin_2.ownerid] + self.owner.plan_activated_users = [non_admin.ownerid] + self.owner.email = "owner@codecov.io" + self.owner.delinquent = True + self.owner.save() + self.add_second_owner() + self.other_owner.delinquent = False + self.other_owner.save() + + response = self._send_event( + payload={ + "type": "invoice.payment_succeeded", + "data": { + "object": { + "customer": self.owner.stripe_customer_id, + "subscription": self.owner.stripe_subscription_id, + "total": 24000, + "hosted_invoice_url": "https://stripe.com", } }, } @@ -131,10 +228,44 @@ def test_invoice_payment_succeeded_sets_multiple_owners_delinquent_false(self): assert self.owner.delinquent is False assert self.other_owner.delinquent is False - def test_invoice_payment_failed_sets_owner_delinquent_true(self): + expected_calls = [ + call( + to_addr=self.owner.email, + subject="You're all set", + template_name="success-after-failed-payment", + amount=240, + cta_link="https://stripe.com", + date=datetime.now().strftime("%B %-d, %Y"), + ), + call( + to_addr=admin_1.email, + subject="You're all set", + template_name="success-after-failed-payment", + amount=240, + cta_link="https://stripe.com", + date=datetime.now().strftime("%B %-d, %Y"), + ), + call( + to_addr=admin_2.email, + subject="You're all set", + template_name="success-after-failed-payment", + amount=240, + cta_link="https://stripe.com", + date=datetime.now().strftime("%B %-d, %Y"), + ), + ] + + mocked_send_email.assert_has_calls(expected_calls) + + @patch("services.billing.stripe.PaymentIntent.retrieve") + def test_invoice_payment_failed_sets_owner_delinquent_true( + self, retrieve_paymentintent_mock + ): self.owner.delinquent = False self.owner.save() + retrieve_paymentintent_mock.return_value = MockPaymentIntent() + response = self._send_event( payload={ "type": "invoice.payment_failed", @@ -142,11 +273,9 @@ 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", + "payment_intent": "payment_intent_asdf", } }, } @@ -156,13 +285,18 @@ def test_invoice_payment_failed_sets_owner_delinquent_true(self): assert response.status_code == status.HTTP_204_NO_CONTENT assert self.owner.delinquent is True - def test_invoice_payment_failed_sets_multiple_owners_delinquent_true(self): + @patch("services.billing.stripe.PaymentIntent.retrieve") + def test_invoice_payment_failed_sets_multiple_owners_delinquent_true( + self, retrieve_paymentintent_mock + ): self.add_second_owner() self.owner.delinquent = False self.owner.save() self.other_owner.delinquent = False self.other_owner.save() + retrieve_paymentintent_mock.return_value = MockPaymentIntent() + response = self._send_event( payload={ "type": "invoice.payment_failed", @@ -170,11 +304,9 @@ 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", + "payment_intent": "payment_intent_asdf", } }, } @@ -187,7 +319,12 @@ def test_invoice_payment_failed_sets_multiple_owners_delinquent_true(self): 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): + @patch("services.billing.stripe.PaymentIntent.retrieve") + def test_invoice_payment_failed_sends_email_to_admins( + self, + retrieve_paymentintent_mock, + mocked_send_email, + ): non_admin = OwnerFactory(email="non-admin@codecov.io") admin_1 = OwnerFactory(email="admin1@codecov.io") admin_2 = OwnerFactory(email="admin2@codecov.io") @@ -196,6 +333,8 @@ def test_invoice_payment_failed_sends_email_to_admins(self, mocked_send_email): self.owner.email = "owner@codecov.io" self.owner.save() + retrieve_paymentintent_mock.return_value = MockPaymentIntent() + response = self._send_event( payload={ "type": "invoice.payment_failed", @@ -203,11 +342,9 @@ def test_invoice_payment_failed_sends_email_to_admins(self, mocked_send_email): "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", + "payment_intent": "payment_intent_asdf", } }, } @@ -225,7 +362,7 @@ def test_invoice_payment_failed_sends_email_to_admins(self, mocked_send_email): name=self.owner.username, amount=240, card_type="visa", - last_four=1234, + last_four="1234", cta_link="https://stripe.com", date=datetime.now().strftime("%B %-d, %Y"), ), @@ -236,7 +373,7 @@ def test_invoice_payment_failed_sends_email_to_admins(self, mocked_send_email): name=admin_1.username, amount=240, card_type="visa", - last_four=1234, + last_four="1234", cta_link="https://stripe.com", date=datetime.now().strftime("%B %-d, %Y"), ), @@ -247,16 +384,20 @@ def test_invoice_payment_failed_sends_email_to_admins(self, mocked_send_email): name=admin_2.username, amount=240, card_type="visa", - last_four=1234, + 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") + @patch("services.billing.stripe.PaymentIntent.retrieve") def test_invoice_payment_failed_sends_email_to_admins_no_card( - self, mocked_send_email + self, + retrieve_paymentintent_mock, + mocked_send_email, ): non_admin = OwnerFactory(email="non-admin@codecov.io") admin_1 = OwnerFactory(email="admin1@codecov.io") @@ -266,6 +407,8 @@ def test_invoice_payment_failed_sends_email_to_admins_no_card( self.owner.email = "owner@codecov.io" self.owner.save() + retrieve_paymentintent_mock.return_value = MockPaymentIntent(noCard=True) + response = self._send_event( payload={ "type": "invoice.payment_failed", @@ -276,6 +419,7 @@ def test_invoice_payment_failed_sends_email_to_admins_no_card( "default_payment_method": None, "total": 24000, "hosted_invoice_url": "https://stripe.com", + "payment_intent": "payment_intent_asdf", } }, } diff --git a/billing/views.py b/billing/views.py index b4878b54fc..fbbe78f2fd 100644 --- a/billing/views.py +++ b/billing/views.py @@ -46,11 +46,42 @@ def invoice_payment_succeeded(self, invoice: stripe.Invoice) -> None: owners: QuerySet[Owner] = Owner.objects.filter( stripe_customer_id=invoice.customer, stripe_subscription_id=invoice.subscription, + delinquent=True, ) - owners.update(delinquent=False) + if not owners.exists(): + return + + admins = get_all_admins_for_owners(owners) + owners.update(delinquent=False) self._log_updated(list(owners)) + # Send a success email to all admins + + task_service = TaskService() + template_vars = { + "amount": invoice.total / 100, + "date": datetime.now().strftime("%B %-d, %Y"), + "cta_link": invoice.hosted_invoice_url, + } + + for admin in admins: + if admin.email: + task_service.send_email( + to_addr=admin.email, + subject="You're all set", + template_name="success-after-failed-payment", + **template_vars, + ) + + # temporary just making sure these look okay in the real world + task_service.send_email( + to_addr="spencer.murray@sentry.io", + subject="You're all set", + template_name="success-after-failed-payment", + **template_vars, + ) + def invoice_payment_failed(self, invoice: stripe.Invoice) -> None: log.info( "Invoice Payment Failed - Setting Delinquency status True", @@ -70,9 +101,13 @@ def invoice_payment_failed(self, invoice: stripe.Invoice) -> None: admins = get_all_admins_for_owners(owners) task_service = TaskService() + payment_intent = stripe.PaymentIntent.retrieve( + invoice["payment_intent"], expand=["payment_method"] + ) card = ( - invoice.default_payment_method.card - if invoice.default_payment_method + payment_intent.payment_method.card + if payment_intent.payment_method + and not isinstance(payment_intent.payment_method, str) else None ) template_vars = {