diff --git a/api/organisations/chargebee/__init__.py b/api/organisations/chargebee/__init__.py index 663a9b137aa2..a12b60779abb 100644 --- a/api/organisations/chargebee/__init__.py +++ b/api/organisations/chargebee/__init__.py @@ -1,4 +1,7 @@ from .chargebee import ( # noqa + add_1000_api_calls, + add_1000_api_calls_scale_up, + add_1000_api_calls_start_up, add_single_seat, extract_subscription_metadata, get_customer_id_from_subscription_id, diff --git a/api/organisations/chargebee/chargebee.py b/api/organisations/chargebee/chargebee.py index faaf9b61a880..1f10c9f6630c 100644 --- a/api/organisations/chargebee/chargebee.py +++ b/api/organisations/chargebee/chargebee.py @@ -11,11 +11,17 @@ from ..subscriptions.constants import CHARGEBEE from ..subscriptions.exceptions import ( CannotCancelChargebeeSubscription, + UpgradeAPIUsageError, + UpgradeAPIUsagePaymentFailure, UpgradeSeatsError, UpgradeSeatsPaymentFailure, ) from .cache import ChargebeeCache -from .constants import ADDITIONAL_SEAT_ADDON_ID +from .constants import ( + ADDITIONAL_API_SCALE_UP_ADDON_ID, + ADDITIONAL_API_START_UP_ADDON_ID, + ADDITIONAL_SEAT_ADDON_ID, +) from .metadata import ChargebeeObjMetadata chargebee.configure(settings.CHARGEBEE_API_KEY, settings.CHARGEBEE_SITE) @@ -203,6 +209,54 @@ def add_single_seat(subscription_id: str): raise UpgradeSeatsError(msg) from e +def add_1000_api_calls_start_up( + subscription_id: str, count: int = 1, invoice_immediately: bool = False +) -> None: + add_1000_api_calls(ADDITIONAL_API_START_UP_ADDON_ID, subscription_id, count) + + +def add_1000_api_calls_scale_up( + subscription_id: str, count: int = 1, invoice_immediately: bool = False +) -> None: + add_1000_api_calls(ADDITIONAL_API_SCALE_UP_ADDON_ID, subscription_id, count) + + +def add_1000_api_calls( + addon_id: str, + subscription_id: str, + count: int = 1, + invoice_immediately: bool = False, +) -> None: + if not count: + return + try: + chargebee.Subscription.update( + subscription_id, + { + "addons": [{"id": addon_id, "quantity": count}], + "prorate": False, + "invoice_immediately": invoice_immediately, + }, + ) + + except ChargebeeAPIError as e: + api_error_code = e.json_obj["api_error_code"] + if api_error_code in CHARGEBEE_PAYMENT_ERROR_CODES: + logger.warning( + f"Payment declined ({api_error_code}) during additional " + f"api calls upgrade to a CB subscription for subscription_id " + f"{subscription_id}" + ) + raise UpgradeAPIUsagePaymentFailure() from e + + msg = ( + "Failed to add additional API calls to CB subscription for subscription id: %s" + % subscription_id + ) + logger.error(msg) + raise UpgradeAPIUsageError(msg) from e + + def _convert_chargebee_subscription_to_dictionary( chargebee_subscription: chargebee.Subscription, ) -> dict: diff --git a/api/organisations/chargebee/constants.py b/api/organisations/chargebee/constants.py index e7682d81ebac..f4dbf841bdd9 100644 --- a/api/organisations/chargebee/constants.py +++ b/api/organisations/chargebee/constants.py @@ -1 +1,4 @@ ADDITIONAL_SEAT_ADDON_ID = "additional-team-members-scale-up-v2-monthly" + +ADDITIONAL_API_START_UP_ADDON_ID = "additional-api-start-up-monthly" +ADDITIONAL_API_SCALE_UP_ADDON_ID = "additional-api-scale-up-monthly" diff --git a/api/organisations/migrations/0054_create_api_billing.py b/api/organisations/migrations/0054_create_api_billing.py new file mode 100644 index 000000000000..98d00c503497 --- /dev/null +++ b/api/organisations/migrations/0054_create_api_billing.py @@ -0,0 +1,30 @@ +# Generated by Django 3.2.25 on 2024-04-08 15:07 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('organisations', '0053_create_api_limit_access_block'), + ] + + operations = [ + migrations.RenameModel( + old_name='OranisationAPIUsageNotification', + new_name='OrganisationAPIUsageNotification', + ), + migrations.CreateModel( + name='OrganisationAPIBilling', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('api_overage', models.IntegerField()), + ('immediate_invoice', models.BooleanField(default=False)), + ('billed_at', models.DateTimeField()), + ('created_at', models.DateTimeField(auto_now_add=True, null=True)), + ('updated_at', models.DateTimeField(auto_now=True, null=True)), + ('organisation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_billing', to='organisations.organisation')), + ], + ), + ] diff --git a/api/organisations/models.py b/api/organisations/models.py index 1323b2c7162d..5cff7d353838 100644 --- a/api/organisations/models.py +++ b/api/organisations/models.py @@ -458,7 +458,7 @@ def erase_api_notifications(self): self.organisation.api_usage_notifications.all().delete() -class OranisationAPIUsageNotification(models.Model): +class OrganisationAPIUsageNotification(models.Model): organisation = models.ForeignKey( Organisation, on_delete=models.CASCADE, related_name="api_usage_notifications" ) @@ -490,3 +490,30 @@ class HubspotOrganisation(models.Model): hubspot_id = models.CharField(max_length=100) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + + +class OrganisationAPIBilling(models.Model): + """ + Tracks API billing for when accounts go over their API usage + limits. This model is what allows subsequent billing runs + to not double bill an organisation for the same use. + + Even though api_overage is charge per thousand API calls, this + class tracks the actual rounded count of API calls that are + billed for (i.e., 52000 for an account with 52233 api calls). + We're intentionally rounding down to the closest thousands. + + The option to set immediate_invoice means whether or not the + API billing was processed immediately versus pushed onto the + subsequent subscription billing period. + """ + + organisation = models.ForeignKey( + Organisation, on_delete=models.CASCADE, related_name="api_billing" + ) + api_overage = models.IntegerField(null=False) + immediate_invoice = models.BooleanField(null=False, default=False) + billed_at = models.DateTimeField(null=False) + + created_at = models.DateTimeField(null=True, auto_now_add=True) + updated_at = models.DateTimeField(null=True, auto_now=True) diff --git a/api/organisations/subscriptions/constants.py b/api/organisations/subscriptions/constants.py index 690120ec262c..cd3b8ac5ef23 100644 --- a/api/organisations/subscriptions/constants.py +++ b/api/organisations/subscriptions/constants.py @@ -40,6 +40,13 @@ ) FREE_PLAN_ID = "free" TRIAL_SUBSCRIPTION_ID = "trial" +SCALE_UP = "scale-up" +SCALE_UP_12_MONTHS_V2 = "scale-up-12-months-v2" +SCALE_UP_QUARTERLY_V2_SEMIANNUAL = "scale-up-quarterly-v2-semiannual" +SCALE_UP_V2 = "scale-up-v2" +STARTUP = "startup" +STARTUP_ANNUAL_V2 = "startup-annual-v2" +STARTUP_V2 = "startup-v2" class SubscriptionCacheEntity(Enum): diff --git a/api/organisations/subscriptions/exceptions.py b/api/organisations/subscriptions/exceptions.py index 1b24e20bd007..74acd3c6fa92 100644 --- a/api/organisations/subscriptions/exceptions.py +++ b/api/organisations/subscriptions/exceptions.py @@ -22,6 +22,18 @@ class UpgradeSeatsPaymentFailure(APIException): ) +class UpgradeAPIUsageError(APIException): + default_detail = "Failed to upgrade API use in Chargebee" + + +class UpgradeAPIUsagePaymentFailure(APIException): + status_code = 400 + default_detail = ( + "API usage upgrade has failed due to a payment issue. " + "If this persists, contact the organisation admin." + ) + + class SubscriptionDoesNotSupportSeatUpgrade(APIException): status_code = 400 default_detail = "Please Upgrade your plan to add additional seats/users" diff --git a/api/organisations/tasks.py b/api/organisations/tasks.py index 37701d68829f..b9bae7ce1961 100644 --- a/api/organisations/tasks.py +++ b/api/organisations/tasks.py @@ -5,16 +5,21 @@ from dateutil.relativedelta import relativedelta from django.conf import settings from django.core.mail import send_mail -from django.db.models import Max +from django.db.models import F, Max from django.template.loader import render_to_string from django.utils import timezone from integrations.flagsmith.client import get_client from organisations import subscription_info_cache +from organisations.chargebee import ( + add_1000_api_calls_scale_up, + add_1000_api_calls_start_up, +) from organisations.models import ( APILimitAccessBlock, - OranisationAPIUsageNotification, Organisation, + OrganisationAPIBilling, + OrganisationAPIUsageNotification, OrganisationRole, Subscription, ) @@ -34,7 +39,13 @@ API_USAGE_ALERT_THRESHOLDS, API_USAGE_GRACE_PERIOD, ) -from .subscriptions.constants import SubscriptionCacheEntity +from .subscriptions.constants import ( + SCALE_UP, + SCALE_UP_V2, + STARTUP, + STARTUP_V2, + SubscriptionCacheEntity, +) logger = logging.getLogger(__name__) @@ -130,7 +141,7 @@ def send_admin_api_usage_notification( fail_silently=True, ) - OranisationAPIUsageNotification.objects.create( + OrganisationAPIUsageNotification.objects.create( organisation=organisation, percent_usage=matched_threshold, notified_at=timezone.now(), @@ -162,7 +173,7 @@ def _handle_api_usage_notifications(organisation: Organisation) -> None: if matched_threshold is None: return - if OranisationAPIUsageNotification.objects.filter( + if OrganisationAPIUsageNotification.objects.filter( notified_at__gt=period_starts_at, percent_usage=matched_threshold, ).exists(): @@ -197,6 +208,85 @@ def handle_api_usage_notifications() -> None: ) +def charge_for_api_call_count_overages(): + now = timezone.now() + + # Get the period where we're interested in any new API usage + # notifications for the relevant billing period (ie, this month). + api_usage_notified_at = now - timedelta(days=30) + + # Since we're only interested in monthly billed accounts, set a wide + # threshold to catch as many billing periods that could be roughly + # considered to be a "monthly" subscription, while still ruling out + # non-monthly subscriptions. + month_window_start = timedelta(days=25) + month_window_end = timedelta(days=35) + + # Only apply charges to ongoing subscriptions that are close to + # being charged due to being at the end of the billing term. + closing_billing_term = now + timedelta(hours=1) + + organisation_ids = set( + OrganisationAPIUsageNotification.objects.filter( + notified_at__gte=api_usage_notified_at, + percent_usage__gte=100, + ) + .exclude( + organisation__api_billing__billed_at__gt=api_usage_notified_at, + ) + .values_list("organisation_id", flat=True) + ) + + for organisation in Organisation.objects.filter( + id__in=organisation_ids, + subscription_information_cache__current_billing_term_ends_at__lte=closing_billing_term, + subscription_information_cache__current_billing_term_ends_at__gte=now, + subscription_information_cache__current_billing_term_starts_at__lte=F( + "subscription_information_cache__current_billing_term_ends_at" + ) + - month_window_start, + subscription_information_cache__current_billing_term_starts_at__gte=F( + "subscription_information_cache__current_billing_term_ends_at" + ) + - month_window_end, + ).select_related( + "subscription_information_cache", + "subscription", + ): + subscription_cache = organisation.subscription_information_cache + api_usage = get_current_api_usage(organisation.id, "30d") + api_usage_ratio = api_usage / subscription_cache.allowed_30d_api_calls + + if api_usage_ratio < 1.0: + logger.warning("API Usage does not match API Notification") + continue + + api_overage = api_usage - subscription_cache.allowed_30d_api_calls + + if organisation.subscription.plan in {SCALE_UP, SCALE_UP_V2}: + add_1000_api_calls_scale_up( + organisation.subscription.subscription_id, api_overage // 1000 + ) + elif organisation.subscription.plan in {STARTUP, STARTUP_V2}: + add_1000_api_calls_start_up( + organisation.subscription.subscription_id, api_overage // 1000 + ) + else: + logger.error( + f"Unable to bill for API overages for plan `{organisation.subscription.plan}`" + ) + continue + + # Save a copy of what was just billed in order to avoid + # double billing on a subsequent task run. + OrganisationAPIBilling.objects.create( + organisation=organisation, + api_overage=(1000 * (api_overage // 1000)), + immediate_invoice=False, + billed_at=now, + ) + + def restrict_use_due_to_api_limit_grace_period_over() -> None: """ Restrict API use once a grace period has ended. @@ -208,7 +298,7 @@ def restrict_use_due_to_api_limit_grace_period_over() -> None: grace_period = timezone.now() - timedelta(days=API_USAGE_GRACE_PERIOD) month_start = timezone.now() - timedelta(30) queryset = ( - OranisationAPIUsageNotification.objects.filter( + OrganisationAPIUsageNotification.objects.filter( notified_at__gt=month_start, notified_at__lt=grace_period, percent_usage__gte=100, @@ -270,7 +360,7 @@ def unrestrict_after_api_limit_grace_period_is_stale() -> None: month_start = timezone.now() - timedelta(30) still_restricted_organisation_notifications = ( - OranisationAPIUsageNotification.objects.filter( + OrganisationAPIUsageNotification.objects.filter( notified_at__gt=month_start, percent_usage__gte=100, ) @@ -296,13 +386,28 @@ def unrestrict_after_api_limit_grace_period_is_stale() -> None: organisation.api_limit_access_block.delete() -if settings.ENABLE_API_USAGE_ALERTING: +def register_recurring_tasks() -> None: + """ + Helper function to get codecov coverage. + """ + assert settings.ENABLE_API_USAGE_ALERTING + register_recurring_task( run_every=timedelta(hours=12), )(handle_api_usage_notifications) + + register_recurring_task( + run_every=timedelta(minutes=30), + )(charge_for_api_call_count_overages) + register_recurring_task( run_every=timedelta(hours=12), )(restrict_use_due_to_api_limit_grace_period_over) + register_recurring_task( run_every=timedelta(hours=12), )(unrestrict_after_api_limit_grace_period_is_stale) + + +if settings.ENABLE_API_USAGE_ALERTING: + register_recurring_tasks() # pragma: no cover diff --git a/api/organisations/views.py b/api/organisations/views.py index bd3faf9e2efe..4c2e4c31f441 100644 --- a/api/organisations/views.py +++ b/api/organisations/views.py @@ -24,8 +24,8 @@ from organisations.chargebee import webhook_event_types, webhook_handlers from organisations.exceptions import OrganisationHasNoPaidSubscription from organisations.models import ( - OranisationAPIUsageNotification, Organisation, + OrganisationAPIUsageNotification, OrganisationRole, OrganisationWebhook, ) @@ -319,7 +319,7 @@ class OrganisationAPIUsageNotificationView(ListAPIView): def get_queryset(self): organisation = Organisation.objects.get(id=self.kwargs["organisation_pk"]) if not hasattr(organisation, "subscription_information_cache"): - return OranisationAPIUsageNotification.objects.none() + return OrganisationAPIUsageNotification.objects.none() subscription_cache = organisation.subscription_information_cache billing_starts_at = subscription_cache.current_billing_term_starts_at now = timezone.now() @@ -327,7 +327,7 @@ def get_queryset(self): month_delta = relativedelta(now, billing_starts_at).months period_starts_at = relativedelta(months=month_delta) + billing_starts_at - queryset = OranisationAPIUsageNotification.objects.filter( + queryset = OrganisationAPIUsageNotification.objects.filter( organisation_id=organisation.id, notified_at__gt=period_starts_at, ) diff --git a/api/tests/unit/organisations/chargebee/test_unit_chargebee_chargebee.py b/api/tests/unit/organisations/chargebee/test_unit_chargebee_chargebee.py index d5cea88097f6..e3dae9715336 100644 --- a/api/tests/unit/organisations/chargebee/test_unit_chargebee_chargebee.py +++ b/api/tests/unit/organisations/chargebee/test_unit_chargebee_chargebee.py @@ -4,9 +4,12 @@ import pytest from _pytest.monkeypatch import MonkeyPatch from chargebee import APIError +from chargebee.api_error import APIError as ChargebeeAPIError +from pytest_mock import MockerFixture from pytz import UTC from organisations.chargebee import ( + add_1000_api_calls, add_single_seat, extract_subscription_metadata, get_customer_id_from_subscription_id, @@ -19,10 +22,15 @@ get_subscription_metadata_from_id, ) from organisations.chargebee.chargebee import cancel_subscription -from organisations.chargebee.constants import ADDITIONAL_SEAT_ADDON_ID +from organisations.chargebee.constants import ( + ADDITIONAL_API_SCALE_UP_ADDON_ID, + ADDITIONAL_SEAT_ADDON_ID, +) from organisations.chargebee.metadata import ChargebeeObjMetadata from organisations.subscriptions.exceptions import ( CannotCancelChargebeeSubscription, + UpgradeAPIUsageError, + UpgradeAPIUsagePaymentFailure, UpgradeSeatsError, ) @@ -595,3 +603,74 @@ def test_add_single_seat_throws_upgrade_seats_error_error_if_api_error( == "Failed to add additional seat to CB subscription for subscription id: %s" % subscription_id ) + + +def test_add_1000_api_calls_when_count_is_empty(mocker: MockerFixture) -> None: + # Given + subscription_mock = mocker.patch("chargebee.Subscription.update") + + # When + result = add_1000_api_calls( + addon_id=ADDITIONAL_API_SCALE_UP_ADDON_ID, + subscription_id="subscription23", + count=0, + invoice_immediately=True, + ) + + # Then + assert result is None + subscription_mock.assert_not_called() + + +def test_add_1000_api_calls_when_chargebee_api_error_has_error_code( + mocker: MockerFixture, +) -> None: + # Given + chargebee_mock = mocker.patch("organisations.chargebee.chargebee.chargebee") + chargebee_response_data = { + "message": "Subscription cannot be created as the payment collection failed. Gateway Error: Card declined.", + "type": "payment", + "api_error_code": "payment_processing_failed", + "param": "item_id", + "error_code": "DeprecatedField", + } + + chargebee_mock.Subscription.update.side_effect = ChargebeeAPIError( + http_code=400, json_obj=chargebee_response_data + ) + + # When / Then + with pytest.raises(UpgradeAPIUsagePaymentFailure): + add_1000_api_calls( + addon_id=ADDITIONAL_API_SCALE_UP_ADDON_ID, + subscription_id="subscription23", + count=1, + invoice_immediately=True, + ) + + +def test_add_1000_api_calls_when_chargebee_api_error_has_no_error_code( + mocker: MockerFixture, +) -> None: + # Given + chargebee_mock = mocker.patch("organisations.chargebee.chargebee.chargebee") + chargebee_response_data = { + "message": "Some massive data failure", + "api_error_code": "halt_and_catch_fire", + "type": "failure", + "param": "item_id", + "error_code": "DeprecatedField", + } + + chargebee_mock.Subscription.update.side_effect = ChargebeeAPIError( + http_code=400, json_obj=chargebee_response_data + ) + + # When / Then + with pytest.raises(UpgradeAPIUsageError): + add_1000_api_calls( + addon_id=ADDITIONAL_API_SCALE_UP_ADDON_ID, + subscription_id="subscription23", + count=1, + invoice_immediately=True, + ) diff --git a/api/tests/unit/organisations/test_unit_organisations_models.py b/api/tests/unit/organisations/test_unit_organisations_models.py index deedb576fcd0..1d90e690bab5 100644 --- a/api/tests/unit/organisations/test_unit_organisations_models.py +++ b/api/tests/unit/organisations/test_unit_organisations_models.py @@ -10,8 +10,8 @@ from environments.models import Environment from organisations.chargebee.metadata import ChargebeeObjMetadata from organisations.models import ( - OranisationAPIUsageNotification, Organisation, + OrganisationAPIUsageNotification, OrganisationSubscriptionInformationCache, Subscription, ) @@ -530,7 +530,7 @@ def test_reset_of_api_notifications(organisation: Organisation) -> None: ) # Create a notification which should be deleted shortly. - OranisationAPIUsageNotification.objects.create( + OrganisationAPIUsageNotification.objects.create( organisation=organisation, percent_usage=90, notified_at=now, @@ -538,7 +538,7 @@ def test_reset_of_api_notifications(organisation: Organisation) -> None: # Keep a notification which should not be deleted. organisation2 = Organisation.objects.create(name="Test org2") - oapiun = OranisationAPIUsageNotification.objects.create( + oapiun = OrganisationAPIUsageNotification.objects.create( organisation=organisation2, percent_usage=90, notified_at=now, @@ -549,5 +549,5 @@ def test_reset_of_api_notifications(organisation: Organisation) -> None: osic.save() # Then - assert OranisationAPIUsageNotification.objects.count() == 1 - assert OranisationAPIUsageNotification.objects.first() == oapiun + assert OrganisationAPIUsageNotification.objects.count() == 1 + assert OrganisationAPIUsageNotification.objects.first() == oapiun diff --git a/api/tests/unit/organisations/test_unit_organisations_tasks.py b/api/tests/unit/organisations/test_unit_organisations_tasks.py index 3489417323ee..8082698ef5c7 100644 --- a/api/tests/unit/organisations/test_unit_organisations_tasks.py +++ b/api/tests/unit/organisations/test_unit_organisations_tasks.py @@ -1,19 +1,21 @@ import uuid from datetime import timedelta -from unittest.mock import MagicMock +from unittest.mock import MagicMock, call import pytest from django.core.mail.message import EmailMultiAlternatives from django.utils import timezone from freezegun.api import FrozenDateTimeFactory +from pytest_django.fixtures import SettingsWrapper from pytest_mock import MockerFixture from organisations.chargebee.metadata import ChargebeeObjMetadata from organisations.constants import API_USAGE_GRACE_PERIOD from organisations.models import ( APILimitAccessBlock, - OranisationAPIUsageNotification, Organisation, + OrganisationAPIBilling, + OrganisationAPIUsageNotification, OrganisationRole, OrganisationSubscriptionInformationCache, UserOrganisation, @@ -28,8 +30,10 @@ from organisations.tasks import ( ALERT_EMAIL_MESSAGE, ALERT_EMAIL_SUBJECT, + charge_for_api_call_count_overages, finish_subscription_cancellation, handle_api_usage_notifications, + register_recurring_tasks, restrict_use_due_to_api_limit_grace_period_over, send_org_over_limit_alert, send_org_subscription_cancelled_alert, @@ -267,7 +271,7 @@ def test_handle_api_usage_notifications_when_feature_flag_is_off( assert len(mailoutbox) == 0 assert ( - OranisationAPIUsageNotification.objects.filter( + OrganisationAPIUsageNotification.objects.filter( organisation=organisation, ).count() == 0 @@ -300,7 +304,7 @@ def test_handle_api_usage_notifications_below_100( get_client_mock.return_value = client_mock client_mock.get_identity_flags.return_value.is_feature_enabled.return_value = True - assert not OranisationAPIUsageNotification.objects.filter( + assert not OrganisationAPIUsageNotification.objects.filter( organisation=organisation, ).exists() @@ -343,12 +347,12 @@ def test_handle_api_usage_notifications_below_100( assert email.to == ["admin@example.com"] assert ( - OranisationAPIUsageNotification.objects.filter( + OrganisationAPIUsageNotification.objects.filter( organisation=organisation, ).count() == 1 ) - api_usage_notification = OranisationAPIUsageNotification.objects.filter( + api_usage_notification = OrganisationAPIUsageNotification.objects.filter( organisation=organisation, ).first() @@ -358,12 +362,12 @@ def test_handle_api_usage_notifications_below_100( handle_api_usage_notifications() assert ( - OranisationAPIUsageNotification.objects.filter( + OrganisationAPIUsageNotification.objects.filter( organisation=organisation, ).count() == 1 ) - assert OranisationAPIUsageNotification.objects.first() == api_usage_notification + assert OrganisationAPIUsageNotification.objects.first() == api_usage_notification @pytest.mark.freeze_time("2023-01-19T09:09:47.325132+00:00") @@ -393,7 +397,7 @@ def test_handle_api_usage_notifications_above_100( get_client_mock.return_value = client_mock client_mock.get_identity_flags.return_value.is_feature_enabled.return_value = True - assert not OranisationAPIUsageNotification.objects.filter( + assert not OrganisationAPIUsageNotification.objects.filter( organisation=organisation, ).exists() @@ -437,12 +441,12 @@ def test_handle_api_usage_notifications_above_100( assert email.to == ["admin@example.com", "staff@example.com"] assert ( - OranisationAPIUsageNotification.objects.filter( + OrganisationAPIUsageNotification.objects.filter( organisation=organisation, ).count() == 1 ) - api_usage_notification = OranisationAPIUsageNotification.objects.filter( + api_usage_notification = OrganisationAPIUsageNotification.objects.filter( organisation=organisation, ).first() @@ -452,12 +456,327 @@ def test_handle_api_usage_notifications_above_100( handle_api_usage_notifications() assert ( - OranisationAPIUsageNotification.objects.filter( + OrganisationAPIUsageNotification.objects.filter( organisation=organisation, ).count() == 1 ) - assert OranisationAPIUsageNotification.objects.first() == api_usage_notification + + assert OrganisationAPIUsageNotification.objects.first() == api_usage_notification + + +@pytest.mark.freeze_time("2023-01-19T09:09:47.325132+00:00") +def test_charge_for_api_call_count_overages_scale_up( + organisation: Organisation, + mocker: MockerFixture, +) -> None: + # Given + now = timezone.now() + OrganisationSubscriptionInformationCache.objects.create( + organisation=organisation, + allowed_seats=10, + allowed_projects=3, + allowed_30d_api_calls=10_000, + chargebee_email="test@example.com", + current_billing_term_starts_at=now - timedelta(days=30), + current_billing_term_ends_at=now + timedelta(minutes=30), + ) + organisation.subscription.subscription_id = "fancy_sub_id23" + organisation.subscription.plan = "scale-up-v2" + organisation.subscription.save() + OrganisationAPIUsageNotification.objects.create( + organisation=organisation, + percent_usage=100, + notified_at=now, + ) + + mocker.patch("organisations.chargebee.chargebee.chargebee.Subscription.retrieve") + mock_chargebee_update = mocker.patch( + "organisations.chargebee.chargebee.chargebee.Subscription.update" + ) + + mock_api_usage = mocker.patch( + "organisations.tasks.get_current_api_usage", + ) + mock_api_usage.return_value = 12_005 + assert OrganisationAPIBilling.objects.count() == 0 + + # When + charge_for_api_call_count_overages() + + # Then + mock_chargebee_update.assert_called_once_with( + organisation.subscription.subscription_id, + { + "addons": [ + { + "id": "additional-api-scale-up-monthly", + "quantity": 2, # Two thousand API requests. + } + ], + "prorate": False, + "invoice_immediately": False, + }, + ) + + assert OrganisationAPIBilling.objects.count() == 1 + api_billing = OrganisationAPIBilling.objects.first() + assert api_billing.organisation == organisation + assert api_billing.api_overage == 2000 + assert api_billing.immediate_invoice is False + assert api_billing.billed_at == now + + +@pytest.mark.freeze_time("2023-01-19T09:09:47.325132+00:00") +def test_charge_for_api_call_count_overages_with_not_covered_plan( + organisation: Organisation, + mocker: MockerFixture, +) -> None: + # Given + now = timezone.now() + OrganisationSubscriptionInformationCache.objects.create( + organisation=organisation, + allowed_seats=10, + allowed_projects=3, + allowed_30d_api_calls=10_000, + chargebee_email="test@example.com", + current_billing_term_starts_at=now - timedelta(days=30), + current_billing_term_ends_at=now + timedelta(minutes=30), + ) + organisation.subscription.subscription_id = "fancy_sub_id23" + + # This plan name is what this test hinges on. + organisation.subscription.plan = "some-plan-not-covered-by-usage" + + organisation.subscription.save() + OrganisationAPIUsageNotification.objects.create( + organisation=organisation, + percent_usage=100, + notified_at=now, + ) + + mocker.patch("organisations.chargebee.chargebee.chargebee.Subscription.retrieve") + mock_chargebee_update = mocker.patch( + "organisations.chargebee.chargebee.chargebee.Subscription.update" + ) + + mock_api_usage = mocker.patch( + "organisations.tasks.get_current_api_usage", + ) + mock_api_usage.return_value = 12_005 + assert OrganisationAPIBilling.objects.count() == 0 + + # When + charge_for_api_call_count_overages() + + # Then + mock_chargebee_update.assert_not_called() + assert OrganisationAPIBilling.objects.count() == 0 + + +@pytest.mark.freeze_time("2023-01-19T09:09:47.325132+00:00") +def test_charge_for_api_call_count_overages_sub_1_api_usage_ratio( + organisation: Organisation, + mocker: MockerFixture, +) -> None: + # Given + now = timezone.now() + OrganisationSubscriptionInformationCache.objects.create( + organisation=organisation, + allowed_seats=10, + allowed_projects=3, + allowed_30d_api_calls=10_000, + chargebee_email="test@example.com", + current_billing_term_starts_at=now - timedelta(days=30), + current_billing_term_ends_at=now + timedelta(minutes=30), + ) + organisation.subscription.subscription_id = "fancy_sub_id23" + organisation.subscription.plan = "scale-up-v2" + organisation.subscription.save() + OrganisationAPIUsageNotification.objects.create( + organisation=organisation, + percent_usage=100, + notified_at=now, + ) + + mocker.patch("organisations.chargebee.chargebee.chargebee.Subscription.retrieve") + mock_chargebee_update = mocker.patch( + "organisations.chargebee.chargebee.chargebee.Subscription.update" + ) + + mock_api_usage = mocker.patch( + "organisations.tasks.get_current_api_usage", + ) + mock_api_usage.return_value = 2_000 + assert OrganisationAPIBilling.objects.count() == 0 + + # When + charge_for_api_call_count_overages() + + # Then + mock_chargebee_update.assert_not_called() + assert OrganisationAPIBilling.objects.count() == 0 + + +@pytest.mark.freeze_time("2023-01-19T09:09:47.325132+00:00") +def test_charge_for_api_call_count_overages_start_up( + organisation: Organisation, + mocker: MockerFixture, +) -> None: + # Given + now = timezone.now() + OrganisationSubscriptionInformationCache.objects.create( + organisation=organisation, + allowed_seats=10, + allowed_projects=3, + allowed_30d_api_calls=10_000, + chargebee_email="test@example.com", + current_billing_term_starts_at=now - timedelta(days=30), + current_billing_term_ends_at=now + timedelta(minutes=30), + ) + organisation.subscription.subscription_id = "fancy_sub_id23" + organisation.subscription.plan = "startup-v2" + organisation.subscription.save() + OrganisationAPIUsageNotification.objects.create( + organisation=organisation, + percent_usage=100, + notified_at=now, + ) + + mocker.patch("organisations.chargebee.chargebee.chargebee.Subscription.retrieve") + mock_chargebee_update = mocker.patch( + "organisations.chargebee.chargebee.chargebee.Subscription.update" + ) + + mock_api_usage = mocker.patch( + "organisations.tasks.get_current_api_usage", + ) + mock_api_usage.return_value = 12_005 + assert OrganisationAPIBilling.objects.count() == 0 + + # When + charge_for_api_call_count_overages() + + # Then + mock_chargebee_update.assert_called_once_with( + organisation.subscription.subscription_id, + { + "addons": [ + { + "id": "additional-api-start-up-monthly", + "quantity": 2, # Two thousand API requests. + } + ], + "prorate": False, + "invoice_immediately": False, + }, + ) + + assert OrganisationAPIBilling.objects.count() == 1 + api_billing = OrganisationAPIBilling.objects.first() + assert api_billing.organisation == organisation + assert api_billing.api_overage == 2000 + assert api_billing.immediate_invoice is False + assert api_billing.billed_at == now + + # Now attempt to rebill the account should fail + calls_mock = mocker.patch( + "organisations.tasks.add_1000_api_calls_start_up", + ) + charge_for_api_call_count_overages() + assert OrganisationAPIBilling.objects.count() == 1 + calls_mock.assert_not_called() + + +@pytest.mark.freeze_time("2023-01-19T09:09:47.325132+00:00") +def test_charge_for_api_call_count_overages_with_yearly_account( + organisation: Organisation, + mocker: MockerFixture, +) -> None: + # Given + now = timezone.now() + OrganisationSubscriptionInformationCache.objects.create( + organisation=organisation, + allowed_seats=10, + allowed_projects=3, + allowed_30d_api_calls=10_000, + chargebee_email="test@example.com", + current_billing_term_starts_at=now - timedelta(days=365), + current_billing_term_ends_at=now + timedelta(hours=6), + ) + organisation.subscription.subscription_id = "fancy_sub_id23" + organisation.subscription.plan = "startup-v2" + organisation.subscription.save() + OrganisationAPIUsageNotification.objects.create( + organisation=organisation, + percent_usage=100, + notified_at=now, + ) + + mocker.patch("organisations.chargebee.chargebee.chargebee.Subscription.retrieve") + mock_chargebee_update = mocker.patch( + "organisations.chargebee.chargebee.chargebee.Subscription.update" + ) + + mock_api_usage = mocker.patch( + "organisations.tasks.get_current_api_usage", + ) + + mock_api_usage.return_value = 12_005 + assert OrganisationAPIBilling.objects.count() == 0 + + # When + charge_for_api_call_count_overages() + + # Then + mock_chargebee_update.assert_not_called() + assert OrganisationAPIBilling.objects.count() == 0 + + +@pytest.mark.freeze_time("2023-01-19T09:09:47.325132+00:00") +def test_charge_for_api_call_count_overages_with_bad_plan( + organisation: Organisation, + mocker: MockerFixture, +) -> None: + # Given + now = timezone.now() + OrganisationSubscriptionInformationCache.objects.create( + organisation=organisation, + allowed_seats=10, + allowed_projects=3, + allowed_30d_api_calls=10_000, + chargebee_email="test@example.com", + current_billing_term_starts_at=now - timedelta(days=30), + current_billing_term_ends_at=now + timedelta(hours=6), + ) + organisation.subscription.subscription_id = "fancy_sub_id23" + organisation.subscription.plan = "some-bad-plan-someone-randomly-made" + organisation.subscription.save() + OrganisationAPIUsageNotification.objects.create( + organisation=organisation, + percent_usage=100, + notified_at=now, + ) + + mocker.patch("organisations.chargebee.chargebee.chargebee.Subscription.retrieve") + mock_chargebee_update = mocker.patch( + "organisations.chargebee.chargebee.chargebee.Subscription.update" + ) + + mock_api_usage = mocker.patch( + "organisations.tasks.get_current_api_usage", + ) + + mock_api_usage.return_value = 12_005 + assert OrganisationAPIBilling.objects.count() == 0 + + # When + charge_for_api_call_count_overages() + + # Then + # Since the plan is not known ahead of time, it isn't charged. + mock_chargebee_update.assert_not_called() + assert OrganisationAPIBilling.objects.count() == 0 @pytest.mark.freeze_time("2023-01-19T09:09:47.325132+00:00") @@ -483,31 +802,31 @@ def test_restrict_use_due_to_api_limit_grace_period_over( organisation5.subscription.subscription_id = "subscription-id" organisation5.subscription.save() - OranisationAPIUsageNotification.objects.create( + OrganisationAPIUsageNotification.objects.create( notified_at=now, organisation=organisation, percent_usage=100, ) - OranisationAPIUsageNotification.objects.create( + OrganisationAPIUsageNotification.objects.create( notified_at=now, organisation=organisation, percent_usage=120, ) - OranisationAPIUsageNotification.objects.create( + OrganisationAPIUsageNotification.objects.create( notified_at=now, organisation=organisation2, percent_usage=100, ) # Should be ignored, since percent usage is less than 100. - OranisationAPIUsageNotification.objects.create( + OrganisationAPIUsageNotification.objects.create( notified_at=now, organisation=organisation3, percent_usage=90, ) # Should be ignored, since not on a free plan. - OranisationAPIUsageNotification.objects.create( + OrganisationAPIUsageNotification.objects.create( notified_at=now, organisation=organisation5, percent_usage=120, @@ -517,7 +836,7 @@ def test_restrict_use_due_to_api_limit_grace_period_over( freezer.move_to(now) # Should be ignored, since the notify period is too recent. - OranisationAPIUsageNotification.objects.create( + OrganisationAPIUsageNotification.objects.create( notified_at=now, organisation=organisation3, percent_usage=120, @@ -597,17 +916,17 @@ def test_unrestrict_after_api_limit_grace_period_is_stale( APILimitAccessBlock.objects.create(organisation=organisation2) APILimitAccessBlock.objects.create(organisation=organisation3) - OranisationAPIUsageNotification.objects.create( + OrganisationAPIUsageNotification.objects.create( notified_at=now, organisation=organisation, percent_usage=100, ) - OranisationAPIUsageNotification.objects.create( + OrganisationAPIUsageNotification.objects.create( notified_at=now, organisation=organisation, percent_usage=120, ) - OranisationAPIUsageNotification.objects.create( + OrganisationAPIUsageNotification.objects.create( notified_at=now, organisation=organisation2, percent_usage=100, @@ -617,7 +936,7 @@ def test_unrestrict_after_api_limit_grace_period_is_stale( freezer.move_to(now) # Exclude the organisation since there's a recent notification. - OranisationAPIUsageNotification.objects.create( + OrganisationAPIUsageNotification.objects.create( notified_at=now, organisation=organisation3, percent_usage=120, @@ -649,3 +968,31 @@ def test_unrestrict_after_api_limit_grace_period_is_stale( assert organisation4.stop_serving_flags is True assert organisation4.block_access_to_admin is True assert getattr(organisation4, "api_limit_access_block", None) is None + + +def test_register_recurring_tasks( + mocker: MockerFixture, settings: SettingsWrapper +) -> None: + # Given + settings.ENABLE_API_USAGE_ALERTING = True + register_task_mock = mocker.patch("organisations.tasks.register_recurring_task") + + # When + register_recurring_tasks() + + # Then + # Check when the tasks have been registered + register_task_mock.call_args_list == [ + call(run_every=timedelta(seconds=43200)), + call(run_every=timedelta(seconds=1800)), + call(run_every=timedelta(seconds=43200)), + call(run_every=timedelta(seconds=43200)), + ] + + # And check which tasks were passed in + register_task_mock.return_value.call_args_list == [ + call(handle_api_usage_notifications), + call(charge_for_api_call_count_overages), + call(restrict_use_due_to_api_limit_grace_period_over), + call(unrestrict_after_api_limit_grace_period_is_stale), + ] diff --git a/api/tests/unit/organisations/test_unit_organisations_views.py b/api/tests/unit/organisations/test_unit_organisations_views.py index 428ebb04c5d1..05c4dd079ca9 100644 --- a/api/tests/unit/organisations/test_unit_organisations_views.py +++ b/api/tests/unit/organisations/test_unit_organisations_views.py @@ -26,8 +26,8 @@ from organisations.chargebee.metadata import ChargebeeObjMetadata from organisations.invites.models import Invite from organisations.models import ( - OranisationAPIUsageNotification, Organisation, + OrganisationAPIUsageNotification, OrganisationRole, OrganisationSubscriptionInformationCache, OrganisationWebhook, @@ -1706,7 +1706,7 @@ def test_defaults_to_empty_api_notifications_when_no_subscription_information_ca ) now = timezone.now() - OranisationAPIUsageNotification.objects.create( + OrganisationAPIUsageNotification.objects.create( organisation=organisation, percent_usage=90, notified_at=now, @@ -1747,17 +1747,17 @@ def test_retrieves_api_usage_notifications( ) # Add three notifications, but we only get the 100% one. - OranisationAPIUsageNotification.objects.create( + OrganisationAPIUsageNotification.objects.create( organisation=organisation, percent_usage=90, notified_at=now, ) - OranisationAPIUsageNotification.objects.create( + OrganisationAPIUsageNotification.objects.create( organisation=organisation, percent_usage=75, notified_at=now, ) - OranisationAPIUsageNotification.objects.create( + OrganisationAPIUsageNotification.objects.create( organisation=organisation, percent_usage=100, notified_at=now, @@ -1798,7 +1798,7 @@ def test_doesnt_retrieve_stale_api_usage_notifications( ) # Create a notification in the past which should not be shown. - OranisationAPIUsageNotification.objects.create( + OrganisationAPIUsageNotification.objects.create( organisation=organisation, percent_usage=90, notified_at=now - timedelta(20),