Skip to content

Move subscription models #8746

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 4 commits into from
Jan 10, 2022
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
1 change: 1 addition & 0 deletions readthedocs/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ def INSTALLED_APPS(self): # noqa

'readthedocs.gold',
'readthedocs.payments',
'readthedocs.subscriptions',
'readthedocs.notifications',
'readthedocs.integrations',
'readthedocs.analytics',
Expand Down
Empty file.
99 changes: 99 additions & 0 deletions readthedocs/subscriptions/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""Admin interface for subscription models."""

from datetime import timedelta

from django.contrib import admin
from django.db.models import Q
from django.utils import timezone
from django.utils.html import format_html

from readthedocs.core.history import ExtraSimpleHistoryAdmin
from readthedocs.subscriptions.models import Plan, PlanFeature, Subscription


class PlanFeatureInline(admin.TabularInline):
model = PlanFeature


class PlanAdmin(admin.ModelAdmin):
prepopulated_fields = {'slug': ('name',)}
list_display = (
'name',
'slug',
'price',
'trial',
'published',
)
filter_horizontal = ('for_organizations',)
inlines = (PlanFeatureInline,)


class PlanFeatureAdmin(admin.ModelAdmin):
list_display = ('get_feature_type_display', 'plan')
list_select_related = ('plan',)
search_fields = ('plan__name', 'feature_type')


class SubscriptionDateFilter(admin.SimpleListFilter):

"""Filter for the status of the subscriptions related to their date."""

title = 'subscription date'
parameter_name = 'subscription_date'

TRIALING = 'trialing'
TRIAL_ENDING = 'trial_ending'
TRIAL_ENDED = 'trial_ended'
EXPIRED = 'expired'

def lookups(self, request, model_admin):
return (
(self.TRIALING, 'trial active'),
(self.TRIAL_ENDING, 'trial ending'),
(self.TRIAL_ENDED, 'trial ended'),
(self.EXPIRED, 'subscription expired'),
)

def queryset(self, request, queryset):
trial_queryset = (
queryset.filter(
Q(status='trialing') |
Q(status__isnull=True),
),
) # yapf: disabled
if self.value() == self.TRIALING:
return trial_queryset.filter(trial_end_date__gt=timezone.now(),)
if self.value() == self.TRIAL_ENDING:
return trial_queryset.filter(
trial_end_date__lt=timezone.now() + timedelta(days=7),
trial_end_date__gt=timezone.now(),
)
if self.value() == self.TRIAL_ENDED:
return trial_queryset.filter(trial_end_date__lt=timezone.now(),)
if self.value() == self.EXPIRED:
return queryset.filter(end_date__lt=timezone.now())


class SubscriptionAdmin(ExtraSimpleHistoryAdmin):
model = Subscription
list_display = ('organization', 'plan', 'status', 'stripe_subscription', 'trial_end_date')
list_filter = ('status', SubscriptionDateFilter, 'plan')
list_select_related = ('organization', 'plan')
raw_id_fields = ('organization',)
readonly_fields = ('stripe_subscription',)
search_fields = ('organization__name', 'stripe_id')

# pylint: disable=no-self-use
def stripe_subscription(self, obj):
if obj.stripe_id:
return format_html(
'<a href="{}" target="_blank" rel="noopener noreferrer">{}</a>',
"https://dashboard.stripe.com/subscriptions/{}".format(obj.stripe_id),
obj.stripe_id,
)
return None


admin.site.register(Subscription, SubscriptionAdmin)
admin.site.register(Plan, PlanAdmin)
admin.site.register(PlanFeature, PlanFeatureAdmin)
7 changes: 7 additions & 0 deletions readthedocs/subscriptions/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""Subscriptions app."""

from django.apps import AppConfig


class SubscriptionsConfig(AppConfig):
name = 'subscriptions'
220 changes: 220 additions & 0 deletions readthedocs/subscriptions/managers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
"""Subscriptions managers."""

from datetime import datetime

import structlog
from django.conf import settings
from django.db import models
from django.utils import timezone

from readthedocs.core.history import set_change_reason
from readthedocs.subscriptions.utils import get_or_create_stripe_customer

log = structlog.get_logger(__name__)


class SubscriptionManager(models.Manager):

"""Model manager for Subscriptions."""

def get_or_create_default_subscription(self, organization):
"""
Get or create a trialing subscription for `organization`.

If the organization doesn't have a subscription attached,
the following steps are executed.

- If the organization doesn't have a stripe customer, one is created.
- A new stripe subscription is created using the default plan.
- A new subscription object is created in our database
with the information from the stripe subscription.
"""
if hasattr(organization, 'subscription'):
return organization.subscription

from readthedocs.subscriptions.models import Plan
plan = Plan.objects.filter(slug=settings.ORG_DEFAULT_SUBSCRIPTION_PLAN_SLUG).first()
# This should happen only on development.
if not plan:
log.warning(
'No default plan found, not creating a subscription.',
organization_slug=organization.slug,
)
return None

stripe_customer = get_or_create_stripe_customer(organization)
stripe_subscription = stripe_customer.subscriptions.create(
plan=plan.stripe_id,
trial_period_days=plan.trial,
)
return self.create(
plan=plan,
organization=organization,
stripe_id=stripe_subscription.id,
status=stripe_subscription.status,
start_date=timezone.make_aware(
datetime.fromtimestamp(int(stripe_subscription.start)),
),
end_date=timezone.make_aware(
datetime.fromtimestamp(int(stripe_subscription.current_period_end)),
),
trial_end_date=timezone.make_aware(
datetime.fromtimestamp(int(stripe_subscription.trial_end)),
),
)

def update_from_stripe(self, *, rtd_subscription, stripe_subscription):
"""
Update the RTD subscription object with the information of the stripe subscription.

:param subscription: Subscription object to update.
:param stripe_subscription: Stripe subscription object from API
:type stripe_subscription: stripe.Subscription
"""
# Documentation doesn't say what will be this value once the
# subscription is ``canceled``. I'm assuming that ``current_period_end``
# will have the same value than ``ended_at``
# https://stripe.com/docs/api/subscriptions/object?lang=python#subscription_object-current_period_end
start_date = getattr(stripe_subscription, 'current_period_start', None)
end_date = getattr(stripe_subscription, 'current_period_end', None)

try:
start_date = timezone.make_aware(
datetime.fromtimestamp(start_date),
)
end_date = timezone.make_aware(
datetime.fromtimestamp(end_date),
)
except TypeError:
log.error(
'Stripe subscription invalid date.',
start_date=start_date,
end_date=end_date,
stripe_subscription=stripe_subscription.id,
)
start_date = None
end_date = None
trial_end_date = None

rtd_subscription.status = stripe_subscription.status

# This should only happen if an existing user creates a new subscription,
# after their previous subscription was cancelled.
if stripe_subscription.id != rtd_subscription.stripe_id:
log.info(
'Replacing stripe subscription.',
old_stripe_subscription=rtd_subscription.stripe_id,
new_stripe_subscription=stripe_subscription.id,
)
rtd_subscription.stripe_id = stripe_subscription.id

# Update trial end date if it's present
trial_end_date = getattr(stripe_subscription, 'trial_end', None)
if trial_end_date:
try:
trial_end_date = timezone.make_aware(
datetime.fromtimestamp(trial_end_date),
)
rtd_subscription.trial_end_date = trial_end_date
except TypeError:
log.error(
'Stripe subscription trial end date invalid. ',
trial_end_date=trial_end_date,
stripe_subscription=stripe_subscription.id,
)

# Update the plan in case it was changed from the Portal.
# Try our best to match a plan that is not custom. This mostly just
# updates the UI now that we're using the Stripe Portal. A miss here
# just won't update the UI, but this shouldn't happen for most users.
from readthedocs.subscriptions.models import Plan
try:
plan = (
Plan.objects
# Exclude "custom" here, as we historically reused Stripe plan
# id for custom plans. We don't have a better attribute to
# filter on here.
.exclude(slug__contains='custom')
.exclude(name__icontains='Custom')
.get(stripe_id=stripe_subscription.plan.id)
)
rtd_subscription.plan = plan
except (Plan.DoesNotExist, Plan.MultipleObjectsReturned):
log.error(
'Plan lookup failed, skipping plan update.',
stripe_subscription=stripe_subscription.id,
stripe_plan=stripe_subscription.plan.id,
)

if stripe_subscription.status == 'canceled':
# Remove ``stripe_id`` when canceled so the customer can
# re-subscribe using our form.
rtd_subscription.stripe_id = None

elif stripe_subscription.status == 'active' and end_date:
# Save latest active date (end_date) to notify owners about their subscription
# is ending and disable this organization after N days of unpaid. We check for
# ``active`` here because Stripe will continue sending updates for the
# subscription, with a new ``end_date``, even after the subscription enters
# an unpaid state.
rtd_subscription.end_date = end_date

elif stripe_subscription.status == 'past_due' and start_date:
# When Stripe marks the subscription as ``past_due``,
# it means the usage of RTD service for the current period/month was not paid at all.
# At this point, we need to update our ``end_date`` to the last period the customer paid
# (which is the start date of the current ``past_due`` period --it could be the end date
# of the trial or the end date of the last paid period).
rtd_subscription.end_date = start_date

klass = self.__class__.__name__
change_reason = f'origin=stripe-subscription class={klass}'

# Ensure that the organization is in the correct state.
# We want to always ensure the organization is never disabled
# if the subscription is valid.
organization = rtd_subscription.organization
if stripe_subscription.status == 'active' and organization.disabled:
log.warning(
'Re-enabling organization with valid subscription.',
organization_slug=organization.slug,
stripe_subscription=rtd_subscription.id,
)
organization.disabled = False
set_change_reason(organization, change_reason)
organization.save()

set_change_reason(rtd_subscription, change_reason)
rtd_subscription.save()
return rtd_subscription


class PlanFeatureManager(models.Manager):

"""Model manager for PlanFeature."""

# pylint: disable=redefined-builtin
def get_feature(self, obj, type):
"""
Get feature `type` for `obj`.

:param obj: An organization or project instance.
:param type: The type of the feature (PlanFeature.TYPE_*).
:returns: A PlanFeature object or None.
"""
# Avoid circular imports.
from readthedocs.organizations.models import Organization
from readthedocs.projects.models import Project

if isinstance(obj, Project):
organization = obj.organizations.first()
elif isinstance(obj, Organization):
organization = obj
else:
raise TypeError

feature = self.filter(
feature_type=type,
plan__subscriptions__organization=organization,
)
return feature.first()
Loading