Skip to content

Commit 3e5b72d

Browse files
authored
Subscriptions: use stripe subscriptions to show details (#9550)
Ref #9312
1 parent 0afdbe4 commit 3e5b72d

File tree

4 files changed

+111
-56
lines changed

4 files changed

+111
-56
lines changed

readthedocs/organizations/models.py

+11
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,17 @@ class Meta:
115115
def __str__(self):
116116
return self.name
117117

118+
@property
119+
def stripe_subscription(self):
120+
# TODO: remove this once we don't depend on our Subscription models.
121+
from readthedocs.subscriptions.models import Subscription
122+
123+
subscription = Subscription.objects.get_or_create_default_subscription(self)
124+
if not subscription:
125+
# This only happens during development.
126+
return None
127+
return self.stripe_customer.subscriptions.latest()
128+
118129
def get_absolute_url(self):
119130
return reverse('organization_detail', args=(self.slug,))
120131

readthedocs/subscriptions/templates/subscriptions/subscription_detail.html

+14-14
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@
1212

1313
{% block edit_content %}
1414
<div>
15-
{% if subscription.status == 'trialing' %}
15+
{% if stripe_subscription.status == 'trialing' %}
1616
<ul class="notifications">
1717
<li class="notification notification-info">
1818
{% url 'pricing' as pricing_url %}
1919
{% blocktrans trimmed with pricing_url=pricing_url %}
20-
You are currently on a trial.
20+
You are currently on a trial.
2121
Please choose a <a href="{{ pricing_url }}">paid plan</a> that fits your organization
2222
prior to the end of your trial.
2323
Upgrade your account by clicking on <strong>Manage Subscription</strong> below.
@@ -26,50 +26,50 @@
2626
</ul>
2727
{% endif %}
2828

29-
{% if subscription and subscription.plan and subscription.status != 'canceled' and organization.stripe_id %}
29+
{% if stripe_subscription.status != 'canceled' %}
3030
<dl>
3131
<dt>{% trans "Plan" %}:</dt>
3232
<dd>
3333
<span class="subscription-plan-name">
34-
{{ subscription.plan.name }}
34+
{{ stripe_price.product.name }}
3535
</span>
3636
<span class="subscription-plan-price">
37-
(${{ subscription.plan.price }} {% trans "per month" %})
37+
({{ stripe_price.human_readable_price }})
3838
</span>
3939
</dd>
4040

41-
{% if subscription.plan.features.exists %}
41+
{% if features %}
4242
<dt>{% trans "Plan Features:" %}</dt>
4343
<dd>
44-
{% for feature in subscription.plan.features.all %}
44+
{% for feature in features %}
4545
<span class="subscription-plan-{{ feature.feature_type }}" style="display: block;">
4646
{{ feature.description_display }}
4747
</span>
4848
{% endfor %}
4949
</dd>
5050
{% endif %}
5151

52-
{% if subscription.start_date %}
52+
{% if stripe_subscription.start_date %}
5353
<dt>{% trans "Signed up" %}:</dt>
5454
<dd>
55-
{{ subscription.start_date|timesince }} {% trans "ago" %}
55+
{{ stripe_subscription.start_date|timesince }} {% trans "ago" %}
5656
</dd>
5757
{% endif %}
5858

5959
<dt>{% trans "Subscription Status" %}:</dt>
6060
<dd>
61-
{{ subscription.get_status_display }}
61+
{{ stripe_subscription.get_status_display }}
6262
</dd>
6363

64-
{% if not subscription.is_trial_ended %}
64+
{% if stripe_subscription.status == 'trialing' and stripe_subscription.trial_end %}
6565
<dt>{% trans "Trial ends" %}:</dt>
6666
<dd>
67-
{{ subscription.trial_end_date|date:"SHORT_DATE_FORMAT" }}
67+
{{ stripe_subscription.trial_end|date:"SHORT_DATE_FORMAT" }}
6868
</dd>
69-
{% elif subscription.end_date %}
69+
{% elif subscription_end_date %}
7070
<dt>{% trans "Subscription ends" %}:</dt>
7171
<dd>
72-
{{ subscription.end_date|date:"SHORT_DATE_FORMAT" }}
72+
{{ subscription_end_date|date:"SHORT_DATE_FORMAT" }}
7373
</dd>
7474
{% endif %}
7575
</dl>

readthedocs/subscriptions/tests/test_views.py

+48-30
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ def setUp(self):
2323
self.user = get(User)
2424
self.organization = get(Organization, stripe_id='123', owners=[self.user])
2525
self.plan = get(Plan, published=True, slug=settings.ORG_DEFAULT_SUBSCRIPTION_PLAN_SLUG)
26+
self.stripe_subscription = self._create_stripe_subscription(
27+
customer_id=self.organization.stripe_id,
28+
subscription_id="sub_a1b2c3d4",
29+
)
30+
self.stripe_customer = self.stripe_subscription.customer
31+
32+
self.organization.stripe_customer = self.stripe_customer
33+
self.organization.save()
2634
self.subscription = get(
2735
Subscription,
2836
organization=self.organization,
@@ -31,11 +39,38 @@ def setUp(self):
3139
)
3240
self.client.force_login(self.user)
3341

42+
def _create_stripe_subscription(
43+
self, customer_id="cus_a1b2c3", subscription_id="sub_a1b2c3"
44+
):
45+
stripe_customer = get(
46+
djstripe.Customer,
47+
id=customer_id,
48+
)
49+
stripe_subscription = get(
50+
djstripe.Subscription,
51+
id=subscription_id,
52+
start_date=timezone.now(),
53+
current_period_end=timezone.now() + timezone.timedelta(days=30),
54+
trial_end=timezone.now() + timezone.timedelta(days=30),
55+
status=SubscriptionStatus.active,
56+
customer=stripe_customer,
57+
)
58+
stripe_price = get(
59+
djstripe.Price,
60+
unit_amount=50000,
61+
)
62+
stripe_item = get(
63+
djstripe.SubscriptionItem,
64+
price=stripe_price,
65+
subscription=stripe_subscription,
66+
)
67+
return stripe_subscription
68+
3469
def test_active_subscription(self):
3570
resp = self.client.get(reverse('subscription_detail', args=[self.organization.slug]))
3671
self.assertEqual(resp.status_code, 200)
37-
self.assertEqual(resp.context['subscription'], self.subscription)
38-
self.assertContains(resp, 'active')
72+
self.assertEqual(resp.context["stripe_subscription"], self.stripe_subscription)
73+
self.assertContains(resp, "active")
3974
# The subscribe form isn't shown, but the manage susbcription button is.
4075
self.assertContains(resp, 'Manage Subscription')
4176
self.assertNotContains(resp, 'Create Subscription')
@@ -66,25 +101,16 @@ def test_manage_subscription(self, mock_request):
66101
def test_user_without_subscription(
67102
self, customer_create_mock, customer_retrieve_mock
68103
):
69-
stripe_customer = get(
70-
djstripe.Customer,
71-
id="cus_a1b2c3",
72-
)
73-
stripe_subscription = get(
74-
djstripe.Subscription,
75-
id="sub_a1b2c3",
76-
start_date=timezone.now(),
77-
current_period_end=timezone.now() + timezone.timedelta(days=30),
78-
trial_end=timezone.now() + timezone.timedelta(days=30),
79-
status=SubscriptionStatus.active,
80-
customer=stripe_customer,
81-
)
104+
stripe_subscription = self._create_stripe_subscription()
105+
stripe_customer = stripe_subscription.customer
82106
stripe_customer.subscribe = mock.MagicMock()
83107
stripe_customer.subscribe.return_value = stripe_subscription
84108
customer_retrieve_mock.return_value = stripe_customer
85109

86-
self.subscription.delete()
87110
self.organization.refresh_from_db()
111+
self.organization.stripe_customer = None
112+
self.organization.save()
113+
self.subscription.delete()
88114
self.assertFalse(hasattr(self.organization, 'subscription'))
89115
self.assertIsNone(self.organization.stripe_customer)
90116

@@ -106,26 +132,16 @@ def test_user_without_subscription(
106132
def test_user_without_subscription_and_customer(
107133
self, customer_create_mock, customer_retrieve_mock, sync_from_stripe_data_mock
108134
):
109-
stripe_customer = get(
110-
djstripe.Customer,
111-
id="cus_a1b2c3",
112-
)
113-
stripe_subscription = get(
114-
djstripe.Subscription,
115-
id="sub_a1b2c3",
116-
start_date=timezone.now(),
117-
current_period_end=timezone.now() + timezone.timedelta(days=30),
118-
trial_end=timezone.now() + timezone.timedelta(days=30),
119-
status=SubscriptionStatus.active,
120-
customer=stripe_customer,
121-
)
135+
stripe_subscription = self._create_stripe_subscription()
136+
stripe_customer = stripe_subscription.customer
122137
stripe_customer.subscribe = mock.MagicMock()
123138
stripe_customer.subscribe.return_value = stripe_subscription
124139
customer_retrieve_mock.return_value = None
125140
sync_from_stripe_data_mock.return_value = stripe_customer
126141

127142
# When stripe_id is None, a new customer is created.
128143
self.organization.stripe_id = None
144+
self.organization.stripe_customer = None
129145
self.organization.save()
130146
self.subscription.delete()
131147
self.organization.refresh_from_db()
@@ -147,10 +163,12 @@ def test_user_without_subscription_and_customer(
147163

148164
def test_user_with_canceled_subscription(self):
149165
self.subscription.status = 'canceled'
166+
self.stripe_subscription.status = SubscriptionStatus.canceled
167+
self.stripe_subscription.save()
150168
self.subscription.save()
151169
resp = self.client.get(reverse('subscription_detail', args=[self.organization.slug]))
152170
self.assertEqual(resp.status_code, 200)
153-
self.assertEqual(resp.context['subscription'], self.subscription)
171+
self.assertEqual(resp.context["stripe_subscription"], self.stripe_subscription)
154172
# The Manage Subscription form isn't shown, but the Subscribe is.
155173
self.assertNotContains(resp, 'Manage Subscription')
156174
self.assertContains(resp, 'Create Subscription')

readthedocs/subscriptions/views.py

+38-12
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@
99
from django.shortcuts import get_object_or_404
1010
from django.urls import reverse
1111
from django.utils.translation import gettext_lazy as _
12+
from djstripe import models as djstripe
13+
from djstripe.enums import SubscriptionStatus
1214
from vanilla import DetailView, GenericView
1315

1416
from readthedocs.organizations.views.base import OrganizationMixin
1517
from readthedocs.subscriptions.forms import PlanForm
16-
from readthedocs.subscriptions.models import Plan, Subscription
18+
from readthedocs.subscriptions.models import Plan
1719
from readthedocs.subscriptions.utils import get_or_create_stripe_customer
1820

1921
log = structlog.get_logger(__name__)
@@ -23,9 +25,10 @@ class DetailSubscription(OrganizationMixin, DetailView):
2325

2426
"""Detail for the subscription of a organization."""
2527

26-
model = Subscription
28+
model = djstripe.Subscription
2729
form_class = PlanForm
28-
template_name = 'subscriptions/subscription_detail.html'
30+
template_name = "subscriptions/subscription_detail.html"
31+
context_object_name = "stripe_subscription"
2932

3033
def get(self, request, *args, **kwargs):
3134
super().get(request, *args, **kwargs)
@@ -56,8 +59,11 @@ def redirect_to_checkout(self, form):
5659
Users can buy a new subscription if the current one
5760
has been deleted after they canceled it.
5861
"""
59-
subscription = self.get_object()
60-
if not subscription or subscription.status != 'canceled':
62+
stripe_subscription = self.get_object()
63+
if (
64+
not stripe_subscription
65+
or stripe_subscription.status != SubscriptionStatus.canceled
66+
):
6167
raise Http404()
6268

6369
plan = get_object_or_404(Plan, id=form.cleaned_data['plan'])
@@ -103,10 +109,30 @@ def get_object(self):
103109
We retry the operation when the user visits the subscription page.
104110
"""
105111
org = self.get_organization()
106-
return (
107-
Subscription.objects
108-
.get_or_create_default_subscription(org)
109-
)
112+
return org.stripe_subscription
113+
114+
def get_context_data(self, **kwargs):
115+
context = super().get_context_data(**kwargs)
116+
stripe_subscription = self.get_object()
117+
if stripe_subscription:
118+
context[
119+
"features"
120+
] = self.get_organization().subscription.plan.features.all()
121+
122+
stripe_price = stripe_subscription.items.first().price
123+
context["stripe_price"] = stripe_price
124+
125+
# When Stripe marks the subscription as ``past_due``,
126+
# it means the usage of RTD service for the current period/month was not paid at all.
127+
# Show the end date as the last period the customer paid.
128+
context["subscription_end_date"] = stripe_subscription.current_period_end
129+
if stripe_subscription.status == SubscriptionStatus.past_due:
130+
latest_paid_invoice = stripe_subscription.invoices.filter(
131+
paid=True
132+
).first()
133+
context["subscription_end_date"] = latest_paid_invoice.period_end
134+
135+
return context
110136

111137
def get_success_url(self):
112138
return reverse(
@@ -131,18 +157,18 @@ def get_success_url(self):
131157
def post(self, request, *args, **kwargs):
132158
"""Redirect the user to the Stripe billing portal."""
133159
organization = self.get_organization()
134-
stripe_customer = organization.stripe_id
160+
stripe_customer = organization.stripe_customer
135161
return_url = request.build_absolute_uri(self.get_success_url())
136162
try:
137163
billing_portal = stripe.billing_portal.Session.create(
138-
customer=stripe_customer,
164+
customer=stripe_customer.id,
139165
return_url=return_url,
140166
)
141167
return HttpResponseRedirect(billing_portal.url)
142168
except: # noqa
143169
log.exception(
144170
'There was an error connecting to Stripe to create the billing portal session.',
145-
stripe_customer=stripe_customer,
171+
stripe_customer=stripe_customer.id,
146172
organization_slug=organization.slug,
147173
)
148174
messages.error(

0 commit comments

Comments
 (0)