Skip to content
Merged
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
3 changes: 2 additions & 1 deletion enterprise_access/apps/api/serializers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
CustomerBillingCreateCheckoutSessionRequestSerializer,
CustomerBillingCreateCheckoutSessionSuccessResponseSerializer,
CustomerBillingCreateCheckoutSessionValidationFailedResponseSerializer,
StripeEventSummaryReadOnlySerializer
StripeEventSummaryReadOnlySerializer,
StripeSubscriptionPlanInfoResponseSerializer
)
from .provisioning import (
ProvisioningRequestSerializer,
Expand Down
21 changes: 21 additions & 0 deletions enterprise_access/apps/api/serializers/customer_billing.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,3 +275,24 @@ class Meta:
model = StripeEventSummary
fields = '__all__'
read_only_fields = [field.name for field in StripeEventSummary._meta.get_fields()]


# pylint: disable=abstract-method
class StripeSubscriptionPlanInfoResponseSerializer(serializers.Serializer):
"""
Response serializer for response body from GET /api/v1/stripe-event-summary/get-stripe-subscription-plan-info
"""
upcoming_invoice_amount_due = serializers.CharField(
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The field type has been changed from IntegerField to CharField. In the model (StripeEventSummary), upcoming_invoice_amount_due is defined as an IntegerField storing amounts in cents. Converting this to a CharField in the response serializer will change the API response format from an integer to a string, which is a breaking change for API consumers.

Unless there's a specific requirement for string representation (e.g., to support very large numbers or decimal precision), this should remain as an IntegerField to maintain API compatibility. If the change is intentional, it should be documented as a breaking change.

Suggested change
upcoming_invoice_amount_due = serializers.CharField(
upcoming_invoice_amount_due = serializers.IntegerField(

Copilot uses AI. Check for mistakes.
required=False,
help_text='Upcoming invoice amount due related to this event/subscription',
)

currency = serializers.CharField(
required=False,
help_text='Three-letter ISO currency code associated with the subscription.',
)

canceled_date = serializers.DateTimeField(
required=False,
help_text='Timestamp when the subscription is scheduled to be canceled',
)
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
Tests for StripEventSummary viewset.
"""
import uuid
from datetime import timedelta
from datetime import datetime, timedelta
from datetime import timezone as tz
Comment thread
kiram15 marked this conversation as resolved.
from urllib.parse import urlencode

from django.urls import reverse
Expand Down Expand Up @@ -180,9 +181,9 @@ def test_get_stripe_event_summary_by_subscription_uuid_no_auth(self):
assert response.status_code == 403


class StripeEventUpcomingInvoiceAmountDueTests(APITest):
class StripeSubscriptionPlanInfoTests(APITest):
"""
Tests for first_upcoming_invoice_amount_due endpoint.
Tests for get_stripe_subscription_plan_info endpoint.
"""

def setUp(self):
Expand All @@ -203,7 +204,7 @@ def setUp(self):
expires_at=timezone.now() + timedelta(hours=1),
)

subscription_event_data = {
self.subscription_created_event_data = {
'id': 'evt_test_sub_created',
'type': 'customer.subscription.created',
'data': {
Expand All @@ -230,25 +231,85 @@ def setUp(self):
}
}

self.subscription_updated_event_data = {
'id': 'evt_test_sub_updated',
'type': 'customer.subscription.updated',
'data': {
'object': {
'object': 'subscription',
'id': 'sub_test_789',
'status': 'active',
'currency': 'usd',
'cancel_at': 1631664000, # 2021-09-15 00:00:00 UTC
}
},
'metadata': {
'checkout_intent_id': self.checkout_intent.id,
'enterprise_customer_name': 'Test Enterprise',
'enterprise_customer_slug': 'test-enterprise',
}
}

# Creating a StripeEventData record triggers a create of related StripeEventSummary
self.stripe_event_data = StripeEventData.objects.create(
event_id='evt_test_subscription',
event_type='customer.subscription.created',
checkout_intent=self.checkout_intent,
data=subscription_event_data,
data=self.subscription_created_event_data,
)

test_summary = StripeEventSummary.objects.filter(event_id='evt_test_subscription').first()
test_summary.upcoming_invoice_amount_due = 200
test_summary.subscription_plan_uuid = self.subscription_plan_uuid
test_summary.save(update_fields=['upcoming_invoice_amount_due', 'subscription_plan_uuid'])

def test_get_first_upcoming_invoice_amount_due(self):
self.stripe_event_data = StripeEventData.objects.create(
event_id='evt_test_subscription_update',
event_type='customer.subscription.updated',
checkout_intent=self.checkout_intent,
data=self.subscription_updated_event_data,
)
test_summary = StripeEventSummary.objects.filter(event_id='evt_test_subscription_update').first()
test_summary.subscription_cancel_at = datetime(2021, 9, 15, 0, 0, 0, tzinfo=tz.utc)
test_summary.subscription_plan_uuid = self.subscription_plan_uuid
test_summary.save(update_fields=['subscription_cancel_at', 'subscription_plan_uuid'])

def test_get_stripe_subscription_plan_info(self):
self.set_jwt_cookie([{
'system_wide_role': SYSTEM_ENTERPRISE_ADMIN_ROLE,
'context': self.enterprise_uuid, # implicit access to this enterprise
}])
query_params = {
'subscription_plan_uuid': self.subscription_plan_uuid,
}

url = reverse('api:v1:stripe-event-summary-get-stripe-subscription-plan-info')
url += f"?{urlencode(query_params)}"
response = self.client.get(url)
assert response.status_code == 200
assert response.data == {
'canceled_date': '2021-09-15T00:00:00Z',
'currency': 'usd',
'upcoming_invoice_amount_due': '200',
}

def test_get_stripe_subscription_plan_info_missing_subscription_plan_uuid(self):
self.set_jwt_cookie([{
'system_wide_role': SYSTEM_ENTERPRISE_ADMIN_ROLE,
'context': self.enterprise_uuid,
}])

url = reverse('api:v1:stripe-event-summary-get-stripe-subscription-plan-info')
response = self.client.get(url)

assert response.status_code == 400
assert response.data[0] == 'subscription_plan_uuid query param is required'

def test_get_stripe_subscription_plan_info_old_url_passthrough(self):
self.set_jwt_cookie([{
'system_wide_role': SYSTEM_ENTERPRISE_ADMIN_ROLE,
'context': self.enterprise_uuid, # implicit access to this enterprise
}])
query_params = {
'subscription_plan_uuid': self.subscription_plan_uuid,
}
Expand All @@ -258,6 +319,7 @@ def test_get_first_upcoming_invoice_amount_due(self):
response = self.client.get(url)
assert response.status_code == 200
assert response.data == {
'canceled_date': '2021-09-15T00:00:00Z',
'currency': 'usd',
'upcoming_invoice_amount_due': 200,
'upcoming_invoice_amount_due': '200',
}
55 changes: 46 additions & 9 deletions enterprise_access/apps/api/v1/views/customer_billing.py
Original file line number Diff line number Diff line change
Expand Up @@ -604,17 +604,54 @@ def list(self, request, *args, **kwargs):
)
def first_upcoming_invoice_amount_due(self, request, *args, **kwargs):
"""
Given a license-manager SubscriptionPlan uuid, returns an upcoming
invoice amount due, dervied from Stripe's preview invoice API.
Deprecated first-invoice-upcoming-amount-due endpoint.

Temporary passthrough to aid with transitioning to get-stripe-subscription-plan-info.
"""
return self.get_stripe_subscription_plan_info(request, *args, **kwargs)

@action(
detail=False,
methods=['get'],
url_path='get-stripe-subscription-plan-info',
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW, to mitigate deployment risk I think we could introduce a simple passthrough:

@action(
    detail=False,
    methods=['get'],
    url_path='first-invoice-upcoming-amount-due',
)
def first_upcoming_invoice_amount_due(self, request, *args, **kwargs):
    """
    Deprecated first-invoice-upcoming-amount-due endpoint.

    Temporary passthrough to aid with transitioning to get-stripe-subscription-plan-info.
    """
    return self.get_stripe_subscription_plan_info(request, *args, **kwargs)

)
def get_stripe_subscription_plan_info(self, request, *args, **kwargs):
Comment thread
kiram15 marked this conversation as resolved.
"""
Given a license-manager SubscriptionPlan uuid, returns information needed for the
Subscription management page on admin portal, like the upcoming subscription price
and if the subscription has been cancelled
Comment thread
kiram15 marked this conversation as resolved.
"""
subscription_plan_uuid = self.request.query_params.get('subscription_plan_uuid')
Comment thread
kiram15 marked this conversation as resolved.
summary = StripeEventSummary.objects.filter(
if not subscription_plan_uuid:
raise exceptions.ValidationError(detail='subscription_plan_uuid query param is required')
subscription_plan_uuid = self.request.query_params.get('subscription_plan_uuid')
created_event_summary = StripeEventSummary.objects.filter(
event_type='customer.subscription.created',
subscription_plan_uuid=subscription_plan_uuid,
).first()
if not (subscription_plan_uuid and summary):
).order_by('-stripe_event_created_at').first()
updated_event_summary = StripeEventSummary.objects.filter(
event_type='customer.subscription.updated',
subscription_plan_uuid=subscription_plan_uuid,
).order_by('-stripe_event_created_at').first()

canceled_date, currency, upcoming_invoice_amount_due = None, None, None

if updated_event_summary:
canceled_date = updated_event_summary.subscription_cancel_at

if created_event_summary:
currency = created_event_summary.currency
upcoming_invoice_amount_due = created_event_summary.upcoming_invoice_amount_due

response_serializer = serializers.StripeSubscriptionPlanInfoResponseSerializer(
data={
'upcoming_invoice_amount_due': upcoming_invoice_amount_due,
'currency': currency,
'canceled_date': canceled_date,
},
)
if not (subscription_plan_uuid and (updated_event_summary or created_event_summary)):
return Response({})
return Response({
'upcoming_invoice_amount_due': summary.upcoming_invoice_amount_due,
'currency': summary.currency,
})
if not response_serializer.is_valid():
return HttpResponseServerError()
return Response(response_serializer.data, status=status.HTTP_200_OK)
Comment thread
kiram15 marked this conversation as resolved.
2 changes: 1 addition & 1 deletion enterprise_access/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -654,7 +654,7 @@ def root(*path_fragments):
}

# Enable the customer billing API endpoints under /api/v1/customer-billing/*
ENABLE_CUSTOMER_BILLING_API = False
ENABLE_CUSTOMER_BILLING_API = True

DEFAULT_SSP_PRICE_LOOKUP_KEY = 'teams_subscription_license_yearly'

Expand Down