Skip to content
Draft
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
1 change: 1 addition & 0 deletions api/app_analytics/analytics_db_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,7 @@ def _get_start_date_and_stop_date_for_subscribed_organisation(
raise NotFound("No billing periods found for this organisation.")

month_delta = relativedelta(now, starts_at).months
month_delta += relativedelta(now, starts_at).years * 12
date_start = relativedelta(months=month_delta) + starts_at
return date_start, now

Expand Down
33 changes: 33 additions & 0 deletions api/tests/unit/app_analytics/test_analytics_db_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,39 @@ def test_get_usage_data__current_billing_period__passes_correct_date_range(
)


@pytest.mark.freeze_time("2023-01-19T09:09:47.325132+00:00")
def test_get_usage_data__current_billing_period_annual_plan__passes_correct_date_range(
mocker: MockerFixture,
settings: SettingsWrapper,
organisation: Organisation,
cache: OrganisationSubscriptionInformationCache,
) -> None:
# Given
# A billing term that started more than 12 months ago (annual plan): the
# months-only delta used to drop the years component and land a year early.
settings.USE_POSTGRES_FOR_ANALYTICS = True
cache.current_billing_term_starts_at = datetime(
2021, 12, 30, 9, 9, 47, 325132, tzinfo=UTC
)
cache.save()
mocked_get_usage_data_from_local_db = mocker.patch(
"app_analytics.analytics_db_service.get_usage_data_from_local_db", autospec=True
)

# When
get_usage_data(organisation, period=CURRENT_BILLING_PERIOD)

# Then the current period start is this month, not a year ago.
mocked_get_usage_data_from_local_db.assert_called_once_with(
organisation=organisation,
environment_id=None,
project_id=None,
date_start=datetime(2022, 12, 30, 9, 9, 47, 325132, tzinfo=UTC),
date_stop=datetime(2023, 1, 19, 9, 9, 47, 325132, tzinfo=UTC),
labels_filter=None,
)


@pytest.mark.freeze_time("2023-01-19T09:09:47.325132+00:00")
def test_get_usage_data__previous_billing_period__passes_correct_date_range(
mocker: MockerFixture,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { FC } from 'react'
import Tooltip from 'components/Tooltip'
import { GraceState } from './types'

type GraceChipProps = {
grace: GraceState
daysLeft?: number
}

const TONE: Record<GraceState, string> = {
available: 'success',
countdown: 'warning',
covering: 'info',
restricted: 'danger',
used: 'danger',
}

const LABEL: Record<GraceState, string> = {
available: 'Grace period: available',
countdown: 'Grace period: ending',
covering: 'Grace period: covering this period',
restricted: 'Restricted',
used: 'Grace period: used',
}

const EXPLANATION: Record<GraceState, string> = {
available:
'Your first month over the limit is covered. We never cut off your API without warning.',
countdown:
'You are over your limit. Flag serving pauses when the grace window ends, unless usage drops back under.',
covering:
'You are over your limit, but this month is covered by your grace period, so there is no overage charge.',
restricted:
'The grace window has passed. Flag serving and admin access are paused, but this page stays readable.',
used: 'Your grace period has already been used, so usage above the limit is charged as overage.',
}

/** PROTOTYPE (#8184). Grace period status, per the "Grace period states" design. */
const GraceChip: FC<GraceChipProps> = ({ daysLeft, grace }) => {
const label =
grace === 'countdown' && daysLeft ? `${daysLeft} days left` : LABEL[grace]

return (
<Tooltip
title={
<span className={`usage-proto__chip usage-proto__chip--${TONE[grace]}`}>
{label}
</span>
}
>
{EXPLANATION[grace]}
</Tooltip>
)
}

export default GraceChip
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { FC } from 'react'
import Constants from 'common/constants'
import { Button } from 'components/base/forms/Button'
import { UsageView } from './types'

type UsageBannerProps = {
view: UsageView
}

type Banner = {
tone: 'warning' | 'danger'
title: string
body: string
action?: string
}

const bannerFor = (view: UsageView): Banner | null => {
if (view.restricted) {
return {
action: 'Upgrade plan',
body: 'Flag serving and admin access are paused until usage drops below your limit or you upgrade. This page stays available so you can see what happened.',
title: 'Your organisation is restricted',
tone: 'danger',
}
}
if (view.grace === 'countdown') {
return {
action: 'Upgrade plan',
body: `You are over your plan limit. Flag serving pauses in ${
view.graceDaysLeft ?? 0
} days unless usage drops back under the limit.`,
title: 'Your organisation is over its plan limit',
tone: 'warning',
}
}
if (view.grace === 'covering') {
return {
body: 'This month is covered by your grace period, so there is no overage charge. A later month over the limit will be charged.',
title: 'Your organisation is over its plan limit',
tone: 'warning',
}
}
if (view.grace === 'used') {
return {
action: 'Upgrade plan',
body: 'Usage above your plan limit is charged as overage. Upgrading raises the limit and stops the charges.',
title: 'Your organisation has exceeded its plan limit',
tone: 'danger',
}
}
return null
}

/** PROTOTYPE (#8184). The over-limit and restricted headers from S2 and S4. */
const UsageBanner: FC<UsageBannerProps> = ({ view }) => {
const banner = bannerFor(view)
if (!banner) {
return null
}

return (
<div className={`usage-proto__banner usage-proto__banner--${banner.tone}`}>
<div>
<div className='usage-proto__banner-title'>{banner.title}</div>
<div className='usage-proto__banner-body'>{banner.body}</div>
</div>
{banner.action && (
<Button
onClick={() => {
document.location.replace(Constants.getUpgradeUrl('usage'))
}}
>
{banner.action}
</Button>
)}
</div>
)
}

export default UsageBanner
Loading
Loading