From 6976f81c910d5d8de4f194fc27e799b72778b9f6 Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Thu, 16 Nov 2023 11:30:39 -0500 Subject: [PATCH] feat: Remove all but first admin when subscription has reached cancellation date (#2965) --- api/organisations/models.py | 18 +++++ api/organisations/tasks.py | 24 +++++- api/organisations/tests/test_models.py | 5 +- api/organisations/views.py | 5 +- .../test_unit_organisation_tasks.py | 74 +++++++++++++++++++ .../test_unit_organisation_views.py | 40 +++++++++- 6 files changed, 159 insertions(+), 7 deletions(-) create mode 100644 api/tests/unit/organisations/test_unit_organisation_tasks.py diff --git a/api/organisations/models.py b/api/organisations/models.py index c691766e57d5..aaec83e2eef0 100644 --- a/api/organisations/models.py +++ b/api/organisations/models.py @@ -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) @@ -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) diff --git a/api/organisations/tasks.py b/api/organisations/tasks.py index 17726da44912..d8866815f311 100644 --- a/api/organisations/tasks.py +++ b/api/organisations/tasks.py @@ -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 @@ -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() diff --git a/api/organisations/tests/test_models.py b/api/organisations/tests/test_models.py index eb9cba55a182..c525c29c4d7b 100644 --- a/api/organisations/tests/test_models.py +++ b/api/organisations/tests/test_models.py @@ -11,6 +11,7 @@ from organisations.models import ( TRIAL_SUBSCRIPTION_ID, Organisation, + OrganisationRole, OrganisationSubscriptionInformationCache, Subscription, ) @@ -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 @@ -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 ) diff --git a/api/organisations/views.py b/api/organisations/views.py index c436040f2e0a..517072fda6d9 100644 --- a/api/organisations/views.py +++ b/api/organisations/views.py @@ -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"] @@ -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, ) diff --git a/api/tests/unit/organisations/test_unit_organisation_tasks.py b/api/tests/unit/organisations/test_unit_organisation_tasks.py new file mode 100644 index 000000000000..53f4082dbd8d --- /dev/null +++ b/api/tests/unit/organisations/test_unit_organisation_tasks.py @@ -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 diff --git a/api/tests/unit/organisations/test_unit_organisation_views.py b/api/tests/unit/organisations/test_unit_organisation_views.py index da86ba671613..1dcea8cc96db 100644 --- a/api/tests/unit/organisations/test_unit_organisation_views.py +++ b/api/tests/unit/organisations/test_unit_organisation_views.py @@ -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, ): @@ -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, ): @@ -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