Skip to content

Commit 3a80bfe

Browse files
authored
Merge pull request #5052 from kobotoolbox/TASK-934-update-canceled-sub-handling-backend
[TASK-934] Anchor default plan cycle to canceled subscription end date
2 parents 76a82a1 + 191cd09 commit 3a80bfe

File tree

8 files changed

+195
-50
lines changed

8 files changed

+195
-50
lines changed

kobo/apps/organizations/models.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,26 @@ def active_subscription_billing_details(self):
5151
).first()
5252

5353
return None
54+
55+
@cache_for_request
56+
def canceled_subscription_billing_cycle_anchor(self):
57+
"""
58+
Returns cancelation date of most recently canceled subscription
59+
"""
60+
# Only check for subscriptions if Stripe is enabled
61+
if settings.STRIPE_ENABLED:
62+
qs = Organization.objects.prefetch_related('djstripe_customers').filter(
63+
djstripe_customers__subscriptions__status='canceled',
64+
djstripe_customers__subscriber=self.id,
65+
).order_by(
66+
'-djstripe_customers__subscriptions__ended_at'
67+
).values(
68+
anchor=F('djstripe_customers__subscriptions__ended_at'),
69+
).first()
70+
if qs:
71+
return qs['anchor']
72+
73+
return None
5474

5575

5676
class OrganizationUser(AbstractOrganizationUser):

kobo/apps/organizations/utils.py

Lines changed: 58 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,43 +7,79 @@
77
from kobo.apps.organizations.models import Organization
88

99

10-
def organization_month_start(organization: Union[Organization, None]):
11-
now = timezone.now()
12-
first_of_this_month = now.date().replace(day=1)
13-
# If no organization/subscription, just use the first day of current month
10+
def get_monthly_billing_dates(organization: Union[Organization, None]):
11+
"""Returns start and end dates of an organization's monthly billing cycle"""
12+
13+
now = timezone.now().replace(tzinfo=pytz.UTC)
14+
first_of_this_month = now.replace(day=1)
15+
# Using `days=31` gets the last day of the month
16+
last_of_this_month = first_of_this_month + relativedelta(days=31)
17+
18+
# If no organization, just use the calendar month
1419
if not organization:
15-
return first_of_this_month
20+
return first_of_this_month, last_of_this_month
21+
22+
# If no active subscription, check for canceled subscription
1623
if not (billing_details := organization.active_subscription_billing_details()):
17-
return first_of_this_month
24+
if not (
25+
canceled_subscription_anchor
26+
:= organization.canceled_subscription_billing_cycle_anchor()
27+
):
28+
return first_of_this_month, last_of_this_month
29+
30+
period_end = canceled_subscription_anchor.replace(tzinfo=pytz.UTC)
31+
while period_end < now:
32+
period_end += relativedelta(months=1)
33+
period_start = period_end - relativedelta(months=1)
34+
return period_start, period_end
35+
1836
if not billing_details.get('billing_cycle_anchor'):
19-
return first_of_this_month
37+
return first_of_this_month, last_of_this_month
2038

21-
# Subscription is billed monthly, use the current billing period start date
39+
# Subscription is billed monthly, use the current billing period dates
2240
if billing_details.get('recurring_interval') == 'month':
23-
return billing_details.get('current_period_start').replace(tzinfo=pytz.UTC)
41+
period_start = billing_details.get('current_period_start').replace(
42+
tzinfo=pytz.UTC
43+
)
44+
period_end = billing_details.get('current_period_end').replace(
45+
tzinfo=pytz.UTC
46+
)
47+
return period_start, period_end
2448

2549
# Subscription is billed yearly - count backwards from the end of the current billing year
26-
month_start = billing_details.get('current_period_end').replace(tzinfo=pytz.UTC)
27-
while month_start > now:
28-
month_start -= relativedelta(months=1)
29-
return month_start
50+
period_start = billing_details.get('current_period_end').replace(tzinfo=pytz.UTC)
51+
while period_start > now:
52+
period_start -= relativedelta(months=1)
53+
period_end = period_start + relativedelta(months=1)
54+
return period_start, period_end
3055

3156

32-
def organization_year_start(organization: Union[Organization, None]):
33-
now = timezone.now()
57+
def get_yearly_billing_dates(organization: Union[Organization, None]):
58+
"""Returns start and end dates of an organization's annual billing cycle"""
59+
now = timezone.now().replace(tzinfo=pytz.UTC)
3460
first_of_this_year = now.date().replace(month=1, day=1)
61+
last_of_this_year = now.date().replace(month=12, day=31)
62+
3563
if not organization:
36-
return first_of_this_year
64+
return first_of_this_year, last_of_this_year
3765
if not (billing_details := organization.active_subscription_billing_details()):
38-
return first_of_this_year
66+
return first_of_this_year, last_of_this_year
3967
if not (anchor_date := billing_details.get('billing_cycle_anchor')):
40-
return first_of_this_year
68+
return first_of_this_year, last_of_this_year
4169

42-
# Subscription is billed yearly, use the provided anchor date as start date
70+
# Subscription is billed yearly, use the dates from the subscription
4371
if billing_details.get('subscription_interval') == 'year':
44-
return billing_details.get('current_period_start').replace(tzinfo=pytz.UTC)
72+
period_start = billing_details.get('current_period_start').replace(
73+
tzinfo=pytz.UTC
74+
)
75+
period_end = billing_details.get('current_period_end').replace(
76+
tzinfo=pytz.UTC
77+
)
78+
return period_start, period_end
4579

4680
# Subscription is monthly, calculate this year's start based on anchor date
47-
while anchor_date + relativedelta(years=1) < now:
81+
period_start = anchor_date.replace(tzinfo=pytz.UTC) + relativedelta(years=1)
82+
while period_start < now:
4883
anchor_date += relativedelta(years=1)
49-
return anchor_date.replace(tzinfo=pytz.UTC)
84+
period_end = period_start + relativedelta(years=1)
85+
return period_start, period_end

kobo/apps/project_ownership/tests/api/v2/test_api.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -394,9 +394,6 @@ def test_account_usage_transferred_to_new_user(self):
394394
'current_year': 1,
395395
'current_month': 1,
396396
},
397-
'current_month_start': today.replace(day=1).strftime('%Y-%m-%d'),
398-
'current_year_start': today.replace(month=1, day=1).strftime('%Y-%m-%d'),
399-
'billing_period_end': None,
400397
}
401398

402399
expected_empty_data = {
@@ -414,9 +411,6 @@ def test_account_usage_transferred_to_new_user(self):
414411
'current_year': 0,
415412
'current_month': 0,
416413
},
417-
'current_month_start': today.replace(day=1).strftime('%Y-%m-%d'),
418-
'current_year_start': today.replace(month=1, day=1).strftime('%Y-%m-%d'),
419-
'billing_period_end': None,
420414
}
421415

422416
service_usage_url = reverse(
@@ -425,12 +419,16 @@ def test_account_usage_transferred_to_new_user(self):
425419
# someuser has some usage metrics
426420
self.client.login(username='someuser', password='someuser')
427421
response = self.client.get(service_usage_url)
428-
assert response.data == expected_data
422+
assert response.data['total_nlp_usage'] == expected_data['total_nlp_usage']
423+
assert response.data['total_storage_bytes'] == expected_data['total_storage_bytes']
424+
assert response.data['total_submission_count'] == expected_data['total_submission_count']
429425

430426
# anotheruser's usage should be 0
431427
self.client.login(username='anotheruser', password='anotheruser')
432428
response = self.client.get(service_usage_url)
433-
assert response.data == expected_empty_data
429+
assert response.data['total_nlp_usage'] == expected_empty_data['total_nlp_usage']
430+
assert response.data['total_storage_bytes'] == expected_empty_data['total_storage_bytes']
431+
assert response.data['total_submission_count'] == expected_empty_data['total_submission_count']
434432

435433
# Transfer project from someuser to anotheruser
436434
self.client.login(username='someuser', password='someuser')
@@ -452,12 +450,16 @@ def test_account_usage_transferred_to_new_user(self):
452450

453451
# someuser should have no usage reported anymore
454452
response = self.client.get(service_usage_url)
455-
assert response.data == expected_empty_data
453+
assert response.data['total_nlp_usage'] == expected_empty_data['total_nlp_usage']
454+
assert response.data['total_storage_bytes'] == expected_empty_data['total_storage_bytes']
455+
assert response.data['total_submission_count'] == expected_empty_data['total_submission_count']
456456

457457
# anotheruser should now have usage reported
458458
self.client.login(username='anotheruser', password='anotheruser')
459459
response = self.client.get(service_usage_url)
460-
assert response.data == expected_data
460+
assert response.data['total_nlp_usage'] == expected_data['total_nlp_usage']
461+
assert response.data['total_storage_bytes'] == expected_data['total_storage_bytes']
462+
assert response.data['total_submission_count'] == expected_data['total_submission_count']
461463

462464
@patch(
463465
'kobo.apps.project_ownership.models.transfer.reset_kc_permissions',

kobo/apps/stripe/tests/test_organization_usage.py

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import timeit
22

33
import pytest
4+
from dateutil.relativedelta import relativedelta
45
from django.core.cache import cache
56
from django.test import override_settings
67
from django.urls import reverse
@@ -11,14 +12,14 @@
1112
from kobo.apps.kobo_auth.shortcuts import User
1213
from kobo.apps.organizations.models import Organization, OrganizationUser
1314
from kobo.apps.stripe.tests.utils import generate_enterprise_subscription, generate_plan_subscription
14-
from kobo.apps.trackers.submission_utils import create_mock_assets, add_mock_submissions
15+
from kobo.apps.trackers.tests.submission_utils import create_mock_assets, add_mock_submissions
1516
from kpi.tests.api.v2.test_api_service_usage import ServiceUsageAPIBase
1617
from kpi.tests.api.v2.test_api_asset_usage import AssetUsageAPITestCase
1718
from rest_framework import status
1819

1920

2021

21-
class OrganizationServiceUsageAPITestCase(ServiceUsageAPIBase):
22+
class OrganizationServiceUsageAPIMultiUserTestCase(ServiceUsageAPIBase):
2223
"""
2324
Test organization service usage when Stripe is enabled.
2425
@@ -146,6 +147,83 @@ def test_endpoint_is_cached(self):
146147
self.expected_file_size() * self.expected_submissions_multi
147148
)
148149

150+
class OrganizationServiceUsageAPITestCase(ServiceUsageAPIBase):
151+
org_id = 'orgAKWMFskafsngf'
152+
153+
@classmethod
154+
def setUpTestData(cls):
155+
super().setUpTestData()
156+
cls.now = timezone.now()
157+
158+
cls.organization = baker.make(
159+
Organization, id=cls.org_id, name='test organization'
160+
)
161+
cls.organization.add_user(cls.anotheruser, is_admin=True)
162+
cls.asset = create_mock_assets([cls.anotheruser])[0]
163+
164+
165+
def setUp(self):
166+
super().setUp()
167+
url = reverse(self._get_endpoint('organizations-list'))
168+
self.detail_url = f'{url}{self.org_id}/service_usage/'
169+
170+
def tearDown(self):
171+
cache.clear()
172+
173+
def test_plan_canceled_this_month(self):
174+
"""
175+
When a user cancels their subscription, they revert to the default community plan
176+
with a billing cycle anchored to the end date of their canceled subscription
177+
"""
178+
179+
subscription = generate_plan_subscription(self.organization, age_days=30)
180+
181+
num_submissions = 5
182+
add_mock_submissions([self.asset], num_submissions, 15)
183+
184+
canceled_at = timezone.now()
185+
subscription.status = 'canceled'
186+
subscription.ended_at = canceled_at
187+
subscription.save()
188+
189+
current_billing_period_end = canceled_at + relativedelta(months=1)
190+
191+
response = self.client.get(self.detail_url)
192+
193+
assert (
194+
response.data['total_submission_count']['current_month']
195+
== 0
196+
)
197+
assert response.data['current_month_start'] == canceled_at.isoformat()
198+
assert response.data['current_month_end'] == current_billing_period_end.isoformat()
199+
200+
def test_plan_canceled_last_month(self):
201+
subscription = generate_plan_subscription(self.organization, age_days=60)
202+
203+
num_submissions = 5
204+
add_mock_submissions([self.asset], num_submissions, 15)
205+
206+
canceled_at = timezone.now() - relativedelta(days=45)
207+
subscription.status = 'canceled'
208+
subscription.ended_at = canceled_at
209+
subscription.save()
210+
211+
current_billing_period_start = canceled_at + relativedelta(months=1)
212+
current_billing_period_end = current_billing_period_start + relativedelta(
213+
months=1
214+
)
215+
response = self.client.get(self.detail_url)
216+
217+
assert (
218+
response.data['total_submission_count']['current_month']
219+
== 5
220+
)
221+
assert response.data['current_month_start'] == current_billing_period_start.isoformat()
222+
assert (
223+
response.data['current_month_end']
224+
== current_billing_period_end.isoformat()
225+
)
226+
149227

150228
class OrganizationAssetUsageAPITestCase(AssetUsageAPITestCase):
151229
"""

kobo/apps/stripe/tests/utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88

99
def generate_plan_subscription(
10-
organization: Organization, metadata: dict = None, customer: Customer = None
10+
organization: Organization, metadata: dict = None, customer: Customer = None, age_days=0
1111
) -> Subscription:
1212
"""Create a subscription for a product with custom metadata"""
1313
now = timezone.now()

kobo/apps/trackers/submission_utils.py renamed to kobo/apps/trackers/tests/submission_utils.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import os
22
import uuid
33

4+
from dateutil.relativedelta import relativedelta
45
from django.conf import settings
56
from django.utils import timezone
67
from model_bakery import baker
@@ -61,12 +62,15 @@ def expected_file_size(submissions: int = 1):
6162

6263

6364
def update_xform_counters(
64-
asset: Asset, xform: KobocatXForm = None, submissions: int = 1
65+
asset: Asset,
66+
xform: KobocatXForm = None,
67+
submissions: int = 1,
68+
age_days: int = 0,
6569
):
6670
"""
6771
Create/update the daily submission counter and the shadow xform we use to query it
6872
"""
69-
today = timezone.now()
73+
today = timezone.now() - relativedelta(days=age_days)
7074
if xform:
7175
xform.attachment_storage_bytes += (
7276
expected_file_size(submissions)
@@ -124,7 +128,7 @@ def update_xform_counters(
124128
counter.save()
125129

126130

127-
def add_mock_submissions(assets: list, submissions_per_asset: int = 1):
131+
def add_mock_submissions(assets: list, submissions_per_asset: int = 1, age_days: int = 0):
128132
"""
129133
Add one (default) or more submissions to an asset
130134
"""
@@ -158,6 +162,6 @@ def add_mock_submissions(assets: list, submissions_per_asset: int = 1):
158162

159163
asset.deployment.mock_submissions(asset_submissions, flush_db=False)
160164
all_submissions = all_submissions + asset_submissions
161-
update_xform_counters(asset, submissions=submissions_per_asset)
165+
update_xform_counters(asset, submissions=submissions_per_asset, age_days=age_days)
162166

163167
return all_submissions

0 commit comments

Comments
 (0)