Skip to content

Subscriptions: use stripe subscriptions to show details #9550

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 3 commits into from
Sep 1, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
11 changes: 11 additions & 0 deletions readthedocs/organizations/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +124 to +126
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it makes sense to use the following?

Suggested change
if not subscription:
# This only happens during development.
return None
if settings.DEBUG and 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,))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@

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

{% if subscription and subscription.plan and subscription.status != 'canceled' and organization.stripe_id %}
{% if stripe_subscription.status != 'canceled' %}
<dl>
<dt>{% trans "Plan" %}:</dt>
<dd>
<span class="subscription-plan-name">
{{ subscription.plan.name }}
{{ stripe_price.product.name }}
</span>
<span class="subscription-plan-price">
(${{ subscription.plan.price }} {% trans "per month" %})
({{ stripe_price.human_readable_price }})
</span>
</dd>

{% if subscription.plan.features.exists %}
{% if features %}
<dt>{% trans "Plan Features:" %}</dt>
<dd>
{% for feature in subscription.plan.features.all %}
{% for feature in features %}
<span class="subscription-plan-{{ feature.feature_type }}" style="display: block;">
{{ feature.description_display }}
</span>
{% endfor %}
</dd>
{% endif %}

{% if subscription.start_date %}
{% if stripe_subscription.start_date %}
<dt>{% trans "Signed up" %}:</dt>
<dd>
{{ subscription.start_date|timesince }} {% trans "ago" %}
{{ stripe_subscription.start_date|timesince }} {% trans "ago" %}
</dd>
{% endif %}

<dt>{% trans "Subscription Status" %}:</dt>
<dd>
{{ subscription.get_status_display }}
{{ stripe_subscription.get_status_display }}
</dd>

{% if not subscription.is_trial_ended %}
{% if stripe_subscription.status == 'trialing' and stripe_subscription.trial_end %}
<dt>{% trans "Trial ends" %}:</dt>
<dd>
{{ subscription.trial_end_date|date:"SHORT_DATE_FORMAT" }}
{{ stripe_subscription.trial_end|date:"SHORT_DATE_FORMAT" }}
</dd>
{% elif subscription.end_date %}
{% elif subscription_end_date %}
<dt>{% trans "Subscription ends" %}:</dt>
<dd>
{{ subscription.end_date|date:"SHORT_DATE_FORMAT" }}
{{ subscription_end_date|date:"SHORT_DATE_FORMAT" }}
</dd>
{% endif %}
</dl>
Expand Down
78 changes: 48 additions & 30 deletions readthedocs/subscriptions/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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')
Expand Down Expand Up @@ -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)

Expand All @@ -106,26 +132,16 @@ 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
sync_from_stripe_data_mock.return_value = stripe_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()
Expand All @@ -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')
48 changes: 36 additions & 12 deletions readthedocs/subscriptions/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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)
Expand Down Expand Up @@ -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'])
Expand Down Expand Up @@ -103,10 +109,28 @@ 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:
context[
"subscription_end_date"
] = stripe_subscription.current_period_start
return context

def get_success_url(self):
return reverse(
Expand All @@ -131,18 +155,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(
Expand Down