Skip to content

Commit

Permalink
feat: Organisation reverts to free plan (#3096)
Browse files Browse the repository at this point in the history
  • Loading branch information
zachaysan authored Dec 18, 2023
1 parent a7ab573 commit e5efdc8
Show file tree
Hide file tree
Showing 9 changed files with 220 additions and 50 deletions.
4 changes: 0 additions & 4 deletions api/organisations/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,3 @@

class OrganisationsConfig(AppConfig):
name = "organisations"

def ready(self):
# noinspection PyUnresolvedReferences
import organisations.signals # noqa
2 changes: 1 addition & 1 deletion api/organisations/chargebee/webhook_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ def process_subscription(request: Request) -> Response:
return Response(status=status.HTTP_200_OK)

if subscription["status"] in ("non_renewing", "cancelled"):
existing_subscription.cancel(
existing_subscription.prepare_for_cancel(
datetime.fromtimestamp(subscription.get("current_term_end")).replace(
tzinfo=timezone.utc
),
Expand Down
48 changes: 48 additions & 0 deletions api/organisations/migrations/0050_add_historical_subscription.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Generated by Django 3.2.23 on 2023-12-06 17:10

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import simple_history.models
import uuid


class Migration(migrations.Migration):

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('organisations', '0049_subscription_billing_status'),
]

operations = [
migrations.CreateModel(
name='HistoricalSubscription',
fields=[
('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('deleted_at', models.DateTimeField(blank=True, db_index=True, default=None, editable=False, null=True)),
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)),
('subscription_id', models.CharField(blank=True, max_length=100, null=True)),
('subscription_date', models.DateTimeField(blank=True, null=True)),
('plan', models.CharField(blank=True, default='free', max_length=100, null=True)),
('max_seats', models.IntegerField(default=1)),
('max_api_calls', models.BigIntegerField(default=50000)),
('cancellation_date', models.DateTimeField(blank=True, null=True)),
('customer_id', models.CharField(blank=True, max_length=100, null=True)),
('billing_status', models.CharField(blank=True, choices=[('ACTIVE', 'Active'), ('DUNNING', 'Dunning')], max_length=20, null=True)),
('payment_method', models.CharField(blank=True, choices=[('CHARGEBEE', 'Chargebee'), ('XERO', 'Xero'), ('AWS_MARKETPLACE', 'AWS Marketplace')], max_length=20, null=True)),
('notes', models.CharField(blank=True, max_length=500, null=True)),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField()),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('organisation', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='organisations.organisation')),
],
options={
'verbose_name': 'historical subscription',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': 'history_date',
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
]
65 changes: 58 additions & 7 deletions api/organisations/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
LifecycleModelMixin,
hook,
)
from simple_history.models import HistoricalRecords

from organisations.chargebee import (
get_customer_id_from_subscription_id,
Expand Down Expand Up @@ -132,7 +133,7 @@ def is_auto_seat_upgrade_available(self) -> bool:
@hook(BEFORE_DELETE)
def cancel_subscription(self):
if self.has_paid_subscription():
self.subscription.cancel()
self.subscription.prepare_for_cancel()

@hook(AFTER_CREATE)
def create_subscription(self):
Expand Down Expand Up @@ -198,7 +199,7 @@ class Subscription(LifecycleModelMixin, SoftDeleteExportableModel):
subscription_id = models.CharField(max_length=100, blank=True, null=True)
subscription_date = models.DateTimeField(blank=True, null=True)
plan = models.CharField(max_length=100, null=True, blank=True, default=FREE_PLAN_ID)
max_seats = models.IntegerField(default=1)
max_seats = models.IntegerField(default=MAX_SEATS_IN_FREE_PLAN)
max_api_calls = models.BigIntegerField(default=MAX_API_CALLS_IN_FREE_PLAN)
cancellation_date = models.DateTimeField(blank=True, null=True)
customer_id = models.CharField(max_length=100, blank=True, null=True)
Expand All @@ -218,6 +219,9 @@ class Subscription(LifecycleModelMixin, SoftDeleteExportableModel):
)
notes = models.CharField(max_length=500, blank=True, null=True)

# Intentionally avoid the AuditLog for subscriptions.
history = HistoricalRecords()

def update_plan(self, plan_id):
plan_metadata = get_plan_meta_data(plan_id)
self.cancellation_date = None
Expand All @@ -237,17 +241,64 @@ def update_mailer_lite_subscribers(self):
mailer_lite = MailerLite()
mailer_lite.update_organisation_users(self.organisation.id)

def cancel(self, cancellation_date=timezone.now(), update_chargebee=True):
self.cancellation_date = cancellation_date
def save_as_free_subscription(self):
"""
Wipes a subscription to a normal free plan.
The only normal field that is retained is the notes field.
"""
self.subscription_id = None
self.subscription_date = None
self.plan = FREE_PLAN_ID
self.max_seats = MAX_SEATS_IN_FREE_PLAN
self.max_api_calls = MAX_API_CALLS_IN_FREE_PLAN
self.cancellation_date = None
self.customer_id = None
self.billing_status = None
self.payment_method = None

self.save()
# If the date is in the future, a recurring task takes it.
if cancellation_date <= timezone.now():
self.organisation.cancel_users()

if not getattr(self.organisation, "subscription_information_cache", None):
return

self.organisation.subscription_information_cache.delete()

def prepare_for_cancel(
self, cancellation_date=timezone.now(), update_chargebee=True
) -> None:
"""
This method get's a subscription ready for cancelation.
If cancellation_date is in the future some aspects are
reserved for a task after the date has passed.
"""
# Avoid circular import.
from organisations.tasks import send_org_subscription_cancelled_alert

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

send_org_subscription_cancelled_alert.delay(
kwargs={
"organisation_name": self.organisation.name,
"formatted_cancellation_date": cancellation_date.strftime(
"%Y-%m-%d %H:%M:%S"
),
}
)

if cancellation_date <= timezone.now():
# Since the date is immediate, wipe data right away.
self.organisation.cancel_users()
self.save_as_free_subscription()
return

# Since the date is in the future, a task takes it.
self.cancellation_date = cancellation_date
self.billing_status = None
self.save()

def get_portal_url(self, redirect_url):
if not self.subscription_id:
return None
Expand Down
29 changes: 0 additions & 29 deletions api/organisations/signals.py

This file was deleted.

12 changes: 12 additions & 0 deletions api/organisations/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@ def send_org_over_limit_alert(organisation_id):
)


@register_task_handler()
def send_org_subscription_cancelled_alert(
organisation_name: str,
formatted_cancellation_date: str,
):
FFAdminUser.send_alert_to_admin_users(
subject=f"Organisation {organisation_name} has cancelled their subscription",
message=f"Organisation {organisation_name} has cancelled their subscription on {formatted_cancellation_date}",
)


@register_task_handler()
def update_organisation_subscription_information_influx_cache():
subscription_info_cache.update_caches((SubscriptionCacheEntity.INFLUX,))
Expand All @@ -61,3 +72,4 @@ def finish_subscription_cancellation():
cancellation_date__gt=previously,
):
subscription.organisation.cancel_users()
subscription.save_as_free_subscription()
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
)
from organisations.subscriptions.constants import (
CHARGEBEE,
FREE_PLAN_ID,
FREE_PLAN_SUBSCRIPTION_METADATA,
XERO,
)
Expand Down Expand Up @@ -85,7 +86,12 @@ def test_cancel_subscription_cancels_chargebee_subscription(
)
# refresh subscription object
subscription.refresh_from_db()
assert subscription.cancellation_date
# Subscription has been immediately transformed to free.
assert subscription.cancellation_date is None
assert subscription.subscription_id is None
assert subscription.billing_status is None
assert subscription.payment_method is None
assert subscription.plan == FREE_PLAN_ID


def test_organisation_rebuild_environment_document_on_stop_serving_flags_changed(
Expand Down
96 changes: 91 additions & 5 deletions api/tests/unit/organisations/test_unit_organisations_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@

import pytest
from django.utils import timezone
from pytest_mock import MockerFixture

from organisations.chargebee.metadata import ChargebeeObjMetadata
from organisations.models import (
Organisation,
OrganisationRole,
OrganisationSubscriptionInformationCache,
UserOrganisation,
)
from organisations.subscriptions.constants import (
FREE_PLAN_ID,
MAX_API_CALLS_IN_FREE_PLAN,
MAX_SEATS_IN_FREE_PLAN,
)
from organisations.subscriptions.xero.metadata import XeroSubscriptionMetadata
Expand All @@ -20,6 +23,7 @@
ALERT_EMAIL_SUBJECT,
finish_subscription_cancellation,
send_org_over_limit_alert,
send_org_subscription_cancelled_alert,
)
from users.models import FFAdminUser

Expand Down Expand Up @@ -76,8 +80,62 @@ def test_send_org_over_limit_alert_for_organisation_with_subscription(
assert kwargs["subject"] == ALERT_EMAIL_SUBJECT


def test_finish_subscription_cancellation(db: None):
organisation1 = Organisation.objects.create()
def test_subscription_cancellation(db: None) -> None:
# Given
organisation = Organisation.objects.create()
OrganisationSubscriptionInformationCache.objects.create(
organisation=organisation,
)
UserOrganisation.objects.create(
organisation=organisation,
user=FFAdminUser.objects.create(email=f"{uuid.uuid4()}@example.com"),
role=OrganisationRole.ADMIN,
)

assert organisation.subscription_information_cache
subscription = organisation.subscription
notes = "Notes to be kept"
subscription.subscription_id = "id"
subscription.subscription_date = timezone.now()
subscription.plan = "plan_code"
subscription.max_seats = 1_000_000
subscription.max_api_calls = 1_000_000
subscription.cancellation_date = timezone.now()
subscription.customer_id = "customer23"
subscription.billing_status = "ACTIVE"
subscription.payment_method = "CHARGEBEE"
subscription.notes = notes

subscription.save()

# When
finish_subscription_cancellation()

# Then
organisation.refresh_from_db()
subscription.refresh_from_db()
assert getattr(organisation, "subscription_information_cache", None) is None
assert subscription.subscription_id is None
assert subscription.subscription_date is None
assert subscription.plan == FREE_PLAN_ID
assert subscription.max_seats == MAX_SEATS_IN_FREE_PLAN
assert subscription.max_api_calls == MAX_API_CALLS_IN_FREE_PLAN
assert subscription.cancellation_date is None
assert subscription.customer_id is None
assert subscription.billing_status is None
assert subscription.payment_method is None
assert subscription.cancellation_date is None
assert subscription.notes == notes


@pytest.mark.freeze_time("2023-01-19T09:12:34+00:00")
def test_finish_subscription_cancellation(db: None, mocker: MockerFixture) -> None:
# Given
send_org_subscription_cancelled_alert_task = mocker.patch(
"organisations.tasks.send_org_subscription_cancelled_alert"
)

organisation1 = Organisation.objects.create(name="TestCorp")
organisation2 = Organisation.objects.create()
organisation3 = Organisation.objects.create()
organisation4 = Organisation.objects.create()
Expand All @@ -91,7 +149,15 @@ def test_finish_subscription_cancellation(db: None):
role=OrganisationRole.ADMIN,
)
future = timezone.now() + timedelta(days=20)
organisation1.subscription.cancel(cancellation_date=future)
organisation1.subscription.prepare_for_cancel(cancellation_date=future)

# Test one of the send org alerts.
send_org_subscription_cancelled_alert_task.delay.assert_called_once_with(
kwargs={
"organisation_name": organisation1.name,
"formatted_cancellation_date": "2023-02-08 09:12:34",
}
)

# Two organisations are impacted.
for __ in range(organisation_user_count):
Expand All @@ -101,7 +167,7 @@ def test_finish_subscription_cancellation(db: None):
role=OrganisationRole.ADMIN,
)

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

Expand All @@ -111,7 +177,7 @@ def test_finish_subscription_cancellation(db: None):
user=FFAdminUser.objects.create(email=f"{uuid.uuid4()}@example.com"),
role=OrganisationRole.ADMIN,
)
organisation3.subscription.cancel(
organisation3.subscription.prepare_for_cancel(
cancellation_date=timezone.now() - timedelta(hours=4)
)

Expand All @@ -136,3 +202,23 @@ def test_finish_subscription_cancellation(db: None):
assert organisation2.num_seats == 1
assert organisation3.num_seats == 1
assert organisation4.num_seats == organisation_user_count


def test_send_org_subscription_cancelled_alert(db: None, mocker: MockerFixture) -> None:
# Given
send_mail_mock = mocker.patch("users.models.send_mail")

# When
send_org_subscription_cancelled_alert(
organisation_name="TestCorp",
formatted_cancellation_date="2023-02-08 09:12:34",
)

# Then
send_mail_mock.assert_called_once_with(
subject="Organisation TestCorp has cancelled their subscription",
message="Organisation TestCorp has cancelled their subscription on 2023-02-08 09:12:34",
from_email="[email protected]",
recipient_list=[],
fail_silently=True,
)
Loading

3 comments on commit e5efdc8

@vercel
Copy link

@vercel vercel bot commented on e5efdc8 Dec 18, 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.bullet-train.io
docs.flagsmith.com
docs-git-main-flagsmith.vercel.app
docs-flagsmith.vercel.app

@vercel
Copy link

@vercel vercel bot commented on e5efdc8 Dec 18, 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 e5efdc8 Dec 18, 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.