Skip to content

Subscriptions: attach stripe_subscription to Organization model? #9652

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

Closed
stsewd opened this issue Oct 11, 2022 · 4 comments · Fixed by #9751
Closed

Subscriptions: attach stripe_subscription to Organization model? #9652

stsewd opened this issue Oct 11, 2022 · 4 comments · Fixed by #9751
Labels
Needed: design decision A core team decision is required

Comments

@stsewd
Copy link
Member

stsewd commented Oct 11, 2022

Currently, we only attach the stripe customer to the organization object, and we have a little helper to get the stripe subscription

return self.stripe_customer.subscriptions.latest()

All good, but if we want to query organizations by their subscription status, then we have a little problem... A customer can have more than one subscription, and to have our query work around the "current" subscription, we need a subquery like

current_sub = djstripe.Subscription.objects.filter(customer__rtd_organization=OuterRef("id")).order_by("-created").values("id")[:1]
q = Organization.objects.filter(stripe_customer__subscriptions__id=Subquery(current_sub))

Which is... ugly

Luckily, we always have just one active subscription per customer (or a subscription that isn't canceled per customer),
so we are okay omitting the subquery if we filter subscriptions by a status different from canceled, but when we require querying by canceled subscriptions or without a given status, than we need to use the subquery

def subscription_trialing(self):
"""
Organizations with subscriptions in a trialing state.
Trialing state is defined by either having a subscription in the
trialing state or by having an organization created in the last 30 days
"""
date_now = timezone.now()
return self.filter(
Q(
(
Q(
subscription__status='trialing',
) | Q(
subscription__status__exact='',
)
),
subscription__trial_end_date__gt=date_now,
) | Q(
subscription__isnull=True,
pub_date__gt=date_now - timedelta(days=30),
),
)
def subscription_trial_ending(self):
"""
Organizations with subscriptions in trial ending state.
If the organization subscription is either explicitly in a trialing
state, or at least has trial end date in the trial ending date range,
consider this organization's subscription trial ending. Also, if the
subscription is null, use the organization creation date to calculate a
trial end date instead.
"""
date_now = timezone.now()
date_next_week = date_now + timedelta(days=7)
# TODO: this can be refactored to use
# ``self.subscription_trialing`` and add the 7 days filter to
# that response
return self.filter(
Q(
(
Q(
subscription__status='trialing',
) | Q(
subscription__status__exact='',
)
),
subscription__trial_end_date__lt=date_next_week,
subscription__trial_end_date__gt=date_now,
) | Q(
subscription__isnull=True,
pub_date__lt=date_next_week - timedelta(days=30),
pub_date__gt=date_now - timedelta(days=30),
),
)
# TODO: once we are settled into the Trial Plan, merge this method with the
# previous one (``subscription_trial_ending``)
def subscription_trial_plan_ending(self):
"""Organizations with subscriptions to Trial Plan about to end."""
date_now = timezone.now()
date_next_week = date_now + timedelta(days=7)
return self.filter(
subscription__plan__stripe_id=settings.RTD_ORG_DEFAULT_STRIPE_SUBSCRIPTION_PRICE,
subscription__status='trialing',
subscription__trial_end_date__lt=date_next_week,
subscription__trial_end_date__gt=date_now,
)
# TODO: once we are settled into the Trial Plan, merge this method with the
# following one (``subscription_trial_ended``).
def subscription_trial_plan_ended(self):
"""
Organizations with subscriptions to Trial Plan ended.
Trial Plan in Stripe has a 30-day trial set up. After that period ends,
the subscription goes to ``active`` and we know that the trial ended.
It also checks that ``end_date`` or ``trial_end_date`` are in the past.
"""
date_now = timezone.now()
return self.filter(
~Q(subscription__status="trialing"),
Q(
subscription__plan__stripe_id=settings.RTD_ORG_DEFAULT_STRIPE_SUBSCRIPTION_PRICE
),
Q(subscription__end_date__lt=date_now)
| Q(subscription__trial_end_date__lt=date_now),
)
def subscription_trial_ended(self):
"""
Organizations with subscriptions in trial ended state.
Filter for organization subscription past the trial date, or
organizations older than 30 days old
"""
date_now = timezone.now()
return self.filter(
Q(
(
Q(
subscription__status='trialing',
) | Q(
subscription__status__exact='',
)
),
subscription__trial_end_date__lt=date_now,
) | Q(
subscription__isnull=True,
pub_date__lt=date_now - timedelta(days=30),
),
)
def subscription_ended(self):
"""
Organization with paid subscriptions ended.
Filter for organization with paid subscriptions that have
ended (canceled, past_due or unpaid) and they didn't renew them yet.
https://stripe.com/docs/api#subscription_object-status
"""
return self.filter(
subscription__status__in=['canceled', 'past_due', 'unpaid'],
)
def disable_soon(self, days, exact=False):
"""
Filter organizations that will eventually be marked as disabled.
This will return organizations that the paid/trial subscription has
ended ``days`` ago.
:param days: Days after the subscription has ended
:param exact: Make the ``days`` date to match exactly that day after the
subscription has ended (useful to send emails only once)
"""
date_today = timezone.now().date()
end_date = date_today - timedelta(days=days)
if exact:
# We use ``__date`` here since the field is a DateTimeField
trial_filter = {'subscription__trial_end_date__date': end_date}
paid_filter = {'subscription__end_date__date': end_date}
else:
trial_filter = {'subscription__trial_end_date__lt': end_date}
paid_filter = {'subscription__end_date__lt': end_date}
trial_ended = self.subscription_trial_ended().filter(**trial_filter)
paid_ended = self.subscription_ended().filter(**paid_filter)
# Exclude organizations with custom plans (locked=True)
orgs = (trial_ended | paid_ended).exclude(subscription__locked=True)
# Exclude organizations that are already disabled
orgs = orgs.exclude(disabled=True)
return orgs.distinct()
.

So the question is: are we okay with using a subquery for those cases? Or maybe we should attach the current stripe subscription into the organization model? We would need to keep it up to date with the "current" subscription, but that shouldn't be complicated (we just need to listen to the subscription.created event), and I think queries will be more clear having just to write Organization.objects.filter(stripe_subscription__=...).

@stsewd stsewd added the Needed: design decision A core team decision is required label Oct 11, 2022
@ericholscher
Copy link
Member

I think the big question in my mind is, will customers always have only 1 subscription? I think in the past we've created additional subscription objects to add on additional features/billing. I think this is likely something that we would also do in the future, so I don't think we can make that assumption.

I think having the subscription assigned to the organization as the "primary" or main one is probably the best path forward. I think being explicit here is good, instead of relying on implicit data in the DB. We likely need some logic around what types of subscription can be the "main" subscription, if we're relying on incoming webhooks, and we'd need to filter out any additional subscriptions.

I'm imagining the case in the future where we are selling support as an additional subscription upsell, have a customer with:

  • Organization: Example
  • Plan: Advanced
  • Support Plan: Pro

We need some way to not blow up in this scenario :)

@humitos
Copy link
Member

humitos commented Oct 11, 2022

IIRC, for the cases that Eric mentions we are still using 1 subscription with multiple products. We have customers paying the regular plan + additional builders in the same subscription. I'm not saying that making the assumption that we will always have 1 subscription per customer is good, tho --just noticing that it's possible to have 1 subscription with multiple products on it.

@stsewd
Copy link
Member Author

stsewd commented Oct 11, 2022

Currently, we have 0 users that have more than one subscription that isn't canceled, and as Manuel mentioned, currently we are just adding a new product into the subscription. And looks like we can combine yearly/monthly products in the same subscription D:

Read the Docs Inc – Stripe

What we can't do is to have a different trial for each product. Just mentioning what we can do before going for multiple subscriptions :)

So, +1 on having a "main" subscription.

Another solutions could be to change our querysets to be related to the Subscriptions instead of the Organizations,
this is query for subscriptions where their trial is about to end instead of querying for organizations that have subscriptions that are about to end. Then we will have access to the organization from stripe_subscription.customer.rtd_organization.

And instead of querying subscriptions that are cancelled/trial-ended, we can make use of the stripe webhook to email users the exact time the subscription ends the trial.

The only one query that we can't migrate by simply change that relation is

def disable_soon(self, days, exact=False):

But we could add an attribute in the organization for that, something like subscription_ended, we will need to have that field up to date when a subscription is created/canceled obviously.

Just a thought, I'm fine moving with the "main subscription" idea too, but will require a data migration.

@ericholscher
Copy link
Member

ericholscher commented Oct 12, 2022

I'm good with a main subscription then 👍 But I don't fully understand the different proposals, so happy to defer to you @stsewd on what you think is best.

stsewd added a commit that referenced this issue Nov 7, 2022
Instead of trying to replace the existing queries, I'm making use of djstripe events/webhooks.

- Instead of a daily task to email users with a canceled subscription,
  we use a webhook to email users the exact time the subscription changes to "canceled" (this is the moment the subs ends, not when a user cancels a subscription, since it will remain active till the end of the cicle).
- Instead of querying organizations with subs in trialing, we query the subs directly,
  and get the organization from them.

With this, we don't depend on having just one subs per-organization, and also stop relying on our models a little less.
The queries that are used to disable orgs aren't migrated yet, since they query canceled subscriptions (#9652 (comment)).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Needed: design decision A core team decision is required
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants