From 05d2f53a760bded6d367b9bae8350183e96c7ffa Mon Sep 17 00:00:00 2001 From: Deep Santoshwar Date: Thu, 13 Aug 2026 01:38:41 +0530 Subject: [PATCH] fix(billing): max seats stays out of sync after plan upgrades Seats and API calls bought as Chargebee addons only ever reached the subscription information cache. The subscription record itself was updated from the plan's own metadata, and only when the plan id changed, so buying extra seats left `max_seats` stale indefinitely. Take the seat and API call allowances from the metadata extracted from the webhook payload, which accounts for the plan and its addons. --- .../chargebee/webhook_handlers.py | 13 ++ .../test_unit_organisations_views.py | 111 ++++++++++++++++++ 2 files changed, 124 insertions(+) diff --git a/api/organisations/chargebee/webhook_handlers.py b/api/organisations/chargebee/webhook_handlers.py index ab32b7f4b2d8..b4edc1dcde3c 100644 --- a/api/organisations/chargebee/webhook_handlers.py +++ b/api/organisations/chargebee/webhook_handlers.py @@ -151,6 +151,19 @@ def process_subscription(request: Request) -> Response: # noqa: C901 chargebee_subscription=subscription, customer_email=customer["email"], ) + + # `update_plan` only reads the plan's own metadata, so seats and API calls + # bought as addons never reach the subscription, and a subscription whose + # plan is unchanged is never updated at all. The extracted metadata accounts + # for both the plan and its addons, so use it as the source of truth. + if ( + existing_subscription.max_seats != subscription_metadata.seats + or existing_subscription.max_api_calls != subscription_metadata.api_calls + ): + existing_subscription.max_seats = subscription_metadata.seats + existing_subscription.max_api_calls = subscription_metadata.api_calls + existing_subscription.save() + osic_defaults = { "chargebee_updated_at": timezone.now(), "allowed_30d_api_calls": subscription_metadata.api_calls, diff --git a/api/tests/unit/organisations/test_unit_organisations_views.py b/api/tests/unit/organisations/test_unit_organisations_views.py index 9590b9c31766..cc4c0ef87cb7 100644 --- a/api/tests/unit/organisations/test_unit_organisations_views.py +++ b/api/tests/unit/organisations/test_unit_organisations_views.py @@ -1402,6 +1402,117 @@ def test_chargebee_webhook__plan_changed__updates_seats_and_api_calls( # type: assert subscription_information_cache.chargebee_updated_at > updated_at +@mock.patch("organisations.models.get_plan_meta_data") +@mock.patch("organisations.chargebee.webhook_handlers.extract_subscription_metadata") +def test_chargebee_webhook__seats_added_to_same_plan__updates_seats( + mock_extract_subscription_metadata: MagicMock, + mock_get_plan_meta_data: MagicMock, + subscription: Subscription, + admin_client: APIClient, + organisation: Organisation, +) -> None: + # Given + chargebee_email = "chargebee@test.com" + url = reverse("api-v1:chargebee-webhook") + + subscription.subscription_id = "sub-id" + subscription.plan = "scale-up-v2" + subscription.max_seats = 5 + subscription.max_api_calls = 1_000_000 + subscription.save() + + # An additional seat addon raises the allowance without changing the plan. + mock_extract_subscription_metadata.return_value = ChargebeeObjMetadata( + seats=6, + api_calls=1_000_000, + projects=10, + chargebee_email=chargebee_email, + ) + + data = { + "content": { + "subscription": { + "status": "active", + "id": subscription.subscription_id, + "plan_id": subscription.plan, + }, + "customer": {"email": chargebee_email}, + } + } + + # When + response = admin_client.post( + url, data=json.dumps(data), content_type="application/json" + ) + + # Then + assert response.status_code == status.HTTP_200_OK + mock_get_plan_meta_data.assert_not_called() + + subscription.refresh_from_db() + assert subscription.max_seats == 6 + + subscription_information_cache = ( + OrganisationSubscriptionInformationCache.objects.get(organisation=organisation) + ) + assert subscription_information_cache.allowed_seats == 6 + + +@mock.patch("organisations.models.get_plan_meta_data") +@mock.patch("organisations.chargebee.webhook_handlers.extract_subscription_metadata") +def test_chargebee_webhook__plan_changed_with_addons__updates_seats( + mock_extract_subscription_metadata: MagicMock, + mock_get_plan_meta_data: MagicMock, + subscription: Subscription, + admin_client: APIClient, + organisation: Organisation, +) -> None: + # Given + chargebee_email = "chargebee@test.com" + url = reverse("api-v1:chargebee-webhook") + + subscription.subscription_id = "sub-id" + subscription.save() + + # The plan on its own allows 5 seats, but the subscription also carries + # addons worth 3 more. + mock_get_plan_meta_data.return_value = {"seats": 5, "api_calls": 1_000_000} + mock_extract_subscription_metadata.return_value = ChargebeeObjMetadata( + seats=8, + api_calls=1_000_000, + projects=10, + chargebee_email=chargebee_email, + ) + + data = { + "content": { + "subscription": { + "status": "active", + "id": subscription.subscription_id, + "plan_id": "scale-up-v2", + }, + "customer": {"email": chargebee_email}, + } + } + + # When + response = admin_client.post( + url, data=json.dumps(data), content_type="application/json" + ) + + # Then + assert response.status_code == status.HTTP_200_OK + + subscription.refresh_from_db() + assert subscription.plan == "scale-up-v2" + assert subscription.max_seats == 8 + + subscription_information_cache = ( + OrganisationSubscriptionInformationCache.objects.get(organisation=organisation) + ) + assert subscription_information_cache.allowed_seats == 8 + + def test_delete_organisation__other_org_exists__preserves_other_subscriptions( # type: ignore[no-untyped-def] admin_client, admin_user, organisation, subscription ) -> None: