Skip to content

Commit

Permalink
feat: Remove all but first admin when subscription has reached cancel…
Browse files Browse the repository at this point in the history
…lation date (#2965)
  • Loading branch information
zachaysan committed Nov 16, 2023
1 parent 0d2b979 commit 6976f81
Show file tree
Hide file tree
Showing 6 changed files with 159 additions and 7 deletions.
18 changes: 18 additions & 0 deletions api/organisations/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,20 @@ def rebuild_environments(self):
).values_list("id", flat=True):
rebuild_environment_document.delay(args=(environment_id,))

def cancel_users(self):
remaining_seat_holder = (
UserOrganisation.objects.filter(
organisation=self,
role=OrganisationRole.ADMIN,
)
.order_by("date_joined")
.first()
)

UserOrganisation.objects.filter(
organisation=self,
).exclude(id=remaining_seat_holder.id).delete()


class UserOrganisation(models.Model):
user = models.ForeignKey("users.FFAdminUser", on_delete=models.CASCADE)
Expand Down Expand Up @@ -213,6 +227,10 @@ def update_mailer_lite_subscribers(self):
def cancel(self, cancellation_date=timezone.now(), update_chargebee=True):
self.cancellation_date = cancellation_date
self.save()
# If the date is in the future, a recurring task takes it.
if cancellation_date <= timezone.now():
self.organisation.cancel_users()

if self.payment_method == CHARGEBEE and update_chargebee:
cancel_chargebee_subscription(self.subscription_id)

Expand Down
24 changes: 22 additions & 2 deletions api/organisations/tasks.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
from datetime import timedelta

from django.utils import timezone

from organisations import subscription_info_cache
from organisations.models import Organisation
from organisations.models import Organisation, Subscription
from organisations.subscriptions.subscription_service import (
get_subscription_metadata,
)
from task_processor.decorators import register_task_handler
from task_processor.decorators import (
register_recurring_task,
register_task_handler,
)
from users.models import FFAdminUser

from .subscriptions.constants import SubscriptionCacheEntity
Expand Down Expand Up @@ -41,3 +48,16 @@ def update_organisation_subscription_information_cache():
subscription_info_cache.update_caches(
(SubscriptionCacheEntity.CHARGEBEE, SubscriptionCacheEntity.INFLUX)
)


@register_recurring_task(
run_every=timedelta(hours=12),
)
def finish_subscription_cancellation():
now = timezone.now()
previously = now + timedelta(hours=-24)
for subscription in Subscription.objects.filter(
cancellation_date__lt=now,
cancellation_date__gt=previously,
):
subscription.organisation.cancel_users()
5 changes: 4 additions & 1 deletion api/organisations/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from organisations.models import (
TRIAL_SUBSCRIPTION_ID,
Organisation,
OrganisationRole,
OrganisationSubscriptionInformationCache,
Subscription,
)
Expand All @@ -24,6 +25,7 @@
)
from organisations.subscriptions.metadata import BaseSubscriptionMetadata
from organisations.subscriptions.xero.metadata import XeroSubscriptionMetadata
from users.models import FFAdminUser


@pytest.mark.django_db
Expand Down Expand Up @@ -64,7 +66,8 @@ def test_cancel_subscription_cancels_chargebee_subscription(
):
# Given
organisation = Organisation.objects.create(name="Test org")

user = FFAdminUser.objects.create(email="test@example.com")
user.add_organisation(organisation, role=OrganisationRole.ADMIN)
Subscription.objects.filter(organisation=organisation).update(
subscription_id="subscription_id", payment_method=CHARGEBEE
)
Expand Down
5 changes: 3 additions & 2 deletions api/organisations/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,6 @@ def chargebee_webhook(request):
- If subscription is cancelled or not renewing, update subscription on our end to include cancellation date and
send alert to admin users.
"""

if request.data.get("content") and "subscription" in request.data.get("content"):
subscription_data: dict = request.data["content"]["subscription"]
customer_email: str = request.data["content"]["customer"]["email"]
Expand Down Expand Up @@ -310,7 +309,9 @@ def chargebee_webhook(request):

elif subscription_status in ("non_renewing", "cancelled"):
existing_subscription.cancel(
datetime.fromtimestamp(subscription_data.get("current_term_end")),
datetime.fromtimestamp(
subscription_data.get("current_term_end")
).replace(tzinfo=timezone.utc),
update_chargebee=False,
)

Expand Down
74 changes: 74 additions & 0 deletions api/tests/unit/organisations/test_unit_organisation_tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import uuid
from datetime import timedelta

from django.utils import timezone

from organisations.models import (
Organisation,
OrganisationRole,
UserOrganisation,
)
from organisations.tasks import finish_subscription_cancellation
from users.models import FFAdminUser


def test_finish_subscription_cancellation(db: None):
organisation1 = Organisation.objects.create()
organisation2 = Organisation.objects.create()
organisation3 = Organisation.objects.create()
organisation4 = Organisation.objects.create()

# Far future cancellation will be unaffected.
organisation_user_count = 3
for __ in range(organisation_user_count):
UserOrganisation.objects.create(
organisation=organisation1,
user=FFAdminUser.objects.create(email=f"{uuid.uuid4()}@example.com"),
role=OrganisationRole.ADMIN,
)
future = timezone.now() + timedelta(days=20)
organisation1.subscription.cancel(cancellation_date=future)

# Two organisations are impacted.
for __ in range(organisation_user_count):
UserOrganisation.objects.create(
organisation=organisation2,
user=FFAdminUser.objects.create(email=f"{uuid.uuid4()}@example.com"),
role=OrganisationRole.ADMIN,
)

organisation2.subscription.cancel(
cancellation_date=timezone.now() - timedelta(hours=2)
)

for __ in range(organisation_user_count):
UserOrganisation.objects.create(
organisation=organisation3,
user=FFAdminUser.objects.create(email=f"{uuid.uuid4()}@example.com"),
role=OrganisationRole.ADMIN,
)
organisation3.subscription.cancel(
cancellation_date=timezone.now() - timedelta(hours=4)
)

# Remaining organisation4 has not canceled, should be left unaffected.
for __ in range(organisation_user_count):
UserOrganisation.objects.create(
organisation=organisation4,
user=FFAdminUser.objects.create(email=f"{uuid.uuid4()}@example.com"),
role=OrganisationRole.ADMIN,
)

# When
finish_subscription_cancellation()

# Then
organisation1.refresh_from_db()
organisation2.refresh_from_db()
organisation3.refresh_from_db()
organisation4.refresh_from_db()

assert organisation1.num_seats == organisation_user_count
assert organisation2.num_seats == 1
assert organisation3.num_seats == 1
assert organisation4.num_seats == organisation_user_count
40 changes: 38 additions & 2 deletions api/tests/unit/organisations/test_unit_organisation_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -1172,7 +1172,7 @@ def test_make_user_group_admin_success(


def test_make_user_group_admin_forbidden(
staff_client: FFAdminUser,
staff_client: APIClient,
organisation: Organisation,
user_permission_group: UserPermissionGroup,
):
Expand Down Expand Up @@ -1238,7 +1238,7 @@ def test_remove_user_as_group_admin_success(


def test_remove_user_as_group_admin_forbidden(
staff_client: FFAdminUser,
staff_client: APIClient,
organisation: Organisation,
user_permission_group: UserPermissionGroup,
):
Expand Down Expand Up @@ -1340,3 +1340,39 @@ def test_list_my_groups(organisation, api_client):
"id": user_permission_group_1.id,
"name": user_permission_group_1.name,
}


def test_when_subscription_is_cancelled_then_remove_all_but_the_first_user(
staff_client: APIClient,
subscription: Subscription,
organisation: Organisation,
):
# Given
cancellation_date = datetime.now(tz=UTC)
data = {
"content": {
"subscription": {
"status": "cancelled",
"id": subscription.subscription_id,
"current_term_end": datetime.timestamp(cancellation_date),
},
"customer": {
"email": "chargebee@bullet-train.io",
},
}
}

url = reverse("api-v1:chargebee-webhook")
assert organisation.num_seats == 2

# When
response = staff_client.post(
url, data=json.dumps(data), content_type="application/json"
)
# Then
assert response.status_code == 200

subscription.refresh_from_db()
assert subscription.cancellation_date == cancellation_date
organisation.refresh_from_db()
assert organisation.num_seats == 1

3 comments on commit 6976f81

@vercel
Copy link

@vercel vercel bot commented on 6976f81 Nov 16, 2023

Choose a reason for hiding this comment

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

@vercel
Copy link

@vercel vercel bot commented on 6976f81 Nov 16, 2023

Choose a reason for hiding this comment

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

Successfully deployed to the following URLs:

docs – ./docs

docs-git-main-flagsmith.vercel.app
docs.flagsmith.com
docs.bullet-train.io
docs-flagsmith.vercel.app

@vercel
Copy link

@vercel vercel bot commented on 6976f81 Nov 16, 2023

Choose a reason for hiding this comment

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

Please sign in to comment.