From 6c968361291f752d8d00cba3d301eab308038627 Mon Sep 17 00:00:00 2001 From: Jono Booth Date: Wed, 29 Oct 2025 10:50:01 +0200 Subject: [PATCH] WIP: cancellations --- .../apps/api_client/license_manager_client.py | 60 +++++++ .../apps/customer_billing/api.py | 158 ++++++++++++++++++ .../apps/customer_billing/models.py | 10 ++ .../customer_billing/stripe_event_handlers.py | 73 +++++++- 4 files changed, 299 insertions(+), 2 deletions(-) diff --git a/enterprise_access/apps/api_client/license_manager_client.py b/enterprise_access/apps/api_client/license_manager_client.py index 3b3b1081d..a93b121ab 100644 --- a/enterprise_access/apps/api_client/license_manager_client.py +++ b/enterprise_access/apps/api_client/license_manager_client.py @@ -26,6 +26,8 @@ class LicenseManagerApiClient(BaseOAuthClient): customer_agreement_endpoint = api_base_url + 'customer-agreement/' customer_agreement_provisioning_endpoint = api_base_url + 'provisioning-admins/customer-agreement/' subscription_provisioning_endpoint = api_base_url + 'provisioning-admins/subscriptions/' + subscription_plan_renewal_endpoint = api_base_url + 'provisioning-admins/subscription-plan-renewals/' + subscription_plan_renewal_endpoint = api_base_url + 'provisioning-admins/subscription-plan-renewals/' def get_subscription_overview(self, subscription_uuid): """ @@ -211,6 +213,64 @@ def create_subscription_plan( exc, ) from exc + def update_subscription_plan(self, subscription_plan_uuid, **kwargs): + """ + Updates a Subscription Plan via PATCH request to the provisioning endpoint. + + Args: + subscription_plan_uuid (str): The UUID of the SubscriptionPlan to update + **kwargs: Fields to update (e.g., is_active=False) + + Returns: + dict: Updated subscription plan data + + Raises: + APIClientException: If the update request fails + """ + endpoint = f'{self.subscription_provisioning_endpoint}{subscription_plan_uuid}/' + response = self.client.patch(endpoint, json=kwargs) + try: + response.raise_for_status() + return response.json() + except requests.exceptions.HTTPError as exc: + logger.exception( + 'Failed to update subscription plan %s, response %s, exception: %s', + subscription_plan_uuid, + safe_error_response_content(exc), + exc, + ) + raise APIClientException( + f'Could not update subscription plan {subscription_plan_uuid}', + exc, + ) from exc + + def delete_subscription_plan_renewal(self, renewal_uuid): + """ + Deletes a SubscriptionPlanRenewal record. + + Args: + renewal_uuid (str): The UUID of the SubscriptionPlanRenewal to delete + + Raises: + APIClientException: If the delete request fails + """ + endpoint = f'{self.subscription_plan_renewal_endpoint}{renewal_uuid}/' + response = self.client.delete(endpoint) + try: + response.raise_for_status() + logger.info('Successfully deleted subscription plan renewal %s', renewal_uuid) + except requests.exceptions.HTTPError as exc: + logger.exception( + 'Failed to delete subscription plan renewal %s, response %s, exception: %s', + renewal_uuid, + safe_error_response_content(exc), + exc, + ) + raise APIClientException( + f'Could not delete subscription plan renewal {renewal_uuid}', + exc, + ) from exc + class LicenseManagerUserApiClient(BaseUserApiClient): """ diff --git a/enterprise_access/apps/customer_billing/api.py b/enterprise_access/apps/customer_billing/api.py index 6b4d8b033..edcad4d82 100644 --- a/enterprise_access/apps/customer_billing/api.py +++ b/enterprise_access/apps/customer_billing/api.py @@ -450,3 +450,161 @@ def create_free_trial_checkout_session( logger.info(f'Updated checkout intent {intent.id} with Stripe session {checkout_session["id"]}') return checkout_session + + +def handle_subscription_cancellation(checkout_intent: CheckoutIntent, stripe_subscription_id: str): + """ + Handle Stripe subscription cancellation (cancel_at_period_end=True). + + This should: + - Delete the renewal record in license-manager (when ENT-11009 is complete) + - Deactivate the future (paid) plan in license-manager + - Log the cancellation in the CheckoutIntent + + Args: + checkout_intent: The CheckoutIntent associated with the subscription + stripe_subscription_id: The Stripe subscription ID that was canceled + + Raises: + APIClientException: If license-manager API calls fail + """ + from django.utils import timezone + from enterprise_access.apps.api_client.license_manager_client import LicenseManagerApiClient + + logger.info( + 'Handling subscription cancellation for CheckoutIntent %s (stripe_subscription_id=%s)', + checkout_intent.id, + stripe_subscription_id, + ) + + # Record the cancellation timestamp + checkout_intent.subscription_canceled_at = timezone.now() + checkout_intent.save(update_fields=['subscription_canceled_at', 'modified']) + + # Get the subscription plan UUID from the workflow + if not checkout_intent.workflow: + logger.warning( + 'No workflow found for CheckoutIntent %s, cannot retrieve subscription plan UUID', + checkout_intent.id, + ) + return + + try: + subscription_plan_output = checkout_intent.workflow.subscription_plan_output_dict() + subscription_plan_uuid = subscription_plan_output.get('uuid') + + if not subscription_plan_uuid: + logger.warning( + 'No subscription plan UUID found in workflow output for CheckoutIntent %s', + checkout_intent.id, + ) + return + + logger.info( + 'Found subscription plan UUID %s for CheckoutIntent %s', + subscription_plan_uuid, + checkout_intent.id, + ) + + # Deactivate the subscription plan + # Note: Once ENT-11009 is complete and trial/future plans are separate, + # this logic should only deactivate the future plan, not the trial plan. + license_manager_client = LicenseManagerApiClient() + license_manager_client.update_subscription_plan(subscription_plan_uuid, is_active=False) + logger.info( + 'Deactivated subscription plan %s for CheckoutIntent %s', + subscription_plan_uuid, + checkout_intent.id, + ) + + # TODO (ENT-11009): Once renewal records are implemented: + # 1. Get renewal_uuid from the workflow or checkout_intent + # 2. Call license_manager_client.delete_subscription_plan_renewal(renewal_uuid) + + except Exception as exc: + logger.exception( + 'Failed to handle subscription cancellation for CheckoutIntent %s: %s', + checkout_intent.id, + exc, + ) + raise + + +def handle_subscription_deletion(checkout_intent: CheckoutIntent, stripe_subscription_id: str): + """ + Handle Stripe subscription deletion. + + This should: + - Delete the renewal record in license-manager (when ENT-11009 is complete) + - Deactivate the subscription plan(s) in license-manager + - Log the deletion in the CheckoutIntent + + Args: + checkout_intent: The CheckoutIntent associated with the subscription + stripe_subscription_id: The Stripe subscription ID that was deleted + + Raises: + APIClientException: If license-manager API calls fail + """ + from django.utils import timezone + from enterprise_access.apps.api_client.license_manager_client import LicenseManagerApiClient + + logger.info( + 'Handling subscription deletion for CheckoutIntent %s (stripe_subscription_id=%s)', + checkout_intent.id, + stripe_subscription_id, + ) + + # Record the deletion timestamp + checkout_intent.subscription_deleted_at = timezone.now() + checkout_intent.save(update_fields=['subscription_deleted_at', 'modified']) + + # Get the subscription plan UUID from the workflow + if not checkout_intent.workflow: + logger.warning( + 'No workflow found for CheckoutIntent %s, cannot retrieve subscription plan UUID', + checkout_intent.id, + ) + return + + try: + subscription_plan_output = checkout_intent.workflow.subscription_plan_output_dict() + subscription_plan_uuid = subscription_plan_output.get('uuid') + + if not subscription_plan_uuid: + logger.warning( + 'No subscription plan UUID found in workflow output for CheckoutIntent %s', + checkout_intent.id, + ) + return + + logger.info( + 'Found subscription plan UUID %s for CheckoutIntent %s', + subscription_plan_uuid, + checkout_intent.id, + ) + + # Deactivate the subscription plan + # Note: Once ENT-11009 is complete and trial/future plans are separate, + # this logic should deactivate both the trial and future plans. + license_manager_client = LicenseManagerApiClient() + license_manager_client.update_subscription_plan(subscription_plan_uuid, is_active=False) + logger.info( + 'Deactivated subscription plan %s for CheckoutIntent %s', + subscription_plan_uuid, + checkout_intent.id, + ) + + # TODO (ENT-11009): Once renewal records and separate trial/future plans are implemented: + # 1. Get trial_plan_uuid and future_plan_uuid from the workflow or checkout_intent + # 2. Get renewal_uuid from the workflow or checkout_intent + # 3. Call license_manager_client.delete_subscription_plan_renewal(renewal_uuid) + # 4. Call license_manager_client.update_subscription_plan() for both trial and future plans + + except Exception as exc: + logger.exception( + 'Failed to handle subscription deletion for CheckoutIntent %s: %s', + checkout_intent.id, + exc, + ) + raise diff --git a/enterprise_access/apps/customer_billing/models.py b/enterprise_access/apps/customer_billing/models.py index c3cf77585..cdde1f048 100644 --- a/enterprise_access/apps/customer_billing/models.py +++ b/enterprise_access/apps/customer_billing/models.py @@ -170,6 +170,16 @@ class StateChoices(models.TextChoices): blank=True, help_text="Metadata relating to the terms and conditions accepted by the user.", ) + subscription_canceled_at = models.DateTimeField( + null=True, + blank=True, + help_text="Timestamp when the Stripe subscription was canceled (cancel_at_period_end=True)", + ) + subscription_deleted_at = models.DateTimeField( + null=True, + blank=True, + help_text="Timestamp when the Stripe subscription was deleted", + ) history = HistoricalRecords() diff --git a/enterprise_access/apps/customer_billing/stripe_event_handlers.py b/enterprise_access/apps/customer_billing/stripe_event_handlers.py index f0bea7328..3b0300a8f 100644 --- a/enterprise_access/apps/customer_billing/stripe_event_handlers.py +++ b/enterprise_access/apps/customer_billing/stripe_event_handlers.py @@ -203,15 +203,58 @@ def subscription_created(event: stripe.Event) -> None: def subscription_updated(event: stripe.Event) -> None: """ Handle customer.subscription.updated events. - Track when subscriptions have pending updates and update related CheckoutIntent state. + Track when subscriptions have pending updates, cancellations, and update related CheckoutIntent state. """ + from enterprise_access.apps.customer_billing.api import handle_subscription_cancellation + subscription = event.data.object + previous_attributes = event.data.get('previous_attributes', {}) pending_update = getattr(subscription, 'pending_update', None) checkout_intent_id = get_checkout_intent_id_from_subscription(subscription) checkout_intent = get_checkout_intent_or_raise(checkout_intent_id, event.id) link_event_data_to_checkout_intent(event, checkout_intent) + # Check if subscription was just canceled (cancel_at_period_end changed to True) + cancel_at_period_end = getattr(subscription, 'cancel_at_period_end', False) + previous_cancel_at_period_end = previous_attributes.get('cancel_at_period_end', False) + + # Also check if the subscription status changed to 'canceled' + subscription_status = getattr(subscription, 'status', None) + previous_status = previous_attributes.get('status', None) + + if cancel_at_period_end and not previous_cancel_at_period_end: + logger.info( + 'Subscription %s cancel_at_period_end changed to True. checkout_intent_id: %s', + subscription.id, + checkout_intent_id, + ) + try: + handle_subscription_cancellation(checkout_intent, subscription.id) + except Exception as exc: + logger.exception( + 'Failed to handle subscription cancellation for subscription %s, checkout_intent %s: %s', + subscription.id, + checkout_intent_id, + exc, + ) + elif subscription_status == 'canceled' and previous_status and previous_status != 'canceled': + logger.info( + 'Subscription %s status changed to canceled (from %s). checkout_intent_id: %s', + subscription.id, + previous_status, + checkout_intent_id, + ) + try: + handle_subscription_cancellation(checkout_intent, subscription.id) + except Exception as exc: + logger.exception( + 'Failed to handle subscription cancellation for subscription %s, checkout_intent %s: %s', + subscription.id, + checkout_intent_id, + exc, + ) + if pending_update: # TODO: take necessary action on the actual SubscriptionPlan # and update the CheckoutIntent. @@ -225,4 +268,30 @@ def subscription_updated(event: stripe.Event) -> None: @on_stripe_event('customer.subscription.deleted') @staticmethod def subscription_deleted(event: stripe.Event) -> None: - pass + """ + Handle customer.subscription.deleted events. + Delete the renewal and deactivate both trial and future plans. + """ + from enterprise_access.apps.customer_billing.api import handle_subscription_deletion + + subscription = event.data.object + + checkout_intent_id = get_checkout_intent_id_from_subscription(subscription) + checkout_intent = get_checkout_intent_or_raise(checkout_intent_id, event.id) + link_event_data_to_checkout_intent(event, checkout_intent) + + logger.info( + 'Subscription %s was deleted. checkout_intent_id: %s', + subscription.id, + checkout_intent_id, + ) + + try: + handle_subscription_deletion(checkout_intent, subscription.id) + except Exception as exc: + logger.exception( + 'Failed to handle subscription deletion for subscription %s, checkout_intent %s: %s', + subscription.id, + checkout_intent_id, + exc, + )