diff --git a/api/organisations/models.py b/api/organisations/models.py index 4eb9c8656896..f2d96ee058df 100644 --- a/api/organisations/models.py +++ b/api/organisations/models.py @@ -1,8 +1,9 @@ import re -from datetime import timedelta -from typing import Any +from datetime import datetime, timedelta +from typing import Any, NamedTuple 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 @@ -59,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") @@ -307,6 +313,11 @@ def has_active_billing_periods(self) -> bool: and self.organisation.subscription_information_cache.has_active_billing_periods() ) + def get_current_billing_period(self) -> BillingPeriod | None: + if not self.organisation.has_subscription_information_cache(): + return None + return self.organisation.subscription_information_cache.get_current_billing_period() + @property def is_free_plan(self) -> bool: return self.subscription_plan_family == SubscriptionPlanFamily.FREE @@ -587,19 +598,26 @@ 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, - ) + return self.get_current_billing_period() is not None + 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 False + return None - return starts_at <= timezone.now() <= ends_at + 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 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), + ) class OrganisationAPIUsageNotification(models.Model): diff --git a/api/organisations/serializers.py b/api/organisations/serializers.py index 4e31128c419a..f4b8c028e976 100644 --- a/api/organisations/serializers.py +++ b/api/organisations/serializers.py @@ -25,8 +25,14 @@ logger = logging.getLogger(__name__) +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] has_active_billing_periods = serializers.SerializerMethodField() + current_billing_period = serializers.SerializerMethodField() class Meta: model = Subscription @@ -36,6 +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(CurrentBillingPeriodSerializer(allow_null=True)) + def get_current_billing_period( + self, obj: Subscription + ) -> dict[str, typing.Any] | None: + if (period := obj.get_current_billing_period()) is None: + return None + return CurrentBillingPeriodSerializer( + {"starts_at": period.start, "ends_at": period.end} + ).data + 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..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, @@ -979,3 +980,158 @@ 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_get_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.get_current_billing_period() + + # Then + 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_get_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.get_current_billing_period() + + # Then + assert period == BillingPeriod( + start=datetime.fromisoformat("2026-02-28T00:00:00+00:00"), + end=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", + [ + # 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"), + # 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_get_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.get_current_billing_period() is None + + +@pytest.mark.freeze_time("2026-09-10T12:00:00+00:00") +def test_subscription_get_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.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_get_current_billing_period__no_cache__returns_none( + organisation: Organisation, +) -> None: + # Given + assert not organisation.has_subscription_information_cache() + + # When / Then + assert organisation.subscription.get_current_billing_period() is None diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index 09acf96940a9..3ebc0ba09639 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 CurrentBillingPeriod = { + 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: CurrentBillingPeriod | null } export type OnboardingVariant = 'control' | 'single_page' diff --git a/mcp/src/flagsmith_mcp/openapi.json b/mcp/src/flagsmith_mcp/openapi.json index 9a89dad0a760..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": { @@ -7111,6 +7126,17 @@ "type": "boolean", "readOnly": true }, + "current_billing_period": { + "oneOf": [ + { + "$ref": "#/components/schemas/CurrentBillingPeriod" + }, + { + "type": "null" + } + ], + "readOnly": true + }, "deleted_at": { "type": [ "string", diff --git a/openapi.yaml b/openapi.yaml index e14d9a554745..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: @@ -28211,6 +28222,11 @@ components: has_active_billing_periods: type: boolean readOnly: true + current_billing_period: + oneOf: + - $ref: '#/components/schemas/CurrentBillingPeriod' + - type: 'null' + readOnly: true deleted_at: type: - string