Skip to content

Subscriptions: remove old code #10642

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
Sep 5, 2023
Merged
Show file tree
Hide file tree
Changes from 10 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 .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,11 @@ jobs:
- run: git submodule update --init
# Recipe: https://pre-commit.com/#managing-ci-caches
- run:
name: Combine pre-commit config and python versions for caching
name: Combine pre-commit config, python version and testing requirements for caching
command: |
cp common/pre-commit-config.yaml pre-commit-cache-key.txt
python --version --version >> pre-commit-cache-key.txt
cat requirements/testing.txt >> pre-commit-cache-key.txt
- restore_cache:
keys:
- pre-commit-cache-{{ checksum "pre-commit-cache-key.txt" }}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Generated by Django 4.2.4 on 2023-08-17 22:58

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("organizations", "0011_add_stripe_subscription_field"),
]

operations = [
migrations.AddField(
model_name="historicalorganization",
name="never_disable",
field=models.BooleanField(
default=False,
help_text="Never disable this organization, even if its subscription ends",
null=True,
verbose_name="Never disable",
),
),
migrations.AddField(
model_name="organization",
name="never_disable",
field=models.BooleanField(
default=False,
help_text="Never disable this organization, even if its subscription ends",
null=True,
verbose_name="Never disable",
),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Generated by Django 4.2.4 on 2023-08-21 20:28

from django.db import migrations


def forwards_func(apps, schema_editor):
"""Migrate locked subscriptions to never_disable organizations."""
Subscription = apps.get_model("subscriptions", "Subscription")
locked_subscriptions = Subscription.objects.filter(locked=True).select_related(
"organization"
)
for subscription in locked_subscriptions:
subscription.organization.never_disable = True
subscription.organization.save()


class Migration(migrations.Migration):
dependencies = [
("organizations", "0012_add_organization_never_disable"),
("subscriptions", "0002_alter_planfeature_feature_type"),
]

operations = [
migrations.RunPython(forwards_func),
]
44 changes: 25 additions & 19 deletions readthedocs/organizations/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
from readthedocs.core.permissions import AdminPermission
from readthedocs.core.utils import slugify

from readthedocs.subscriptions.utils import get_or_create_stripe_subscription

from . import constants
from .managers import TeamManager, TeamMemberManager
from .querysets import OrganizationQuerySet
Expand All @@ -23,11 +25,7 @@

class Organization(models.Model):

"""
Organization model.

stripe_id: Customer id from Stripe API
"""
"""Organization model."""

# Auto fields
pub_date = models.DateTimeField(_('Publication date'), auto_now_add=True)
Expand Down Expand Up @@ -75,6 +73,13 @@ class Organization(models.Model):
blank=True,
null=True,
)
never_disable = models.BooleanField(
_("Never disable"),
help_text="Never disable this organization, even if its subscription ends",
# TODO: remove after migration
null=True,
default=False,
)
disabled = models.BooleanField(
_('Disabled'),
help_text='Docs and builds are disabled for this organization',
Expand All @@ -91,6 +96,7 @@ class Organization(models.Model):
blank=True,
)

# TODO: This field can be removed, we are now using stripe_customer instead.
stripe_id = models.CharField(
_('Stripe customer ID'),
max_length=100,
Expand Down Expand Up @@ -128,24 +134,24 @@ def __str__(self):
return self.name

def get_or_create_stripe_subscription(self):
# TODO: remove this once we don't depend on our Subscription models.
from readthedocs.subscriptions.models import Subscription

subscription = Subscription.objects.get_or_create_default_subscription(self)
if not subscription:
# This only happens during development.
log.warning("No default subscription created.")
return None
return self.get_stripe_subscription()
return get_or_create_stripe_subscription(self)

def get_stripe_subscription(self):
# Active subscriptions take precedence over non-active subscriptions,
# otherwise we return the must recently created subscription.
active_subscription = self.stripe_customer.subscriptions.filter(
# otherwise we return the most recently created subscription.
active_subscriptions = self.stripe_customer.subscriptions.filter(
status=SubscriptionStatus.active
).first()
if active_subscription:
return active_subscription
)
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()
return self.stripe_customer.subscriptions.order_by("created").last()

def get_absolute_url(self):
Expand Down
127 changes: 58 additions & 69 deletions readthedocs/organizations/querysets.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from django.db import models
from django.db.models import Count, Q
from django.utils import timezone
from djstripe.enums import InvoiceStatus, SubscriptionStatus

from readthedocs.core.utils.extend import SettingsOverrideObject
from readthedocs.subscriptions.constants import DISABLE_AFTER_DAYS
Expand Down Expand Up @@ -38,70 +39,24 @@ def created_days_ago(self, days, field='pub_date'):
query_filter[field + '__day'] = when.day
return self.filter(**query_filter)

# TODO: once we are settled into the Trial Plan, merge this method with the
# following one (``subscription_trial_ended``).
def subscription_trial_plan_ended(self):
"""
Organizations with subscriptions to Trial Plan ended.

Trial Plan in Stripe has a 30-day trial set up. After that period ends,
the subscription goes to ``active`` and we know that the trial ended.

It also checks that ``end_date`` or ``trial_end_date`` are in the past.
"""
date_now = timezone.now()
return self.filter(
~Q(subscription__status="trialing"),
Q(
subscription__plan__stripe_id=settings.RTD_ORG_DEFAULT_STRIPE_SUBSCRIPTION_PRICE
),
Q(subscription__end_date__lt=date_now)
| Q(subscription__trial_end_date__lt=date_now),
)

def subscription_trial_ended(self):
"""
Organizations with subscriptions in trial ended state.

Filter for organization subscription past the trial date, or
organizations older than 30 days old
"""
date_now = timezone.now()
return self.filter(
Q(
(
Q(
subscription__status='trialing',
) | Q(
subscription__status__exact='',
)
),
subscription__trial_end_date__lt=date_now,
) | Q(
subscription__isnull=True,
pub_date__lt=date_now - timedelta(days=30),
),
)

def subscription_ended(self):
"""
Organization with paid subscriptions ended.

Filter for organization with paid subscriptions that have
ended (canceled, past_due or unpaid) and they didn't renew them yet.

https://stripe.com/docs/api#subscription_object-status
the subscription is canceled.
"""
return self.filter(
subscription__status__in=['canceled', 'past_due', 'unpaid'],
stripe_subscription__status=SubscriptionStatus.canceled,
stripe_subscription__items__price__id=settings.RTD_ORG_DEFAULT_STRIPE_SUBSCRIPTION_PRICE,
)

def disable_soon(self, days, exact=False):
def subscription_ended(self, days, exact=False):
"""
Filter organizations that will eventually be marked as disabled.
Filter organizations which their subscription has ended.

This will return organizations that the paid/trial subscription has
ended ``days`` ago.
This will return organizations which their subscription has been canceled,
or hasn't been paid for ``days``.

:param days: Days after the subscription has ended
:param exact: Make the ``days`` date to match exactly that day after the
Expand All @@ -112,22 +67,58 @@ def disable_soon(self, days, exact=False):

if exact:
# We use ``__date`` here since the field is a DateTimeField
trial_filter = {'subscription__trial_end_date__date': end_date}
paid_filter = {'subscription__end_date__date': end_date}
subscription_ended = self.filter(
Q(
stripe_subscription__status=SubscriptionStatus.canceled,
stripe_subscription__ended_at__date=end_date,
)
| Q(
stripe_subscription__status__in=[
SubscriptionStatus.past_due,
SubscriptionStatus.incomplete,
SubscriptionStatus.unpaid,
],
stripe_subscription__latest_invoice__due_date__date=end_date,
stripe_subscription__latest_invoice__status=InvoiceStatus.open,
)
)
else:
trial_filter = {'subscription__trial_end_date__lt': end_date}
paid_filter = {'subscription__end_date__lt': end_date}

trial_ended = self.subscription_trial_ended().filter(**trial_filter)
paid_ended = self.subscription_ended().filter(**paid_filter)
subscription_ended = self.filter(
Q(
stripe_subscription__status=SubscriptionStatus.canceled,
stripe_subscription__ended_at__lt=end_date,
)
| Q(
stripe_subscription__status__in=[
SubscriptionStatus.past_due,
SubscriptionStatus.incomplete,
SubscriptionStatus.unpaid,
],
stripe_subscription__latest_invoice__due_date__date__lt=end_date,
stripe_subscription__latest_invoice__status=InvoiceStatus.open,
)
)

return subscription_ended.distinct()

# Exclude organizations with custom plans (locked=True)
orgs = (trial_ended | paid_ended).exclude(subscription__locked=True)
def disable_soon(self, days, exact=False):
"""
Filter organizations that will eventually be marked as disabled.

# Exclude organizations that are already disabled
orgs = orgs.exclude(disabled=True)
These are organizations which their subscription has ended,
excluding organizations that can't be disabled, or are already disabled.

return orgs.distinct()
:param days: Days after the subscription has ended
:param exact: Make the ``days`` date to match exactly that day after the
subscription has ended (useful to send emails only once)
"""
return (
self.subscription_ended(days=days, exact=exact)
# Exclude organizations that can't be disabled.
.exclude(never_disable=True)
# Exclude organizations that are already disabled
.exclude(disabled=True)
)

def clean_artifacts(self):
"""
Expand All @@ -137,13 +128,11 @@ def clean_artifacts(self):
are disabled and their artifacts weren't cleaned already. We should be
safe to cleanup all their artifacts at this point.
"""
end_date = timezone.now().date() - timedelta(days=3 * DISABLE_AFTER_DAYS)
orgs = self.filter(
return self.subscription_ended(days=3 * DISABLE_AFTER_DAYS, exact=True).filter(
disabled=True,
subscription__end_date__lt=end_date,
artifacts_cleaned=False,
)
return orgs.distinct()


def single_owner(self, user):
"""Returns organizations where `user` is the only owner."""
Expand Down
16 changes: 16 additions & 0 deletions readthedocs/organizations/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
from django.db.models import Count
from django.db.models.signals import pre_delete
from django.dispatch import receiver
from djstripe.enums import SubscriptionStatus

from readthedocs.builds.constants import BUILD_FINAL_STATES
from readthedocs.builds.models import Build
from readthedocs.builds.signals import build_complete
from readthedocs.organizations.models import Organization, Team, TeamMember
from readthedocs.payments.utils import cancel_subscription
from readthedocs.projects.models import Project

from .tasks import (
Expand Down Expand Up @@ -37,6 +39,7 @@ def remove_organization_completely(sender, instance, using, **kwargs):

This includes:

- Stripe customer
- Projects
- Versions
- Builds (deleted on cascade)
Expand All @@ -46,6 +49,19 @@ def remove_organization_completely(sender, instance, using, **kwargs):
- Artifacts (HTML, PDF, etc)
"""
organization = instance

stripe_customer = organization.stripe_customer
if stripe_customer:
log.info(
"Canceling subscriptions",
organization_slug=organization.slug,
stripe_customer_id=stripe_customer.id,
)
for subscription in stripe_customer.subscriptions.exclude(
status=SubscriptionStatus.canceled
):
cancel_subscription(subscription.id)

log.info("Removing organization completely", organization_slug=organization.slug)

# ``Project`` has a ManyToMany relationship with ``Organization``. We need
Expand Down
Loading