Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions enterprise_access/apps/api_client/license_manager_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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):
"""
Expand Down
158 changes: 158 additions & 0 deletions enterprise_access/apps/customer_billing/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 10 additions & 0 deletions enterprise_access/apps/customer_billing/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
73 changes: 71 additions & 2 deletions enterprise_access/apps/customer_billing/stripe_event_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
)