Skip to content

Commit 4fc4868

Browse files
committed
WIP: add email task
1 parent 54908d2 commit 4fc4868

File tree

3 files changed

+159
-1
lines changed

3 files changed

+159
-1
lines changed

enterprise_access/apps/customer_billing/stripe_event_handlers.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@
99

1010
from enterprise_access.apps.customer_billing.models import CheckoutIntent, StripeEventData
1111
from enterprise_access.apps.customer_billing.stripe_event_types import StripeEventType
12-
from enterprise_access.apps.customer_billing.tasks import send_trial_cancellation_email_task
12+
from enterprise_access.apps.customer_billing.tasks import (
13+
send_trial_cancellation_email_task,
14+
send_billing_error_email_task,
15+
)
1316

1417
logger = logging.getLogger(__name__)
1518

@@ -349,6 +352,16 @@ def subscription_updated(event: stripe.Event) -> None:
349352

350353
# Past due transition
351354
if current_status == "past_due" and prior_status != "past_due":
355+
# Fire billing error email to enterprise admins
356+
try:
357+
send_billing_error_email_task.delay(checkout_intent_id=checkout_intent.id)
358+
except Exception as exc: # pylint: disable=broad-exception-caught
359+
logger.exception(
360+
"Failed to enqueue billing error email for CheckoutIntent %s: %s",
361+
checkout_intent.id,
362+
str(exc),
363+
)
364+
352365
enterprise_uuid = checkout_intent.enterprise_uuid
353366
if enterprise_uuid:
354367
cancel_all_future_plans(

enterprise_access/apps/customer_billing/tasks.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,3 +288,110 @@ def send_trial_cancellation_email_task(
288288
str(exc),
289289
)
290290
raise
291+
292+
293+
@shared_task(base=LoggedTaskWithRetry)
294+
def send_billing_error_email_task(checkout_intent_id: int):
295+
"""
296+
Send Braze email notification when a subscription encounters a billing error
297+
(e.g., transitions to past_due).
298+
299+
The email includes a link to the Stripe billing portal so admins can fix their
300+
payment method and restart the subscription.
301+
302+
Args:
303+
checkout_intent_id (int): ID of the CheckoutIntent record
304+
"""
305+
try:
306+
checkout_intent = CheckoutIntent.objects.get(id=checkout_intent_id)
307+
except CheckoutIntent.DoesNotExist:
308+
logger.error(
309+
"Billing error email not sent: CheckoutIntent %s not found",
310+
checkout_intent_id,
311+
)
312+
return
313+
314+
enterprise_slug = checkout_intent.enterprise_slug
315+
logger.info(
316+
"Sending billing error email for CheckoutIntent %s (enterprise slug: %s)",
317+
checkout_intent_id,
318+
enterprise_slug,
319+
)
320+
321+
braze_client = BrazeApiClient()
322+
lms_client = LmsApiClient()
323+
324+
# Fetch enterprise customer data to get admin users
325+
try:
326+
enterprise_data = lms_client.get_enterprise_customer_data(
327+
enterprise_customer_slug=enterprise_slug
328+
)
329+
except Exception as exc: # pylint: disable=broad-exception-caught
330+
logger.error(
331+
"Failed to fetch enterprise data for slug %s: %s. Cannot send billing error email.",
332+
enterprise_slug,
333+
str(exc),
334+
)
335+
return
336+
337+
admin_users = enterprise_data.get("admin_users", [])
338+
339+
if not admin_users:
340+
logger.error(
341+
"Billing error email not sent: No admin users found for enterprise slug %s. "
342+
"Verify admin setup in LMS.",
343+
enterprise_slug,
344+
)
345+
return
346+
347+
# Generate Stripe billing portal URL for fixing payment method
348+
portal_url = _get_billing_portal_url(checkout_intent)
349+
350+
braze_trigger_properties = {
351+
"restart_subscription_url": portal_url,
352+
}
353+
354+
# Create Braze recipients for all admin users
355+
recipients = []
356+
for admin in admin_users:
357+
try:
358+
admin_email = admin["email"]
359+
recipient = braze_client.create_braze_recipient(
360+
user_email=admin_email,
361+
lms_user_id=admin.get("lms_user_id"),
362+
)
363+
recipients.append(recipient)
364+
except Exception as exc: # pylint: disable=broad-exception-caught
365+
logger.warning(
366+
"Failed to create Braze recipient for admin email %s: %s",
367+
admin_email,
368+
str(exc),
369+
)
370+
371+
if not recipients:
372+
logger.error(
373+
"Billing error email not sent: No valid Braze recipients created for enterprise slug %s. "
374+
"Check admin email errors above.",
375+
enterprise_slug,
376+
)
377+
return
378+
379+
# Send the campaign message to all admin recipients
380+
try:
381+
braze_client.send_campaign_message(
382+
settings.BRAZE_BILLING_ERROR_CAMPAIGN,
383+
recipients=recipients,
384+
trigger_properties=braze_trigger_properties,
385+
)
386+
logger.info(
387+
"Successfully sent billing error emails for CheckoutIntent %s to %d recipients",
388+
checkout_intent_id,
389+
len(recipients),
390+
)
391+
except Exception as exc: # pylint: disable=broad-exception-caught
392+
logger.exception(
393+
"Braze API error: Failed to send billing error email for CheckoutIntent %s. Error: %s",
394+
checkout_intent_id,
395+
str(exc),
396+
)
397+
raise

enterprise_access/apps/customer_billing/tests/test_stripe_event_handlers.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,44 @@ def test_subscription_updated_skips_email_when_no_trial_end(self):
282282
StripeEventHandler.dispatch(mock_event)
283283
mock_task.delay.assert_not_called()
284284

285+
@mock.patch(
286+
"enterprise_access.apps.customer_billing.stripe_event_handlers.cancel_all_future_plans"
287+
)
288+
@mock.patch(
289+
"enterprise_access.apps.customer_billing.stripe_event_handlers.send_billing_error_email_task"
290+
)
291+
@mock.patch.object(CheckoutIntent, "previous_summary")
292+
def test_subscription_updated_past_due_cancels_future_plans(
293+
self, mock_prev_summary, mock_send_billing_error, mock_cancel
294+
):
295+
"""Past-due transition triggers cancel_all_future_plans with expected args."""
296+
subscription_id = "sub_test_past_due_123"
297+
subscription_data = {
298+
"id": subscription_id,
299+
"status": "past_due",
300+
"metadata": self._create_mock_stripe_subscription(self.checkout_intent.id),
301+
}
302+
303+
# Simulate prior status was not past_due
304+
mock_prev_summary.return_value = AttrDict({"subscription_status": "active"})
305+
306+
mock_event = self._create_mock_stripe_event(
307+
"customer.subscription.updated", subscription_data
308+
)
309+
310+
StripeEventHandler.dispatch(mock_event)
311+
312+
mock_cancel.assert_called_once()
313+
mock_send_billing_error.delay.assert_called_once_with(checkout_intent_id=self.checkout_intent.id)
314+
_, kwargs = mock_cancel.call_args
315+
self.assertEqual(
316+
kwargs["enterprise_uuid"], self.checkout_intent.enterprise_uuid
317+
)
318+
self.assertEqual(kwargs["reason"], "delayed_payment")
319+
self.assertEqual(
320+
kwargs["subscription_id_for_logs"], subscription_id
321+
)
322+
285323
def test_future_plans_of_current_selects_children(self):
286324
"""future_plans_of_current returns plans whose prior_renewals link to current plan."""
287325
current_uuid = "1111-aaaa"

0 commit comments

Comments
 (0)