From 08a0f952f14a7d1992307cf65c8be3188f797cbb Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Wed, 5 Aug 2026 10:00:48 -0300 Subject: [PATCH 1/2] fix(usage): count whole years when resolving the current billing period The current period was derived from the months component of the delta between now and the billing term start, so whole years were dropped. A term that began more than twelve months ago resolved to a period a year early, and the usage page reported a window over a year long instead of the current month. The same arithmetic existed in three places and was correct in only one of them, so it now lives in one function used by all three: the usage data query, the API usage notifications view and the notification task. The view had the same bug, which meant a stale notification could be treated as current. Closes #6099 Co-Authored-By: Claude Opus 5 (1M context) --- api/app_analytics/analytics_db_service.py | 10 ++-- api/organisations/services.py | 21 ++++++++ api/organisations/task_helpers.py | 10 +--- api/organisations/views.py | 5 +- .../test_analytics_db_service.py | 33 +++++++++++++ .../test_unit_organisations_services.py | 49 +++++++++++++++++++ .../test_unit_organisations_views.py | 46 +++++++++++++++++ 7 files changed, 157 insertions(+), 17 deletions(-) create mode 100644 api/organisations/services.py create mode 100644 api/tests/unit/organisations/test_unit_organisations_services.py diff --git a/api/app_analytics/analytics_db_service.py b/api/app_analytics/analytics_db_service.py index dd5f336daf1a..2410ef513666 100644 --- a/api/app_analytics/analytics_db_service.py +++ b/api/app_analytics/analytics_db_service.py @@ -29,6 +29,7 @@ from environments.models import Environment from features.models import Feature from organisations.models import Organisation, OrganisationSubscriptionInformationCache +from organisations.services import get_current_billing_period_start_date logger = structlog.get_logger("app_analytics") @@ -341,8 +342,7 @@ def _get_start_date_and_stop_date_for_subscribed_organisation( else: raise NotFound("No billing periods found for this organisation.") - month_delta = relativedelta(now, starts_at).months - date_start = relativedelta(months=month_delta) + starts_at + date_start = get_current_billing_period_start_date(starts_at, now) return date_start, now case constants.PREVIOUS_BILLING_PERIOD: @@ -351,10 +351,8 @@ def _get_start_date_and_stop_date_for_subscribed_organisation( else: raise NotFound("No billing periods found for this organisation.") - month_delta = relativedelta(now, starts_at).months - 1 - month_delta += relativedelta(now, starts_at).years * 12 - date_start = relativedelta(months=month_delta) + starts_at - date_stop = relativedelta(months=month_delta + 1) + starts_at + date_stop = get_current_billing_period_start_date(starts_at, now) + date_start = date_stop - relativedelta(months=1) return date_start, date_stop case constants.NINETY_DAY_PERIOD: diff --git a/api/organisations/services.py b/api/organisations/services.py new file mode 100644 index 000000000000..4c600e3ef227 --- /dev/null +++ b/api/organisations/services.py @@ -0,0 +1,21 @@ +from datetime import datetime + +from dateutil.relativedelta import relativedelta + + +def get_current_billing_period_start_date( + billing_term_starts_at: datetime, + now: datetime, +) -> datetime: + """ + Return the start of the monthly period an organisation is currently in. + + A billing term can be longer than a month, an annual plan being the common + case, but API usage is allowed per month. The current period therefore + starts at the most recent monthly anniversary of the term start, which for + a term that began more than a year ago means counting the years as well as + the months. + """ + elapsed = relativedelta(now, billing_term_starts_at) + months_elapsed = elapsed.years * 12 + elapsed.months + return billing_term_starts_at + relativedelta(months=months_elapsed) diff --git a/api/organisations/task_helpers.py b/api/organisations/task_helpers.py index ac0b675f2b5e..dc76b6b3f21a 100644 --- a/api/organisations/task_helpers.py +++ b/api/organisations/task_helpers.py @@ -1,7 +1,6 @@ from datetime import timedelta import structlog -from dateutil.relativedelta import relativedelta from django.conf import settings from django.core.mail import send_mail from django.template.loader import render_to_string @@ -16,6 +15,7 @@ OrganisationAPIUsageNotification, OrganisationRole, ) +from organisations.services import get_current_billing_period_start_date from organisations.subscriptions.constants import MAX_API_CALLS_IN_FREE_PLAN from users.models import FFAdminUser @@ -123,9 +123,7 @@ def handle_api_usage_notification_for_organisation(organisation: Organisation) - ) return - # Truncate to the closest active month to get start of current period. - month_delta = _get_total_months(relativedelta(now, billing_starts_at)) - period_starts_at = relativedelta(months=month_delta) + billing_starts_at + period_starts_at = get_current_billing_period_start_date(billing_starts_at, now) allowed_api_calls = subscription_cache.allowed_30d_api_calls @@ -182,7 +180,3 @@ def handle_api_usage_notification_for_organisation(organisation: Organisation) - ) _send_api_usage_notification(organisation, matched_threshold) - - -def _get_total_months(rd: relativedelta) -> int: - return rd.months + rd.years * 12 diff --git a/api/organisations/views.py b/api/organisations/views.py index ee6068272a8d..682e43642763 100644 --- a/api/organisations/views.py +++ b/api/organisations/views.py @@ -4,7 +4,6 @@ import logging from datetime import timedelta -from dateutil.relativedelta import relativedelta from django.utils import timezone from drf_spectacular.utils import extend_schema, extend_schema_view from rest_framework import status, viewsets @@ -48,6 +47,7 @@ SubscriptionDetailsSerializer, UpdateSubscriptionSerializer, ) +from organisations.services import get_current_billing_period_start_date from permissions.permissions_calculator import get_organisation_permission_data from permissions.serializers import ( PermissionModelSerializer, @@ -393,8 +393,7 @@ def get_queryset(self): # type: ignore[no-untyped-def] # by defaulting to something as a reasonable default. billing_starts_at = billing_starts_at or now - timedelta(days=30) - month_delta = relativedelta(now, billing_starts_at).months - period_starts_at = relativedelta(months=month_delta) + billing_starts_at + period_starts_at = get_current_billing_period_start_date(billing_starts_at, now) queryset = OrganisationAPIUsageNotification.objects.filter( organisation_id=organisation.id, diff --git a/api/tests/unit/app_analytics/test_analytics_db_service.py b/api/tests/unit/app_analytics/test_analytics_db_service.py index 50321bdcc47a..5a55bb5c30e3 100644 --- a/api/tests/unit/app_analytics/test_analytics_db_service.py +++ b/api/tests/unit/app_analytics/test_analytics_db_service.py @@ -707,6 +707,39 @@ def test_get_usage_data__current_billing_period__passes_correct_date_range( ) +@pytest.mark.freeze_time("2023-01-19T09:09:47.325132+00:00") +def test_get_usage_data__current_billing_period_annual_term__passes_correct_date_range( + mocker: MockerFixture, + settings: SettingsWrapper, + organisation: Organisation, + cache: OrganisationSubscriptionInformationCache, +) -> None: + # Given + # A term that started more than twelve months ago, as an annual plan does. + settings.USE_POSTGRES_FOR_ANALYTICS = True + cache.current_billing_term_starts_at = datetime( + 2021, 12, 30, 9, 9, 47, 325132, tzinfo=UTC + ) + cache.save() + mocked_get_usage_data_from_local_db = mocker.patch( + "app_analytics.analytics_db_service.get_usage_data_from_local_db", autospec=True + ) + + # When + get_usage_data(organisation, period=CURRENT_BILLING_PERIOD) + + # Then + # The current month of the term, not the same month a year earlier. + mocked_get_usage_data_from_local_db.assert_called_once_with( + organisation=organisation, + environment_id=None, + project_id=None, + date_start=datetime(2022, 12, 30, 9, 9, 47, 325132, tzinfo=UTC), + date_stop=datetime(2023, 1, 19, 9, 9, 47, 325132, tzinfo=UTC), + labels_filter=None, + ) + + @pytest.mark.freeze_time("2023-01-19T09:09:47.325132+00:00") def test_get_usage_data__previous_billing_period__passes_correct_date_range( mocker: MockerFixture, diff --git a/api/tests/unit/organisations/test_unit_organisations_services.py b/api/tests/unit/organisations/test_unit_organisations_services.py new file mode 100644 index 000000000000..2c4311cb612b --- /dev/null +++ b/api/tests/unit/organisations/test_unit_organisations_services.py @@ -0,0 +1,49 @@ +from datetime import UTC, datetime + +import pytest + +from organisations.services import get_current_billing_period_start_date + + +@pytest.mark.parametrize( + "billing_term_starts_at, now, expected", + [ + pytest.param( + datetime(2026, 1, 10, 9, 0, tzinfo=UTC), + datetime(2026, 1, 20, 9, 0, tzinfo=UTC), + datetime(2026, 1, 10, 9, 0, tzinfo=UTC), + id="first_month_of_the_term", + ), + pytest.param( + datetime(2026, 1, 10, 9, 0, tzinfo=UTC), + datetime(2026, 5, 3, 9, 0, tzinfo=UTC), + datetime(2026, 4, 10, 9, 0, tzinfo=UTC), + id="part_way_through_a_monthly_term", + ), + pytest.param( + # An annual term, well over a year old: the months-only delta used + # to drop the years and land a period a year early. + datetime(2024, 9, 15, 9, 0, tzinfo=UTC), + datetime(2026, 7, 2, 9, 0, tzinfo=UTC), + datetime(2026, 6, 15, 9, 0, tzinfo=UTC), + id="term_older_than_a_year", + ), + pytest.param( + datetime(2024, 9, 15, 9, 0, tzinfo=UTC), + datetime(2026, 9, 15, 9, 0, tzinfo=UTC), + datetime(2026, 9, 15, 9, 0, tzinfo=UTC), + id="exactly_on_an_anniversary", + ), + ], +) +def test_get_current_billing_period_start_date__returns_latest_monthly_anniversary( + billing_term_starts_at: datetime, + now: datetime, + expected: datetime, +) -> None: + # When + result = get_current_billing_period_start_date(billing_term_starts_at, now) + + # Then + assert result == expected + assert result <= now diff --git a/api/tests/unit/organisations/test_unit_organisations_views.py b/api/tests/unit/organisations/test_unit_organisations_views.py index 062ea3bba09c..220c89f5528e 100644 --- a/api/tests/unit/organisations/test_unit_organisations_views.py +++ b/api/tests/unit/organisations/test_unit_organisations_views.py @@ -2055,6 +2055,52 @@ def test_get_api_usage_notifications__with_cache__returns_highest_notification( assert response.data["results"][0]["percent_usage"] == 100 +@pytest.mark.freeze_time("2023-01-19T09:09:47.325132+00:00") +def test_get_api_usage_notifications__annual_term__returns_current_period_notification( + staff_client: APIClient, + organisation: Organisation, +) -> None: + # Given + # A term that started more than twelve months ago, as an annual plan does. + url = reverse( + "api-v1:organisations:organisation-api-usage-notification", + args=[organisation.id], + ) + + now = timezone.now() + OrganisationSubscriptionInformationCache.objects.create( + organisation=organisation, + allowed_seats=10, + allowed_projects=3, + allowed_30d_api_calls=100, + chargebee_email="test@example.com", + current_billing_term_starts_at=now - timedelta(days=400), + current_billing_term_ends_at=now + timedelta(days=330), + ) + + # Notified in a month of the term that has already passed, so it should not + # be returned. Counting only the months of the delta would have included it. + OrganisationAPIUsageNotification.objects.create( + organisation=organisation, + percent_usage=90, + notified_at=now - timedelta(days=60), + ) + OrganisationAPIUsageNotification.objects.create( + organisation=organisation, + percent_usage=75, + notified_at=now, + ) + + # When + response = staff_client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + + assert len(response.data["results"]) == 1 + assert response.data["results"][0]["percent_usage"] == 75 + + @pytest.mark.freeze_time("2023-01-19T09:09:47.325132+00:00") def test_get_api_usage_notifications__stale_notification__returns_empty( staff_client: APIClient, From 32455153943745518bdd36f49fd4076756422c4c Mon Sep 17 00:00:00 2001 From: "flagsmith-engineering[bot]" Date: Tue, 1 Sep 2026 15:44:09 +0000 Subject: [PATCH 2/2] chore: Update documentation artefacts --- .../observability/_events-catalogue.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index 6347cd25b168..63032cc895a3 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -2,7 +2,7 @@ ### `api_usage.notification.evaluated` Logged at `info` from: - - `api/organisations/task_helpers.py:155` + - `api/organisations/task_helpers.py:153` Attributes: - `allowed_api_calls` @@ -24,7 +24,7 @@ Attributes: ### `api_usage.notification.sent` Logged at `info` from: - - `api/organisations/task_helpers.py:178` + - `api/organisations/task_helpers.py:176` Attributes: - `matched_threshold` @@ -33,9 +33,9 @@ Attributes: ### `app_analytics.no_analytics_database_configured` Logged at `warning` from: - - `api/app_analytics/analytics_db_service.py:74` - - `api/app_analytics/analytics_db_service.py:187` - - `api/app_analytics/analytics_db_service.py:278` + - `api/app_analytics/analytics_db_service.py:75` + - `api/app_analytics/analytics_db_service.py:188` + - `api/app_analytics/analytics_db_service.py:279` Attributes: - `details`