This repository was archived by the owner on Jun 13, 2025. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 29
feat: Add success after failed payment email (and fix card in failed-payment email) #1065
Merged
spalmurray-codecov
merged 1 commit into
main
from
spalmurray/add-success-after-failed-payment-email
Dec 19, 2024
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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="[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.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,22 +228,54 @@ 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", | ||
| "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", | ||
| "payment_intent": "payment_intent_asdf", | ||
| } | ||
| }, | ||
| } | ||
|
|
@@ -156,25 +285,28 @@ 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", | ||
| "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", | ||
| "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="[email protected]") | ||
| admin_1 = OwnerFactory(email="[email protected]") | ||
| admin_2 = OwnerFactory(email="[email protected]") | ||
|
|
@@ -196,18 +333,18 @@ def test_invoice_payment_failed_sends_email_to_admins(self, mocked_send_email): | |
| self.owner.email = "[email protected]" | ||
| self.owner.save() | ||
|
|
||
| retrieve_paymentintent_mock.return_value = MockPaymentIntent() | ||
|
|
||
| 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", | ||
| "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="[email protected]") | ||
| admin_1 = OwnerFactory(email="[email protected]") | ||
|
|
@@ -266,6 +407,8 @@ def test_invoice_payment_failed_sends_email_to_admins_no_card( | |
| self.owner.email = "[email protected]" | ||
| 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", | ||
| } | ||
| }, | ||
| } | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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="[email protected]", | ||
| 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( | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Turns out you gotta go get the card in the PaymentIntent explicitly. Annoying that it's another fetch, but oh well, this code isn't time critical enough for that to matter. |
||
| 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 = { | ||
|
|
||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.