Skip to content

Commit

Permalink
Merge pull request #5062 from kobotoolbox/TASK-934-update-canceled-su…
Browse files Browse the repository at this point in the history
…b-handling

[TASK-934] update canceled sub handling
  • Loading branch information
jamesrkiger authored Aug 13, 2024
2 parents abd7820 + 1569397 commit a1801ad
Show file tree
Hide file tree
Showing 12 changed files with 360 additions and 65 deletions.
6 changes: 6 additions & 0 deletions jsapp/js/account/subscriptionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class SubscriptionStore {
public planResponse: SubscriptionInfo[] = [];
public addOnsResponse: SubscriptionInfo[] = [];
public activeSubscriptions: SubscriptionInfo[] = [];
public canceledPlans: SubscriptionInfo[] = [];
public isPending = false;
public isInitialised = false;

Expand Down Expand Up @@ -50,6 +51,11 @@ class SubscriptionStore {
this.activeSubscriptions = response.results.filter((sub) =>
ACTIVE_STRIPE_STATUSES.includes(sub.status)
);
this.canceledPlans = response.results.filter(
(sub) =>
sub.items[0]?.price.product.metadata?.product_type == 'plan' &&
sub.status === 'canceled'
);
// get any active plan subscriptions for the user
this.planResponse = this.activeSubscriptions.filter(
(sub) => sub.items[0]?.price.product.metadata?.product_type == 'plan'
Expand Down
3 changes: 2 additions & 1 deletion jsapp/js/account/usage/usage.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,9 @@ export interface AssetWithUsage {

export interface UsageResponse {
current_month_start: string;
current_month_end: string;
current_year_start: string;
billing_period_end: string | null;
current_year_end: string;
total_submission_count: {
current_month: number;
current_year: number;
Expand Down
2 changes: 1 addition & 1 deletion jsapp/js/account/usage/useUsage.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ const loadUsage = async (
usage.total_nlp_usage[`mt_characters_current_${trackingPeriod}`],
currentMonthStart: usage.current_month_start,
currentYearStart: usage.current_year_start,
billingPeriodEnd: usage.billing_period_end,
billingPeriodEnd: usage[`current_${trackingPeriod}_end`],
trackingPeriod,
lastUpdated,
};
Expand Down
4 changes: 4 additions & 0 deletions jsapp/js/account/usage/yourPlan.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ export const YourPlan = () => {
let date;
if (subscriptions.planResponse.length) {
date = subscriptions.planResponse[0].start_date;
} else if (subscriptions.canceledPlans.length){
date =
subscriptions.canceledPlans[subscriptions.canceledPlans.length - 1]
.ended_at;
} else {
date = session.currentAccount.date_joined;
}
Expand Down
20 changes: 20 additions & 0 deletions kobo/apps/organizations/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,26 @@ def active_subscription_billing_details(self):
).first()

return None

@cache_for_request
def canceled_subscription_billing_cycle_anchor(self):
"""
Returns cancelation date of most recently canceled subscription
"""
# Only check for subscriptions if Stripe is enabled
if settings.STRIPE_ENABLED:
qs = Organization.objects.prefetch_related('djstripe_customers').filter(
djstripe_customers__subscriptions__status='canceled',
djstripe_customers__subscriber=self.id,
).order_by(
'-djstripe_customers__subscriptions__ended_at'
).values(
anchor=F('djstripe_customers__subscriptions__ended_at'),
).first()
if qs:
return qs['anchor']

return None


class OrganizationUser(AbstractOrganizationUser):
Expand Down
87 changes: 63 additions & 24 deletions kobo/apps/organizations/utils.py
Original file line number Diff line number Diff line change
@@ -1,49 +1,88 @@
from typing import Union

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

from kobo.apps.organizations.models import Organization


def organization_month_start(organization: Union[Organization, None]):
now = timezone.now()
first_of_this_month = now.date().replace(day=1)
# If no organization/subscription, just use the first day of current month
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 = 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
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()):
return first_of_this_month
if not (
canceled_subscription_anchor
:= organization.canceled_subscription_billing_cycle_anchor()
):
return first_of_this_month, first_of_next_month

period_end = canceled_subscription_anchor.replace(tzinfo=pytz.UTC)
while period_end < now:
period_end += relativedelta(months=1)
period_start = period_end - relativedelta(months=1)
return period_start, period_end

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

# Subscription is billed monthly, use the current billing period start date
# Subscription is billed monthly, use the current billing period dates
if billing_details.get('recurring_interval') == 'month':
return billing_details.get('current_period_start').replace(tzinfo=pytz.UTC)
period_start = billing_details.get('current_period_start').replace(
tzinfo=pytz.UTC
)
period_end = billing_details.get('current_period_end').replace(
tzinfo=pytz.UTC
)
return period_start, period_end

# Subscription is billed yearly - count backwards from the end of the current billing year
month_start = billing_details.get('current_period_end').replace(tzinfo=pytz.UTC)
while month_start > now:
month_start -= relativedelta(months=1)
return month_start
period_start = billing_details.get('current_period_end').replace(tzinfo=pytz.UTC)
while period_start > now:
period_start -= relativedelta(months=1)
period_end = period_start + relativedelta(months=1)
return period_start, period_end


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 = datetime(now.year, 1, 1, tzinfo=pytz.UTC)
first_of_next_year = first_of_this_year + relativedelta(years=1)

def organization_year_start(organization: Union[Organization, None]):
now = timezone.now()
first_of_this_year = now.date().replace(month=1, day=1)
if not organization:
return first_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
return first_of_this_year, first_of_next_year
if not (anchor_date := billing_details.get('billing_cycle_anchor')):
return first_of_this_year
return first_of_this_year, first_of_next_year

# Subscription is billed yearly, use the provided anchor date as start date
if billing_details.get('subscription_interval') == 'year':
return billing_details.get('current_period_start').replace(tzinfo=pytz.UTC)
# Subscription is billed yearly, use the dates from the subscription
if billing_details.get('recurring_interval') == 'year':
period_start = billing_details.get('current_period_start').replace(
tzinfo=pytz.UTC
)
period_end = billing_details.get('current_period_end').replace(
tzinfo=pytz.UTC
)
return period_start, period_end

# Subscription is monthly, calculate this year's start based on anchor date
while anchor_date + relativedelta(years=1) < now:
period_start = anchor_date.replace(tzinfo=pytz.UTC) + relativedelta(years=1)
while period_start < now:
anchor_date += relativedelta(years=1)
return anchor_date.replace(tzinfo=pytz.UTC)
period_end = period_start + relativedelta(years=1)
return period_start, period_end
22 changes: 12 additions & 10 deletions kobo/apps/project_ownership/tests/api/v2/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -394,9 +394,6 @@ def test_account_usage_transferred_to_new_user(self):
'current_year': 1,
'current_month': 1,
},
'current_month_start': today.replace(day=1).strftime('%Y-%m-%d'),
'current_year_start': today.replace(month=1, day=1).strftime('%Y-%m-%d'),
'billing_period_end': None,
}

expected_empty_data = {
Expand All @@ -414,9 +411,6 @@ def test_account_usage_transferred_to_new_user(self):
'current_year': 0,
'current_month': 0,
},
'current_month_start': today.replace(day=1).strftime('%Y-%m-%d'),
'current_year_start': today.replace(month=1, day=1).strftime('%Y-%m-%d'),
'billing_period_end': None,
}

service_usage_url = reverse(
Expand All @@ -425,12 +419,16 @@ def test_account_usage_transferred_to_new_user(self):
# someuser has some usage metrics
self.client.login(username='someuser', password='someuser')
response = self.client.get(service_usage_url)
assert response.data == expected_data
assert response.data['total_nlp_usage'] == expected_data['total_nlp_usage']
assert response.data['total_storage_bytes'] == expected_data['total_storage_bytes']
assert response.data['total_submission_count'] == expected_data['total_submission_count']

# anotheruser's usage should be 0
self.client.login(username='anotheruser', password='anotheruser')
response = self.client.get(service_usage_url)
assert response.data == expected_empty_data
assert response.data['total_nlp_usage'] == expected_empty_data['total_nlp_usage']
assert response.data['total_storage_bytes'] == expected_empty_data['total_storage_bytes']
assert response.data['total_submission_count'] == expected_empty_data['total_submission_count']

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

# someuser should have no usage reported anymore
response = self.client.get(service_usage_url)
assert response.data == expected_empty_data
assert response.data['total_nlp_usage'] == expected_empty_data['total_nlp_usage']
assert response.data['total_storage_bytes'] == expected_empty_data['total_storage_bytes']
assert response.data['total_submission_count'] == expected_empty_data['total_submission_count']

# anotheruser should now have usage reported
self.client.login(username='anotheruser', password='anotheruser')
response = self.client.get(service_usage_url)
assert response.data == expected_data
assert response.data['total_nlp_usage'] == expected_data['total_nlp_usage']
assert response.data['total_storage_bytes'] == expected_data['total_storage_bytes']
assert response.data['total_submission_count'] == expected_data['total_submission_count']

@patch(
'kobo.apps.project_ownership.models.transfer.reset_kc_permissions',
Expand Down
Loading

0 comments on commit a1801ad

Please sign in to comment.