Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Remove all but first admin when subscription has reached cancellation date #2965

Merged
merged 10 commits into from
Nov 16, 2023
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="[email protected]")
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
77 changes: 77 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,77 @@
import uuid
from datetime import timedelta

import pytest
from django.utils import timezone

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


@pytest.mark.django_db
def test_finish_subscription_cancellation():
# Given
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
34 changes: 34 additions & 0 deletions api/tests/unit/organisations/test_unit_organisation_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -1340,3 +1340,37 @@ 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, admin_client, subscription, 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": "[email protected]",
},
}
}

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