From b03098e6a67f53f78f15757cea8bb1720161e1ef Mon Sep 17 00:00:00 2001 From: wadii Date: Thu, 30 Apr 2026 15:46:56 +0200 Subject: [PATCH 1/5] feat: added scale up extra seat addon handling --- api/organisations/chargebee/chargebee.py | 45 ++++++++++++++----- api/organisations/chargebee/constants.py | 10 +++++ api/organisations/subscriptions/constants.py | 6 ++- .../test_unit_chargebee_chargebee.py | 36 +++++++++++++++ 4 files changed, 84 insertions(+), 13 deletions(-) diff --git a/api/organisations/chargebee/chargebee.py b/api/organisations/chargebee/chargebee.py index cbcef7dc402a..ec5f0c3788ec 100644 --- a/api/organisations/chargebee/chargebee.py +++ b/api/organisations/chargebee/chargebee.py @@ -1,5 +1,7 @@ import logging from contextlib import suppress + +import structlog from datetime import datetime from typing import Any @@ -31,9 +33,11 @@ from organisations.chargebee.constants import ( ADDITIONAL_API_SCALE_UP_ADDON_ID, ADDITIONAL_API_START_UP_ADDON_ID, + SEAT_ADDON_BY_PLAN_PREFIX, + SEAT_SCALE_UP_V2_ADDON_BY_BILLING_PERIOD, ) from organisations.chargebee.metadata import ChargebeeObjMetadata -from organisations.subscriptions.constants import CHARGEBEE +from organisations.subscriptions.constants import CHARGEBEE, SubscriptionPlanFamily from organisations.subscriptions.exceptions import ( CannotCancelChargebeeSubscription, UpgradeAPIUsageError, @@ -43,6 +47,7 @@ ) logger = logging.getLogger(__name__) +audit_log = structlog.get_logger("billing") CHARGEBEE_PAYMENT_ERROR_CODES = [ "payment_processing_failed", @@ -224,7 +229,7 @@ def add_single_seat(subscription_id: str) -> None: SubscriptionOps.UpdateParams( addons=[ SubscriptionOps.UpdateAddonParams( - id=_get_additional_seat_addon_id(subscription), + id=addon_id, quantity=current_seats + 1, ) ], @@ -233,6 +238,14 @@ def add_single_seat(subscription_id: str) -> None: ), ) + audit_log.info( + "seat.added", + subscription__id=subscription_id, + addon__id=addon_id, + seats__previous=current_seats, + seats__new=current_seats + 1, + ) + except ChargebeeAPIError as e: api_error_code = e.json_obj["api_error_code"] if api_error_code in CHARGEBEE_PAYMENT_ERROR_CODES: @@ -252,16 +265,24 @@ def add_single_seat(subscription_id: str) -> None: def _get_additional_seat_addon_id(subscription: SubscriptionOps) -> str: - addon_id_prefix = "additional-team-members-scale-up-v2" - addon_suffixes_by_billing_period = {1: "monthly", 6: "semiannual", 12: "annual"} - suffix = addon_suffixes_by_billing_period.get(subscription.billing_period) - if not suffix: - logger.warning( - "Unexpected billing period for subscription ID %s", - subscription.id, - ) - suffix = "monthly" - return "-".join([addon_id_prefix, suffix]) + normalised_plan = SubscriptionPlanFamily.normalise_plan_id( + str(getattr(subscription, "plan_id", "")) + ) + for prefix, addon_id in SEAT_ADDON_BY_PLAN_PREFIX.items(): + if normalised_plan.startswith(prefix): + return addon_id + + v2_addon_id = SEAT_SCALE_UP_V2_ADDON_BY_BILLING_PERIOD.get( + subscription.billing_period + ) + if v2_addon_id: + return v2_addon_id + + logger.warning( + "Unexpected billing period for subscription ID %s", + subscription.id, + ) + return SEAT_SCALE_UP_V2_ADDON_BY_BILLING_PERIOD[1] def add_100k_api_calls_start_up( diff --git a/api/organisations/chargebee/constants.py b/api/organisations/chargebee/constants.py index c063d49586fc..2edfc299dd63 100644 --- a/api/organisations/chargebee/constants.py +++ b/api/organisations/chargebee/constants.py @@ -1,2 +1,12 @@ ADDITIONAL_API_START_UP_ADDON_ID = "additional-api-start-up-monthly" ADDITIONAL_API_SCALE_UP_ADDON_ID = "additional-api-scale-up-monthly" + +SEAT_ADDON_BY_PLAN_PREFIX: dict[str, str] = { + "scaleupv4": "Additional-Team-Members-Scale-Up-v4", +} + +SEAT_SCALE_UP_V2_ADDON_BY_BILLING_PERIOD: dict[int, str] = { + 1: "additional-team-members-scale-up-v2-monthly", + 6: "additional-team-members-scale-up-v2-semiannual", + 12: "additional-team-members-scale-up-v2-annual", +} diff --git a/api/organisations/subscriptions/constants.py b/api/organisations/subscriptions/constants.py index b0f433ce11e0..6716f14accfb 100644 --- a/api/organisations/subscriptions/constants.py +++ b/api/organisations/subscriptions/constants.py @@ -59,9 +59,13 @@ class SubscriptionPlanFamily(Enum): SCALE_UP = "SCALE_UP" ENTERPRISE = "ENTERPRISE" + @staticmethod + def normalise_plan_id(plan_id: str) -> str: + return str(plan_id).replace("-", "").lower() + @classmethod def get_by_plan_id(cls, plan_id: str) -> "SubscriptionPlanFamily": - match str(plan_id).replace("-", "").lower(): + match cls.normalise_plan_id(plan_id): case p if p.startswith("scaleup"): return cls.SCALE_UP case p if p.startswith("startup"): 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 bf1ac00c5e16..c5fa82f79f82 100644 --- a/api/tests/unit/organisations/chargebee/test_unit_chargebee_chargebee.py +++ b/api/tests/unit/organisations/chargebee/test_unit_chargebee_chargebee.py @@ -600,6 +600,42 @@ def test_add_single_seat__no_existing_addon__creates_addon_with_quantity_one( ) +def test_add_single_seat__scale_up_v4_plan__uses_v4_addon( + mocker: MockerFixture, +) -> None: + # Given + subscription_id = "subscription-id" + expected_addon_id = "Additional-Team-Members-Scale-Up-v4" + + mocked_subscription = mocker.MagicMock( + id=subscription_id, + plan_id="Scale-Up-v4", + addons=[], + billing_period=1, + ) + mocked_chargebee = mocker.patch( + "organisations.chargebee.chargebee.chargebee_client", autospec=True + ) + mocked_chargebee.Subscription.retrieve.return_value.subscription = ( + mocked_subscription + ) + + # When + add_single_seat(subscription_id) + + # Then + mocked_chargebee.Subscription.update.assert_called_once_with( + subscription_id, + SubscriptionOps.UpdateParams( + addons=[ + SubscriptionOps.UpdateAddonParams(id=expected_addon_id, quantity=1) + ], + prorate=True, + invoice_immediately=True, + ), + ) + + def test_add_single_seat__api_error__raises_upgrade_seats_error( # type: ignore[no-untyped-def] mocker, caplog ) -> None: From 05ec9d3ffa42894c63a00df23031beb9c2353ba3 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:53:53 +0000 Subject: [PATCH 2/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- api/organisations/chargebee/chargebee.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/api/organisations/chargebee/chargebee.py b/api/organisations/chargebee/chargebee.py index ec5f0c3788ec..49f15780d570 100644 --- a/api/organisations/chargebee/chargebee.py +++ b/api/organisations/chargebee/chargebee.py @@ -1,10 +1,9 @@ import logging from contextlib import suppress - -import structlog from datetime import datetime from typing import Any +import structlog from chargebee.api_error import ( # type: ignore[import-untyped] APIError as ChargebeeAPIError, ) From 928cdc3ac197683b05e8a70586cd3fda5b5c696d Mon Sep 17 00:00:00 2001 From: wadii Date: Thu, 30 Apr 2026 16:08:49 +0200 Subject: [PATCH 3/5] feat: regenerated events catalogue --- .../observability/_events-catalogue.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index b83a02aed053..faf2920fd0be 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -39,6 +39,17 @@ Logged at `warning` from: Attributes: - `details` +### `billing.seat.added` + +Logged at `info` from: + - `api/organisations/chargebee/chargebee.py:240` + +Attributes: + - `addon.id` + - `seats.new` + - `seats.previous` + - `subscription.id` + ### `code_references.cleanup_issues.created` Logged at `info` from: From f0526a83484b8fcb2acd5c62b840eac39b93a797 Mon Sep 17 00:00:00 2001 From: wadii Date: Fri, 1 May 2026 13:24:54 +0200 Subject: [PATCH 4/5] feat: renamed logger and pass org_id in add seats --- api/organisations/chargebee/chargebee.py | 7 ++++--- api/organisations/models.py | 2 +- .../chargebee/test_unit_chargebee_chargebee.py | 8 ++++---- .../unit/organisations/test_unit_organisations_models.py | 4 +++- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/api/organisations/chargebee/chargebee.py b/api/organisations/chargebee/chargebee.py index 49f15780d570..df8e516c38cc 100644 --- a/api/organisations/chargebee/chargebee.py +++ b/api/organisations/chargebee/chargebee.py @@ -46,7 +46,7 @@ ) logger = logging.getLogger(__name__) -audit_log = structlog.get_logger("billing") +log = structlog.get_logger("billing") CHARGEBEE_PAYMENT_ERROR_CODES = [ "payment_processing_failed", @@ -210,7 +210,7 @@ def cancel_subscription(subscription_id: str) -> None: raise CannotCancelChargebeeSubscription(msg) from e -def add_single_seat(subscription_id: str) -> None: +def add_single_seat(subscription_id: str, organisation_id: int) -> None: try: subscription = chargebee_client.Subscription.retrieve( subscription_id @@ -237,8 +237,9 @@ def add_single_seat(subscription_id: str) -> None: ), ) - audit_log.info( + log.info( "seat.added", + organisation__id=organisation_id, subscription__id=subscription_id, addon__id=addon_id, seats__previous=current_seats, diff --git a/api/organisations/models.py b/api/organisations/models.py index 20b82e6c35e5..236920cd00b0 100644 --- a/api/organisations/models.py +++ b/api/organisations/models.py @@ -462,7 +462,7 @@ def add_single_seat(self): # type: ignore[no-untyped-def] if not self.can_auto_upgrade_seats: raise SubscriptionDoesNotSupportSeatUpgrade() - add_single_seat(self.subscription_id) # type: ignore[arg-type] + add_single_seat(self.subscription_id, organisation_id=self.organisation_id) # type: ignore[arg-type] def is_in_trial(self) -> bool: return self.subscription_id == TRIAL_SUBSCRIPTION_ID 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 c5fa82f79f82..124c71f9dde8 100644 --- a/api/tests/unit/organisations/chargebee/test_unit_chargebee_chargebee.py +++ b/api/tests/unit/organisations/chargebee/test_unit_chargebee_chargebee.py @@ -534,7 +534,7 @@ def test_add_single_seat__existing_addon__increments_quantity(mocker) -> None: ) # When - add_single_seat(subscription_id) + add_single_seat(subscription_id, organisation_id=1) # Then mocked_chargebee.Subscription.update.assert_called_once_with( @@ -585,7 +585,7 @@ def test_add_single_seat__no_existing_addon__creates_addon_with_quantity_one( ) # When - add_single_seat(subscription_id) + add_single_seat(subscription_id, organisation_id=1) # Then mocked_chargebee.Subscription.update.assert_called_once_with( @@ -621,7 +621,7 @@ def test_add_single_seat__scale_up_v4_plan__uses_v4_addon( ) # When - add_single_seat(subscription_id) + add_single_seat(subscription_id, organisation_id=1) # Then mocked_chargebee.Subscription.update.assert_called_once_with( @@ -672,7 +672,7 @@ def test_add_single_seat__api_error__raises_upgrade_seats_error( # type: ignore # When with pytest.raises(UpgradeSeatsError): - add_single_seat(subscription_id) + add_single_seat(subscription_id, organisation_id=1) # Then mocked_chargebee.Subscription.update.assert_called_once_with( diff --git a/api/tests/unit/organisations/test_unit_organisations_models.py b/api/tests/unit/organisations/test_unit_organisations_models.py index a97b860c55a0..9c0b40d8d66e 100644 --- a/api/tests/unit/organisations/test_unit_organisations_models.py +++ b/api/tests/unit/organisations/test_unit_organisations_models.py @@ -503,7 +503,9 @@ def test_add_single_seat__upgradable_plan__calls_chargebee_add_single_seat( subscription.add_single_seat() # type: ignore[no-untyped-call] # Then - mocked_add_single_seat.assert_called_once_with(subscription_id) + mocked_add_single_seat.assert_called_once_with( + subscription_id, organisation_id=subscription.organisation_id + ) def test_add_single_seat__non_upgradable_plan__raises_error( From 392915aede879903bc086e47ca3314f304dde2f4 Mon Sep 17 00:00:00 2001 From: wadii Date: Fri, 1 May 2026 14:21:57 +0200 Subject: [PATCH 5/5] feat: regenerated events catalgoue --- .../deployment-self-hosting/observability/_events-catalogue.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index faf2920fd0be..a65be03445fb 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -46,6 +46,7 @@ Logged at `info` from: Attributes: - `addon.id` + - `organisation.id` - `seats.new` - `seats.previous` - `subscription.id`