Skip to content
Open
43 changes: 33 additions & 10 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 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
Expand Down Expand Up @@ -321,6 +322,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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we make this a NamedTuple?

Something like:

class BillingPeriod(NamedTuple):
    start: datetime
    end: datetime

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm also not sure this should be a property. self.organisation.subscription_information_cache.current_billing_period() relies on timezone.now(), hence it is dynamic which for me feels wrong for a property.

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
Expand Down Expand Up @@ -601,19 +608,35 @@ 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."""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name of this function is sufficient, we don't need the docstring.

Suggested change
"""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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def current_billing_period(self) -> tuple[datetime, datetime] | None:
def get_current_billing_period(self) -> tuple[datetime, datetime] | None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, as above, let's use the NamedTuple here

"""
Returns True if current date is within the billing term.
If either start or end date is None, returns False.
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, ends_at = (
self.current_billing_term_starts_at,
self.current_billing_term_ends_at,
)

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

# 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.
Comment on lines +625 to +627

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# 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

return starts_at <= timezone.now() <= ends_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.
Comment on lines +634 to +635

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# 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),
)


class OrganisationAPIUsageNotification(models.Model):
Expand Down
17 changes: 17 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,17 @@ 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.current_billing_period) is None:
return None
starts_at, ends_at = period
return CurrentBillingPeriodSerializer(
{"ends_at": ends_at, "starts_at": starts_at}
).data


class OrganisationSerializerFull(serializers.ModelSerializer): # type: ignore[type-arg]
subscription = SubscriptionSerializer(required=False)
Expand Down
155 changes: 155 additions & 0 deletions api/tests/unit/organisations/test_unit_organisations_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1024,3 +1024,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_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),
)


# 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",
[
# 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. 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"),
],
)
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
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 @@ -20129,6 +20129,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 @@ -27856,6 +27867,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