|
| 1 | +"""Subscriptions managers.""" |
| 2 | + |
| 3 | +from datetime import datetime |
| 4 | + |
| 5 | +import structlog |
| 6 | +from django.conf import settings |
| 7 | +from django.db import models |
| 8 | +from django.utils import timezone |
| 9 | + |
| 10 | +from readthedocs.core.history import set_change_reason |
| 11 | +from readthedocs.subscriptions.utils import get_or_create_stripe_customer |
| 12 | + |
| 13 | +log = structlog.get_logger(__name__) |
| 14 | + |
| 15 | + |
| 16 | +class SubscriptionManager(models.Manager): |
| 17 | + |
| 18 | + """Model manager for Subscriptions.""" |
| 19 | + |
| 20 | + def get_or_create_default_subscription(self, organization): |
| 21 | + """ |
| 22 | + Get or create a trialing subscription for `organization`. |
| 23 | +
|
| 24 | + If the organization doesn't have a subscription attached, |
| 25 | + the following steps are executed. |
| 26 | +
|
| 27 | + - If the organization doesn't have a stripe customer, one is created. |
| 28 | + - A new stripe subscription is created using the default plan. |
| 29 | + - A new subscription object is created in our database |
| 30 | + with the information from the stripe subscription. |
| 31 | + """ |
| 32 | + if hasattr(organization, 'subscription'): |
| 33 | + return organization.subscription |
| 34 | + |
| 35 | + from readthedocs.subscriptions.models import Plan |
| 36 | + plan = Plan.objects.filter(slug=settings.ORG_DEFAULT_SUBSCRIPTION_PLAN_SLUG).first() |
| 37 | + # This should happen only on development. |
| 38 | + if not plan: |
| 39 | + log.warning( |
| 40 | + 'No default plan found, not creating a subscription.', |
| 41 | + organization_slug=organization.slug, |
| 42 | + ) |
| 43 | + return None |
| 44 | + |
| 45 | + stripe_customer = get_or_create_stripe_customer(organization) |
| 46 | + stripe_subscription = stripe_customer.subscriptions.create( |
| 47 | + plan=plan.stripe_id, |
| 48 | + trial_period_days=plan.trial, |
| 49 | + ) |
| 50 | + return self.create( |
| 51 | + plan=plan, |
| 52 | + organization=organization, |
| 53 | + stripe_id=stripe_subscription.id, |
| 54 | + status=stripe_subscription.status, |
| 55 | + start_date=timezone.make_aware( |
| 56 | + datetime.fromtimestamp(int(stripe_subscription.start)), |
| 57 | + ), |
| 58 | + end_date=timezone.make_aware( |
| 59 | + datetime.fromtimestamp(int(stripe_subscription.current_period_end)), |
| 60 | + ), |
| 61 | + trial_end_date=timezone.make_aware( |
| 62 | + datetime.fromtimestamp(int(stripe_subscription.trial_end)), |
| 63 | + ), |
| 64 | + ) |
| 65 | + |
| 66 | + def update_from_stripe(self, *, rtd_subscription, stripe_subscription): |
| 67 | + """ |
| 68 | + Update the RTD subscription object with the information of the stripe subscription. |
| 69 | +
|
| 70 | + :param subscription: Subscription object to update. |
| 71 | + :param stripe_subscription: Stripe subscription object from API |
| 72 | + :type stripe_subscription: stripe.Subscription |
| 73 | + """ |
| 74 | + # Documentation doesn't say what will be this value once the |
| 75 | + # subscription is ``canceled``. I'm assuming that ``current_period_end`` |
| 76 | + # will have the same value than ``ended_at`` |
| 77 | + # https://stripe.com/docs/api/subscriptions/object?lang=python#subscription_object-current_period_end |
| 78 | + start_date = getattr(stripe_subscription, 'current_period_start', None) |
| 79 | + end_date = getattr(stripe_subscription, 'current_period_end', None) |
| 80 | + |
| 81 | + try: |
| 82 | + start_date = timezone.make_aware( |
| 83 | + datetime.fromtimestamp(start_date), |
| 84 | + ) |
| 85 | + end_date = timezone.make_aware( |
| 86 | + datetime.fromtimestamp(end_date), |
| 87 | + ) |
| 88 | + except TypeError: |
| 89 | + log.error( |
| 90 | + 'Stripe subscription invalid date.', |
| 91 | + start_date=start_date, |
| 92 | + end_date=end_date, |
| 93 | + stripe_subscription=stripe_subscription.id, |
| 94 | + ) |
| 95 | + start_date = None |
| 96 | + end_date = None |
| 97 | + trial_end_date = None |
| 98 | + |
| 99 | + rtd_subscription.status = stripe_subscription.status |
| 100 | + |
| 101 | + # This should only happen if an existing user creates a new subscription, |
| 102 | + # after their previous subscription was cancelled. |
| 103 | + if stripe_subscription.id != rtd_subscription.stripe_id: |
| 104 | + log.info( |
| 105 | + 'Replacing stripe subscription.', |
| 106 | + old_stripe_subscription=rtd_subscription.stripe_id, |
| 107 | + new_stripe_subscription=stripe_subscription.id, |
| 108 | + ) |
| 109 | + rtd_subscription.stripe_id = stripe_subscription.id |
| 110 | + |
| 111 | + # Update trial end date if it's present |
| 112 | + trial_end_date = getattr(stripe_subscription, 'trial_end', None) |
| 113 | + if trial_end_date: |
| 114 | + try: |
| 115 | + trial_end_date = timezone.make_aware( |
| 116 | + datetime.fromtimestamp(trial_end_date), |
| 117 | + ) |
| 118 | + rtd_subscription.trial_end_date = trial_end_date |
| 119 | + except TypeError: |
| 120 | + log.error( |
| 121 | + 'Stripe subscription trial end date invalid. ', |
| 122 | + trial_end_date=trial_end_date, |
| 123 | + stripe_subscription=stripe_subscription.id, |
| 124 | + ) |
| 125 | + |
| 126 | + # Update the plan in case it was changed from the Portal. |
| 127 | + # Try our best to match a plan that is not custom. This mostly just |
| 128 | + # updates the UI now that we're using the Stripe Portal. A miss here |
| 129 | + # just won't update the UI, but this shouldn't happen for most users. |
| 130 | + from readthedocs.subscriptions.models import Plan |
| 131 | + try: |
| 132 | + plan = ( |
| 133 | + Plan.objects |
| 134 | + # Exclude "custom" here, as we historically reused Stripe plan |
| 135 | + # id for custom plans. We don't have a better attribute to |
| 136 | + # filter on here. |
| 137 | + .exclude(slug__contains='custom') |
| 138 | + .exclude(name__icontains='Custom') |
| 139 | + .get(stripe_id=stripe_subscription.plan.id) |
| 140 | + ) |
| 141 | + rtd_subscription.plan = plan |
| 142 | + except (Plan.DoesNotExist, Plan.MultipleObjectsReturned): |
| 143 | + log.error( |
| 144 | + 'Plan lookup failed, skipping plan update.', |
| 145 | + stripe_subscription=stripe_subscription.id, |
| 146 | + stripe_plan=stripe_subscription.plan.id, |
| 147 | + ) |
| 148 | + |
| 149 | + if stripe_subscription.status == 'canceled': |
| 150 | + # Remove ``stripe_id`` when canceled so the customer can |
| 151 | + # re-subscribe using our form. |
| 152 | + rtd_subscription.stripe_id = None |
| 153 | + |
| 154 | + elif stripe_subscription.status == 'active' and end_date: |
| 155 | + # Save latest active date (end_date) to notify owners about their subscription |
| 156 | + # is ending and disable this organization after N days of unpaid. We check for |
| 157 | + # ``active`` here because Stripe will continue sending updates for the |
| 158 | + # subscription, with a new ``end_date``, even after the subscription enters |
| 159 | + # an unpaid state. |
| 160 | + rtd_subscription.end_date = end_date |
| 161 | + |
| 162 | + elif stripe_subscription.status == 'past_due' and start_date: |
| 163 | + # When Stripe marks the subscription as ``past_due``, |
| 164 | + # it means the usage of RTD service for the current period/month was not paid at all. |
| 165 | + # At this point, we need to update our ``end_date`` to the last period the customer paid |
| 166 | + # (which is the start date of the current ``past_due`` period --it could be the end date |
| 167 | + # of the trial or the end date of the last paid period). |
| 168 | + rtd_subscription.end_date = start_date |
| 169 | + |
| 170 | + klass = self.__class__.__name__ |
| 171 | + change_reason = f'origin=stripe-subscription class={klass}' |
| 172 | + |
| 173 | + # Ensure that the organization is in the correct state. |
| 174 | + # We want to always ensure the organization is never disabled |
| 175 | + # if the subscription is valid. |
| 176 | + organization = rtd_subscription.organization |
| 177 | + if stripe_subscription.status == 'active' and organization.disabled: |
| 178 | + log.warning( |
| 179 | + 'Re-enabling organization with valid subscription.', |
| 180 | + organization_slug=organization.slug, |
| 181 | + stripe_subscription=rtd_subscription.id, |
| 182 | + ) |
| 183 | + organization.disabled = False |
| 184 | + set_change_reason(organization, change_reason) |
| 185 | + organization.save() |
| 186 | + |
| 187 | + set_change_reason(rtd_subscription, change_reason) |
| 188 | + rtd_subscription.save() |
| 189 | + return rtd_subscription |
| 190 | + |
| 191 | + |
| 192 | +class PlanFeatureManager(models.Manager): |
| 193 | + |
| 194 | + """Model manager for PlanFeature.""" |
| 195 | + |
| 196 | + # pylint: disable=redefined-builtin |
| 197 | + def get_feature(self, obj, type): |
| 198 | + """ |
| 199 | + Get feature `type` for `obj`. |
| 200 | +
|
| 201 | + :param obj: An organization or project instance. |
| 202 | + :param type: The type of the feature (PlanFeature.TYPE_*). |
| 203 | + :returns: A PlanFeature object or None. |
| 204 | + """ |
| 205 | + # Avoid circular imports. |
| 206 | + from readthedocs.organizations.models import Organization |
| 207 | + from readthedocs.projects.models import Project |
| 208 | + |
| 209 | + if isinstance(obj, Project): |
| 210 | + organization = obj.organizations.first() |
| 211 | + elif isinstance(obj, Organization): |
| 212 | + organization = obj |
| 213 | + else: |
| 214 | + raise TypeError |
| 215 | + |
| 216 | + feature = self.filter( |
| 217 | + feature_type=type, |
| 218 | + plan__subscriptions__organization=organization, |
| 219 | + ) |
| 220 | + return feature.first() |
0 commit comments