From 4aadf5b60880282270900e8349bcec3600984ea3 Mon Sep 17 00:00:00 2001 From: Jono Booth Date: Mon, 1 Dec 2025 09:15:08 +0200 Subject: [PATCH] feat: handle payment error on trial --- .../apps/api_client/license_manager_client.py | 124 +++-- .../tests/test_license_manager_client.py | 62 +++ .../customer_billing/stripe_event_handlers.py | 192 +++++++ .../apps/customer_billing/tasks.py | 509 +++++++++--------- .../tests/test_stripe_event_handlers.py | 275 +++++++++- enterprise_access/settings/base.py | 1 + enterprise_access/settings/test.py | 1 + 7 files changed, 876 insertions(+), 288 deletions(-) diff --git a/enterprise_access/apps/api_client/license_manager_client.py b/enterprise_access/apps/api_client/license_manager_client.py index 6859ab52..3f1e2000 100644 --- a/enterprise_access/apps/api_client/license_manager_client.py +++ b/enterprise_access/apps/api_client/license_manager_client.py @@ -28,6 +28,71 @@ class LicenseManagerApiClient(BaseOAuthClient): subscription_provisioning_endpoint = api_base_url + 'provisioning-admins/subscriptions/' subscription_plan_renewal_provisioning_endpoint = api_base_url + 'provisioning-admins/subscription-plan-renewals/' + def list_subscriptions(self, enterprise_customer_uuid): + """ + List subscription plans for an enterprise. + + Returns a paginated DRF list response: { count, next, previous, results: [...] } + """ + try: + params = { + 'enterprise_customer_uuid': enterprise_customer_uuid, + } + + response = self.client.get( + self.subscriptions_endpoint, + params=params, + timeout=settings.LICENSE_MANAGER_CLIENT_TIMEOUT, + ) + response.raise_for_status() + return response.json() + except requests.exceptions.HTTPError as exc: + logger.exception( + 'Failed to list subscriptions for enterprise %s, response: %s, exc: %s', + enterprise_customer_uuid, safe_error_response_content(exc), exc, + ) + raise + + def update_subscription_plan(self, subscription_uuid, salesforce_opportunity_line_item=None, **kwargs): + """ + Partially update a SubscriptionPlan via the provisioning-admins endpoint. + + Accepts any fields supported by license-manager for SubscriptionPlan patching, including: + - is_active (bool) + - change_reason (str) + - salesforce_opportunity_line_item (str) + + Args: + subscription_uuid (str): Subscription plan UUID. + salesforce_opportunity_line_item (str|None): Optional Salesforce OLI to associate. + **kwargs: Additional JSON fields to patch. + + Returns: + dict: JSON response from license-manager. + """ + payload = {**kwargs} + if salesforce_opportunity_line_item: + payload['salesforce_opportunity_line_item'] = salesforce_opportunity_line_item + + if not payload: + raise ValueError('Must supply payload to update subscription plan') + + endpoint = f"{self.subscription_provisioning_endpoint}{subscription_uuid}/" + try: + response = self.client.patch( + endpoint, + json=payload, + timeout=settings.LICENSE_MANAGER_CLIENT_TIMEOUT, + ) + response.raise_for_status() + return response.json() + except requests.exceptions.HTTPError as exc: + logger.exception( + 'Failed to update subscription %s, payload=%s, response: %s, exc: %s', + subscription_uuid, payload, safe_error_response_content(exc), exc, + ) + raise + def get_subscription_overview(self, subscription_uuid): """ Call license-manager API for data about a SubscriptionPlan. @@ -212,46 +277,6 @@ def create_subscription_plan( exc, ) from exc - def update_subscription_plan(self, subscription_uuid, salesforce_opportunity_line_item): - """ - Update a SubscriptionPlan's Salesforce Opportunity Line Item. - - Arguments: - subscription_uuid (str): UUID of the SubscriptionPlan to update - salesforce_opportunity_line_item (str): Salesforce OLI to associate with the plan - - Returns: - dict: Updated subscription plan data from the API - - Raises: - APIClientException: If the API call fails - """ - endpoint = f"{self.api_base_url}subscription-plans/{subscription_uuid}/" - payload = { - 'salesforce_opportunity_line_item': salesforce_opportunity_line_item - } - - try: - response = self.client.patch( - endpoint, - json=payload, - timeout=settings.LICENSE_MANAGER_CLIENT_TIMEOUT - ) - response.raise_for_status() - return response.json() - except requests.exceptions.HTTPError as exc: - logger.exception( - 'Failed to update subscription plan %s with OLI %s, response %s, exception: %s', - subscription_uuid, - salesforce_opportunity_line_item, - safe_error_response_content(exc), - exc, - ) - raise APIClientException( - f'Could not update subscription plan {subscription_uuid}', - exc, - ) from exc - def create_subscription_plan_renewal( self, prior_subscription_plan_uuid: str, @@ -321,6 +346,25 @@ def create_subscription_plan_renewal( ) raise + def retrieve_subscription_plan_renewal(self, renewal_id: int) -> dict: + """Fetch the details for a specific subscription plan renewal.""" + endpoint = f'{self.subscription_plan_renewal_provisioning_endpoint}{renewal_id}/' + try: + response = self.client.get(endpoint, timeout=settings.LICENSE_MANAGER_CLIENT_TIMEOUT) + response.raise_for_status() + return response.json() + except requests.exceptions.HTTPError as exc: + logger.exception( + 'Failed to retrieve subscription plan renewal %s, response: %s, exc: %s', + renewal_id, + safe_error_response_content(exc), + exc, + ) + raise APIClientException( + f'Could not fetch subscription plan renewal {renewal_id}', + exc, + ) from exc + def process_subscription_plan_renewal(self, renewal_id: int) -> dict: """ Process an existing subscription plan renewal via the license-manager service. diff --git a/enterprise_access/apps/api_client/tests/test_license_manager_client.py b/enterprise_access/apps/api_client/tests/test_license_manager_client.py index 83224730..895dfbaf 100644 --- a/enterprise_access/apps/api_client/tests/test_license_manager_client.py +++ b/enterprise_access/apps/api_client/tests/test_license_manager_client.py @@ -90,6 +90,68 @@ def test_create_customer_agreement(self, mock_oauth_client): json=expected_payload, ) + @mock.patch('enterprise_access.apps.api_client.base_oauth.OAuthAPIClient', autospec=True) + def test_list_subscriptions_params(self, mock_oauth_client): + mock_get = mock_oauth_client.return_value.get + mock_get.return_value.json.return_value = {'results': []} + + lm_client = LicenseManagerApiClient() + enterprise_uuid = 'ec-uuid-123' + + # Should only set enterprise_customer_uuid parameter + result = lm_client.list_subscriptions(enterprise_uuid) + self.assertEqual(result, {'results': []}) + + # Verify URL and params + expected_url = ( + 'http://license-manager.example.com' + '/api/v1/subscriptions/' + ) + mock_get.assert_called_with( + expected_url, + params={'enterprise_customer_uuid': enterprise_uuid}, + timeout=settings.LICENSE_MANAGER_CLIENT_TIMEOUT, + ) + + @mock.patch('enterprise_access.apps.api_client.base_oauth.OAuthAPIClient', autospec=True) + def test_update_subscription_plan_patch(self, mock_oauth_client): + mock_patch = mock_oauth_client.return_value.patch + mock_patch.return_value.json.return_value = {'uuid': 'plan-uuid', 'is_active': False} + + lm_client = LicenseManagerApiClient() + payload = {'is_active': False, 'change_reason': 'delayed_payment'} + result = lm_client.update_subscription_plan('plan-uuid', **payload) + + self.assertEqual(result, mock_patch.return_value.json.return_value) + expected_url = ( + 'http://license-manager.example.com' + '/api/v1/provisioning-admins/subscriptions/plan-uuid/' + ) + mock_patch.assert_called_once_with( + expected_url, + json=payload, + timeout=settings.LICENSE_MANAGER_CLIENT_TIMEOUT, + ) + + @mock.patch('enterprise_access.apps.api_client.base_oauth.OAuthAPIClient', autospec=True) + def test_retrieve_subscription_plan_renewal(self, mock_oauth_client): + mock_get = mock_oauth_client.return_value.get + mock_get.return_value.json.return_value = {'id': 42} + + lm_client = LicenseManagerApiClient() + renewal_id = 42 + result = lm_client.retrieve_subscription_plan_renewal(renewal_id) + + self.assertEqual(result, {'id': 42}) + expected_url = ( + 'http://license-manager.example.com' + '/api/v1/provisioning-admins/subscription-plan-renewals/42/' + ) + mock_get.assert_called_once_with( + expected_url, + timeout=settings.LICENSE_MANAGER_CLIENT_TIMEOUT, + ) + @mock.patch('enterprise_access.apps.api_client.base_oauth.OAuthAPIClient', autospec=True) def test_create_subscription_plan(self, mock_oauth_client): mock_post = mock_oauth_client.return_value.post diff --git a/enterprise_access/apps/customer_billing/stripe_event_handlers.py b/enterprise_access/apps/customer_billing/stripe_event_handlers.py index 6c530be4..e066467a 100644 --- a/enterprise_access/apps/customer_billing/stripe_event_handlers.py +++ b/enterprise_access/apps/customer_billing/stripe_event_handlers.py @@ -16,6 +16,7 @@ ) from enterprise_access.apps.customer_billing.stripe_event_types import StripeEventType from enterprise_access.apps.customer_billing.tasks import ( + send_billing_error_email_task, send_payment_receipt_email, send_trial_cancellation_email_task, send_trial_end_and_subscription_started_email_task, @@ -126,6 +127,195 @@ def link_event_data_to_checkout_intent(event, checkout_intent): event_data.save() # this triggers a post_save signal that updates the related summary record +def _get_subscription_plan_uuid_from_checkout_intent(checkout_intent: CheckoutIntent | None) -> str | None: + """Return the anchor SubscriptionPlan UUID using the latest StripeEventSummary only.""" + if not checkout_intent: + return None + + try: + summary_with_uuid = ( + StripeEventSummary.objects + .filter(checkout_intent=checkout_intent, subscription_plan_uuid__isnull=False) + .order_by('-stripe_event_created_at') + .first() + ) + if summary_with_uuid and summary_with_uuid.subscription_plan_uuid: + return str(summary_with_uuid.subscription_plan_uuid) + except Exception as exc: # pylint: disable=broad-exception-caught + logger.exception( + "Failed resolving subscription plan uuid from StripeEventSummary for CheckoutIntent %s: %s", + checkout_intent.id, + exc, + ) + + return None + + +def _get_current_plan_uuid(checkout_intent: CheckoutIntent | None) -> str | None: + """Return the plan currently in effect for the checkout intent.""" + anchor_uuid = _get_subscription_plan_uuid_from_checkout_intent(checkout_intent) + if not (checkout_intent and anchor_uuid): + return None + + current_plan_uuid = anchor_uuid + processed_renewals = ( + SelfServiceSubscriptionRenewal.objects + .filter( + checkout_intent=checkout_intent, + processed_at__isnull=False, + renewed_subscription_plan_uuid__isnull=False, + ) + .order_by('processed_at', 'created') + ) + + for renewal in processed_renewals: + current_plan_uuid = str(renewal.renewed_subscription_plan_uuid) + + return current_plan_uuid + + +def _get_future_plan_uuids(checkout_intent: CheckoutIntent | None, current_plan_uuid: str | None) -> list[str]: + """Gather future plan UUIDs using pending renewal summaries.""" + if not (checkout_intent and current_plan_uuid): + return [] + + pending_summaries = ( + StripeEventSummary.objects.filter( + checkout_intent=checkout_intent, + stripe_event_data__renewal_processing__processed_at__isnull=True, + stripe_event_data__renewal_processing__prior_subscription_plan_uuid__isnull=False, + stripe_event_data__renewal_processing__renewed_subscription_plan_uuid__isnull=False, + ) + .order_by('stripe_event_created_at', 'event_id') + .select_related('stripe_event_data__renewal_processing') + ) + parent_to_child: dict[str, str] = {} + + for summary in pending_summaries: + renewal = summary.stripe_event_data.renewal_processing + parent_uuid = str(renewal.prior_subscription_plan_uuid) + child_uuid = str(renewal.renewed_subscription_plan_uuid) + + if parent_uuid == child_uuid: + continue + + parent_to_child.setdefault(parent_uuid, child_uuid) + + future_plan_uuids: list[str] = [] + visited: set[str] = set() + next_parent = str(current_plan_uuid) + + while next_parent in parent_to_child: + child_uuid = parent_to_child[next_parent] + if child_uuid in visited: + break + future_plan_uuids.append(child_uuid) + visited.add(child_uuid) + next_parent = child_uuid + + return future_plan_uuids + + +def cancel_all_future_plans( + enterprise_uuid: str, + reason: str = 'delayed_payment', + subscription_id_for_logs: str | None = None, + checkout_intent: CheckoutIntent | None = None, +) -> list[str]: + """ + Deactivate all future renewal plans descending from the anchor plan for this enterprise. + Returns list of deactivated descendant plan UUIDs (may be empty). + """ + deactivated: list[str] = [] + try: + current_plan_uuid = _get_current_plan_uuid(checkout_intent) + if not current_plan_uuid: + logger.warning( + ( + "Skipping future plan cancellation for enterprise %s (subscription %s): " + "unable to resolve the current SubscriptionPlan UUID from CheckoutIntent." + ), + enterprise_uuid, + subscription_id_for_logs, + ) + return deactivated + + future_plan_uuids = _get_future_plan_uuids(checkout_intent, current_plan_uuid) + + if not future_plan_uuids: + return deactivated + + client = LicenseManagerApiClient() + for future_uuid in future_plan_uuids: + try: + client.update_subscription_plan( + future_uuid, + is_active=False, + change_reason=reason, + ) + deactivated.append(str(future_uuid)) + logger.info( + "Deactivated future plan %s for enterprise %s (reason=%s) (subscription %s)", + future_uuid, + enterprise_uuid, + reason, + subscription_id_for_logs, + ) + except Exception as exc: # pylint: disable=broad-except + logger.exception( + "Failed to deactivate future plan %s for enterprise %s (reason=%s): %s", + future_uuid, + enterprise_uuid, + reason, + exc, + ) + except Exception as exc: # pylint: disable=broad-except + logger.exception( + "Unexpected error canceling future plans for enterprise %s (subscription %s): %s", + enterprise_uuid, + subscription_id_for_logs, + exc, + ) + + return deactivated + + +def _handle_past_due_transition( + subscription, + checkout_intent: CheckoutIntent, + prior_status: str | None, +) -> None: + """Process the transition to past_due state.""" + current_status = subscription.get("status") + if current_status != 'past_due' or prior_status == 'past_due': + return + + try: + send_billing_error_email_task.delay(checkout_intent_id=checkout_intent.id) + except Exception as exc: # pylint: disable=broad-exception-caught + logger.exception( + "Failed to enqueue billing error email for CheckoutIntent %s: %s", + checkout_intent.id, + exc, + ) + + enterprise_uuid = checkout_intent.enterprise_uuid + if not enterprise_uuid: + logger.error( + "Cannot deactivate future plans for subscription %s: CheckoutIntent %s missing enterprise_uuid", + subscription.get('id'), + checkout_intent.id, + ) + return + + cancel_all_future_plans( + enterprise_uuid=str(enterprise_uuid), + reason='delayed_payment', + subscription_id_for_logs=subscription.get('id'), + checkout_intent=checkout_intent, + ) + + class StripeEventHandler: """ Container for Stripe event handler logic. @@ -335,6 +525,8 @@ def subscription_updated(event: stripe.Event) -> None: f"Subscription {subscription.id} already canceled (status unchanged), skipping cancellation email" ) + _handle_past_due_transition(subscription, checkout_intent, prior_status) + @on_stripe_event("customer.subscription.deleted") @staticmethod def subscription_deleted(event: stripe.Event) -> None: diff --git a/enterprise_access/apps/customer_billing/tasks.py b/enterprise_access/apps/customer_billing/tasks.py index 5f65e0a0..02d89655 100644 --- a/enterprise_access/apps/customer_billing/tasks.py +++ b/enterprise_access/apps/customer_billing/tasks.py @@ -23,126 +23,138 @@ logger = logging.getLogger(__name__) -@shared_task(base=LoggedTaskWithRetry) -def send_payment_receipt_email( - invoice_data, - subscription_data, - enterprise_customer_name, - enterprise_slug, -): - """ - Send payment receipt emails to enterprise admins after successful payment. - - Args: - invoice_data (dict): The Stripe invoice data containing payment details - subscription_data (dict): The Stripe subscription data - enterprise_customer_name (str): Name of the enterprise organization - enterprise_slug (str): URL-friendly slug for the enterprise - - Raises: - BrazeClientError: If there's an error communicating with Braze - Exception: For any other unexpected errors during email sending - """ - logger.info( - 'Sending payment receipt confirmation email for enterprise %s (slug: %s)', - enterprise_customer_name, - enterprise_slug, - ) +def _get_admin_recipients(enterprise_slug: str | None) -> list: + """Return Braze recipients for all admins of the given enterprise slug.""" + if not enterprise_slug: + logger.error( + "Email not sent: Missing enterprise slug; cannot look up admin recipients.", + ) + return [] - braze_client = BrazeApiClient() lms_client = LmsApiClient() + try: + enterprise_data = lms_client.get_enterprise_customer_data( + enterprise_customer_slug=enterprise_slug + ) + except Exception as exc: # pylint: disable=broad-exception-caught + logger.error( + "Failed to fetch enterprise data for slug %s: %s. Cannot create recipients.", + enterprise_slug, + str(exc), + ) + return [] - enterprise_data = lms_client.get_enterprise_customer_data(enterprise_customer_slug=enterprise_slug) - admin_users = enterprise_data.get('admin_users', []) - + admin_users = enterprise_data.get("admin_users", []) if not admin_users: logger.error( - 'Payment receipt confirmation email not sent: No admin users found for enterprise %s (slug: %s)', - enterprise_customer_name, + "Email not sent: No admin users found for enterprise slug %s. Verify admin setup in LMS.", enterprise_slug, ) - return - - # Format the payment date - payment_date = datetime.fromtimestamp(invoice_data.get('created', 0)) - formatted_date = format_datetime_obj(payment_date, '%d %B %Y') - - # Get payment method details - payment_method = invoice_data.get('payment_intent', {}).get('payment_method', {}) - card_details = payment_method.get('card', {}) - payment_method_display = f"{card_details.get('brand', 'Card')} - {card_details.get('last4', '****')}" + return [] - # Get subscription details - quantity = subscription_data.get('quantity', 0) - price_per_license = subscription_data.get('plan', {}).get('amount', 0) - total_amount = quantity * price_per_license - - # Get billing address - billing_details = payment_method.get('billing_details', {}) - address = billing_details.get('address', {}) - billing_address = '\n'.join(filter(None, [ - address.get('line1', ''), - address.get('line2', ''), - f"{address.get('city', '')}, {address.get('state', '')} {address.get('postal_code', '')}", - address.get('country', '') - ])) - - braze_trigger_properties = { - 'total_paid_amount': cents_to_dollars(total_amount), - 'date_paid': formatted_date, - 'payment_method': payment_method_display, - 'license_count': quantity, - 'price_per_license': cents_to_dollars(price_per_license), - 'customer_name': billing_details.get('name', ''), - 'organization': enterprise_customer_name, - 'billing_address': billing_address, - 'enterprise_admin_portal_url': f'{settings.ENTERPRISE_ADMIN_PORTAL_URL}/{enterprise_slug}', - 'receipt_number': invoice_data.get('id', ''), - } - - recipients = [] + braze_client = BrazeApiClient() + recipients: list = [] for admin in admin_users: try: - admin_email = admin['email'] + admin_email = admin.get("email") recipient = braze_client.create_braze_recipient( user_email=admin_email, - lms_user_id=admin.get('lms_user_id'), + lms_user_id=admin.get("lms_user_id"), ) recipients.append(recipient) - except Exception as exc: # pylint: disable=broad-exception-caught + except Exception as exc: # pylint: disable-broad-exception-caught logger.warning( - 'Failed to create Braze recipient for admin email %s: %s', - admin_email, - str(exc) + "Failed to create Braze recipient for admin email %s: %s", + admin.get("email"), + str(exc), ) if not recipients: logger.error( - 'Payment receipt confirmation email not sent: No valid Braze recipients created for enterprise %s.' - ' Check admin email errors above.', - enterprise_customer_name + "Email not sent: No valid Braze recipients created for enterprise slug %s. Check admin email errors above.", + enterprise_slug, ) - return + return recipients + + +def _get_billing_portal_url(checkout_intent): + """ + Generate Stripe billing portal URL for the customer to restart their subscription. + Falls back to learner portal if billing portal creation fails. + + Args: + checkout_intent (CheckoutIntent): The checkout intent record + + Returns: + str: Stripe billing portal URL or fallback learner portal URL + """ + # Construct the return URL where user will be redirected after using the portal + return_url = ( + f"{settings.ENTERPRISE_LEARNER_PORTAL_URL}/{checkout_intent.enterprise_slug}" + if checkout_intent.enterprise_slug + else settings.ENTERPRISE_LEARNER_PORTAL_URL + ) + + # Use the reusable API helper to create the portal session try: - braze_client.send_campaign_message( - settings.BRAZE_ENTERPRISE_PROVISION_PAYMENT_RECEIPT_CAMPAIGN, - recipients=recipients, - trigger_properties=braze_trigger_properties, + portal_session = create_stripe_billing_portal_session( + checkout_intent=checkout_intent, + return_url=return_url, ) - logger.info( - 'Successfully sent payment receipt confirmation emails for enterprise %s to %d recipients', - enterprise_customer_name, - len(recipients) + return portal_session.url + except (ValueError, stripe.StripeError) as exc: + logger.warning( + "Could not create billing portal URL for CheckoutIntent %s: %s. " + "Using fallback learner portal URL.", + checkout_intent.id, + str(exc), ) + return return_url - except Exception as exc: - logger.exception( - 'Braze API error: Failed to send payment receipt confirmation email for enterprise %s. Error: %s', - enterprise_customer_name, - str(exc) - ) - raise + +def _prepare_admin_recipients_and_portal(checkout_intent_or_id): + """ + Prepare Braze recipients for enterprise admins and the Stripe billing portal URL. + + Centralizes the common logic used by multiple email tasks: + - Load CheckoutIntent + - Fetch enterprise admins from LMS + - Create Braze recipients + - Build Stripe billing portal URL (with learner portal fallback) + + Args: + checkout_intent_or_id (CheckoutIntent | int): CheckoutIntent instance or ID + + Returns: + tuple[list, str | None, str | None]: (recipients, enterprise_slug, portal_url) + - recipients: list of Braze recipients (empty if any prerequisite fails) + - enterprise_slug: slug string if available; otherwise None + - portal_url: URL string if available; otherwise None + """ + if isinstance(checkout_intent_or_id, CheckoutIntent): + checkout_intent = checkout_intent_or_id + checkout_intent_id = checkout_intent.id + else: + checkout_intent_id = checkout_intent_or_id + try: + checkout_intent = CheckoutIntent.objects.get(id=checkout_intent_id) + except CheckoutIntent.DoesNotExist: + logger.error( + "Email not sent: CheckoutIntent %s not found", + checkout_intent_id, + ) + return [], None, None + + enterprise_slug = checkout_intent.enterprise_slug + # Build the portal URL early; it doesn't depend on LMS call success + portal_url = _get_billing_portal_url(checkout_intent) + + recipients = _get_admin_recipients(enterprise_slug) + if not recipients: + return [], enterprise_slug, portal_url + + return recipients, enterprise_slug, portal_url @shared_task(base=LoggedTaskWithRetry) @@ -258,41 +270,6 @@ def send_enterprise_provision_signup_confirmation_email( raise -def _get_billing_portal_url(checkout_intent): - """ - Generate Stripe billing portal URL for the customer to restart their subscription. - Falls back to learner portal if billing portal creation fails. - - Args: - checkout_intent (CheckoutIntent): The checkout intent record - - Returns: - str: Stripe billing portal URL or fallback learner portal URL - """ - # Construct the return URL where user will be redirected after using the portal - return_url = ( - f"{settings.ENTERPRISE_LEARNER_PORTAL_URL}/{checkout_intent.enterprise_slug}" - if checkout_intent.enterprise_slug - else settings.ENTERPRISE_LEARNER_PORTAL_URL - ) - - # Use the reusable API helper to create the portal session - try: - portal_session = create_stripe_billing_portal_session( - checkout_intent=checkout_intent, - return_url=return_url, - ) - return portal_session.url - except (ValueError, stripe.StripeError) as exc: - logger.warning( - "Could not create billing portal URL for CheckoutIntent %s: %s. " - "Using fallback learner portal URL.", - checkout_intent.id, - str(exc), - ) - return return_url - - @shared_task(base=LoggedTaskWithRetry) def send_trial_cancellation_email_task( checkout_intent_id, trial_end_timestamp @@ -313,88 +290,30 @@ def send_trial_cancellation_email_task( BrazeClientError: If there's an error communicating with Braze Exception: For any other unexpected errors during email sending """ - try: - checkout_intent = CheckoutIntent.objects.get(id=checkout_intent_id) - except CheckoutIntent.DoesNotExist: - logger.error( - "Email not sent: CheckoutIntent %s not found for trial cancellation email", - checkout_intent_id, - ) + recipients, enterprise_slug, portal_url = _prepare_admin_recipients_and_portal(checkout_intent_id) + + if not recipients: return - enterprise_slug = checkout_intent.enterprise_slug logger.info( "Sending trial cancellation email for CheckoutIntent %s (enterprise slug: %s)", checkout_intent_id, enterprise_slug, ) - braze_client = BrazeApiClient() - lms_client = LmsApiClient() - - # Fetch enterprise customer data to get admin users - try: - enterprise_data = lms_client.get_enterprise_customer_data( - enterprise_customer_slug=enterprise_slug - ) - except Exception as exc: # pylint: disable=broad-exception-caught - logger.error( - "Failed to fetch enterprise data for slug %s: %s. Cannot send cancellation email.", - enterprise_slug, - str(exc), - ) - return - - admin_users = enterprise_data.get("admin_users", []) - - if not admin_users: - logger.error( - "Cancellation email not sent: No admin users found for enterprise slug %s. " - "Verify admin setup in LMS.", - enterprise_slug, - ) - return - # Format trial end date for email template trial_end_date = datetime.fromtimestamp(trial_end_timestamp).strftime( "%B %d, %Y" ) - # Generate Stripe billing portal URL for restarting subscription - restart_url = _get_billing_portal_url(checkout_intent) - braze_trigger_properties = { "trial_end_date": trial_end_date, - "restart_subscription_url": restart_url, + "restart_subscription_url": portal_url, } - # Create Braze recipients for all admin users - recipients = [] - for admin in admin_users: - try: - admin_email = admin["email"] - recipient = braze_client.create_braze_recipient( - user_email=admin_email, - lms_user_id=admin.get("lms_user_id"), - ) - recipients.append(recipient) - except Exception as exc: # pylint: disable=broad-exception-caught - logger.warning( - "Failed to create Braze recipient for admin email %s: %s", - admin_email, - str(exc), - ) - - if not recipients: - logger.error( - "Cancellation email not sent: No valid Braze recipients created for enterprise slug %s. " - "Check admin email errors above.", - enterprise_slug, - ) - return - # Send the campaign message to all admin recipients try: + braze_client = BrazeApiClient() braze_client.send_campaign_message( settings.BRAZE_TRIAL_CANCELLATION_CAMPAIGN, recipients=recipients, @@ -416,7 +335,56 @@ def send_trial_cancellation_email_task( @shared_task(base=LoggedTaskWithRetry) -def send_trial_ending_reminder_email_task(checkout_intent_id): # pylint: disable=too-many-statements +def send_billing_error_email_task(checkout_intent_id: int): + """ + Send Braze email notification when a subscription encounters a billing error + (e.g., transitions to past_due). + + The email includes a link to the Stripe billing portal so admins can fix their + payment method and restart the subscription. + + Args: + checkout_intent_id (int): ID of the CheckoutIntent record + """ + recipients, enterprise_slug, portal_url = _prepare_admin_recipients_and_portal(checkout_intent_id) + + if not recipients: + return + + logger.info( + "Sending billing error email for CheckoutIntent %s (enterprise slug: %s)", + checkout_intent_id, + enterprise_slug, + ) + + braze_trigger_properties = { + "restart_subscription_url": portal_url, + } + + # Send the campaign message to all admin recipients + try: + braze_client = BrazeApiClient() + braze_client.send_campaign_message( + settings.BRAZE_BILLING_ERROR_CAMPAIGN, + recipients=recipients, + trigger_properties=braze_trigger_properties, + ) + logger.info( + "Successfully sent billing error emails for CheckoutIntent %s to %d recipients", + checkout_intent_id, + len(recipients), + ) + except Exception as exc: # pylint: disable=broad-exception-caught + logger.exception( + "Braze API error: Failed to send billing error email for CheckoutIntent %s. Error: %s", + checkout_intent_id, + str(exc), + ) + raise + + +@shared_task(base=LoggedTaskWithRetry) +def send_trial_ending_reminder_email_task(checkout_intent_id): """ Send Braze email notification 72 hours before trial subscription ends. @@ -447,30 +415,9 @@ def send_trial_ending_reminder_email_task(checkout_intent_id): # pylint: disabl enterprise_slug, ) - braze_client = BrazeApiClient() - lms_client = LmsApiClient() - - # Fetch enterprise customer data to get admin users - try: - enterprise_data = lms_client.get_enterprise_customer_data( - enterprise_customer_slug=enterprise_slug - ) - except Exception as exc: # pylint: disable=broad-exception-caught - logger.error( - "Failed to fetch enterprise data for slug %s: %s. Cannot send trial ending reminder email.", - enterprise_slug, - str(exc), - ) - return - - admin_users = enterprise_data.get("admin_users", []) - - if not admin_users: - logger.error( - "Trial ending reminder email not sent: No admin users found for enterprise slug %s. " - "Verify admin setup in LMS.", - enterprise_slug, - ) + # DRY: reuse shared helper to build recipients and billing portal URL + recipients, _slug, subscription_management_url = _prepare_admin_recipients_and_portal(checkout_intent) + if not recipients: return # Retrieve subscription details from Stripe @@ -551,8 +498,6 @@ def send_trial_ending_reminder_email_task(checkout_intent_id): # pylint: disabl ) return - subscription_management_url = _get_billing_portal_url(checkout_intent) - braze_trigger_properties = { "renewal_date": renewal_date, "subscription_management_url": subscription_management_url, @@ -561,33 +506,8 @@ def send_trial_ending_reminder_email_task(checkout_intent_id): # pylint: disabl "total_paid_amount": total_paid_amount, } - # Create Braze recipients for all admin users - recipients = [] - for admin in admin_users: - try: - admin_email = admin["email"] - recipient = braze_client.create_braze_recipient( - user_email=admin_email, - lms_user_id=admin.get("lms_user_id"), - ) - recipients.append(recipient) - except Exception as exc: # pylint: disable=broad-exception-caught - logger.warning( - "Failed to create Braze recipient for admin email %s: %s", - admin_email, - str(exc), - ) - - if not recipients: - logger.error( - "Trial ending reminder email not sent: No valid Braze recipients created for enterprise slug %s. " - "Check admin email errors above.", - enterprise_slug, - ) - return - try: - braze_client.send_campaign_message( + BrazeApiClient().send_campaign_message( settings.BRAZE_ENTERPRISE_PROVISION_TRIAL_ENDING_SOON_CAMPAIGN, recipients=recipients, trigger_properties=braze_trigger_properties, @@ -717,3 +637,100 @@ def send_trial_end_and_subscription_started_email_task( enterprise_slug, str(exc) ) raise + + +@shared_task(base=LoggedTaskWithRetry) +def send_payment_receipt_email( + invoice_data, + subscription_data, + enterprise_customer_name, + enterprise_slug, +): + """ + Send payment receipt emails to enterprise admins after successful payment. + + Args: + invoice_data (dict): The Stripe invoice data containing payment details + subscription_data (dict): The Stripe subscription data + enterprise_customer_name (str): Name of the enterprise organization + enterprise_slug (str): URL-friendly slug for the enterprise + + Raises: + BrazeClientError: If there's an error communicating with Braze + Exception: For any other unexpected errors during email sending + """ + logger.info( + 'Sending payment receipt confirmation email for enterprise %s (slug: %s)', + enterprise_customer_name, + enterprise_slug, + ) + + braze_client = BrazeApiClient() + + recipients = _get_admin_recipients(enterprise_slug) + if not recipients: + logger.error( + 'Payment receipt confirmation email not sent: No admin users found for enterprise %s (slug: %s)', + enterprise_customer_name, + enterprise_slug, + ) + return + + # Format the payment date + payment_date = datetime.fromtimestamp(invoice_data.get('created', 0)) + formatted_date = format_datetime_obj(payment_date, '%d %B %Y') + + # Get payment method details + payment_method = invoice_data.get('payment_intent', {}).get('payment_method', {}) + card_details = payment_method.get('card', {}) + payment_method_display = f"{card_details.get('brand', 'Card')} - {card_details.get('last4', '****')}" + + # Get subscription details + quantity = subscription_data.get('quantity', 0) + price_per_license = subscription_data.get('plan', {}).get('amount', 0) + total_amount = quantity * price_per_license + + # Get billing address + billing_details = payment_method.get('billing_details', {}) + address = billing_details.get('address', {}) + billing_address = '\n'.join(filter(None, [ + address.get('line1', ''), + address.get('line2', ''), + f"{address.get('city', '')}, {address.get('state', '')} {address.get('postal_code', '')}", + address.get('country', '') + ])) + + braze_trigger_properties = { + 'total_paid_amount': cents_to_dollars(total_amount), + 'date_paid': formatted_date, + 'payment_method': payment_method_display, + 'license_count': quantity, + 'price_per_license': cents_to_dollars(price_per_license), + 'customer_name': billing_details.get('name', ''), + 'organization': enterprise_customer_name, + 'billing_address': billing_address, + 'enterprise_admin_portal_url': f'{settings.ENTERPRISE_ADMIN_PORTAL_URL}/{enterprise_slug}', + 'receipt_number': invoice_data.get('id', ''), + } + + # recipients already prepared above + + try: + braze_client.send_campaign_message( + settings.BRAZE_ENTERPRISE_PROVISION_PAYMENT_RECEIPT_CAMPAIGN, + recipients=recipients, + trigger_properties=braze_trigger_properties, + ) + logger.info( + 'Successfully sent payment receipt confirmation emails for enterprise %s to %d recipients', + enterprise_customer_name, + len(recipients) + ) + + except Exception as exc: + logger.exception( + 'Braze API error: Failed to send payment receipt confirmation email for enterprise %s. Error: %s', + enterprise_customer_name, + str(exc) + ) + raise diff --git a/enterprise_access/apps/customer_billing/tests/test_stripe_event_handlers.py b/enterprise_access/apps/customer_billing/tests/test_stripe_event_handlers.py index 57cb232d..a00c7a8b 100644 --- a/enterprise_access/apps/customer_billing/tests/test_stripe_event_handlers.py +++ b/enterprise_access/apps/customer_billing/tests/test_stripe_event_handlers.py @@ -1,6 +1,7 @@ """ Unit tests for Stripe event handlers. """ +import uuid from contextlib import nullcontext from datetime import timedelta from random import randint @@ -21,8 +22,14 @@ StripeEventData, StripeEventSummary ) -from enterprise_access.apps.customer_billing.stripe_event_handlers import StripeEventHandler +from enterprise_access.apps.customer_billing.stripe_event_handlers import ( + StripeEventHandler, + _get_future_plan_uuids, + _get_subscription_plan_uuid_from_checkout_intent, + cancel_all_future_plans +) from enterprise_access.apps.customer_billing.tests.factories import ( + CheckoutIntentFactory, StripeEventDataFactory, StripeEventSummaryFactory, get_stripe_object_for_event_type @@ -289,7 +296,6 @@ def test_subscription_updated_sends_cancellation_email_for_canceled_trial( StripeEventHandler.dispatch(mock_event) - # Verify the email task was queued mock_email_task.delay.assert_called_once_with( checkout_intent_id=self.checkout_intent.id, trial_end_timestamp=trial_end_timestamp, @@ -316,6 +322,271 @@ def test_subscription_updated_skips_email_when_no_trial_end(self): StripeEventHandler.dispatch(mock_event) mock_task.delay.assert_not_called() + @mock.patch( + "enterprise_access.apps.customer_billing.stripe_event_handlers.cancel_all_future_plans" + ) + @mock.patch( + "enterprise_access.apps.customer_billing.stripe_event_handlers.send_billing_error_email_task" + ) + @mock.patch.object(CheckoutIntent, "previous_summary") + def test_subscription_updated_past_due_cancels_future_plans( + self, + mock_prev_summary, + mock_send_billing_error, + mock_cancel_future_plans, + ): + """Past-due transition cancels all future plans and sends billing email.""" + subscription_id = "sub_test_past_due_123" + subscription_data = { + "id": subscription_id, + "status": "past_due", + "metadata": self._create_mock_stripe_subscription(self.checkout_intent.id), + } + + mock_prev_summary.return_value = AttrDict({"subscription_status": "active"}) + self.checkout_intent.enterprise_uuid = uuid.uuid4() + self.checkout_intent.save(update_fields=["enterprise_uuid"]) + mock_cancel_future_plans.return_value = ["future-plan-uuid"] + + mock_event = self._create_mock_stripe_event( + "customer.subscription.updated", subscription_data + ) + + StripeEventHandler.dispatch(mock_event) + + mock_send_billing_error.delay.assert_called_once_with(checkout_intent_id=self.checkout_intent.id) + mock_cancel_future_plans.assert_called_once_with( + enterprise_uuid=str(self.checkout_intent.enterprise_uuid), + reason="delayed_payment", + subscription_id_for_logs=subscription_id, + checkout_intent=self.checkout_intent, + ) + + @mock.patch( + "enterprise_access.apps.customer_billing.stripe_event_handlers.cancel_all_future_plans" + ) + @mock.patch( + "enterprise_access.apps.customer_billing.stripe_event_handlers.send_billing_error_email_task" + ) + @mock.patch.object(CheckoutIntent, "previous_summary") + def test_subscription_updated_past_due_no_status_change_skips_cancellation( + self, + mock_prev_summary, + mock_send_billing_error, + mock_cancel_future_plans, + ): + """If status is already past_due, no emails or cancellations are triggered.""" + subscription_data = { + "id": "sub_test_past_due_unchanged", + "status": "past_due", + "metadata": self._create_mock_stripe_subscription(self.checkout_intent.id), + } + + mock_prev_summary.return_value = AttrDict({"subscription_status": "past_due"}) + self.checkout_intent.enterprise_uuid = uuid.uuid4() + self.checkout_intent.save(update_fields=["enterprise_uuid"]) + + mock_event = self._create_mock_stripe_event( + "customer.subscription.updated", subscription_data + ) + + StripeEventHandler.dispatch(mock_event) + + mock_send_billing_error.delay.assert_not_called() + mock_cancel_future_plans.assert_not_called() + + @mock.patch( + "enterprise_access.apps.customer_billing.stripe_event_handlers.cancel_all_future_plans" + ) + @mock.patch( + "enterprise_access.apps.customer_billing.stripe_event_handlers.send_billing_error_email_task" + ) + @mock.patch.object(CheckoutIntent, "previous_summary") + def test_subscription_updated_past_due_missing_enterprise_uuid( + self, + mock_prev_summary, + mock_send_billing_error, + mock_cancel_future_plans, + ): + """If the checkout intent is missing enterprise UUID, no LM calls are made.""" + subscription_data = { + "id": "sub_test_past_due_missing_ent", + "status": "past_due", + "metadata": self._create_mock_stripe_subscription(self.checkout_intent.id), + } + + mock_prev_summary.return_value = AttrDict({"subscription_status": "active"}) + self.checkout_intent.enterprise_uuid = None + mock_cancel_future_plans.return_value = ["future-plan-uuid"] + + mock_event = self._create_mock_stripe_event( + "customer.subscription.updated", subscription_data + ) + + StripeEventHandler.dispatch(mock_event) + + mock_send_billing_error.delay.assert_called_once_with(checkout_intent_id=self.checkout_intent.id) + mock_cancel_future_plans.assert_not_called() + + def test_get_future_plan_uuids_collects_descendants(self): + """Future plan helper traverses renewal graph depth-first.""" + checkout_intent = CheckoutIntentFactory() + current_plan_uuid = uuid.uuid4() + future_one = uuid.uuid4() + future_two = uuid.uuid4() + future_three = uuid.uuid4() + + base_time = timezone.now() + + event_one = StripeEventDataFactory(checkout_intent=checkout_intent) + SelfServiceSubscriptionRenewal.objects.create( + checkout_intent=checkout_intent, + subscription_plan_renewal_id=301, + stripe_event_data=event_one, + stripe_subscription_id="sub-future-1", + prior_subscription_plan_uuid=current_plan_uuid, + renewed_subscription_plan_uuid=future_one, + ) + StripeEventSummaryFactory( + stripe_event_data=event_one, + stripe_event_created_at=base_time, + ) + event_two_data = StripeEventDataFactory(checkout_intent=checkout_intent) + SelfServiceSubscriptionRenewal.objects.create( + checkout_intent=checkout_intent, + subscription_plan_renewal_id=302, + stripe_event_data=event_two_data, + stripe_subscription_id="sub-future-2", + prior_subscription_plan_uuid=future_one, + renewed_subscription_plan_uuid=future_two, + ) + StripeEventSummaryFactory( + stripe_event_data=event_two_data, + stripe_event_created_at=base_time + timedelta(minutes=1), + ) + event_three_data = StripeEventDataFactory(checkout_intent=checkout_intent) + SelfServiceSubscriptionRenewal.objects.create( + checkout_intent=checkout_intent, + subscription_plan_renewal_id=303, + stripe_event_data=event_three_data, + stripe_subscription_id="sub-future-3", + prior_subscription_plan_uuid=future_two, + renewed_subscription_plan_uuid=future_three, + ) + StripeEventSummaryFactory( + stripe_event_data=event_three_data, + stripe_event_created_at=base_time + timedelta(minutes=2), + ) + # Processed renewals shouldn't be considered future plans. + processed_event = StripeEventDataFactory(checkout_intent=checkout_intent) + SelfServiceSubscriptionRenewal.objects.create( + checkout_intent=checkout_intent, + subscription_plan_renewal_id=304, + stripe_event_data=processed_event, + stripe_subscription_id="sub-processed", + prior_subscription_plan_uuid=future_three, + renewed_subscription_plan_uuid=uuid.uuid4(), + processed_at=timezone.now(), + ) + StripeEventSummaryFactory( + stripe_event_data=processed_event, + stripe_event_created_at=base_time + timedelta(minutes=3), + ) + + result = _get_future_plan_uuids(checkout_intent, str(current_plan_uuid)) + + self.assertEqual(result, [str(future_one), str(future_two), str(future_three)]) + + def test_get_subscription_plan_uuid_reads_from_summary(self): + """Subscription plan lookup pulls from latest summary record only.""" + checkout_intent = CheckoutIntentFactory() + target_uuid = uuid.uuid4() + event_data = StripeEventDataFactory(checkout_intent=checkout_intent) + summary = event_data.summary + summary.subscription_plan_uuid = target_uuid + summary.checkout_intent = checkout_intent + summary.stripe_event_created_at = timezone.now() + summary.save(update_fields=['subscription_plan_uuid', 'checkout_intent', 'stripe_event_created_at', 'modified']) + + result = _get_subscription_plan_uuid_from_checkout_intent(checkout_intent) + + self.assertEqual(result, str(target_uuid)) + + def test_get_subscription_plan_uuid_returns_none_without_summary(self): + """Helper returns None when no subscription plan available on summaries.""" + checkout_intent = CheckoutIntentFactory() + workflow = ProvisionNewCustomerWorkflowFactory() + checkout_intent.workflow = workflow + checkout_intent.save() + + result = _get_subscription_plan_uuid_from_checkout_intent(checkout_intent) + + self.assertIsNone(result) + + @mock.patch( + "enterprise_access.apps.customer_billing.stripe_event_handlers.LicenseManagerApiClient" + ) + @mock.patch( + "enterprise_access.apps.customer_billing.stripe_event_handlers._get_future_plan_uuids" + ) + @mock.patch( + "enterprise_access.apps.customer_billing.stripe_event_handlers._get_current_plan_uuid" + ) + def test_cancel_all_future_plans_deactivates_each_plan( + self, + mock_get_current_plan, + mock_get_future_plans, + MockClient, + ): + """Cancel helper deactivates every future plan resolved for the enterprise.""" + mock_get_current_plan.return_value = "plan-current" + mock_get_future_plans.return_value = ["future-one", "future-two"] + + result = cancel_all_future_plans( + enterprise_uuid=str(self.checkout_intent.enterprise_uuid), + reason="delayed_payment", + subscription_id_for_logs="sub-example", + checkout_intent=self.checkout_intent, + ) + + self.assertEqual(result, ["future-one", "future-two"]) + mock_get_current_plan.assert_called_once_with(self.checkout_intent) + mock_get_future_plans.assert_called_once_with(self.checkout_intent, "plan-current") + MockClient.assert_called_once() + MockClient.return_value.update_subscription_plan.assert_has_calls([ + mock.call("future-one", is_active=False, change_reason="delayed_payment"), + mock.call("future-two", is_active=False, change_reason="delayed_payment"), + ]) + + @mock.patch( + "enterprise_access.apps.customer_billing.stripe_event_handlers._get_future_plan_uuids" + ) + @mock.patch( + "enterprise_access.apps.customer_billing.stripe_event_handlers._get_current_plan_uuid", + return_value=None, + ) + @mock.patch( + "enterprise_access.apps.customer_billing.stripe_event_handlers.LicenseManagerApiClient" + ) + def test_cancel_all_future_plans_skips_without_current_plan( + self, + MockClient, + mock_get_current_plan, + mock_get_future_plans, + ): + """Without a current plan UUID the helper exits early.""" + result = cancel_all_future_plans( + enterprise_uuid=str(self.checkout_intent.enterprise_uuid), + reason="delayed_payment", + subscription_id_for_logs="sub-missing", + checkout_intent=self.checkout_intent, + ) + + self.assertEqual(result, []) + MockClient.assert_not_called() + mock_get_future_plans.assert_not_called() + mock_get_current_plan.assert_called_once_with(self.checkout_intent) + @mock.patch( "enterprise_access.apps.customer_billing.stripe_event_handlers.send_trial_ending_reminder_email_task" ) diff --git a/enterprise_access/settings/base.py b/enterprise_access/settings/base.py index d297517e..58bb65a4 100644 --- a/enterprise_access/settings/base.py +++ b/enterprise_access/settings/base.py @@ -534,6 +534,7 @@ def root(*path_fragments): # Braze campaigns for customer billing (apps.customer_billing) BRAZE_TRIAL_CANCELLATION_CAMPAIGN = '' BRAZE_ENTERPRISE_PROVISION_TRIAL_ENDING_SOON_CAMPAIGN = '' +BRAZE_BILLING_ERROR_CAMPAIGN = '' # Braze configuration BRAZE_API_URL = '' diff --git a/enterprise_access/settings/test.py b/enterprise_access/settings/test.py index 603371a4..1e632b64 100644 --- a/enterprise_access/settings/test.py +++ b/enterprise_access/settings/test.py @@ -54,6 +54,7 @@ BRAZE_GROUPS_EMAIL_AUTO_REMINDER_DAY_85_CAMPAIGN = 'test-day-85-reminder-campaign' BRAZE_TRIAL_CANCELLATION_CAMPAIGN = 'test-trial-cancellation-campaign' BRAZE_ENTERPRISE_PROVISION_TRIAL_ENDING_SOON_CAMPAIGN = 'test-trial-ending-reminder-campaign' +BRAZE_BILLING_ERROR_CAMPAIGN = 'test-billing-error-campaign' ################### Kafka Related Settings ############################## KAFKA_ENABLED = False