diff --git a/readthedocs/organizations/models.py b/readthedocs/organizations/models.py index 11853d04936..886d722b564 100644 --- a/readthedocs/organizations/models.py +++ b/readthedocs/organizations/models.py @@ -115,6 +115,17 @@ class Meta: def __str__(self): return self.name + @property + def 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. + return None + return self.stripe_customer.subscriptions.latest() + def get_absolute_url(self): return reverse('organization_detail', args=(self.slug,)) diff --git a/readthedocs/subscriptions/templates/subscriptions/subscription_detail.html b/readthedocs/subscriptions/templates/subscriptions/subscription_detail.html index afcef56d695..1ec8f6009ae 100644 --- a/readthedocs/subscriptions/templates/subscriptions/subscription_detail.html +++ b/readthedocs/subscriptions/templates/subscriptions/subscription_detail.html @@ -12,12 +12,12 @@ {% block edit_content %}
- {% if subscription.status == 'trialing' %} + {% if stripe_subscription.status == 'trialing' %} {% endif %} - {% if subscription and subscription.plan and subscription.status != 'canceled' and organization.stripe_id %} + {% if stripe_subscription.status != 'canceled' %}
{% trans "Plan" %}:
- {{ subscription.plan.name }} + {{ stripe_price.product.name }} - (${{ subscription.plan.price }} {% trans "per month" %}) + ({{ stripe_price.human_readable_price }})
- {% if subscription.plan.features.exists %} + {% if features %}
{% trans "Plan Features:" %}
- {% for feature in subscription.plan.features.all %} + {% for feature in features %} {{ feature.description_display }} @@ -49,27 +49,27 @@
{% endif %} - {% if subscription.start_date %} + {% if stripe_subscription.start_date %}
{% trans "Signed up" %}:
- {{ subscription.start_date|timesince }} {% trans "ago" %} + {{ stripe_subscription.start_date|timesince }} {% trans "ago" %}
{% endif %}
{% trans "Subscription Status" %}:
- {{ subscription.get_status_display }} + {{ stripe_subscription.get_status_display }}
- {% if not subscription.is_trial_ended %} + {% if stripe_subscription.status == 'trialing' and stripe_subscription.trial_end %}
{% trans "Trial ends" %}:
- {{ subscription.trial_end_date|date:"SHORT_DATE_FORMAT" }} + {{ stripe_subscription.trial_end|date:"SHORT_DATE_FORMAT" }}
- {% elif subscription.end_date %} + {% elif subscription_end_date %}
{% trans "Subscription ends" %}:
- {{ subscription.end_date|date:"SHORT_DATE_FORMAT" }} + {{ subscription_end_date|date:"SHORT_DATE_FORMAT" }}
{% endif %}
diff --git a/readthedocs/subscriptions/tests/test_views.py b/readthedocs/subscriptions/tests/test_views.py index e7bacf9fec0..4f366f2363f 100644 --- a/readthedocs/subscriptions/tests/test_views.py +++ b/readthedocs/subscriptions/tests/test_views.py @@ -23,6 +23,14 @@ def setUp(self): self.user = get(User) self.organization = get(Organization, stripe_id='123', owners=[self.user]) self.plan = get(Plan, published=True, slug=settings.ORG_DEFAULT_SUBSCRIPTION_PLAN_SLUG) + self.stripe_subscription = self._create_stripe_subscription( + customer_id=self.organization.stripe_id, + subscription_id="sub_a1b2c3d4", + ) + self.stripe_customer = self.stripe_subscription.customer + + self.organization.stripe_customer = self.stripe_customer + self.organization.save() self.subscription = get( Subscription, organization=self.organization, @@ -31,11 +39,38 @@ def setUp(self): ) self.client.force_login(self.user) + def _create_stripe_subscription( + self, customer_id="cus_a1b2c3", subscription_id="sub_a1b2c3" + ): + stripe_customer = get( + djstripe.Customer, + id=customer_id, + ) + stripe_subscription = get( + djstripe.Subscription, + id=subscription_id, + start_date=timezone.now(), + current_period_end=timezone.now() + timezone.timedelta(days=30), + trial_end=timezone.now() + timezone.timedelta(days=30), + status=SubscriptionStatus.active, + customer=stripe_customer, + ) + stripe_price = get( + djstripe.Price, + unit_amount=50000, + ) + stripe_item = get( + djstripe.SubscriptionItem, + price=stripe_price, + subscription=stripe_subscription, + ) + return stripe_subscription + def test_active_subscription(self): resp = self.client.get(reverse('subscription_detail', args=[self.organization.slug])) self.assertEqual(resp.status_code, 200) - self.assertEqual(resp.context['subscription'], self.subscription) - self.assertContains(resp, 'active') + self.assertEqual(resp.context["stripe_subscription"], self.stripe_subscription) + self.assertContains(resp, "active") # The subscribe form isn't shown, but the manage susbcription button is. self.assertContains(resp, 'Manage Subscription') self.assertNotContains(resp, 'Create Subscription') @@ -66,25 +101,16 @@ def test_manage_subscription(self, mock_request): def test_user_without_subscription( self, customer_create_mock, customer_retrieve_mock ): - stripe_customer = get( - djstripe.Customer, - id="cus_a1b2c3", - ) - stripe_subscription = get( - djstripe.Subscription, - id="sub_a1b2c3", - start_date=timezone.now(), - current_period_end=timezone.now() + timezone.timedelta(days=30), - trial_end=timezone.now() + timezone.timedelta(days=30), - status=SubscriptionStatus.active, - customer=stripe_customer, - ) + stripe_subscription = self._create_stripe_subscription() + stripe_customer = stripe_subscription.customer stripe_customer.subscribe = mock.MagicMock() stripe_customer.subscribe.return_value = stripe_subscription customer_retrieve_mock.return_value = stripe_customer - self.subscription.delete() self.organization.refresh_from_db() + self.organization.stripe_customer = None + self.organization.save() + self.subscription.delete() self.assertFalse(hasattr(self.organization, 'subscription')) self.assertIsNone(self.organization.stripe_customer) @@ -106,19 +132,8 @@ def test_user_without_subscription( def test_user_without_subscription_and_customer( self, customer_create_mock, customer_retrieve_mock, sync_from_stripe_data_mock ): - stripe_customer = get( - djstripe.Customer, - id="cus_a1b2c3", - ) - stripe_subscription = get( - djstripe.Subscription, - id="sub_a1b2c3", - start_date=timezone.now(), - current_period_end=timezone.now() + timezone.timedelta(days=30), - trial_end=timezone.now() + timezone.timedelta(days=30), - status=SubscriptionStatus.active, - customer=stripe_customer, - ) + stripe_subscription = self._create_stripe_subscription() + stripe_customer = stripe_subscription.customer stripe_customer.subscribe = mock.MagicMock() stripe_customer.subscribe.return_value = stripe_subscription customer_retrieve_mock.return_value = None @@ -126,6 +141,7 @@ def test_user_without_subscription_and_customer( # When stripe_id is None, a new customer is created. self.organization.stripe_id = None + self.organization.stripe_customer = None self.organization.save() self.subscription.delete() self.organization.refresh_from_db() @@ -147,10 +163,12 @@ def test_user_without_subscription_and_customer( def test_user_with_canceled_subscription(self): self.subscription.status = 'canceled' + self.stripe_subscription.status = SubscriptionStatus.canceled + self.stripe_subscription.save() self.subscription.save() resp = self.client.get(reverse('subscription_detail', args=[self.organization.slug])) self.assertEqual(resp.status_code, 200) - self.assertEqual(resp.context['subscription'], self.subscription) + self.assertEqual(resp.context["stripe_subscription"], self.stripe_subscription) # The Manage Subscription form isn't shown, but the Subscribe is. self.assertNotContains(resp, 'Manage Subscription') self.assertContains(resp, 'Create Subscription') diff --git a/readthedocs/subscriptions/views.py b/readthedocs/subscriptions/views.py index 175533e31f5..0774c303f6e 100644 --- a/readthedocs/subscriptions/views.py +++ b/readthedocs/subscriptions/views.py @@ -9,11 +9,13 @@ from django.shortcuts import get_object_or_404 from django.urls import reverse from django.utils.translation import gettext_lazy as _ +from djstripe import models as djstripe +from djstripe.enums import SubscriptionStatus from vanilla import DetailView, GenericView from readthedocs.organizations.views.base import OrganizationMixin from readthedocs.subscriptions.forms import PlanForm -from readthedocs.subscriptions.models import Plan, Subscription +from readthedocs.subscriptions.models import Plan from readthedocs.subscriptions.utils import get_or_create_stripe_customer log = structlog.get_logger(__name__) @@ -23,9 +25,10 @@ class DetailSubscription(OrganizationMixin, DetailView): """Detail for the subscription of a organization.""" - model = Subscription + model = djstripe.Subscription form_class = PlanForm - template_name = 'subscriptions/subscription_detail.html' + template_name = "subscriptions/subscription_detail.html" + context_object_name = "stripe_subscription" def get(self, request, *args, **kwargs): super().get(request, *args, **kwargs) @@ -56,8 +59,11 @@ def redirect_to_checkout(self, form): Users can buy a new subscription if the current one has been deleted after they canceled it. """ - subscription = self.get_object() - if not subscription or subscription.status != 'canceled': + stripe_subscription = self.get_object() + if ( + not stripe_subscription + or stripe_subscription.status != SubscriptionStatus.canceled + ): raise Http404() plan = get_object_or_404(Plan, id=form.cleaned_data['plan']) @@ -103,10 +109,30 @@ def get_object(self): We retry the operation when the user visits the subscription page. """ org = self.get_organization() - return ( - Subscription.objects - .get_or_create_default_subscription(org) - ) + return org.stripe_subscription + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + stripe_subscription = self.get_object() + if stripe_subscription: + context[ + "features" + ] = self.get_organization().subscription.plan.features.all() + + stripe_price = stripe_subscription.items.first().price + context["stripe_price"] = stripe_price + + # 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. + # Show the end date as the last period the customer paid. + context["subscription_end_date"] = stripe_subscription.current_period_end + if stripe_subscription.status == SubscriptionStatus.past_due: + latest_paid_invoice = stripe_subscription.invoices.filter( + paid=True + ).first() + context["subscription_end_date"] = latest_paid_invoice.period_end + + return context def get_success_url(self): return reverse( @@ -131,18 +157,18 @@ def get_success_url(self): def post(self, request, *args, **kwargs): """Redirect the user to the Stripe billing portal.""" organization = self.get_organization() - stripe_customer = organization.stripe_id + stripe_customer = organization.stripe_customer return_url = request.build_absolute_uri(self.get_success_url()) try: billing_portal = stripe.billing_portal.Session.create( - customer=stripe_customer, + customer=stripe_customer.id, return_url=return_url, ) return HttpResponseRedirect(billing_portal.url) except: # noqa log.exception( 'There was an error connecting to Stripe to create the billing portal session.', - stripe_customer=stripe_customer, + stripe_customer=stripe_customer.id, organization_slug=organization.slug, ) messages.error(