Repository: readthedocs/readthedocs.org
Pull request #12046: Subscriptions: improve subscription detection and respect never_disable
Author: login: stsewd
Created: 2025-03-12T23:19:21Z | Merged: 2025-03-19T22:05:12Z

- Updated the `get_stripe_subscription` method to prioritize certain subscription statuses over others, so unpaid subscriptions are still visible to the user.
- Our webhooks now respect the never_disable setting from the organization, so we don't disable some orgs automatically, we also skip sending notifications. We should mark this attribute only after being contacted by the owners, and marked as false once the issue has been resolved, so we still send notifications next time they don't pay on time.


Diff:
diff --git a/readthedocs/organizations/models.py b/readthedocs/organizations/models.py
index 5a552a7c091..731f05b13ba 100644
--- a/readthedocs/organizations/models.py
+++ b/readthedocs/organizations/models.py
@@ -143,21 +143,33 @@ def __str__(self):
         return self.name
 
     def get_stripe_subscription(self):
-        # Active subscriptions take precedence over non-active subscriptions,
-        # otherwise we return the most recently created subscription.
-        active_subscriptions = self.stripe_customer.subscriptions.filter(
-            status=SubscriptionStatus.active
-        )
-        if active_subscriptions:
-            if active_subscriptions.count() > 1:
-                # NOTE: this should never happen, unless we manually
-                # created another subscription for the user or if there
-                # is a bug in our code.
-                log.exception(
-                    "Organization has more than one active subscription",
-                    organization_slug=self.slug,
-                )
-            return active_subscriptions.order_by("created").last()
+        status_priority = [
+            # Past due and unpaid should be taken into consideration first,
+            # as the user needs to pay before they can access the service.
+            # See https://docs.stripe.com/billing/subscriptions/overview#subscription-statuses.
+            SubscriptionStatus.unpaid,
+            SubscriptionStatus.past_due,
+            SubscriptionStatus.incomplete_expired,
+            SubscriptionStatus.incomplete,
+            SubscriptionStatus.active,
+            SubscriptionStatus.trialing,
+        ]
+        for status in status_priority:
+            subscriptions = self.stripe_customer.subscriptions.filter(status=status)
+            if subscriptions.exists():
+                if subscriptions.count() > 1:
+                    # NOTE: this should never happen, unless we manually
+                    # created another subscription for the user or if there
+                    # is a bug in our code.
+                    log.exception(
+                        "Organization has more than one subscription with the same status",
+                        organization_slug=self.slug,
+                        subscription_status=status,
+                    )
+
+                return subscriptions.order_by("created").last()
+
+        # Fall back to the most recently created subscription.
         return self.stripe_customer.subscriptions.order_by("created").last()
 
     def get_absolute_url(self):
diff --git a/readthedocs/subscriptions/event_handlers.py b/readthedocs/subscriptions/event_handlers.py
index 23ddbc64ad8..18438f87887 100644
--- a/readthedocs/subscriptions/event_handlers.py
+++ b/readthedocs/subscriptions/event_handlers.py
@@ -127,15 +127,18 @@ def subscription_updated_event(event):
         log.info("Re-enabling organization.", organization_slug=organization.slug)
         organization.disabled = False
 
-    if stripe_subscription.status not in (
-        SubscriptionStatus.active,
-        SubscriptionStatus.trialing,
-    ):
-        log.info(
-            "Organization disabled due its subscription is not active anymore.",
-            organization_slug=organization.slug,
-        )
-        organization.disabled = True
+    if stripe_subscription.status not in (SubscriptionStatus.active, SubscriptionStatus.trialing):
+        if organization.never_disable:
+            log.info(
+                "Organization can't be disabled, skipping deactivation.",
+                organization_slug=organization.slug,
+            )
+        else:
+            log.info(
+                "Organization disabled due its subscription is not active anymore.",
+                organization_slug=organization.slug,
+            )
+            organization.disabled = True
 
     new_stripe_subscription = organization.get_stripe_subscription()
     if organization.stripe_subscription != new_stripe_subscription:
@@ -147,7 +150,7 @@ def subscription_updated_event(event):
             organization_slug=organization.slug,
             old_stripe_subscription_id=old_subscription_id,
         )
-        organization.stripe_subscription = stripe_subscription
+        organization.stripe_subscription = new_stripe_subscription
 
     organization.save()
 
@@ -183,6 +186,13 @@ def subscription_canceled(event):
         log.error("Subscription isn't attached to an organization")
         return
 
+    if organization.never_disable:
+        log.info(
+            "Organization can't be disabled, skipping notification.",
+            organization_slug=organization.slug,
+        )
+        return
+
     log.bind(organization_slug=organization.slug)
     is_trial_subscription = stripe_subscription.items.filter(
         price__id=settings.RTD_ORG_DEFAULT_STRIPE_SUBSCRIPTION_PRICE
diff --git a/readthedocs/subscriptions/tests/test_event_handlers.py b/readthedocs/subscriptions/tests/test_event_handlers.py
index c828333aede..042f303a2a7 100644
--- a/readthedocs/subscriptions/tests/test_event_handlers.py
+++ b/readthedocs/subscriptions/tests/test_event_handlers.py
@@ -351,6 +351,86 @@ def test_subscription_canceled_no_organization_attached(self, notification_send)
         event_handlers.subscription_updated_event(event)
         notification_send.assert_not_called()
 
+    @mock.patch(
+        "readthedocs.subscriptions.event_handlers.SubscriptionEndedNotification.send"
+    )
+    def test_subscription_canceled_on_never_disable_organization(self, notification_send):
+        customer = get(djstripe.Customer)
+        self.organization.stripe_customer = customer
+        self.organization.never_disable = True
+        self.organization.save()
+
+        start_date = timezone.now()
+        end_date = timezone.now() + timezone.timedelta(days=30)
+        stripe_subscription = get(
+            djstripe.Subscription,
+            id="sub_9LtsU02uvjO6Ed",
+            status=SubscriptionStatus.canceled,
+            current_period_start=start_date,
+            current_period_end=end_date,
+            trial_end=end_date,
+            customer=customer,
+        )
+        event = get(
+            djstripe.Event,
+            data={
+                "object": {
+                    "id": stripe_subscription.id,
+                    "object": "subscription",
+                }
+            },
+        )
+        event_handlers.subscription_canceled(event)
+        event_handlers.subscription_updated_event(event)
+
+        self.organization.refresh_from_db()
+        assert not self.organization.disabled
+        notification_send.assert_not_called()
+
+    def test_subscription_precedence(self):
+        customer = get(djstripe.Customer, id="cus_KMiHJXFHpLkcRP")
+        self.organization.stripe_customer = customer
+        self.organization.save()
+
+        assert self.organization.stripe_subscription is None
+
+        start_date = timezone.now()
+        end_date = timezone.now() + timezone.timedelta(days=30)
+
+        statuses = [
+            SubscriptionStatus.canceled,
+            SubscriptionStatus.trialing,
+            SubscriptionStatus.active,
+            SubscriptionStatus.incomplete,
+            SubscriptionStatus.incomplete_expired,
+            SubscriptionStatus.past_due,
+            SubscriptionStatus.unpaid,
+        ]
+        for i, status in enumerate(statuses):
+            stripe_subscription = get(
+                djstripe.Subscription,
+                id=f"sub_{i}",
+                status=status,
+                current_period_start=start_date,
+                current_period_end=end_date,
+                trial_end=end_date,
+                customer=customer,
+            )
+            event = get(
+                djstripe.Event,
+                data={
+                    "object": {
+                        "id": stripe_subscription.id,
+                        "object": "subscription",
+                    }
+                },
+            )
+
+            event_handlers.subscription_updated_event(event)
+
+            self.organization.refresh_from_db()
+            assert self.organization.stripe_subscription == stripe_subscription
+
     def test_register_events(self):
         def test_func():
             pass


Reviews:
user: login: humitos
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-17T16:34:50Z
user: login: stsewd
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T01:08:13Z
user: login: humitos
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T13:19:18Z

Review comments:
diff_hunk: @@ -143,21 +143,33 @@ def __str__(self):
         return self.name
 
     def get_stripe_subscription(self):
-        # Active subscriptions take precedence over non-active subscriptions,
-        # otherwise we return the most recently created subscription.
-        active_subscriptions = self.stripe_customer.subscriptions.filter(
-            status=SubscriptionStatus.active
-        )
-        if active_subscriptions:
-            if active_subscriptions.count() > 1:
-                # NOTE: this should never happen, unless we manually
-                # created another subscription for the user or if there
-                # is a bug in our code.
-                log.exception(
-                    "Organization has more than one active subscription",
-                    organization_slug=self.slug,
-                )
-            return active_subscriptions.order_by("created").last()
+        status_priority = [
+            # Past due and unpaid should be taken into consideration first,
+            # as the user needs to pay before they can access the service.
+            # See https://docs.stripe.com/billing/subscriptions/overview#subscription-statuses.
+            SubscriptionStatus.unpaid,
+            SubscriptionStatus.past_due,
+            SubscriptionStatus.incomplete_expired,
+            SubscriptionStatus.incomplete,
+            SubscriptionStatus.active,
+            SubscriptionStatus.trialing,
+        ]
+        for status in status_priority:
+            subscriptions = self.stripe_customer.subscriptions.filter(status=status)
+            if subscriptions.exists():
+                if subscriptions.count() > 1:
+                    # NOTE: this should never happen, unless we manually
+                    # created another subscription for the user or if there
+                    # is a bug in our code.
+                    log.exception(
+                        "Organization has more than one active subscription",
path: readthedocs/organizations/models.py
user: login: humitos
body: ```suggestion
                        "Organization has more than one subscription in the same status",
```
created_at: 2025-03-17T16:28:28Z
author_association: MEMBER
diff_hunk: @@ -127,9 +127,13 @@ def subscription_updated_event(event):
         log.info("Re-enabling organization.", organization_slug=organization.slug)
         organization.disabled = False
 
-    if stripe_subscription.status not in (
-        SubscriptionStatus.active,
-        SubscriptionStatus.trialing,
+    if (
+        stripe_subscription.status
+        not in (
+            SubscriptionStatus.active,
+            SubscriptionStatus.trialing,
+        )
+        and not organization.never_disable
path: readthedocs/subscriptions/event_handlers.py
user: login: humitos
body: I would put this `if` inside the outer `if` and call `log.info` when the `never_disable` condition is met. That way, we can know _why_ the organization is not being disabled even if the subscription is unpaid.
created_at: 2025-03-17T16:30:54Z
author_association: MEMBER
diff_hunk: @@ -127,9 +127,13 @@ def subscription_updated_event(event):
         log.info("Re-enabling organization.", organization_slug=organization.slug)
         organization.disabled = False
 
-    if stripe_subscription.status not in (
-        SubscriptionStatus.active,
-        SubscriptionStatus.trialing,
+    if (
+        stripe_subscription.status
+        not in (
+            SubscriptionStatus.active,
+            SubscriptionStatus.trialing,
+        )
+        and not organization.never_disable
path: readthedocs/subscriptions/event_handlers.py
user: login: stsewd
body: Maybe log it as an exception so we at least see it in sentry?
created_at: 2025-03-18T01:08:12Z
author_association: MEMBER
diff_hunk: @@ -127,9 +127,13 @@ def subscription_updated_event(event):
         log.info("Re-enabling organization.", organization_slug=organization.slug)
         organization.disabled = False
 
-    if stripe_subscription.status not in (
-        SubscriptionStatus.active,
-        SubscriptionStatus.trialing,
+    if (
+        stripe_subscription.status
+        not in (
+            SubscriptionStatus.active,
+            SubscriptionStatus.trialing,
+        )
+        and not organization.never_disable
path: readthedocs/subscriptions/event_handlers.py
user: login: humitos
body: I think `log.info` is enough since it's not an error but an expected situation 👍🏼 
created_at: 2025-03-18T13:19:18Z
author_association: MEMBER

PR comments:
user: login: agjohnson
created_at: 2025-03-18T13:16:47Z
body: @humitos Does this logic affect our logic for detecting and disabling organizations that have a subscription move to unpaid status?
author_association: CONTRIBUTOR
user: login: humitos
created_at: 2025-03-18T13:21:09Z
body: @agjohnson I think it shouldn't
author_association: MEMBER
user: login: stsewd
created_at: 2025-03-19T21:42:57Z
body: > Does this logic affect our logic for detecting and disabling organizations that have a subscription move to unpaid status?

We still disable orgs with unpaid subscriptions, but we now can mark the orgs as never disabled, and also their subscription will show the unpaid one instead of a previous random one.
author_association: MEMBER