From b3778916d15f9624fc7db61f0b5b3d009605d28a Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 10 Sep 2026 09:59:30 -0300 Subject: [PATCH 01/10] feat(usage): return the current billing period The usage page cannot show which period someone is in or when their allowance resets. The term dates are on the subscription information cache but reach no response. Adds current_billing_period to the subscription, null outside an active term. A term can run longer than a month, so the window opens at the most recent monthly anniversary of its start. Contributes to #8257 Co-Authored-By: Claude Opus 5 (1M context) --- api/organisations/models.py | 25 +++- api/organisations/serializers.py | 18 +++ .../test_unit_organisations_models.py | 125 ++++++++++++++++++ frontend/common/types/responses.ts | 7 + 4 files changed, 174 insertions(+), 1 deletion(-) diff --git a/api/organisations/models.py b/api/organisations/models.py index 4eb9c8656896..bcc597860e03 100644 --- a/api/organisations/models.py +++ b/api/organisations/models.py @@ -1,8 +1,9 @@ import re -from datetime import timedelta +from datetime import datetime, timedelta from typing import Any from common.core.utils import is_enterprise, is_saas +from dateutil.relativedelta import relativedelta from django.conf import settings from django.core.cache import caches from django.core.validators import MaxValueValidator, MinValueValidator @@ -307,6 +308,12 @@ def has_active_billing_periods(self) -> bool: and self.organisation.subscription_information_cache.has_active_billing_periods() ) + @property + def current_billing_period(self) -> tuple[datetime, datetime] | None: + if not self.organisation.has_subscription_information_cache(): + return None + return self.organisation.subscription_information_cache.current_billing_period() + @property def is_free_plan(self) -> bool: return self.subscription_plan_family == SubscriptionPlanFamily.FREE @@ -601,6 +608,22 @@ def has_active_billing_periods(self) -> bool: return starts_at <= timezone.now() <= ends_at + def current_billing_period(self) -> tuple[datetime, datetime] | None: + """ + Returns the monthly allowance window, or None outside a billing term. + A term can run longer than a month, so the window opens at the most + recent monthly anniversary of its start. + """ + starts_at = self.current_billing_term_starts_at + if starts_at is None or not self.has_active_billing_periods(): + return None + + elapsed = relativedelta(timezone.now(), starts_at) + period_starts_at = starts_at + relativedelta( + months=elapsed.years * 12 + elapsed.months + ) + return period_starts_at, period_starts_at + relativedelta(months=1) + class OrganisationAPIUsageNotification(models.Model): organisation = models.ForeignKey( diff --git a/api/organisations/serializers.py b/api/organisations/serializers.py index 4e31128c419a..3362f68bfc73 100644 --- a/api/organisations/serializers.py +++ b/api/organisations/serializers.py @@ -25,8 +25,19 @@ logger = logging.getLogger(__name__) +CURRENT_BILLING_PERIOD_SCHEMA = { + "type": "object", + "nullable": True, + "properties": { + "starts_at": {"type": "string", "format": "date-time"}, + "ends_at": {"type": "string", "format": "date-time"}, + }, +} + + class SubscriptionSerializer(serializers.ModelSerializer): # type: ignore[type-arg] has_active_billing_periods = serializers.SerializerMethodField() + current_billing_period = serializers.SerializerMethodField() class Meta: model = Subscription @@ -36,6 +47,13 @@ class Meta: def get_has_active_billing_periods(self, obj): # type: ignore[no-untyped-def] return obj.has_active_billing_periods + @extend_schema_field(CURRENT_BILLING_PERIOD_SCHEMA) + def get_current_billing_period(self, obj: Subscription) -> dict[str, str] | None: + if (period := obj.current_billing_period) is None: + return None + starts_at, ends_at = period + return {"starts_at": starts_at.isoformat(), "ends_at": ends_at.isoformat()} + class OrganisationSerializerFull(serializers.ModelSerializer): # type: ignore[type-arg] subscription = SubscriptionSerializer(required=False) diff --git a/api/tests/unit/organisations/test_unit_organisations_models.py b/api/tests/unit/organisations/test_unit_organisations_models.py index 22049b715036..ba9a5b1be89c 100644 --- a/api/tests/unit/organisations/test_unit_organisations_models.py +++ b/api/tests/unit/organisations/test_unit_organisations_models.py @@ -979,3 +979,128 @@ def test_organisation_openfeature_evaluation_context__targeting_key_set__uses_it # Then assert context.targeting_key == "a" * 32 + + +@pytest.mark.freeze_time("2026-09-10T12:00:00+00:00") +@pytest.mark.parametrize( + "term_starts_at, term_ends_at, expected_starts_at, expected_ends_at", + [ + # Monthly term. + ( + "2026-09-01T00:00:00+00:00", + "2026-10-01T00:00:00+00:00", + "2026-09-01T00:00:00+00:00", + "2026-10-01T00:00:00+00:00", + ), + # Annual term, first year. + ( + "2026-01-05T00:00:00+00:00", + "2027-01-05T00:00:00+00:00", + "2026-09-05T00:00:00+00:00", + "2026-10-05T00:00:00+00:00", + ), + # Over a year old. Ignoring the year would land in 2025 (#6099). + ( + "2024-09-03T00:00:00+00:00", + "2027-04-03T00:00:00+00:00", + "2026-09-03T00:00:00+00:00", + "2026-10-03T00:00:00+00:00", + ), + # Exactly on an anniversary. + ( + "2025-09-10T12:00:00+00:00", + "2027-09-10T12:00:00+00:00", + "2026-09-10T12:00:00+00:00", + "2026-10-10T12:00:00+00:00", + ), + ], +) +def test_current_billing_period__within_term__returns_monthly_window( + organisation: Organisation, + term_starts_at: str, + term_ends_at: str, + expected_starts_at: str, + expected_ends_at: str, +) -> None: + # Given + cache = OrganisationSubscriptionInformationCache.objects.create( + organisation=organisation, + current_billing_term_starts_at=datetime.fromisoformat(term_starts_at), + current_billing_term_ends_at=datetime.fromisoformat(term_ends_at), + ) + + # When + period = cache.current_billing_period() + + # Then + assert period == ( + datetime.fromisoformat(expected_starts_at), + datetime.fromisoformat(expected_ends_at), + ) + + +@pytest.mark.freeze_time("2026-09-10T12:00:00+00:00") +@pytest.mark.parametrize( + "term_starts_at, term_ends_at", + [ + # No term, ie every free plan. + (None, None), + # Half a term. + ("2026-09-01T00:00:00+00:00", None), + (None, "2026-10-01T00:00:00+00:00"), + # Term ended, cache not caught up. + ("2026-07-01T00:00:00+00:00", "2026-08-01T00:00:00+00:00"), + # Term not started. + ("2026-10-01T00:00:00+00:00", "2026-11-01T00:00:00+00:00"), + ], +) +def test_current_billing_period__no_active_term__returns_none( + organisation: Organisation, + term_starts_at: str | None, + term_ends_at: str | None, +) -> None: + # Given + cache = OrganisationSubscriptionInformationCache.objects.create( + organisation=organisation, + current_billing_term_starts_at=( + datetime.fromisoformat(term_starts_at) if term_starts_at else None + ), + current_billing_term_ends_at=( + datetime.fromisoformat(term_ends_at) if term_ends_at else None + ), + ) + + # When / Then + assert cache.current_billing_period() is None + + +@pytest.mark.freeze_time("2026-09-10T12:00:00+00:00") +def test_subscription_current_billing_period__with_cache__reads_through( + organisation: Organisation, +) -> None: + # Given + OrganisationSubscriptionInformationCache.objects.create( + organisation=organisation, + current_billing_term_starts_at=datetime.fromisoformat( + "2026-09-01T00:00:00+00:00" + ), + current_billing_term_ends_at=datetime.fromisoformat( + "2026-10-01T00:00:00+00:00" + ), + ) + + # When / Then + assert organisation.subscription.current_billing_period == ( + datetime.fromisoformat("2026-09-01T00:00:00+00:00"), + datetime.fromisoformat("2026-10-01T00:00:00+00:00"), + ) + + +def test_subscription_current_billing_period__no_cache__returns_none( + organisation: Organisation, +) -> None: + # Given + assert not organisation.has_subscription_information_cache() + + # When / Then + assert organisation.subscription.current_billing_period is None diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index 09acf96940a9..cb3d56c7b6c1 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -517,6 +517,12 @@ export type AuditLogDetail = AuditLogItem & { } export type PaymentMethod = 'CHARGEBEE' | 'XERO' | 'AWS_MARKETPLACE' +/** The monthly allowance window, null outside an active billing term. */ +export type BillingPeriod = { + starts_at: string + ends_at: string +} + export type Subscription = { id: number uuid: string @@ -530,6 +536,7 @@ export type Subscription = { payment_method: PaymentMethod | null notes: string | null has_active_billing_periods: boolean + current_billing_period: BillingPeriod | null } export type OnboardingVariant = 'control' | 'single_page' From 7a9641d948fa6e005ba1efdefed0b6949f493cc8 Mon Sep 17 00:00:00 2001 From: "flagsmith-engineering[bot]" Date: Thu, 10 Sep 2026 13:00:51 +0000 Subject: [PATCH 02/10] chore: Update documentation artefacts --- mcp/src/flagsmith_mcp/openapi.json | 17 +++++++++++++++++ openapi.yaml | 12 ++++++++++++ 2 files changed, 29 insertions(+) diff --git a/mcp/src/flagsmith_mcp/openapi.json b/mcp/src/flagsmith_mcp/openapi.json index 9a89dad0a760..0e1f6ff2cae8 100644 --- a/mcp/src/flagsmith_mcp/openapi.json +++ b/mcp/src/flagsmith_mcp/openapi.json @@ -7111,6 +7111,23 @@ "type": "boolean", "readOnly": true }, + "current_billing_period": { + "type": [ + "object", + "null" + ], + "properties": { + "starts_at": { + "type": "string", + "format": "date-time" + }, + "ends_at": { + "type": "string", + "format": "date-time" + } + }, + "readOnly": true + }, "deleted_at": { "type": [ "string", diff --git a/openapi.yaml b/openapi.yaml index e14d9a554745..0492b3b579d7 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -28211,6 +28211,18 @@ components: has_active_billing_periods: type: boolean readOnly: true + current_billing_period: + type: + - object + - 'null' + properties: + starts_at: + type: string + format: date-time + ends_at: + type: string + format: date-time + readOnly: true deleted_at: type: - string From e55b4b499189d2bd98908c04708b2c00d912d5ef Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Fri, 11 Sep 2026 17:14:22 -0300 Subject: [PATCH 03/10] fix(usage): end the billing period on the term's anniversary Counting the end from the period start loses the original day when a month is too short for it, so a term starting on the 31st produced a window that could end before today. Co-Authored-By: Claude Opus 5 (1M context) --- api/organisations/models.py | 9 ++++--- .../test_unit_organisations_models.py | 27 +++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/api/organisations/models.py b/api/organisations/models.py index bcc597860e03..b4f52e6753d4 100644 --- a/api/organisations/models.py +++ b/api/organisations/models.py @@ -619,10 +619,13 @@ def current_billing_period(self) -> tuple[datetime, datetime] | None: return None elapsed = relativedelta(timezone.now(), starts_at) - period_starts_at = starts_at + relativedelta( - months=elapsed.years * 12 + elapsed.months + months = elapsed.years * 12 + elapsed.months + # 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 ( + starts_at + relativedelta(months=months), + starts_at + relativedelta(months=months + 1), ) - return period_starts_at, period_starts_at + relativedelta(months=1) class OrganisationAPIUsageNotification(models.Model): diff --git a/api/tests/unit/organisations/test_unit_organisations_models.py b/api/tests/unit/organisations/test_unit_organisations_models.py index ba9a5b1be89c..60ab566437a4 100644 --- a/api/tests/unit/organisations/test_unit_organisations_models.py +++ b/api/tests/unit/organisations/test_unit_organisations_models.py @@ -1039,6 +1039,33 @@ def test_current_billing_period__within_term__returns_monthly_window( ) +# February clamps a 31st term start to the 28th. Counting the end from that +# clamped date rather than the term start would close the window on 28 March. +@pytest.mark.freeze_time("2026-03-01T00:00:00+00:00") +def test_current_billing_period__term_starts_on_the_31st__ends_on_the_anniversary( + organisation: Organisation, +) -> None: + # Given + cache = OrganisationSubscriptionInformationCache.objects.create( + organisation=organisation, + current_billing_term_starts_at=datetime.fromisoformat( + "2026-01-31T00:00:00+00:00" + ), + current_billing_term_ends_at=datetime.fromisoformat( + "2027-01-31T00:00:00+00:00" + ), + ) + + # When + period = cache.current_billing_period() + + # Then + assert period == ( + datetime.fromisoformat("2026-02-28T00:00:00+00:00"), + datetime.fromisoformat("2026-03-31T00:00:00+00:00"), + ) + + @pytest.mark.freeze_time("2026-09-10T12:00:00+00:00") @pytest.mark.parametrize( "term_starts_at, term_ends_at", From add0729ade12cb4fe18b79ebaa00aa9a3341a818 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Fri, 11 Sep 2026 17:42:03 -0300 Subject: [PATCH 04/10] fix(usage): close the billing period before the term ends has_active_billing_periods admits the term's final instant, so a window opened there ran past the end of the term. The period now checks the range itself, exclusive of the end. Also requires both dates in the OpenAPI schema, which always ship together when the period is not null. Co-Authored-By: Claude Opus 5 (1M context) --- api/organisations/models.py | 7 ++++++- api/organisations/serializers.py | 1 + .../unit/organisations/test_unit_organisations_models.py | 3 +++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/api/organisations/models.py b/api/organisations/models.py index b4f52e6753d4..85fba75fa64b 100644 --- a/api/organisations/models.py +++ b/api/organisations/models.py @@ -615,7 +615,12 @@ def current_billing_period(self) -> tuple[datetime, datetime] | None: recent monthly anniversary of its start. """ starts_at = self.current_billing_term_starts_at - if starts_at is None or not self.has_active_billing_periods(): + ends_at = self.current_billing_term_ends_at + if starts_at is None or ends_at is None: + return None + # Exclusive of the term end, which has_active_billing_periods admits, + # so the last instant of a term does not open a window beyond it. + if not starts_at <= timezone.now() < ends_at: return None elapsed = relativedelta(timezone.now(), starts_at) diff --git a/api/organisations/serializers.py b/api/organisations/serializers.py index 3362f68bfc73..acdf094c82e0 100644 --- a/api/organisations/serializers.py +++ b/api/organisations/serializers.py @@ -32,6 +32,7 @@ "starts_at": {"type": "string", "format": "date-time"}, "ends_at": {"type": "string", "format": "date-time"}, }, + "required": ["starts_at", "ends_at"], } diff --git a/api/tests/unit/organisations/test_unit_organisations_models.py b/api/tests/unit/organisations/test_unit_organisations_models.py index 60ab566437a4..b464e846284f 100644 --- a/api/tests/unit/organisations/test_unit_organisations_models.py +++ b/api/tests/unit/organisations/test_unit_organisations_models.py @@ -1077,6 +1077,9 @@ def test_current_billing_period__term_starts_on_the_31st__ends_on_the_anniversar (None, "2026-10-01T00:00:00+00:00"), # Term ended, cache not caught up. ("2026-07-01T00:00:00+00:00", "2026-08-01T00:00:00+00:00"), + # The term's final instant. has_active_billing_periods admits it, but + # a window opened here would run past the end of the term. + ("2026-08-10T12:00:00+00:00", "2026-09-10T12:00:00+00:00"), # Term not started. ("2026-10-01T00:00:00+00:00", "2026-11-01T00:00:00+00:00"), ], From 1a17371b09c70fc5ce2be6cd22305f087233e5be Mon Sep 17 00:00:00 2001 From: "flagsmith-engineering[bot]" Date: Fri, 11 Sep 2026 20:44:10 +0000 Subject: [PATCH 05/10] chore: Update documentation artefacts --- mcp/src/flagsmith_mcp/openapi.json | 6 +++++- openapi.yaml | 3 +++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/mcp/src/flagsmith_mcp/openapi.json b/mcp/src/flagsmith_mcp/openapi.json index 0e1f6ff2cae8..8f47d60c6a33 100644 --- a/mcp/src/flagsmith_mcp/openapi.json +++ b/mcp/src/flagsmith_mcp/openapi.json @@ -7126,7 +7126,11 @@ "format": "date-time" } }, - "readOnly": true + "readOnly": true, + "required": [ + "starts_at", + "ends_at" + ] }, "deleted_at": { "type": [ diff --git a/openapi.yaml b/openapi.yaml index 0492b3b579d7..81a362255a23 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -28223,6 +28223,9 @@ components: type: string format: date-time readOnly: true + required: + - starts_at + - ends_at deleted_at: type: - string From 754fac5110b781460645d71a99c488d51011158c Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Fri, 11 Sep 2026 17:59:21 -0300 Subject: [PATCH 06/10] fix(usage): read the clock once when resolving the billing period Two readings meant a clock crossing the term end between them could open a window past it. Co-Authored-By: Claude Opus 5 (1M context) --- api/organisations/models.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/api/organisations/models.py b/api/organisations/models.py index 85fba75fa64b..96263b1b335b 100644 --- a/api/organisations/models.py +++ b/api/organisations/models.py @@ -618,12 +618,15 @@ def current_billing_period(self) -> tuple[datetime, datetime] | None: ends_at = self.current_billing_term_ends_at if starts_at is None or ends_at is None: return None - # Exclusive of the term end, which has_active_billing_periods admits, - # so the last instant of a term does not open a window beyond it. - if not starts_at <= timezone.now() < ends_at: + + # One reading, so a clock crossing the term end mid-method cannot open + # a window past it. Exclusive of the end, which + # has_active_billing_periods admits. + now = timezone.now() + if not starts_at <= now < ends_at: return None - elapsed = relativedelta(timezone.now(), starts_at) + elapsed = relativedelta(now, starts_at) months = elapsed.years * 12 + elapsed.months # 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. From 45167048ba40c6e4d0c30e62653724aca14379cb Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Fri, 11 Sep 2026 18:28:43 -0300 Subject: [PATCH 07/10] refactor(usage): rename the response type to CurrentBillingPeriod common/types/requests already exports BillingPeriod for the period selection, and every usage file imports it. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/common/types/responses.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index cb3d56c7b6c1..3ebc0ba09639 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -518,7 +518,7 @@ export type AuditLogDetail = AuditLogItem & { export type PaymentMethod = 'CHARGEBEE' | 'XERO' | 'AWS_MARKETPLACE' /** The monthly allowance window, null outside an active billing term. */ -export type BillingPeriod = { +export type CurrentBillingPeriod = { starts_at: string ends_at: string } @@ -536,7 +536,7 @@ export type Subscription = { payment_method: PaymentMethod | null notes: string | null has_active_billing_periods: boolean - current_billing_period: BillingPeriod | null + current_billing_period: CurrentBillingPeriod | null } export type OnboardingVariant = 'control' | 'single_page' From cfe79326a97df3fd5657ba2ec730375571733c68 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Fri, 11 Sep 2026 19:22:28 -0300 Subject: [PATCH 08/10] refactor(usage): serialise the billing period with a serializer Hand-built ISO strings ignored any configured DRF datetime format, and the schema was maintained separately from the shape it described. has_active_billing_periods now answers from current_billing_period, so the two cannot disagree about whether a term is active. Co-Authored-By: Claude Opus 5 (1M context) --- api/organisations/models.py | 19 ++++--------------- api/organisations/serializers.py | 22 ++++++++++------------ 2 files changed, 14 insertions(+), 27 deletions(-) diff --git a/api/organisations/models.py b/api/organisations/models.py index 96263b1b335b..14de543033c6 100644 --- a/api/organisations/models.py +++ b/api/organisations/models.py @@ -594,19 +594,8 @@ def _get_default_subscription_metadata_kwargs(self) -> dict[str, Any]: } def has_active_billing_periods(self) -> bool: - """ - Returns True if current date is within the billing term. - If either start or end date is None, returns False. - """ - starts_at, ends_at = ( - self.current_billing_term_starts_at, - self.current_billing_term_ends_at, - ) - - if starts_at is None or ends_at is None: - return False - - return starts_at <= timezone.now() <= ends_at + """Whether the organisation is inside a billing term.""" + return self.current_billing_period() is not None def current_billing_period(self) -> tuple[datetime, datetime] | None: """ @@ -620,8 +609,8 @@ def current_billing_period(self) -> tuple[datetime, datetime] | None: return None # One reading, so a clock crossing the term end mid-method cannot open - # a window past it. Exclusive of the end, which - # has_active_billing_periods admits. + # a window past it. The end is exclusive: at that instant the term is + # over and the next one has not been written yet. now = timezone.now() if not starts_at <= now < ends_at: return None diff --git a/api/organisations/serializers.py b/api/organisations/serializers.py index acdf094c82e0..9850508ee6ad 100644 --- a/api/organisations/serializers.py +++ b/api/organisations/serializers.py @@ -25,15 +25,9 @@ logger = logging.getLogger(__name__) -CURRENT_BILLING_PERIOD_SCHEMA = { - "type": "object", - "nullable": True, - "properties": { - "starts_at": {"type": "string", "format": "date-time"}, - "ends_at": {"type": "string", "format": "date-time"}, - }, - "required": ["starts_at", "ends_at"], -} +class CurrentBillingPeriodSerializer(serializers.Serializer): # type: ignore[type-arg] + starts_at = serializers.DateTimeField(read_only=True) + ends_at = serializers.DateTimeField(read_only=True) class SubscriptionSerializer(serializers.ModelSerializer): # type: ignore[type-arg] @@ -48,12 +42,16 @@ class Meta: def get_has_active_billing_periods(self, obj): # type: ignore[no-untyped-def] return obj.has_active_billing_periods - @extend_schema_field(CURRENT_BILLING_PERIOD_SCHEMA) - def get_current_billing_period(self, obj: Subscription) -> dict[str, str] | None: + @extend_schema_field(CurrentBillingPeriodSerializer(allow_null=True)) + def get_current_billing_period( + self, obj: Subscription + ) -> dict[str, typing.Any] | None: if (period := obj.current_billing_period) is None: return None starts_at, ends_at = period - return {"starts_at": starts_at.isoformat(), "ends_at": ends_at.isoformat()} + return CurrentBillingPeriodSerializer( + {"ends_at": ends_at, "starts_at": starts_at} + ).data class OrganisationSerializerFull(serializers.ModelSerializer): # type: ignore[type-arg] From 3c11f840b145e59d3f0197c22edcab7d3c6ca88d Mon Sep 17 00:00:00 2001 From: "flagsmith-engineering[bot]" Date: Fri, 11 Sep 2026 23:21:02 +0000 Subject: [PATCH 09/10] chore: Update documentation artefacts --- mcp/src/flagsmith_mcp/openapi.json | 39 +++++++++++++++++------------- openapi.yaml | 27 +++++++++++---------- 2 files changed, 36 insertions(+), 30 deletions(-) diff --git a/mcp/src/flagsmith_mcp/openapi.json b/mcp/src/flagsmith_mcp/openapi.json index 8f47d60c6a33..8c80d74e1443 100644 --- a/mcp/src/flagsmith_mcp/openapi.json +++ b/mcp/src/flagsmith_mcp/openapi.json @@ -3759,6 +3759,21 @@ "feature" ] }, + "CurrentBillingPeriod": { + "type": "object", + "properties": { + "starts_at": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "ends_at": { + "type": "string", + "format": "date-time", + "readOnly": true + } + } + }, "CustomCreateSegmentOverrideFeatureSegment": { "type": "object", "properties": { @@ -7112,25 +7127,15 @@ "readOnly": true }, "current_billing_period": { - "type": [ - "object", - "null" - ], - "properties": { - "starts_at": { - "type": "string", - "format": "date-time" + "oneOf": [ + { + "$ref": "#/components/schemas/CurrentBillingPeriod" }, - "ends_at": { - "type": "string", - "format": "date-time" + { + "type": "null" } - }, - "readOnly": true, - "required": [ - "starts_at", - "ends_at" - ] + ], + "readOnly": true }, "deleted_at": { "type": [ diff --git a/openapi.yaml b/openapi.yaml index 81a362255a23..38aa1d418484 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -20360,6 +20360,17 @@ components: type: integer required: - user + CurrentBillingPeriod: + type: object + properties: + starts_at: + type: string + format: date-time + readOnly: true + ends_at: + type: string + format: date-time + readOnly: true CustomCreateSegmentOverrideFeatureSegment: type: object properties: @@ -28212,20 +28223,10 @@ components: type: boolean readOnly: true current_billing_period: - type: - - object - - 'null' - properties: - starts_at: - type: string - format: date-time - ends_at: - type: string - format: date-time + oneOf: + - $ref: '#/components/schemas/CurrentBillingPeriod' + - type: 'null' readOnly: true - required: - - starts_at - - ends_at deleted_at: type: - string From f111a47bd9d7c13073e7e0bdf84165000eca9790 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Mon, 21 Sep 2026 09:45:15 -0300 Subject: [PATCH 10/10] refactor(usage): return the billing period as a NamedTuple Addresses review feedback on #8501. The window is a BillingPeriod NamedTuple rather than a bare tuple, and the accessor is a method rather than a property, since it reads the clock. Co-Authored-By: Claude Opus 5 (1M context) --- api/organisations/models.py | 35 +++++++--------- api/organisations/serializers.py | 5 +-- .../test_unit_organisations_models.py | 41 ++++++++++--------- 3 files changed, 38 insertions(+), 43 deletions(-) diff --git a/api/organisations/models.py b/api/organisations/models.py index 14de543033c6..f2d96ee058df 100644 --- a/api/organisations/models.py +++ b/api/organisations/models.py @@ -1,6 +1,6 @@ import re from datetime import datetime, timedelta -from typing import Any +from typing import Any, NamedTuple from common.core.utils import is_enterprise, is_saas from dateutil.relativedelta import relativedelta @@ -60,6 +60,11 @@ environment_cache = caches[settings.ENVIRONMENT_CACHE_NAME] +class BillingPeriod(NamedTuple): + start: datetime + end: datetime + + class OrganisationRole(models.TextChoices): ADMIN = ("ADMIN", "Admin") USER = ("USER", "User") @@ -308,11 +313,10 @@ def has_active_billing_periods(self) -> bool: and self.organisation.subscription_information_cache.has_active_billing_periods() ) - @property - def current_billing_period(self) -> tuple[datetime, datetime] | None: + def get_current_billing_period(self) -> BillingPeriod | None: if not self.organisation.has_subscription_information_cache(): return None - return self.organisation.subscription_information_cache.current_billing_period() + return self.organisation.subscription_information_cache.get_current_billing_period() @property def is_free_plan(self) -> bool: @@ -594,34 +598,25 @@ def _get_default_subscription_metadata_kwargs(self) -> dict[str, Any]: } def has_active_billing_periods(self) -> bool: - """Whether the organisation is inside a billing term.""" - return self.current_billing_period() is not None + return self.get_current_billing_period() is not None - def current_billing_period(self) -> tuple[datetime, datetime] | None: - """ - Returns the monthly allowance window, or None outside a billing term. - A term can run longer than a month, so the window opens at the most - recent monthly anniversary of its start. - """ + def get_current_billing_period(self) -> BillingPeriod | None: starts_at = self.current_billing_term_starts_at ends_at = self.current_billing_term_ends_at if starts_at is None or ends_at is None: return None - # One reading, so a clock crossing the term end mid-method cannot open - # a window past it. The end is exclusive: at that instant the term is - # over and the next one has not been written yet. now = timezone.now() if not starts_at <= now < ends_at: return None elapsed = relativedelta(now, starts_at) months = elapsed.years * 12 + elapsed.months - # 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 ( - starts_at + relativedelta(months=months), - starts_at + relativedelta(months=months + 1), + # Both ends count from the term start; counting the end from the start + # of the window loses the original day when a month is too short for it. + return BillingPeriod( + start=starts_at + relativedelta(months=months), + end=starts_at + relativedelta(months=months + 1), ) diff --git a/api/organisations/serializers.py b/api/organisations/serializers.py index 9850508ee6ad..f4b8c028e976 100644 --- a/api/organisations/serializers.py +++ b/api/organisations/serializers.py @@ -46,11 +46,10 @@ def get_has_active_billing_periods(self, obj): # type: ignore[no-untyped-def] def get_current_billing_period( self, obj: Subscription ) -> dict[str, typing.Any] | None: - if (period := obj.current_billing_period) is None: + if (period := obj.get_current_billing_period()) is None: return None - starts_at, ends_at = period return CurrentBillingPeriodSerializer( - {"ends_at": ends_at, "starts_at": starts_at} + {"starts_at": period.start, "ends_at": period.end} ).data diff --git a/api/tests/unit/organisations/test_unit_organisations_models.py b/api/tests/unit/organisations/test_unit_organisations_models.py index b464e846284f..5c750b74a798 100644 --- a/api/tests/unit/organisations/test_unit_organisations_models.py +++ b/api/tests/unit/organisations/test_unit_organisations_models.py @@ -11,6 +11,7 @@ from environments.models import Environment from organisations.chargebee.metadata import ChargebeeObjMetadata from organisations.models import ( + BillingPeriod, Organisation, OrganisationAPIUsageNotification, OrganisationSubscriptionInformationCache, @@ -1015,7 +1016,7 @@ def test_organisation_openfeature_evaluation_context__targeting_key_set__uses_it ), ], ) -def test_current_billing_period__within_term__returns_monthly_window( +def test_get_current_billing_period__within_term__returns_monthly_window( organisation: Organisation, term_starts_at: str, term_ends_at: str, @@ -1030,19 +1031,19 @@ def test_current_billing_period__within_term__returns_monthly_window( ) # When - period = cache.current_billing_period() + period = cache.get_current_billing_period() # Then - assert period == ( - datetime.fromisoformat(expected_starts_at), - datetime.fromisoformat(expected_ends_at), + assert period == BillingPeriod( + start=datetime.fromisoformat(expected_starts_at), + end=datetime.fromisoformat(expected_ends_at), ) # February clamps a 31st term start to the 28th. Counting the end from that # clamped date rather than the term start would close the window on 28 March. @pytest.mark.freeze_time("2026-03-01T00:00:00+00:00") -def test_current_billing_period__term_starts_on_the_31st__ends_on_the_anniversary( +def test_get_current_billing_period__term_starts_on_the_31st__ends_on_the_anniversary( organisation: Organisation, ) -> None: # Given @@ -1057,12 +1058,12 @@ def test_current_billing_period__term_starts_on_the_31st__ends_on_the_anniversar ) # When - period = cache.current_billing_period() + period = cache.get_current_billing_period() # Then - assert period == ( - datetime.fromisoformat("2026-02-28T00:00:00+00:00"), - datetime.fromisoformat("2026-03-31T00:00:00+00:00"), + assert period == BillingPeriod( + start=datetime.fromisoformat("2026-02-28T00:00:00+00:00"), + end=datetime.fromisoformat("2026-03-31T00:00:00+00:00"), ) @@ -1077,14 +1078,14 @@ def test_current_billing_period__term_starts_on_the_31st__ends_on_the_anniversar (None, "2026-10-01T00:00:00+00:00"), # Term ended, cache not caught up. ("2026-07-01T00:00:00+00:00", "2026-08-01T00:00:00+00:00"), - # The term's final instant. has_active_billing_periods admits it, but - # a window opened here would run past the end of the term. + # The term's final instant. A window opened here would run past the + # end of the term. ("2026-08-10T12:00:00+00:00", "2026-09-10T12:00:00+00:00"), # Term not started. ("2026-10-01T00:00:00+00:00", "2026-11-01T00:00:00+00:00"), ], ) -def test_current_billing_period__no_active_term__returns_none( +def test_get_current_billing_period__no_active_term__returns_none( organisation: Organisation, term_starts_at: str | None, term_ends_at: str | None, @@ -1101,11 +1102,11 @@ def test_current_billing_period__no_active_term__returns_none( ) # When / Then - assert cache.current_billing_period() is None + assert cache.get_current_billing_period() is None @pytest.mark.freeze_time("2026-09-10T12:00:00+00:00") -def test_subscription_current_billing_period__with_cache__reads_through( +def test_subscription_get_current_billing_period__with_cache__reads_through( organisation: Organisation, ) -> None: # Given @@ -1120,17 +1121,17 @@ def test_subscription_current_billing_period__with_cache__reads_through( ) # When / Then - assert organisation.subscription.current_billing_period == ( - datetime.fromisoformat("2026-09-01T00:00:00+00:00"), - datetime.fromisoformat("2026-10-01T00:00:00+00:00"), + assert organisation.subscription.get_current_billing_period() == BillingPeriod( + start=datetime.fromisoformat("2026-09-01T00:00:00+00:00"), + end=datetime.fromisoformat("2026-10-01T00:00:00+00:00"), ) -def test_subscription_current_billing_period__no_cache__returns_none( +def test_subscription_get_current_billing_period__no_cache__returns_none( organisation: Organisation, ) -> None: # Given assert not organisation.has_subscription_information_cache() # When / Then - assert organisation.subscription.current_billing_period is None + assert organisation.subscription.get_current_billing_period() is None