diff --git a/api/app_analytics/analytics_db_service.py b/api/app_analytics/analytics_db_service.py index dd5f336daf1a..8032ee3a303e 100644 --- a/api/app_analytics/analytics_db_service.py +++ b/api/app_analytics/analytics_db_service.py @@ -28,6 +28,7 @@ from app_analytics.types import Labels, PeriodType from environments.models import Environment from features.models import Feature +from organisations.billing_periods import months_elapsed, period_start from organisations.models import Organisation, OrganisationSubscriptionInformationCache logger = structlog.get_logger("app_analytics") @@ -341,9 +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 - return date_start, now + return period_start(starts_at, now), now case constants.PREVIOUS_BILLING_PERIOD: if sub_cache and sub_cache.current_billing_term_starts_at: @@ -351,11 +350,13 @@ 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 - return date_start, date_stop + # Both ends count from the term start, so a month too short + # for its day does not shift the window. + months = months_elapsed(starts_at, now) + return ( + starts_at + relativedelta(months=months - 1), + starts_at + relativedelta(months=months), + ) case constants.NINETY_DAY_PERIOD: date_start = now - relativedelta(days=90) diff --git a/api/organisations/billing_periods.py b/api/organisations/billing_periods.py new file mode 100644 index 000000000000..2370e3d2da3c --- /dev/null +++ b/api/organisations/billing_periods.py @@ -0,0 +1,20 @@ +from datetime import datetime + +from dateutil.relativedelta import relativedelta + + +def months_elapsed(since: datetime, now: datetime) -> int: + """Whole months between two datetimes, years included.""" + elapsed = relativedelta(now, since) + return elapsed.years * 12 + elapsed.months + + +def period_start(billing_term_starts_at: datetime, now: datetime) -> datetime: + """ + Start of the monthly allowance window a term is currently in. A term can + run longer than a month, so this is the most recent monthly anniversary of + its start. + """ + return billing_term_starts_at + relativedelta( + months=months_elapsed(billing_term_starts_at, now) + ) diff --git a/api/organisations/models.py b/api/organisations/models.py index a265f11df649..72e0f82e21a4 100644 --- a/api/organisations/models.py +++ b/api/organisations/models.py @@ -25,6 +25,7 @@ from integrations.lead_tracking.hubspot.tasks import ( track_hubspot_lead_v2, ) +from organisations.billing_periods import months_elapsed from organisations.chargebee import ( # type: ignore[attr-defined] get_customer_id_from_subscription_id, get_max_api_calls_for_plan, @@ -632,8 +633,7 @@ def current_billing_period(self) -> tuple[datetime, datetime] | None: if starts_at is None or not self.has_active_billing_periods(): return None - elapsed = relativedelta(timezone.now(), starts_at) - months = elapsed.years * 12 + elapsed.months + months = months_elapsed(starts_at, timezone.now()) # Both ends count from the term start. Counting the second from the # first loses the original day when a month is too short for it. return ( diff --git a/api/organisations/task_helpers.py b/api/organisations/task_helpers.py index ac0b675f2b5e..211114c8e61c 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 @@ -11,6 +10,7 @@ from app_analytics.influxdb_wrapper import get_current_api_usage from core.helpers import get_current_site_url from integrations.flagsmith.client import get_openfeature_client +from organisations.billing_periods import period_start from organisations.models import ( Organisation, OrganisationAPIUsageNotification, @@ -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 = period_start(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..7049dfd49827 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 @@ -23,6 +22,7 @@ ) from app_analytics.throttles import InfluxQueryThrottle from core.helpers import get_current_site_url +from organisations.billing_periods import period_start from organisations.chargebee import webhook_event_types, webhook_handlers from organisations.exceptions import OrganisationHasNoPaidSubscription from organisations.models import ( @@ -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 = period_start(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..887a5d6963af 100644 --- a/api/tests/unit/app_analytics/test_analytics_db_service.py +++ b/api/tests/unit/app_analytics/test_analytics_db_service.py @@ -7,6 +7,7 @@ from rest_framework.exceptions import NotFound from app_analytics.analytics_db_service import ( + _get_start_date_and_stop_date_for_subscribed_organisation, get_feature_evaluation_data, get_feature_evaluation_data_from_local_db, get_top_organisations_from_local_db, @@ -966,3 +967,40 @@ def test_get_usage_data_for_window__no_analytics_configured__returns_empty( # Then assert result == [] + + +# A term over a year old resolved to the wrong year before #6099, so the current +# period opened twelve months early and the usage shown was not the period's. +@pytest.mark.freeze_time("2026-09-11T00:00:00+00:00") +@pytest.mark.parametrize( + "period, expected_start", + [ + (CURRENT_BILLING_PERIOD, "2026-09-07T00:00:00+00:00"), + (PREVIOUS_BILLING_PERIOD, "2026-08-07T00:00:00+00:00"), + ], +) +def test_get_start_date_and_stop_date__term_over_a_year_old__counts_the_years( + db: None, + organisation: Organisation, + period: PeriodType, + expected_start: str, +) -> None: + # Given + sub_cache = OrganisationSubscriptionInformationCache.objects.create( + organisation=organisation, + current_billing_term_starts_at=datetime.fromisoformat( + "2025-08-07T00:00:00+00:00" + ), + current_billing_term_ends_at=datetime.fromisoformat( + "2027-08-07T00:00:00+00:00" + ), + ) + + # When + date_start, _ = _get_start_date_and_stop_date_for_subscribed_organisation( + sub_cache=sub_cache, + period=period, + ) + + # Then + assert date_start == datetime.fromisoformat(expected_start) diff --git a/api/tests/unit/organisations/test_unit_organisations_billing_periods.py b/api/tests/unit/organisations/test_unit_organisations_billing_periods.py new file mode 100644 index 000000000000..68f24b1c6562 --- /dev/null +++ b/api/tests/unit/organisations/test_unit_organisations_billing_periods.py @@ -0,0 +1,54 @@ +from datetime import datetime + +import pytest + +from organisations.billing_periods import months_elapsed, period_start + + +@pytest.mark.parametrize( + "since, now, expected", + [ + ("2026-09-01T00:00:00+00:00", "2026-09-10T00:00:00+00:00", 0), + ("2026-01-05T00:00:00+00:00", "2026-09-10T00:00:00+00:00", 8), + # The year is the part #6099 dropped. + ("2024-09-03T00:00:00+00:00", "2026-09-10T00:00:00+00:00", 24), + ("2025-08-07T00:00:00+00:00", "2026-09-11T00:00:00+00:00", 13), + ], +) +def test_months_elapsed__spans_years__counts_them( + since: str, now: str, expected: int +) -> None: + # Given / When + elapsed = months_elapsed(datetime.fromisoformat(since), datetime.fromisoformat(now)) + + # Then + assert elapsed == expected + + +@pytest.mark.parametrize( + "term_starts_at, now, expected", + [ + # Annual term over a year old. Dropping the year lands in 2025. + ( + "2024-09-03T00:00:00+00:00", + "2026-09-10T00:00:00+00:00", + "2026-09-03T00:00:00+00:00", + ), + # February is too short for a 31st, so the window opens on the 28th. + ( + "2026-01-31T00:00:00+00:00", + "2026-03-01T00:00:00+00:00", + "2026-02-28T00:00:00+00:00", + ), + ], +) +def test_period_start__long_term__opens_on_the_latest_anniversary( + term_starts_at: str, now: str, expected: str +) -> None: + # Given / When + start = period_start( + datetime.fromisoformat(term_starts_at), datetime.fromisoformat(now) + ) + + # Then + assert start == datetime.fromisoformat(expected) diff --git a/api/tests/unit/organisations/test_unit_organisations_views.py b/api/tests/unit/organisations/test_unit_organisations_views.py index 062ea3bba09c..062bf2b9e3fd 100644 --- a/api/tests/unit/organisations/test_unit_organisations_views.py +++ b/api/tests/unit/organisations/test_unit_organisations_views.py @@ -2234,3 +2234,47 @@ def test_get_detailed_permissions__other_user_as_admin__returns_permissions( "derived_from": {"groups": [], "roles": []}, } ] + + +# A term over a year old resolved to the wrong year before #6099, so the window +# opened twelve months early and swept up notifications from previous periods. +@pytest.mark.freeze_time("2026-09-11T00:00:00+00:00") +def test_get_api_usage_notifications__term_over_a_year_old__excludes_earlier_periods( + staff_client: APIClient, + organisation: Organisation, +) -> None: + # Given + now = timezone.now() + OrganisationSubscriptionInformationCache.objects.create( + organisation=organisation, + current_billing_term_starts_at=datetime.fromisoformat( + "2025-08-07T00:00:00+00:00" + ), + current_billing_term_ends_at=datetime.fromisoformat( + "2027-08-07T00:00:00+00:00" + ), + ) + # Inside the current window, which opens on 7 September 2026. + OrganisationAPIUsageNotification.objects.create( + organisation=organisation, + percent_usage=90, + notified_at=now, + ) + # A year earlier, only reachable if the year is dropped. + OrganisationAPIUsageNotification.objects.create( + organisation=organisation, + percent_usage=100, + notified_at=datetime.fromisoformat("2025-09-20T00:00:00+00:00"), + ) + + url = reverse( + "api-v1:organisations:organisation-api-usage-notification", + args=[organisation.id], + ) + + # When + response = staff_client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + assert [r["percent_usage"] for r in response.data["results"]] == [90] diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index 3826b5823873..7c020d6d1cac 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`