Skip to content

Commit

Permalink
Merge pull request #5060 from kobotoolbox/TASK-934-further-cycle-hand…
Browse files Browse the repository at this point in the history
…ling-fixes

Fix bug in yearly subscription handling, add unit tests and clean up …
  • Loading branch information
jamesrkiger authored Aug 13, 2024
2 parents 10169cf + 5ac71a8 commit 0aa3cff
Show file tree
Hide file tree
Showing 3 changed files with 164 additions and 25 deletions.
27 changes: 15 additions & 12 deletions kobo/apps/organizations/utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from typing import Union

import pytz
from datetime import datetime
from dateutil.relativedelta import relativedelta
from django.utils import timezone

Expand All @@ -11,21 +12,23 @@ def get_monthly_billing_dates(organization: Union[Organization, None]):
"""Returns start and end dates of an organization's monthly billing cycle"""

now = timezone.now().replace(tzinfo=pytz.UTC)
first_of_this_month = now.replace(day=1)
# Using `day=31` gets the last day of the month
last_of_this_month = first_of_this_month + relativedelta(day=31)
first_of_this_month = datetime(now.year, now.month, 1, tzinfo=pytz.UTC)
first_of_next_month = (
first_of_this_month
+ relativedelta(months=1)
)

# If no organization, just use the calendar month
if not organization:
return first_of_this_month, last_of_this_month
return first_of_this_month, first_of_next_month

# If no active subscription, check for canceled subscription
if not (billing_details := organization.active_subscription_billing_details()):
if not (
canceled_subscription_anchor
:= organization.canceled_subscription_billing_cycle_anchor()
):
return first_of_this_month, last_of_this_month
return first_of_this_month, first_of_next_month

period_end = canceled_subscription_anchor.replace(tzinfo=pytz.UTC)
while period_end < now:
Expand All @@ -34,7 +37,7 @@ def get_monthly_billing_dates(organization: Union[Organization, None]):
return period_start, period_end

if not billing_details.get('billing_cycle_anchor'):
return first_of_this_month, last_of_this_month
return first_of_this_month, first_of_next_month

# Subscription is billed monthly, use the current billing period dates
if billing_details.get('recurring_interval') == 'month':
Expand All @@ -57,18 +60,18 @@ def get_monthly_billing_dates(organization: Union[Organization, None]):
def get_yearly_billing_dates(organization: Union[Organization, None]):
"""Returns start and end dates of an organization's annual billing cycle"""
now = timezone.now().replace(tzinfo=pytz.UTC)
first_of_this_year = now.date().replace(month=1, day=1)
last_of_this_year = now.date().replace(month=12, day=31)
first_of_this_year = datetime(now.year, 1, 1, tzinfo=pytz.UTC)
first_of_next_year = first_of_this_year + relativedelta(years=1)

if not organization:
return first_of_this_year, last_of_this_year
return first_of_this_year, first_of_next_year
if not (billing_details := organization.active_subscription_billing_details()):
return first_of_this_year, last_of_this_year
return first_of_this_year, first_of_next_year
if not (anchor_date := billing_details.get('billing_cycle_anchor')):
return first_of_this_year, last_of_this_year
return first_of_this_year, first_of_next_year

# Subscription is billed yearly, use the dates from the subscription
if billing_details.get('subscription_interval') == 'year':
if billing_details.get('recurring_interval') == 'year':
period_start = billing_details.get('current_period_start').replace(
tzinfo=pytz.UTC
)
Expand Down
121 changes: 120 additions & 1 deletion kobo/apps/stripe/tests/test_organization_usage.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import timeit

import pytest
import pytz
from datetime import datetime
from dateutil.relativedelta import relativedelta
from django.core.cache import cache
from django.test import override_settings
Expand Down Expand Up @@ -170,6 +172,73 @@ def setUp(self):
def tearDown(self):
cache.clear()

def test_default_plan_period(self):
"""
Default community plan cycle dates should line up with calendar month
(first of this month to first of next month)
"""

num_submissions = 5
add_mock_submissions([self.asset], num_submissions)

response = self.client.get(self.detail_url)
now = timezone.now()
first_of_month = datetime(now.year, now.month, 1, tzinfo=pytz.UTC)
first_of_next_month = first_of_month + relativedelta(months=1)

assert response.data['total_submission_count']['current_month'] == num_submissions
assert (
response.data['current_month_start']
== first_of_month.isoformat()
)
assert response.data['current_month_end'] == first_of_next_month.isoformat()

def test_monthly_plan_period(self):
"""
Returned cycle dates for monthly plan should be the same as
the dates stored on the subscription object
"""
subscription = generate_plan_subscription(
self.organization
)
num_submissions = 5
add_mock_submissions([self.asset], num_submissions)

response = self.client.get(self.detail_url)

assert (
response.data['total_submission_count']['current_month']
== num_submissions
)
assert response.data['current_month_start'] == subscription.current_period_start.isoformat()
assert (
response.data['current_month_end']
== subscription.current_period_end.isoformat()
)

def test_annual_plan_period(self):
"""
Returned yearly cycle dates for annual plan should be the same as
the dates stored on the subscription object
"""
subscription = generate_plan_subscription(
self.organization, interval='year'
)
num_submissions = 5
add_mock_submissions([self.asset], num_submissions)

response = self.client.get(self.detail_url)

assert (
response.data['total_submission_count']['current_year']
== num_submissions
)
assert response.data['current_year_start'] == subscription.current_period_start.isoformat()
assert (
response.data['current_year_end']
== subscription.current_period_end.isoformat()
)

def test_plan_canceled_this_month(self):
"""
When a user cancels their subscription, they revert to the default community plan
Expand Down Expand Up @@ -216,14 +285,64 @@ def test_plan_canceled_last_month(self):

assert (
response.data['total_submission_count']['current_month']
== 5
== num_submissions
)
assert response.data['current_month_start'] == current_billing_period_start.isoformat()
assert (
response.data['current_month_end']
== current_billing_period_end.isoformat()
)

def test_multiple_canceled_plans(self):
"""
If a user has multiple canceled plans, their default billing cycle
should be anchored to the end date of the most recently canceled plan
"""
subscription = generate_plan_subscription(
self.organization, age_days=60
)

subscription.status = 'canceled'
subscription.ended_at = timezone.now() - relativedelta(days=45)
subscription.save()

subscription = generate_plan_subscription(
self.organization, age_days=40
)

subscription.status = 'canceled'
subscription.ended_at = timezone.now() - relativedelta(days=35)
subscription.save()

subscription = generate_plan_subscription(
self.organization, age_days=30
)

canceled_at = timezone.now() - relativedelta(days=20)
subscription.status = 'canceled'
subscription.ended_at = canceled_at
subscription.save()

current_billing_period_start = canceled_at
current_billing_period_end = (
current_billing_period_start + relativedelta(months=1)
)

num_submissions = 5
add_mock_submissions([self.asset], num_submissions, 15)

response = self.client.get(self.detail_url)

assert response.data['total_submission_count']['current_month'] == num_submissions
assert (
response.data['current_month_start']
== current_billing_period_start.isoformat()
)
assert (
response.data['current_month_end']
== current_billing_period_end.isoformat()
)


class OrganizationAssetUsageAPITestCase(AssetUsageAPITestCase):
"""
Expand Down
41 changes: 29 additions & 12 deletions kobo/apps/stripe/tests/utils.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import Literal

from dateutil.relativedelta import relativedelta
from django.utils import timezone
from djstripe.models import Customer, Product, SubscriptionItem, Subscription, Price
Expand All @@ -7,10 +9,15 @@


def generate_plan_subscription(
organization: Organization, metadata: dict = None, customer: Customer = None, age_days=0
organization: Organization,
metadata: dict = None,
customer: Customer = None,
interval: Literal['year', 'month'] = 'month',
age_days: int = 0,
) -> Subscription:
"""Create a subscription for a product with custom metadata"""
now = timezone.now()
created_date = timezone.now() - relativedelta(days=age_days)
price_id = 'price_sfmOFe33rfsfd36685657'

if not customer:
customer = baker.make(Customer, subscriber=organization, livemode=False)
Expand All @@ -22,23 +29,33 @@ def generate_plan_subscription(
if metadata:
product_metadata = {**product_metadata, **metadata}
product = baker.make(Product, active=True, metadata=product_metadata)
price = baker.make(
Price,
active=True,
id='price_sfmOFe33rfsfd36685657',
product=product,
)

subscription_item = baker.make(SubscriptionItem, price=price, quantity=1, livemode=False)
if not (price := Price.objects.filter(id=price_id).first()):
price = baker.make(
Price,
active=True,
id=price_id,
recurring={'interval': interval},
product=product,
)

period_offset = relativedelta(weeks=2)

if interval == 'year':
period_offset = relativedelta(months=6)

subscription_item = baker.make(
SubscriptionItem, price=price, quantity=1, livemode=False
)
return baker.make(
Subscription,
customer=customer,
status='active',
items=[subscription_item],
livemode=False,
billing_cycle_anchor=now - relativedelta(weeks=2),
current_period_end=now + relativedelta(weeks=2),
current_period_start=now - relativedelta(weeks=2),
billing_cycle_anchor=created_date - period_offset,
current_period_end=created_date + period_offset,
current_period_start=created_date - period_offset,
)


Expand Down

0 comments on commit 0aa3cff

Please sign in to comment.