Skip to content

Subscriptions: attach stripe subscription to organizations #9751

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 2 commits into from
Nov 22, 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Generated by Django 3.2.16 on 2022-11-21 22:52

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("djstripe", "0010_alter_customer_balance"),
("organizations", "0010_add_stripe_customer"),
]

operations = [
migrations.AddField(
model_name="historicalorganization",
name="stripe_subscription",
field=models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="djstripe.subscription",
verbose_name="Stripe subscription",
),
),
migrations.AddField(
model_name="organization",
name="stripe_subscription",
field=models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="rtd_organization",
to="djstripe.subscription",
verbose_name="Stripe subscription",
),
),
]
24 changes: 22 additions & 2 deletions readthedocs/organizations/models.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
"""Organizations models."""
import structlog
from autoslug import AutoSlugField
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.urls import reverse
from django.utils.crypto import salted_hmac
from django.utils.translation import gettext_lazy as _
from djstripe.enums import SubscriptionStatus

from readthedocs.core.history import ExtraHistoricalRecords
from readthedocs.core.permissions import AdminPermission
Expand All @@ -16,6 +18,8 @@
from .querysets import OrganizationQuerySet
from .utils import send_team_add_email

log = structlog.get_logger(__name__)


class Organization(models.Model):

Expand Down Expand Up @@ -101,6 +105,14 @@ class Organization(models.Model):
null=True,
blank=True,
)
stripe_subscription = models.OneToOneField(
"djstripe.Subscription",
verbose_name=_("Stripe subscription"),
on_delete=models.SET_NULL,
related_name="rtd_organization",
null=True,
blank=True,
)

# Managers
objects = OrganizationQuerySet.as_manager()
Expand All @@ -115,15 +127,23 @@ class Meta:
def __str__(self):
return self.name

@property
def stripe_subscription(self):
def get_or_create_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.
Comment on lines 135 to 136
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
if not subscription:
# This only happens during development.
if not subscription and settings.DEBUG:
# This only happens during development.

Make sure that this only happens on development. Otherwise, we should log an error/warning here.

log.warning("No default subscription created.")
return None

# Active subscriptions take precedence over non-active subscriptions,
# otherwise we return the must recently created subscription.
active_subscription = self.stripe_customer.subscriptions.filter(
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
active_subscription = self.stripe_customer.subscriptions.filter(
# Always return the active subscription first if the customer has multiple subscriptions
active_subscription = self.stripe_customer.subscriptions.filter(

status=SubscriptionStatus.active
).first()
if active_subscription:
return active_subscription
return self.stripe_customer.subscriptions.latest()

def get_absolute_url(self):
Expand Down
9 changes: 9 additions & 0 deletions readthedocs/subscriptions/event_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,15 @@ def checkout_completed(event):
return

stripe_subscription_id = event.data["object"]["subscription"]
stripe_subscription = djstripe.Subscription.objects.filter(
id=stripe_subscription_id
).first()
if not stripe_subscription:
log.info("Stripe subscription not found.")
return
Comment on lines +161 to +163
Copy link
Member

Choose a reason for hiding this comment

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

What is the case where this can happen? I'm not sure to follow it. If Stripe Checkout is the one that creates the Subscription, why we would not find it after that?

Copy link
Member Author

Choose a reason for hiding this comment

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

just being defensive, the creation of the subs object could have failed or could have been deleted.

organization.stripe_subscription = stripe_subscription
organization.save()

_update_subscription_from_stripe(
rtd_subscription=organization.subscription,
stripe_subscription_id=stripe_subscription_id,
Expand Down
3 changes: 3 additions & 0 deletions readthedocs/subscriptions/tests/test_event_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,11 +168,14 @@ def test_subscription_checkout_completed_event(self):
status=SubscriptionStatus.canceled,
)

self.assertIsNone(self.organization.stripe_subscription)
event_handlers.checkout_completed(event=event)

subscription.refresh_from_db()
self.organization.refresh_from_db()
self.assertEqual(subscription.stripe_id, stripe_subscription.id)
self.assertEqual(subscription.status, SubscriptionStatus.active)
self.assertEqual(self.organization.stripe_subscription, stripe_subscription)

@mock.patch("readthedocs.subscriptions.event_handlers.cancel_stripe_subscription")
def test_cancel_trial_subscription_after_trial_has_ended(
Expand Down
7 changes: 7 additions & 0 deletions readthedocs/subscriptions/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ def setUp(self):
self.stripe_customer = self.stripe_subscription.customer

self.organization.stripe_customer = self.stripe_customer
self.organization.stripe_subscription = self.stripe_subscription
self.organization.save()
self.subscription = get(
Subscription,
Expand Down Expand Up @@ -113,10 +114,12 @@ def test_user_without_subscription(

self.organization.refresh_from_db()
self.organization.stripe_customer = None
self.organization.stripe_subscription = None
self.organization.save()
self.subscription.delete()
self.assertFalse(hasattr(self.organization, 'subscription'))
self.assertIsNone(self.organization.stripe_customer)
self.assertIsNone(self.organization.stripe_subscription)

resp = self.client.get(reverse('subscription_detail', args=[self.organization.slug]))
self.assertEqual(resp.status_code, 200)
Expand All @@ -125,6 +128,7 @@ def test_user_without_subscription(
self.assertEqual(subscription.status, 'active')
self.assertEqual(subscription.stripe_id, 'sub_a1b2c3')
self.assertEqual(self.organization.stripe_customer, stripe_customer)
self.assertEqual(self.organization.stripe_subscription, stripe_subscription)
customer_retrieve_mock.assert_called_once()
customer_create_mock.assert_not_called()

Expand All @@ -146,12 +150,14 @@ 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.stripe_subscription = None
self.organization.save()
self.subscription.delete()
self.organization.refresh_from_db()
self.assertFalse(hasattr(self.organization, 'subscription'))
self.assertIsNone(self.organization.stripe_id)
self.assertIsNone(self.organization.stripe_customer)
self.assertIsNone(self.organization.stripe_subscription)

customer_retrieve_mock.reset_mock()
resp = self.client.get(reverse('subscription_detail', args=[self.organization.slug]))
Expand All @@ -162,6 +168,7 @@ def test_user_without_subscription_and_customer(
self.assertEqual(subscription.stripe_id, 'sub_a1b2c3')
self.assertEqual(self.organization.stripe_id, 'cus_a1b2c3')
self.assertEqual(self.organization.stripe_customer, stripe_customer)
self.assertEqual(self.organization.stripe_subscription, stripe_subscription)
customer_create_mock.assert_called_once()
customer_retrieve_mock.assert_not_called()

Expand Down
3 changes: 3 additions & 0 deletions readthedocs/subscriptions/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,7 @@ def get_or_create_stripe_subscription(organization):
trial_period_days=settings.RTD_ORG_TRIAL_PERIOD_DAYS,
)
stripe_subscription = stripe_customer.subscriptions.latest()
if organization.stripe_subscription != stripe_subscription:
organization.stripe_subscription = stripe_subscription
organization.save()
return stripe_subscription
2 changes: 1 addition & 1 deletion readthedocs/subscriptions/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ def get_object(self):
We retry the operation when the user visits the subscription page.
"""
org = self.get_organization()
return org.stripe_subscription
return org.get_or_create_stripe_subscription()

def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
Expand Down