Skip to content

Commit 7cd8176

Browse files
committed
feat: send trial ending soon reminder email
1 parent 387a21b commit 7cd8176

File tree

8 files changed

+667
-7
lines changed

8 files changed

+667
-7
lines changed

enterprise_access/apps/customer_billing/models.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -845,3 +845,19 @@ def _timestamp_to_datetime(timestamp):
845845
if timestamp:
846846
return _datetime_from_timestamp(timestamp)
847847
return None
848+
849+
@classmethod
850+
def get_latest_invoice_paid(cls, invoice_id):
851+
"""
852+
Retrieve the most recent invoice.paid event summary for a given invoice ID.
853+
854+
Args:
855+
invoice_id (str): The Stripe invoice ID to look up
856+
857+
Returns:
858+
StripeEventSummary: The most recent invoice.paid event summary, or None if not found
859+
"""
860+
return cls.objects.filter(
861+
stripe_invoice_id=invoice_id,
862+
event_type='invoice.paid',
863+
).order_by('-stripe_event_created_at').first()

enterprise_access/apps/customer_billing/stripe_event_handlers.py

Lines changed: 36 additions & 2 deletions
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_trial_ending_reminder_email_task
15+
)
1316

1417
logger = logging.getLogger(__name__)
1518

@@ -179,7 +182,38 @@ def invoice_paid(event: stripe.Event) -> None:
179182
@on_stripe_event('customer.subscription.trial_will_end')
180183
@staticmethod
181184
def trial_will_end(event: stripe.Event) -> None:
182-
pass
185+
"""
186+
Handle customer.subscription.trial_will_end events.
187+
Send reminder email 72 hours before trial ends.
188+
"""
189+
subscription = event.data.object
190+
checkout_intent_id = get_checkout_intent_id_from_subscription(
191+
subscription
192+
)
193+
try:
194+
checkout_intent = get_checkout_intent_or_raise(
195+
checkout_intent_id, event.id
196+
)
197+
except CheckoutIntent.DoesNotExist:
198+
logger.error(
199+
"[StripeEventHandler] trial_will_end event %s could not find CheckoutIntent id=%s",
200+
event.id,
201+
checkout_intent_id,
202+
)
203+
return
204+
205+
link_event_data_to_checkout_intent(event, checkout_intent)
206+
207+
logger.info(
208+
"Subscription %s trial ending in 72 hours. Queuing trial ending reminder email for checkout_intent_id=%s",
209+
subscription.id,
210+
checkout_intent_id,
211+
)
212+
213+
# Queue the trial ending reminder email task
214+
send_trial_ending_reminder_email_task.delay(
215+
checkout_intent_id=checkout_intent.id,
216+
)
183217

184218
@on_stripe_event('payment_method.attached')
185219
@staticmethod

enterprise_access/apps/customer_billing/tasks.py

Lines changed: 196 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,16 @@
88
import stripe
99
from celery import shared_task
1010
from django.conf import settings
11+
from django.utils import timezone
1112

1213
from enterprise_access.apps.api_client.braze_client import BrazeApiClient
1314
from enterprise_access.apps.api_client.lms_client import LmsApiClient
1415
from enterprise_access.apps.customer_billing.api import create_stripe_billing_portal_session
15-
from enterprise_access.apps.customer_billing.models import CheckoutIntent
16+
from enterprise_access.apps.customer_billing.models import CheckoutIntent, StripeEventSummary
17+
from enterprise_access.apps.customer_billing.stripe_api import get_stripe_trialing_subscription
1618
from enterprise_access.apps.provisioning.utils import validate_trial_subscription
1719
from enterprise_access.tasks import LoggedTaskWithRetry
18-
from enterprise_access.utils import cents_to_dollars
20+
from enterprise_access.utils import cents_to_dollars, format_cents_for_user_display
1921

2022
logger = logging.getLogger(__name__)
2123

@@ -288,3 +290,195 @@ def send_trial_cancellation_email_task(
288290
str(exc),
289291
)
290292
raise
293+
294+
295+
@shared_task(base=LoggedTaskWithRetry)
296+
def send_trial_ending_reminder_email_task(checkout_intent_id):
297+
"""
298+
Send Braze email notification 72 hours before trial subscription ends.
299+
300+
This task handles sending a reminder email to enterprise admins when their
301+
trial subscription is about to end. The email includes subscription details,
302+
renewal information, and a link to manage their subscription.
303+
304+
Args:
305+
checkout_intent_id (int): ID of the CheckoutIntent record
306+
307+
Raises:
308+
BrazeClientError: If there's an error communicating with Braze
309+
Exception: For any other unexpected errors during email sending
310+
"""
311+
try:
312+
checkout_intent = CheckoutIntent.objects.get(id=checkout_intent_id)
313+
except CheckoutIntent.DoesNotExist:
314+
logger.error(
315+
"Email not sent: CheckoutIntent %s not found for trial ending reminder email",
316+
checkout_intent_id,
317+
)
318+
return
319+
320+
enterprise_slug = checkout_intent.enterprise_slug
321+
logger.info(
322+
"Sending trial ending reminder email for CheckoutIntent %s (enterprise slug: %s)",
323+
checkout_intent_id,
324+
enterprise_slug,
325+
)
326+
327+
braze_client = BrazeApiClient()
328+
lms_client = LmsApiClient()
329+
330+
# Fetch enterprise customer data to get admin users
331+
try:
332+
enterprise_data = lms_client.get_enterprise_customer_data(
333+
enterprise_customer_slug=enterprise_slug
334+
)
335+
except Exception as exc: # pylint: disable=broad-exception-caught
336+
logger.error(
337+
"Failed to fetch enterprise data for slug %s: %s. Cannot send trial ending reminder email.",
338+
enterprise_slug,
339+
str(exc),
340+
)
341+
return
342+
343+
admin_users = enterprise_data.get("admin_users", [])
344+
345+
if not admin_users:
346+
logger.error(
347+
"Trial ending reminder email not sent: No admin users found for enterprise slug %s. "
348+
"Verify admin setup in LMS.",
349+
enterprise_slug,
350+
)
351+
return
352+
353+
# Retrieve subscription details from Stripe
354+
try:
355+
if not checkout_intent.stripe_customer_id:
356+
logger.error(
357+
"Trial ending reminder email not sent: No Stripe customer ID for CheckoutIntent %s",
358+
checkout_intent_id,
359+
)
360+
return
361+
362+
# Get the trialing subscription using the existing utility method
363+
subscription = get_stripe_trialing_subscription(
364+
checkout_intent.stripe_customer_id
365+
)
366+
367+
if not subscription:
368+
logger.error(
369+
"Trial ending reminder email not sent: No active trial subscription found for customer %s",
370+
checkout_intent.stripe_customer_id,
371+
)
372+
return
373+
374+
if not subscription["items"].data:
375+
logger.error(
376+
"Trial ending reminder email not sent: Subscription %s has no items",
377+
subscription.id,
378+
)
379+
return
380+
381+
first_item = subscription["items"].data[0]
382+
renewal_date = timezone.make_aware(
383+
datetime.fromtimestamp(first_item.current_period_end)
384+
).strftime("%B %d, %Y")
385+
license_count = first_item.quantity
386+
387+
# Get payment method details with card brand
388+
payment_method_info = ""
389+
if subscription.default_payment_method:
390+
payment_method = stripe.PaymentMethod.retrieve(
391+
subscription.default_payment_method
392+
)
393+
if payment_method.type == "card":
394+
brand = (
395+
payment_method.card.brand.capitalize()
396+
) # e.g., "Visa", "Mastercard"
397+
last4 = payment_method.card.last4
398+
payment_method_info = f"{brand} ending in {last4}"
399+
400+
total_paid_amount = "$0.00 USD"
401+
if subscription.latest_invoice:
402+
invoice_summary = StripeEventSummary.get_latest_invoice_paid(
403+
subscription.latest_invoice
404+
)
405+
406+
if invoice_summary and invoice_summary.invoice_amount_paid is not None:
407+
total_paid_amount = format_cents_for_user_display(
408+
invoice_summary.invoice_amount_paid
409+
)
410+
else:
411+
logger.warning(
412+
"No invoice summary found for invoice %s, falling back to $0.00 USD",
413+
subscription.latest_invoice,
414+
)
415+
416+
except stripe.StripeError as exc:
417+
logger.error(
418+
"Stripe API error while fetching subscription details for CheckoutIntent %s: %s",
419+
checkout_intent_id,
420+
str(exc),
421+
)
422+
return
423+
except Exception as exc: # pylint: disable=broad-exception-caught
424+
logger.error(
425+
"Error retrieving subscription details for CheckoutIntent %s: %s",
426+
checkout_intent_id,
427+
str(exc),
428+
)
429+
return
430+
431+
subscription_management_url = _get_billing_portal_url(checkout_intent)
432+
433+
braze_trigger_properties = {
434+
"renewal_date": renewal_date,
435+
"subscription_management_url": subscription_management_url,
436+
"license_count": license_count,
437+
"payment_method": payment_method_info,
438+
"total_paid_amount": total_paid_amount,
439+
}
440+
441+
# Create Braze recipients for all admin users
442+
recipients = []
443+
for admin in admin_users:
444+
try:
445+
admin_email = admin["email"]
446+
recipient = braze_client.create_braze_recipient(
447+
user_email=admin_email,
448+
lms_user_id=admin.get("lms_user_id"),
449+
)
450+
recipients.append(recipient)
451+
except Exception as exc: # pylint: disable=broad-exception-caught
452+
logger.warning(
453+
"Failed to create Braze recipient for admin email %s: %s",
454+
admin_email,
455+
str(exc),
456+
)
457+
458+
if not recipients:
459+
logger.error(
460+
"Trial ending reminder email not sent: No valid Braze recipients created for enterprise slug %s. "
461+
"Check admin email errors above.",
462+
enterprise_slug,
463+
)
464+
return
465+
466+
try:
467+
braze_client.send_campaign_message(
468+
settings.BRAZE_ENTERPRISE_PROVISION_TRIAL_ENDING_SOON_CAMPAIGN,
469+
recipients=recipients,
470+
trigger_properties=braze_trigger_properties,
471+
)
472+
logger.info(
473+
"Successfully sent trial ending reminder emails for CheckoutIntent %s to %d recipients",
474+
checkout_intent_id,
475+
len(recipients),
476+
)
477+
478+
except Exception as exc:
479+
logger.exception(
480+
"Braze API error: Failed to send trial ending reminder email for CheckoutIntent %s. Error: %s",
481+
checkout_intent_id,
482+
str(exc),
483+
)
484+
raise

enterprise_access/apps/customer_billing/tests/test_stripe_event_handlers.py

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ def test_subscription_updated_skips_email_when_no_trial_end(self):
262262
subscription_data = {
263263
"id": "sub_test_no_trial_123",
264264
"status": "canceled",
265-
"trial_end": None, # No trial
265+
"trial_end": None,
266266
"metadata": self._create_mock_stripe_subscription(
267267
self.checkout_intent.id
268268
),
@@ -277,3 +277,72 @@ def test_subscription_updated_skips_email_when_no_trial_end(self):
277277
) as mock_task:
278278
StripeEventHandler.dispatch(mock_event)
279279
mock_task.delay.assert_not_called()
280+
281+
@mock.patch(
282+
"enterprise_access.apps.customer_billing.stripe_event_handlers.send_trial_ending_reminder_email_task"
283+
)
284+
def test_trial_will_end_handler_success(self, mock_email_task):
285+
"""Test successful trial_will_end event handling."""
286+
trial_end_timestamp = 1640995200
287+
subscription_data = {
288+
"id": "sub_test_trial_will_end_123",
289+
"trial_end": trial_end_timestamp,
290+
"metadata": self._create_mock_stripe_subscription(
291+
self.checkout_intent.id
292+
),
293+
}
294+
295+
mock_event = self._create_mock_stripe_event(
296+
"customer.subscription.trial_will_end", subscription_data
297+
)
298+
299+
StripeEventHandler.dispatch(mock_event)
300+
301+
mock_email_task.delay.assert_called_once_with(
302+
checkout_intent_id=self.checkout_intent.id,
303+
)
304+
305+
event_data = StripeEventData.objects.get(event_id=mock_event.id)
306+
self.assertEqual(event_data.checkout_intent, self.checkout_intent)
307+
308+
@mock.patch(
309+
"enterprise_access.apps.customer_billing.stripe_event_handlers.send_trial_ending_reminder_email_task"
310+
)
311+
def test_trial_will_end_handler_checkout_intent_not_found(
312+
self, mock_email_task
313+
):
314+
"""Test trial_will_end when CheckoutIntent is not found."""
315+
trial_end_timestamp = 1640995200
316+
subscription_data = {
317+
"id": "sub_test_not_found_123",
318+
"trial_end": trial_end_timestamp,
319+
"metadata": self._create_mock_stripe_subscription(99999),
320+
}
321+
322+
mock_event = self._create_mock_stripe_event(
323+
"customer.subscription.trial_will_end", subscription_data
324+
)
325+
326+
StripeEventHandler.dispatch(mock_event)
327+
328+
mock_email_task.delay.assert_not_called()
329+
330+
@mock.patch(
331+
"enterprise_access.apps.customer_billing.stripe_event_handlers.send_trial_ending_reminder_email_task"
332+
)
333+
def test_trial_will_end_handler_no_checkout_intent_metadata(
334+
self, mock_email_task
335+
):
336+
"""Test trial_will_end when subscription has no checkout_intent_id in metadata."""
337+
subscription_data = {
338+
"id": "sub_test_no_metadata_123",
339+
"metadata": {},
340+
}
341+
342+
mock_event = self._create_mock_stripe_event(
343+
"customer.subscription.trial_will_end", subscription_data
344+
)
345+
346+
StripeEventHandler.dispatch(mock_event)
347+
348+
mock_email_task.delay.assert_not_called()

0 commit comments

Comments
 (0)