Skip to content

Commit 1d1da08

Browse files
jajjibhai008iloveagent57
authored andcommitted
feat: send trial ending soon reminder email
1 parent cde61fa commit 1d1da08

File tree

8 files changed

+670
-7
lines changed

8 files changed

+670
-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: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@
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_payment_receipt_email, send_trial_cancellation_email_task
12+
from enterprise_access.apps.customer_billing.tasks import (
13+
send_payment_receipt_email,
14+
send_trial_cancellation_email_task,
15+
send_trial_ending_reminder_email_task
16+
)
1317

1418
logger = logging.getLogger(__name__)
1519

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

191226
@on_stripe_event('payment_method.attached')
192227
@staticmethod

enterprise_access/apps/customer_billing/tasks.py

Lines changed: 196 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,17 @@
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.content_assignments.content_metadata_api import format_datetime_obj
1516
from enterprise_access.apps.customer_billing.api import create_stripe_billing_portal_session
16-
from enterprise_access.apps.customer_billing.models import CheckoutIntent
17+
from enterprise_access.apps.customer_billing.models import CheckoutIntent, StripeEventSummary
18+
from enterprise_access.apps.customer_billing.stripe_api import get_stripe_trialing_subscription
1719
from enterprise_access.apps.provisioning.utils import validate_trial_subscription
1820
from enterprise_access.tasks import LoggedTaskWithRetry
19-
from enterprise_access.utils import cents_to_dollars
21+
from enterprise_access.utils import cents_to_dollars, format_cents_for_user_display
2022

2123
logger = logging.getLogger(__name__)
2224

@@ -411,3 +413,195 @@ def send_trial_cancellation_email_task(
411413
str(exc),
412414
)
413415
raise
416+
417+
418+
@shared_task(base=LoggedTaskWithRetry)
419+
def send_trial_ending_reminder_email_task(checkout_intent_id):
420+
"""
421+
Send Braze email notification 72 hours before trial subscription ends.
422+
423+
This task handles sending a reminder email to enterprise admins when their
424+
trial subscription is about to end. The email includes subscription details,
425+
renewal information, and a link to manage their subscription.
426+
427+
Args:
428+
checkout_intent_id (int): ID of the CheckoutIntent record
429+
430+
Raises:
431+
BrazeClientError: If there's an error communicating with Braze
432+
Exception: For any other unexpected errors during email sending
433+
"""
434+
try:
435+
checkout_intent = CheckoutIntent.objects.get(id=checkout_intent_id)
436+
except CheckoutIntent.DoesNotExist:
437+
logger.error(
438+
"Email not sent: CheckoutIntent %s not found for trial ending reminder email",
439+
checkout_intent_id,
440+
)
441+
return
442+
443+
enterprise_slug = checkout_intent.enterprise_slug
444+
logger.info(
445+
"Sending trial ending reminder email for CheckoutIntent %s (enterprise slug: %s)",
446+
checkout_intent_id,
447+
enterprise_slug,
448+
)
449+
450+
braze_client = BrazeApiClient()
451+
lms_client = LmsApiClient()
452+
453+
# Fetch enterprise customer data to get admin users
454+
try:
455+
enterprise_data = lms_client.get_enterprise_customer_data(
456+
enterprise_customer_slug=enterprise_slug
457+
)
458+
except Exception as exc: # pylint: disable=broad-exception-caught
459+
logger.error(
460+
"Failed to fetch enterprise data for slug %s: %s. Cannot send trial ending reminder email.",
461+
enterprise_slug,
462+
str(exc),
463+
)
464+
return
465+
466+
admin_users = enterprise_data.get("admin_users", [])
467+
468+
if not admin_users:
469+
logger.error(
470+
"Trial ending reminder email not sent: No admin users found for enterprise slug %s. "
471+
"Verify admin setup in LMS.",
472+
enterprise_slug,
473+
)
474+
return
475+
476+
# Retrieve subscription details from Stripe
477+
try:
478+
if not checkout_intent.stripe_customer_id:
479+
logger.error(
480+
"Trial ending reminder email not sent: No Stripe customer ID for CheckoutIntent %s",
481+
checkout_intent_id,
482+
)
483+
return
484+
485+
# Get the trialing subscription using the existing utility method
486+
subscription = get_stripe_trialing_subscription(
487+
checkout_intent.stripe_customer_id
488+
)
489+
490+
if not subscription:
491+
logger.error(
492+
"Trial ending reminder email not sent: No active trial subscription found for customer %s",
493+
checkout_intent.stripe_customer_id,
494+
)
495+
return
496+
497+
if not subscription["items"].data:
498+
logger.error(
499+
"Trial ending reminder email not sent: Subscription %s has no items",
500+
subscription.id,
501+
)
502+
return
503+
504+
first_item = subscription["items"].data[0]
505+
renewal_date = timezone.make_aware(
506+
datetime.fromtimestamp(first_item.current_period_end)
507+
).strftime("%B %d, %Y")
508+
license_count = first_item.quantity
509+
510+
# Get payment method details with card brand
511+
payment_method_info = ""
512+
if subscription.default_payment_method:
513+
payment_method = stripe.PaymentMethod.retrieve(
514+
subscription.default_payment_method
515+
)
516+
if payment_method.type == "card":
517+
brand = (
518+
payment_method.card.brand.capitalize()
519+
) # e.g., "Visa", "Mastercard"
520+
last4 = payment_method.card.last4
521+
payment_method_info = f"{brand} ending in {last4}"
522+
523+
total_paid_amount = "$0.00 USD"
524+
if subscription.latest_invoice:
525+
invoice_summary = StripeEventSummary.get_latest_invoice_paid(
526+
subscription.latest_invoice
527+
)
528+
529+
if invoice_summary and invoice_summary.invoice_amount_paid is not None:
530+
total_paid_amount = format_cents_for_user_display(
531+
invoice_summary.invoice_amount_paid
532+
)
533+
else:
534+
logger.warning(
535+
"No invoice summary found for invoice %s, falling back to $0.00 USD",
536+
subscription.latest_invoice,
537+
)
538+
539+
except stripe.StripeError as exc:
540+
logger.error(
541+
"Stripe API error while fetching subscription details for CheckoutIntent %s: %s",
542+
checkout_intent_id,
543+
str(exc),
544+
)
545+
return
546+
except Exception as exc: # pylint: disable=broad-exception-caught
547+
logger.error(
548+
"Error retrieving subscription details for CheckoutIntent %s: %s",
549+
checkout_intent_id,
550+
str(exc),
551+
)
552+
return
553+
554+
subscription_management_url = _get_billing_portal_url(checkout_intent)
555+
556+
braze_trigger_properties = {
557+
"renewal_date": renewal_date,
558+
"subscription_management_url": subscription_management_url,
559+
"license_count": license_count,
560+
"payment_method": payment_method_info,
561+
"total_paid_amount": total_paid_amount,
562+
}
563+
564+
# Create Braze recipients for all admin users
565+
recipients = []
566+
for admin in admin_users:
567+
try:
568+
admin_email = admin["email"]
569+
recipient = braze_client.create_braze_recipient(
570+
user_email=admin_email,
571+
lms_user_id=admin.get("lms_user_id"),
572+
)
573+
recipients.append(recipient)
574+
except Exception as exc: # pylint: disable=broad-exception-caught
575+
logger.warning(
576+
"Failed to create Braze recipient for admin email %s: %s",
577+
admin_email,
578+
str(exc),
579+
)
580+
581+
if not recipients:
582+
logger.error(
583+
"Trial ending reminder email not sent: No valid Braze recipients created for enterprise slug %s. "
584+
"Check admin email errors above.",
585+
enterprise_slug,
586+
)
587+
return
588+
589+
try:
590+
braze_client.send_campaign_message(
591+
settings.BRAZE_ENTERPRISE_PROVISION_TRIAL_ENDING_SOON_CAMPAIGN,
592+
recipients=recipients,
593+
trigger_properties=braze_trigger_properties,
594+
)
595+
logger.info(
596+
"Successfully sent trial ending reminder emails for CheckoutIntent %s to %d recipients",
597+
checkout_intent_id,
598+
len(recipients),
599+
)
600+
601+
except Exception as exc:
602+
logger.exception(
603+
"Braze API error: Failed to send trial ending reminder email for CheckoutIntent %s. Error: %s",
604+
checkout_intent_id,
605+
str(exc),
606+
)
607+
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)