Skip to content

feat: Capture stripe cancellation in SelfServiceSubscriptionRenewal records#95

Merged
marlonkeating merged 27 commits intomainfrom
mkeating/ENT-11578
Apr 1, 2026
Merged

feat: Capture stripe cancellation in SelfServiceSubscriptionRenewal records#95
marlonkeating merged 27 commits intomainfrom
mkeating/ENT-11578

Conversation

@marlonkeating
Copy link
Copy Markdown
Contributor

@marlonkeating marlonkeating commented Mar 12, 2026

We want to start tracking stripe cancellations within the SelfServiceSubscriptionRenewal object in order to surface the information easier on the front-end.

Jira:
ENT-11578

The main additions are an is_canceled field to relevant models and serializers, API support for reporting cancellation state, a management command for backfilling historical data, and improved admin and test coverage.

Testing Instructions:

  • Pull down PR branch
  • In checkout MFE, go through checkout workflow through billing step
  • Navigate to stripe and select Cancel subscription -> End of the current period
  • Run make app-shell -> ./manage.py fetch_and_handle_stripe_events --created-since-hours-ago=8 --limit=10
  • Navigate to Stripe Event Summary generated by cancellation scheduling (will be latest 'customer.subscription.updated' event), enter made-up uuid in 'Subscription plan uuid' field, Save
  • Create SelfServiceSubscriptionRenewal
    • Checkout intent: Id of that was created during checkout workflow
    • Prior subscription plan uuid: Uuid set in Stripe Event Summary
    • Renewed subscription plan uuid: Make up another uuid
    • Stripe event data: The 'invoice.paid' event linked to the checkout intent
    • Stripe subscription id: From Stripe Event Summary
  • Make POST request: http://localhost:18270/api/v1/stripe-event-summary/get-stripe-subscription-plan-info/?subscription_plan_uuid={prior subscription plan uuid}
  • Verify Response is_canceled, renewed_subscription_plan_uuid matches value on SelfServiceSubscriptionRenewal record

Merge checklist:

  • ./manage.py makemigrations has been run
    • Note: This must be run if you modified any models.
      • It may or may not make a migration depending on exactly what you modified, but it should still be run.

Post merge:

  • Ensure that your changes went out to the stage instance
  • Deploy to prod instance

@marlonkeating
Copy link
Copy Markdown
Contributor Author

@copilot review now

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 13, 2026

@marlonkeating I've opened a new pull request, #96, to work on those changes. Once the pull request is ready, I'll request review from you.

@marlonkeating marlonkeating marked this pull request as ready for review March 13, 2026 16:05
@marlonkeating marlonkeating requested review from a team as code owners March 13, 2026 16:05
Copilot AI review requested due to automatic review settings March 13, 2026 16:05
@marlonkeating marlonkeating requested a review from a team as a code owner March 13, 2026 16:05
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds first-class tracking of Stripe subscription cancellation state on SelfServiceSubscriptionRenewal so the API/front-end can surface whether a self-serve subscription is currently canceled, including backfill for historical events.

Changes:

  • Add is_canceled boolean field to SelfServiceSubscriptionRenewal (and historical model) + update Stripe webhook handling to maintain it.
  • Extend get_stripe_subscription_plan_info API response (+ serializer/tests) to return is_canceled and renewed_subscription_plan_uuid.
  • Add a management command (and tests) to backfill is_canceled from historical Stripe events; enhance admin list display/filtering.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
enterprise_access/apps/customer_billing/models.py Adds is_canceled field to renewal model.
enterprise_access/apps/customer_billing/migrations/0027_historicalselfservicesubscriptionrenewal_is_canceled_and_more.py Migrates is_canceled onto current + historical tables.
enterprise_access/apps/customer_billing/stripe_event_handlers.py Updates webhook handlers to set/clear cancellation state on related renewals.
enterprise_access/apps/customer_billing/management/commands/backfill_subscription_renewal_cancellations.py New backfill command to derive is_canceled from StripeEventSummary history.
enterprise_access/apps/customer_billing/management/commands/tests/test_backfill_cancellations.py Tests for backfill behavior (dry-run, idempotency, restore events).
enterprise_access/apps/customer_billing/admin.py Surfaces is_canceled in admin list display/filter.
enterprise_access/apps/api/v1/views/customer_billing.py API now returns is_canceled + renewed_subscription_plan_uuid.
enterprise_access/apps/api/serializers/customer_billing.py Serializer updated for new response fields.
enterprise_access/apps/api/v1/tests/test_stripe_event_summary_views.py Tests response includes new fields and reflects cancellation state.
enterprise_access/apps/customer_billing/tests/test_stripe_event_handlers.py Tests webhook handling sets/clears is_canceled.
enterprise_access/apps/customer_billing/tests/test_models.py Confirms is_canceled default is False.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Comment thread enterprise_access/apps/customer_billing/admin.py
Comment thread enterprise_access/apps/customer_billing/stripe_event_handlers.py Outdated
Comment thread enterprise_access/apps/customer_billing/stripe_event_handlers.py Outdated
Comment thread enterprise_access/apps/customer_billing/tests/test_stripe_event_handlers.py Outdated
Copilot AI review requested due to automatic review settings March 17, 2026 22:53
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds first-class cancellation tracking on SelfServiceSubscriptionRenewal so the system (and admin-portal-facing API responses) can surface whether a self-service subscription has been canceled, with backfill support for historical Stripe events.

Changes:

  • Add is_canceled boolean field to SelfServiceSubscriptionRenewal (and historical model via migration) and expose it via admin.
  • Update Stripe webhook handling to flip renewal cancellation state on subscription create/update/delete events.
  • Extend get-stripe-subscription-plan-info API response to include is_canceled and renewed_subscription_plan_uuid, and add backfill command + tests.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
enterprise_access/apps/customer_billing/models.py Adds is_canceled field to renewal model.
enterprise_access/apps/customer_billing/migrations/0027_historicalselfservicesubscriptionrenewal_is_canceled_and_more.py Migrates new is_canceled field onto current + historical tables.
enterprise_access/apps/customer_billing/stripe_event_handlers.py Introduces renewal cancellation-state updater and wires it into relevant Stripe events.
enterprise_access/apps/customer_billing/admin.py Displays/filters renewal cancellation state in Django admin.
enterprise_access/apps/customer_billing/management/commands/backfill_subscription_renewal_cancellations.py New management command to backfill is_canceled from historical Stripe events.
enterprise_access/apps/customer_billing/management/commands/tests/test_backfill_cancellations.py Adds coverage for backfill command behavior (dry-run, idempotency, restore handling).
enterprise_access/apps/api/v1/views/customer_billing.py Extends subscription-plan-info endpoint to return is_canceled + renewed_subscription_plan_uuid.
enterprise_access/apps/api/serializers/customer_billing.py Extends response serializer with the new fields.
enterprise_access/apps/api/v1/tests/test_stripe_event_summary_views.py Adds/updates API tests asserting the new response fields.
enterprise_access/apps/customer_billing/tests/test_stripe_event_handlers.py Adds webhook tests verifying renewals get marked canceled/uncanceled.
enterprise_access/apps/customer_billing/tests/test_models.py Adds model default test for is_canceled=False.

You can also share your feedback on Copilot code review. Take the survey.

Comment on lines +76 to +81
self.stdout.write(
self.style.SUCCESS(
f'Backfill complete. Updated renewals: {updated_count}. Unchanged events: {unchanged_count}.'
)
)

Comment on lines +177 to +181
updated = checkout_intent.renewals.update(is_canceled=is_canceled)
logger.info(
'Updated %d renewal(s) for CheckoutIntent %s: is_canceled=%s',
updated, checkout_intent.id, is_canceled,
)
Copilot AI review requested due to automatic review settings March 18, 2026 20:23
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds first-class cancellation tracking to self-service subscription renewals so the frontend can surface “canceled vs active” state and the renewed plan UUID more easily.

Changes:

  • Add is_canceled to SelfServiceSubscriptionRenewal (and historical model) + update webhook handlers to flip the flag on delete/restore.
  • Extend get-stripe-subscription-plan-info API response + serializer to include is_canceled and renewed_subscription_plan_uuid.
  • Introduce a backfill management command with dedicated test coverage; improve admin list display/filtering.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
enterprise_access/apps/customer_billing/tests/test_stripe_event_handlers.py Adds tests asserting renewals are marked canceled/uncanceled based on Stripe subscription events.
enterprise_access/apps/customer_billing/tests/test_models.py Adds model default test for is_canceled=False.
enterprise_access/apps/customer_billing/stripe_event_handlers.py Introduces helper to update renewal cancellation state; hooks it into subscription created/updated/deleted handlers.
enterprise_access/apps/customer_billing/models.py Adds is_canceled BooleanField to SelfServiceSubscriptionRenewal.
enterprise_access/apps/customer_billing/migrations/0027_historicalselfservicesubscriptionrenewal_is_canceled_and_more.py Migration to add is_canceled to both current and historical tables.
enterprise_access/apps/customer_billing/management/commands/backfill_subscription_renewal_cancellations.py New command to backfill is_canceled from historical Stripe delete/restore events.
enterprise_access/apps/customer_billing/management/commands/tests/test_backfill_cancellations.py Comprehensive tests for the backfill command behavior and output.
enterprise_access/apps/customer_billing/admin.py Adds admin list display/filtering for is_canceled and optimizes queryset.
enterprise_access/apps/api/v1/views/customer_billing.py Adds is_canceled and renewed_subscription_plan_uuid to subscription plan info response.
enterprise_access/apps/api/v1/tests/test_stripe_event_summary_views.py Updates/extends API tests for the new response fields.
enterprise_access/apps/api/serializers/customer_billing.py Extends response serializer with is_canceled and renewed_subscription_plan_uuid.
Comments suppressed due to low confidence (1)

enterprise_access/apps/customer_billing/stripe_event_handlers.py:702

  • subscription_deleted calls checkout_intent.previous_summary(...) and then immediately dereferences previous_summary.subscription_status without a None-check. If there is no prior summary (or prior summaries have stripe_event_created_at=NULL, which previous_summary filters out), this will raise and abort handling the deletion event. Add an explicit if not previous_summary: ... return (or otherwise handle the missing-summary case) before accessing subscription_status.
        _update_renewal_cancellation_state(checkout_intent, is_canceled=True)

        previous_summary = checkout_intent.previous_summary(event, stripe_object_type='subscription')
        if previous_summary.subscription_status == StripeSubscriptionStatus.ACTIVE:
            # https://docs.stripe.com/api/subscriptions/object#subscription_object-ended_at

You can also share your feedback on Copilot code review. Take the survey.

Comment on lines +83 to +94
restore_events = StripeEventSummary.objects.filter(
checkout_intent=deleted_event.checkout_intent,
event_type__in=['customer.subscription.created', 'customer.subscription.updated'],
)

if deleted_event.stripe_event_created_at:
restore_events = restore_events.filter(
stripe_event_created_at__gt=deleted_event.stripe_event_created_at,
)
else:
restore_events = restore_events.filter(created__gt=deleted_event.created)

Comment on lines +13 to +17
SelfServiceSubscriptionRenewalFactory,
StripeEventDataFactory
)


Comment thread enterprise_access/apps/customer_billing/models.py
@codecov
Copy link
Copy Markdown

codecov Bot commented Mar 19, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 84.24%. Comparing base (f5e6d43) to head (65d9cf3).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main      #95      +/-   ##
==========================================
+ Coverage   84.23%   84.24%   +0.01%     
==========================================
  Files         144      144              
  Lines       12173    12195      +22     
  Branches     1159     1161       +2     
==========================================
+ Hits        10254    10274      +20     
- Misses       1599     1600       +1     
- Partials      320      321       +1     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

is_canceled = serializers.BooleanField(
default=False,
required=False,
help_text=(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Everything looks good, though copilot did make some good comments, but I was wondering if there's any value in making this a timestamp that is null when it's not canceled?

Comment thread enterprise_access/apps/api/serializers/customer_billing.py Outdated
Copy link
Copy Markdown
Member

@pwnage101 pwnage101 left a comment

Choose a reason for hiding this comment

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

question & suggestion

subscription.id,
checkout_intent.id,
)
_update_renewal_cancellation_state(checkout_intent, is_canceled=True)
Copy link
Copy Markdown
Member

@pwnage101 pwnage101 Mar 19, 2026

Choose a reason for hiding this comment

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

Just to be completely clear, this event handler for customer.subscription.deleted is NOT executed when the admin clicks the cancel button. Our Stripe subscriptions are configured to "cancel_at_period_end", which means only the customer.subscription.updated event is emitted. More info: https://2u-internal.atlassian.net/wiki/spaces/SOL/pages/2202107978/ENT-10715+SSP+Cancellation#User-initiated-cancellation

Was it intentional to NOT update renewals on clicking the cancel button?

Copilot AI review requested due to automatic review settings March 23, 2026 23:20
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds cancellation state tracking to self-service subscription renewals so API/admin surfaces whether a Stripe subscription has been canceled, and provides a backfill command + tests to populate historical data.

Changes:

  • Add is_canceled to SelfServiceSubscriptionRenewal (+ historical model) and expose it via the subscription-plan-info API response.
  • Update Stripe webhook handlers to flip is_canceled based on subscription lifecycle events.
  • Add a management command (and tests) to backfill cancellation state from historical Stripe events; enhance admin list display/filtering.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
enterprise_access/apps/customer_billing/tests/test_stripe_event_handlers.py Adds tests asserting renewals get marked canceled/uncanceled from Stripe events.
enterprise_access/apps/customer_billing/tests/test_models.py Adds default-value test for is_canceled.
enterprise_access/apps/customer_billing/stripe_event_handlers.py Introduces renewal cancellation-state update helper and calls it from subscription handlers.
enterprise_access/apps/customer_billing/models.py Adds is_canceled field to renewal model.
enterprise_access/apps/customer_billing/migrations/0029_historicalselfservicesubscriptionrenewal_is_canceled_and_more.py Migration adding is_canceled to current + historical renewal tables.
enterprise_access/apps/customer_billing/management/commands/backfill_subscription_renewal_cancellations.py New command to backfill is_canceled from deletion/restore events.
enterprise_access/apps/customer_billing/management/commands/tests/test_backfill_cancellations.py New test suite for the backfill command behavior and output.
enterprise_access/apps/customer_billing/admin.py Admin list display/filter additions for renewal cancellation state.
enterprise_access/apps/api/v1/views/customer_billing.py API endpoint now returns is_canceled and renewed_subscription_plan_uuid.
enterprise_access/apps/api/v1/tests/test_stripe_event_summary_views.py Tests for new API fields and cancellation toggling.
enterprise_access/apps/api/serializers/customer_billing.py Response serializer extended with is_canceled and renewed_subscription_plan_uuid.

Comment thread enterprise_access/apps/customer_billing/stripe_event_handlers.py Outdated
Comment on lines 640 to +647
first_related_renewal = SelfServiceSubscriptionRenewal.objects.filter(
Q(prior_subscription_plan_uuid=subscription_plan_uuid) |
Q(renewed_subscription_plan_uuid=subscription_plan_uuid)
).select_related('checkout_intent').order_by('created').first()
if first_related_renewal:
checkout_intent_uuid = first_related_renewal.checkout_intent.uuid
is_canceled = first_related_renewal.is_canceled
renewed_subscription_plan_uuid = first_related_renewal.renewed_subscription_plan_uuid
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

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

The SelfServiceSubscriptionRenewal lookup can match multiple rows when a plan UUID appears as renewed_subscription_plan_uuid in an earlier renewal and later as prior_subscription_plan_uuid in a subsequent renewal (common for intermediate paid plans). Because the query ORs both fields and then orders by created ascending, it may select the wrong renewal and return an incorrect renewed_subscription_plan_uuid / is_canceled. Consider disambiguating (e.g., prefer a renewal where prior_subscription_plan_uuid matches the query, otherwise fall back to a match on renewed_subscription_plan_uuid), or otherwise make the selection deterministic and aligned with the endpoint’s intent.

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings March 24, 2026 22:51
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 7 comments.

Comment on lines +306 to +314
subscription_cancel_at = serializers.DateTimeField(
allow_null=True,
required=False,
help_text=(
'Timestamp when the subscription is scheduled to be canceled. '
'None if no cancellation is scheduled or if the subscription has already been deleted.'
),
)

Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

This serializer now has both canceled_date and subscription_cancel_at, but the view only supplies canceled_date. If subscription_cancel_at is intended to be part of the API contract, ensure the view populates it (and clarify how it differs from canceled_date); otherwise, remove it to avoid an advertised-but-never-returned field.

Suggested change
subscription_cancel_at = serializers.DateTimeField(
allow_null=True,
required=False,
help_text=(
'Timestamp when the subscription is scheduled to be canceled. '
'None if no cancellation is scheduled or if the subscription has already been deleted.'
),
)

Copilot uses AI. Check for mistakes.
'checkout_intent_uuid': str(self.checkout_intent.uuid),
'is_canceled': False,
'renewed_subscription_plan_uuid': str(self.renewed_subscription_plan_uuid),
'subscription_cancel_at': None,
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

These assertions require a subscription_cancel_at key in the response, but get_stripe_subscription_plan_info currently returns canceled_date (and does not populate subscription_cancel_at), so this test will fail. Either update the view to return subscription_cancel_at or adjust the expected response to match the actual response contract.

Suggested change
'subscription_cancel_at': None,

Copilot uses AI. Check for mistakes.
Comment thread enterprise_access/apps/api/tests/test_serializers.py Outdated
Comment on lines +1321 to +1326
event_data = StripeEventData.objects.create(
event_id='evt_test_is_canceled_default',
event_type='customer.subscription.updated',
checkout_intent=self.checkout_intent,
data={'test': 'data'}
)
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

These tests create StripeEventData with data={'test': 'data'}. The post_save signal tries to build a StripeEventSummary via populate_with_summary_data(), which expects data['data']['object']... and will raise/log an error for this payload. Use StripeEventDataFactory (or a minimally valid Stripe payload structure) here to avoid noisy error logs and to better match real event data.

Copilot uses AI. Check for mistakes.
Comment on lines 640 to +648
first_related_renewal = SelfServiceSubscriptionRenewal.objects.filter(
Q(prior_subscription_plan_uuid=subscription_plan_uuid) |
Q(renewed_subscription_plan_uuid=subscription_plan_uuid)
).select_related('checkout_intent').order_by('created').first()
if first_related_renewal:
checkout_intent_uuid = first_related_renewal.checkout_intent.uuid
is_canceled = first_related_renewal.is_canceled
renewed_subscription_plan_uuid = first_related_renewal.renewed_subscription_plan_uuid
canceled_date = first_related_renewal.subscription_cancel_at
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

first_related_renewal is fetched with order_by('created').first(), which returns the oldest renewal. Since SelfServiceSubscriptionRenewal is ordered by -created (and multiple renewals per checkout intent are possible), this can return stale is_canceled / renewed_subscription_plan_uuid values. Prefer selecting the latest renewal (e.g., rely on model ordering or order by -created).

Copilot uses AI. Check for mistakes.
Comment on lines 666 to 674
response_serializer = serializers.StripeSubscriptionPlanInfoResponseSerializer(
data={
'upcoming_invoice_amount_due': upcoming_invoice_amount_due,
'currency': currency,
'canceled_date': canceled_date,
'checkout_intent_uuid': checkout_intent_uuid
'checkout_intent_uuid': checkout_intent_uuid,
'is_canceled': is_canceled,
'renewed_subscription_plan_uuid': renewed_subscription_plan_uuid,
},
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

StripeSubscriptionPlanInfoResponseSerializer now defines subscription_cancel_at, and the tests expect it in the response, but the view never includes subscription_cancel_at in the serializer input dict. As a result, DRF will omit the field from response_serializer.data, and the response schema won’t match the tests/client expectations. Populate subscription_cancel_at explicitly (likely from the renewal) or remove the field/tests if it’s not intended to be returned.

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings March 25, 2026 16:38
Copilot AI review requested due to automatic review settings April 1, 2026 13:11
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.

Comment thread enterprise_access/apps/customer_billing/tests/test_migrations.py
@marlonkeating marlonkeating merged commit 542cc81 into main Apr 1, 2026
5 checks passed
@marlonkeating marlonkeating deleted the mkeating/ENT-11578 branch April 1, 2026 18:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants