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: Organisation reverts to free plan #3096

Merged
merged 19 commits into from
Dec 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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(
Copy link
Member

Choose a reason for hiding this comment

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

I do understand this is a tiny method, but can we add a test for this as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Haha, sure. Added one.

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
Comment on lines +89 to +90
Copy link
Contributor

Choose a reason for hiding this comment

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

Feels like there should be more asserts here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This test was originally targeted at chargebee and other tests enforced the other checks, but I've added more asserts because there is no downside.

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