Skip to content

Commit

Permalink
fix(subscriptions): ensure that manually added subscriptions work cor…
Browse files Browse the repository at this point in the history
…rectly in all deployments (#3182)
  • Loading branch information
matthewelwell authored Dec 20, 2023
1 parent 97db322 commit ae94267
Show file tree
Hide file tree
Showing 11 changed files with 224 additions and 53 deletions.
5 changes: 5 additions & 0 deletions .github/actions/api-deploy-ecs/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,11 @@ runs:
echo ${{ github.sha }} > api/CI_COMMIT_SHA
shell: bash

- name: Write file to indicate SaaS
run: |
echo '' > api/SAAS_DEPLOYMENT
shell: bash

- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v1-node16
with:
Expand Down
11 changes: 10 additions & 1 deletion api/app/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ def create_hash():
return shortuuid.uuid()


def is_enterprise():
return pathlib.Path("./ENTERPRISE_VERSION").exists()


def is_saas():
return pathlib.Path("./SAAS_DEPLOYMENT").exists()


def get_version_info() -> dict:
"""Reads the version info baked into src folder of the docker container"""
version_json = {}
Expand All @@ -27,7 +35,8 @@ def get_version_info() -> dict:
version_json = version_json | {
"ci_commit_sha": _get_file_contents("./CI_COMMIT_SHA"),
"image_tag": image_tag,
"is_enterprise": pathlib.Path("./ENTERPRISE_VERSION").exists(),
"is_enterprise": is_enterprise(),
"is_saas": is_saas(),
}

return version_json
Expand Down
3 changes: 2 additions & 1 deletion api/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,10 +153,11 @@ def xero_subscription(organisation):


@pytest.fixture()
def chargebee_subscription(organisation):
def chargebee_subscription(organisation: Organisation) -> Subscription:
subscription = Subscription.objects.get(organisation=organisation)
subscription.payment_method = CHARGEBEE
subscription.subscription_id = "subscription-id"
subscription.plan = "scale-up-v2"
subscription.save()

# refresh organisation to load subscription
Expand Down
2 changes: 1 addition & 1 deletion api/organisations/chargebee/chargebee.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from datetime import datetime

import chargebee
from chargebee import APIError as ChargebeeAPIError
from chargebee.api_error import APIError as ChargebeeAPIError
from django.conf import settings
from pytz import UTC

Expand Down
71 changes: 49 additions & 22 deletions api/organisations/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
)
from simple_history.models import HistoricalRecords

from app.utils import is_enterprise, is_saas
from organisations.chargebee import (
get_customer_id_from_subscription_id,
get_max_api_calls_for_plan,
Expand All @@ -31,10 +32,12 @@
from organisations.subscriptions.constants import (
CHARGEBEE,
FREE_PLAN_ID,
FREE_PLAN_SUBSCRIPTION_METADATA,
MAX_API_CALLS_IN_FREE_PLAN,
MAX_SEATS_IN_FREE_PLAN,
SUBSCRIPTION_BILLING_STATUSES,
SUBSCRIPTION_PAYMENT_METHODS,
TRIAL_SUBSCRIPTION_ID,
XERO,
)
from organisations.subscriptions.exceptions import (
Expand All @@ -45,8 +48,6 @@
from users.utils.mailer_lite import MailerLite
from webhooks.models import AbstractBaseExportableWebhookModel

TRIAL_SUBSCRIPTION_ID = "trial"

environment_cache = caches[settings.ENVIRONMENT_CACHE_NAME]


Expand Down Expand Up @@ -234,6 +235,10 @@ def update_plan(self, plan_id):
def can_auto_upgrade_seats(self) -> bool:
return self.plan in settings.AUTO_SEAT_UPGRADE_PLANS

@property
def is_free_plan(self) -> bool:
return self.plan == FREE_PLAN_ID

@hook(AFTER_SAVE, when="cancellation_date", has_changed=True)
@hook(AFTER_SAVE, when="subscription_id", has_changed=True)
def update_mailer_lite_subscribers(self):
Expand Down Expand Up @@ -311,33 +316,55 @@ def get_portal_url(self, redirect_url):
return get_portal_url(self.customer_id, redirect_url)

def get_subscription_metadata(self) -> BaseSubscriptionMetadata:
metadata = None
if self.is_free_plan:
# Free plan is the default everywhere, we should prevent
# increased access for all deployment types on the free
# plan.
return FREE_PLAN_SUBSCRIPTION_METADATA

if self.is_in_trial():
metadata = BaseSubscriptionMetadata(
seats=self.max_seats, api_calls=self.max_api_calls
)
elif self.payment_method == CHARGEBEE and self.subscription_id:
if self.organisation.has_subscription_information_cache():
# Getting the data from the subscription information cache because
# data is guaranteed to be up to date by using a Chargebee webhook.
metadata = ChargebeeObjMetadata(
seats=self.organisation.subscription_information_cache.allowed_seats,
api_calls=self.organisation.subscription_information_cache.allowed_30d_api_calls,
projects=self.organisation.subscription_information_cache.allowed_projects,
chargebee_email=self.organisation.subscription_information_cache.chargebee_email,
)
else:
metadata = get_subscription_metadata_from_id(self.subscription_id)
return (
self._get_subscription_metadata_for_saas()
if is_saas()
else self._get_subscription_metadata_for_self_hosted()
)

def _get_subscription_metadata_for_saas(self) -> BaseSubscriptionMetadata:
if self.payment_method == CHARGEBEE and self.subscription_id:
return self._get_subscription_metadata_for_chargebee()
elif self.payment_method == XERO and self.subscription_id:
metadata = XeroSubscriptionMetadata(
return XeroSubscriptionMetadata(
seats=self.max_seats, api_calls=self.max_api_calls
)

return metadata or BaseSubscriptionMetadata(
# Default fall through here means this is a manually added subscription
# or for a payment method that is not covered above. In this situation
# we want the response to be what is stored in the Django database.
# Note that Free plans are caught in the parent method above.
return BaseSubscriptionMetadata(
seats=self.max_seats, api_calls=self.max_api_calls
)

def _get_subscription_metadata_for_chargebee(self) -> ChargebeeObjMetadata:
if self.organisation.has_subscription_information_cache():
# Getting the data from the subscription information cache because
# data is guaranteed to be up to date by using a Chargebee webhook.
return ChargebeeObjMetadata(
seats=self.organisation.subscription_information_cache.allowed_seats,
api_calls=self.organisation.subscription_information_cache.allowed_30d_api_calls,
projects=self.organisation.subscription_information_cache.allowed_projects,
chargebee_email=self.organisation.subscription_information_cache.chargebee_email,
)

return get_subscription_metadata_from_id(self.subscription_id)

def _get_subscription_metadata_for_self_hosted(self) -> BaseSubscriptionMetadata:
if not is_enterprise():
return FREE_PLAN_SUBSCRIPTION_METADATA

return BaseSubscriptionMetadata(
seats=self.max_seats,
api_calls=self.max_api_calls,
projects=settings.MAX_PROJECTS_IN_FREE_PLAN,
projects=None,
)

def add_single_seat(self):
Expand Down
1 change: 1 addition & 0 deletions api/organisations/subscriptions/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
projects=settings.MAX_PROJECTS_IN_FREE_PLAN,
)
FREE_PLAN_ID = "free"
TRIAL_SUBSCRIPTION_ID = "trial"


class SubscriptionCacheEntity(Enum):
Expand Down
3 changes: 2 additions & 1 deletion api/sales_dashboard/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@

from environments.models import Environment
from features.models import Feature
from organisations.models import TRIAL_SUBSCRIPTION_ID, Organisation
from organisations.models import Organisation
from organisations.subscriptions.constants import (
FREE_PLAN_ID,
MAX_API_CALLS_IN_FREE_PLAN,
MAX_SEATS_IN_FREE_PLAN,
TRIAL_SUBSCRIPTION_ID,
)
from projects.models import Project
from users.models import FFAdminUser
Expand Down
8 changes: 8 additions & 0 deletions api/tests/unit/app/test_unit_app_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ def path_side_effect(file_path: str) -> mocker.MagicMock:
if file_path == "./ENTERPRISE_VERSION":
mocked_path_object.exists.return_value = True

if file_path == "./SAAS_DEPLOYMENT":
mocked_path_object.exists.return_value = False

return mocked_path_object

mocked_pathlib.Path.side_effect = path_side_effect
Expand All @@ -35,6 +38,7 @@ def path_side_effect(file_path: str) -> mocker.MagicMock:
"ci_commit_sha": "some_sha",
"image_tag": "2.66.2",
"is_enterprise": True,
"is_saas": False,
"package_versions": {".": "2.66.2"},
}

Expand All @@ -49,6 +53,9 @@ def path_side_effect(file_path: str) -> mocker.MagicMock:
if file_path == "./ENTERPRISE_VERSION":
mocked_path_object.exists.return_value = True

if file_path == "./SAAS_DEPLOYMENT":
mocked_path_object.exists.return_value = False

return mocked_path_object

mocked_pathlib.Path.side_effect = path_side_effect
Expand All @@ -62,4 +69,5 @@ def path_side_effect(file_path: str) -> mocker.MagicMock:
"ci_commit_sha": "unknown",
"image_tag": "unknown",
"is_enterprise": True,
"is_saas": False,
}
1 change: 1 addition & 0 deletions api/tests/unit/app/test_unit_app_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ def test_get_version_info(api_client: APIClient) -> None:
"ci_commit_sha": "unknown",
"image_tag": "unknown",
"is_enterprise": False,
"is_saas": False,
}
Loading

3 comments on commit ae94267

@vercel
Copy link

@vercel vercel bot commented on ae94267 Dec 20, 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 ae94267 Dec 20, 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-git-main-flagsmith.vercel.app
docs.flagsmith.com
docs-flagsmith.vercel.app

@vercel
Copy link

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