Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 30 additions & 12 deletions api/organisations/models.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
16 changes: 16 additions & 0 deletions api/organisations/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
156 changes: 156 additions & 0 deletions api/tests/unit/organisations/test_unit_organisations_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from environments.models import Environment
from organisations.chargebee.metadata import ChargebeeObjMetadata
from organisations.models import (
BillingPeriod,
Organisation,
OrganisationAPIUsageNotification,
OrganisationSubscriptionInformationCache,
Expand Down Expand Up @@ -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
7 changes: 7 additions & 0 deletions frontend/common/types/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'
Expand Down
26 changes: 26 additions & 0 deletions mcp/src/flagsmith_mcp/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -7111,6 +7126,17 @@
"type": "boolean",
"readOnly": true
},
"current_billing_period": {
"oneOf": [
{
"$ref": "#/components/schemas/CurrentBillingPeriod"
},
{
"type": "null"
}
],
"readOnly": true
},
"deleted_at": {
"type": [
"string",
Expand Down
16 changes: 16 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
Loading