diff --git a/static/gsApp/utils/billing.spec.tsx b/static/gsApp/utils/billing.spec.tsx
index 0412e458b416bd..c13af248ca9b9d 100644
--- a/static/gsApp/utils/billing.spec.tsx
+++ b/static/gsApp/utils/billing.spec.tsx
@@ -1348,6 +1348,96 @@ describe('productIsEnabled', () => {
};
expect(productIsEnabled(subscription, DataCategory.PROFILE_DURATION)).toBe(true);
});
+ it('returns true for gifted-only data categories (reserved=0, free>0)', () => {
+ subscription.categories.monitorSeats = {
+ ...subscription.categories.monitorSeats!,
+ reserved: 0,
+ free: 1,
+ prepaid: 1,
+ };
+ expect(productIsEnabled(subscription, DataCategory.MONITOR_SEATS)).toBe(true);
+
+ subscription.categories.uptime = {
+ ...subscription.categories.uptime!,
+ reserved: 0,
+ free: 1,
+ prepaid: 1,
+ };
+ expect(productIsEnabled(subscription, DataCategory.UPTIME)).toBe(true);
+ });
+
+ it('returns false for categories with no quota at all', () => {
+ subscription.categories.monitorSeats = {
+ ...subscription.categories.monitorSeats!,
+ reserved: 0,
+ free: 0,
+ prepaid: 0,
+ };
+ expect(productIsEnabled(subscription, DataCategory.MONITOR_SEATS)).toBe(false);
+ });
+
+ it('returns true for categories with both reserved and gifted quota', () => {
+ subscription.categories.monitorSeats = {
+ ...subscription.categories.monitorSeats!,
+ reserved: 1,
+ free: 1,
+ prepaid: 2,
+ };
+ expect(productIsEnabled(subscription, DataCategory.MONITOR_SEATS)).toBe(true);
+ });
+
+ it('returns true for categories with unlimited prepaid (UNLIMITED_RESERVED sentinel)', () => {
+ subscription.categories.monitorSeats = {
+ ...subscription.categories.monitorSeats!,
+ reserved: UNLIMITED_RESERVED,
+ free: 0,
+ prepaid: UNLIMITED_RESERVED,
+ };
+ expect(productIsEnabled(subscription, DataCategory.MONITOR_SEATS)).toBe(true);
+ });
+
+ it('returns true for categories with softCapType TRUE_FORWARD even with no prepaid quota', () => {
+ subscription.categories.monitorSeats = {
+ ...subscription.categories.monitorSeats!,
+ reserved: 0,
+ free: 0,
+ prepaid: 0,
+ softCapType: 'TRUE_FORWARD',
+ };
+ expect(productIsEnabled(subscription, DataCategory.MONITOR_SEATS)).toBe(true);
+ });
+
+ it('returns true for categories with softCapType ON_DEMAND even with no prepaid quota', () => {
+ subscription.categories.monitorSeats = {
+ ...subscription.categories.monitorSeats!,
+ reserved: 0,
+ free: 0,
+ prepaid: 0,
+ softCapType: 'ON_DEMAND',
+ };
+ expect(productIsEnabled(subscription, DataCategory.MONITOR_SEATS)).toBe(true);
+ });
+
+ it('returns true for subscriptions with hasSoftCap=true even when softCapType is null and no prepaid quota', () => {
+ subscription.hasSoftCap = true;
+ subscription.categories.monitorSeats = {
+ ...subscription.categories.monitorSeats!,
+ reserved: 0,
+ free: 0,
+ prepaid: 0,
+ softCapType: null,
+ };
+ expect(productIsEnabled(subscription, DataCategory.MONITOR_SEATS)).toBe(true);
+
+ subscription.categories.uptime = {
+ ...subscription.categories.uptime!,
+ reserved: 0,
+ free: 0,
+ prepaid: 0,
+ softCapType: null,
+ };
+ expect(productIsEnabled(subscription, DataCategory.UPTIME)).toBe(true);
+ });
});
describe('getSeerTrialCategory', () => {
diff --git a/static/gsApp/utils/billing.tsx b/static/gsApp/utils/billing.tsx
index 1284ad0059c27c..8d77304d868339 100644
--- a/static/gsApp/utils/billing.tsx
+++ b/static/gsApp/utils/billing.tsx
@@ -1040,13 +1040,15 @@ export function productIsEnabled(
if (!metricHistory) {
return false;
}
- const isPaygOnly = metricHistory.reserved === 0;
- return (
- !isPaygOnly ||
+ const hasNonPaygAccess =
+ (metricHistory.prepaid ?? 0) !== 0 ||
+ !!metricHistory.softCapType ||
+ !!subscription.hasSoftCap;
+ const hasPaygBudget =
metricHistory.onDemandBudget > 0 ||
(subscription.onDemandBudgets?.budgetMode === OnDemandBudgetMode.SHARED &&
- subscription.onDemandBudgets.sharedMaxBudget > 0)
- );
+ subscription.onDemandBudgets.sharedMaxBudget > 0);
+ return hasNonPaygAccess || hasPaygBudget;
}
/**
diff --git a/static/gsApp/views/subscriptionPage/usageOverview/components/panel.spec.tsx b/static/gsApp/views/subscriptionPage/usageOverview/components/panel.spec.tsx
index 28ee5d372be356..cb3c0c084baee4 100644
--- a/static/gsApp/views/subscriptionPage/usageOverview/components/panel.spec.tsx
+++ b/static/gsApp/views/subscriptionPage/usageOverview/components/panel.spec.tsx
@@ -669,6 +669,96 @@ describe('ProductBreakdownPanel', () => {
await screen.findByText('Active Contributors (0)'); // wait for billed seats to be loaded
});
+ it('does not show Upgrade required for gifted-only monitors', async () => {
+ subscription.categories.monitorSeats = {
+ ...subscription.categories.monitorSeats!,
+ reserved: 0,
+ free: 1,
+ prepaid: 1,
+ };
+ render(
+
+ );
+
+ await screen.findByRole('heading', {name: 'Cron Monitors'});
+ expect(screen.queryByText('Upgrade required')).not.toBeInTheDocument();
+ });
+
+ it('does not show Upgrade required for gifted-only uptime monitors', async () => {
+ subscription.categories.uptime = {
+ ...subscription.categories.uptime!,
+ reserved: 0,
+ free: 1,
+ prepaid: 1,
+ };
+ render(
+
+ );
+
+ await screen.findByRole('heading', {name: 'Uptime Monitors'});
+ expect(screen.queryByText('Upgrade required')).not.toBeInTheDocument();
+ });
+
+ it('does not show Upgrade required for soft cap monitors with no prepaid quota', async () => {
+ subscription.categories.monitorSeats = {
+ ...subscription.categories.monitorSeats!,
+ reserved: 0,
+ free: 0,
+ prepaid: 0,
+ softCapType: 'TRUE_FORWARD',
+ };
+ subscription.hasSoftCap = true;
+ SubscriptionStore.set(organization.slug, subscription);
+ render(
+
+ );
+
+ await screen.findByRole('heading', {name: 'Cron Monitors'});
+ expect(screen.queryByText('Upgrade required')).not.toBeInTheDocument();
+ });
+
+ it('does not show Upgrade required when hasSoftCap=true but category softCapType is null', async () => {
+ // Legacy soft cap orgs can have hasSoftCap=true on the subscription but
+ // softCapType=null on newer categories (e.g. MONITOR_SEAT, UPTIME)
+ // because create_new_category_histories does not inherit soft_cap_type
+ // from siblings or from the subscription-level soft_cap flag.
+ subscription.hasSoftCap = true;
+ subscription.categories.monitorSeats = {
+ ...subscription.categories.monitorSeats!,
+ reserved: 0,
+ free: 0,
+ prepaid: 0,
+ softCapType: null,
+ };
+ SubscriptionStore.set(organization.slug, subscription);
+ render(
+
+ );
+
+ await screen.findByRole('heading', {name: 'Cron Monitors'});
+ expect(screen.queryByText('Upgrade required')).not.toBeInTheDocument();
+ });
+
it('renders for data category with missing metric history', async () => {
// NOTE(isabella): currently, we would never have this case IRL
// since we would not allow a data category without a metric history to be
diff --git a/static/gsApp/views/subscriptionPage/usageOverview/components/table.spec.tsx b/static/gsApp/views/subscriptionPage/usageOverview/components/table.spec.tsx
index 93639df7efa7b0..41906ff86b7772 100644
--- a/static/gsApp/views/subscriptionPage/usageOverview/components/table.spec.tsx
+++ b/static/gsApp/views/subscriptionPage/usageOverview/components/table.spec.tsx
@@ -428,6 +428,129 @@ describe('UsageOverviewTable', () => {
expect(screen.queryByRole('cell', {name: 'Errors'})).not.toBeInTheDocument();
});
+ it('renders gifted-only monitors as enabled, not disabled', async () => {
+ const sub = SubscriptionFixture({organization, plan: 'am3_business'});
+ sub.categories.monitorSeats = {
+ ...sub.categories.monitorSeats!,
+ reserved: 0,
+ free: 1,
+ prepaid: 1,
+ };
+ sub.categories.uptime = {
+ ...sub.categories.uptime!,
+ reserved: 0,
+ free: 1,
+ prepaid: 1,
+ };
+ SubscriptionStore.set(organization.slug, sub);
+
+ render(
+
+ );
+
+ await screen.findByRole('columnheader', {name: 'Feature'});
+
+ // Gifted-only monitors should appear as enabled rows
+ expect(screen.getByTestId('product-row-monitorSeats')).toBeInTheDocument();
+ expect(
+ screen.queryByTestId('product-row-disabled-monitorSeats')
+ ).not.toBeInTheDocument();
+
+ expect(screen.getByTestId('product-row-uptime')).toBeInTheDocument();
+ expect(screen.queryByTestId('product-row-disabled-uptime')).not.toBeInTheDocument();
+ });
+
+ it('renders soft cap monitors as enabled, not disabled', async () => {
+ const sub = SubscriptionFixture({organization, plan: 'am3_business'});
+ sub.categories.monitorSeats = {
+ ...sub.categories.monitorSeats!,
+ reserved: 0,
+ free: 0,
+ prepaid: 0,
+ softCapType: 'TRUE_FORWARD',
+ };
+ sub.categories.uptime = {
+ ...sub.categories.uptime!,
+ reserved: 0,
+ free: 0,
+ prepaid: 0,
+ softCapType: 'TRUE_FORWARD',
+ };
+ sub.hasSoftCap = true;
+ SubscriptionStore.set(organization.slug, sub);
+
+ render(
+
+ );
+
+ await screen.findByRole('columnheader', {name: 'Feature'});
+
+ // Soft cap monitors should appear as enabled rows
+ expect(screen.getByTestId('product-row-monitorSeats')).toBeInTheDocument();
+ expect(
+ screen.queryByTestId('product-row-disabled-monitorSeats')
+ ).not.toBeInTheDocument();
+
+ expect(screen.getByTestId('product-row-uptime')).toBeInTheDocument();
+ expect(screen.queryByTestId('product-row-disabled-uptime')).not.toBeInTheDocument();
+ });
+
+ it('renders hasSoftCap monitors as enabled even when category softCapType is null', async () => {
+ // Legacy soft cap orgs can have hasSoftCap=true on the subscription but
+ // softCapType=null on newer categories (e.g. MONITOR_SEAT, UPTIME)
+ // because create_new_category_histories does not inherit soft_cap_type
+ // from siblings or from the subscription-level soft_cap flag.
+ const sub = SubscriptionFixture({organization, plan: 'am3_business'});
+ sub.hasSoftCap = true;
+ sub.categories.monitorSeats = {
+ ...sub.categories.monitorSeats!,
+ reserved: 0,
+ free: 0,
+ prepaid: 0,
+ softCapType: null,
+ };
+ sub.categories.uptime = {
+ ...sub.categories.uptime!,
+ reserved: 0,
+ free: 0,
+ prepaid: 0,
+ softCapType: null,
+ };
+ SubscriptionStore.set(organization.slug, sub);
+
+ render(
+
+ );
+
+ await screen.findByRole('columnheader', {name: 'Feature'});
+
+ expect(screen.getByTestId('product-row-monitorSeats')).toBeInTheDocument();
+ expect(
+ screen.queryByTestId('product-row-disabled-monitorSeats')
+ ).not.toBeInTheDocument();
+
+ expect(screen.getByTestId('product-row-uptime')).toBeInTheDocument();
+ expect(screen.queryByTestId('product-row-disabled-uptime')).not.toBeInTheDocument();
+ });
+
it('renders disabled product rows', async () => {
// both profiling categories are disabled because there is no PAYG
subscription.onDemandBudgets = {
diff --git a/static/gsApp/views/subscriptionPage/usageOverview/index.spec.tsx b/static/gsApp/views/subscriptionPage/usageOverview/index.spec.tsx
index da0907c1e1726b..4122fa47f50c40 100644
--- a/static/gsApp/views/subscriptionPage/usageOverview/index.spec.tsx
+++ b/static/gsApp/views/subscriptionPage/usageOverview/index.spec.tsx
@@ -9,6 +9,7 @@ import {DataCategory} from 'sentry/types/core';
import * as useMedia from 'sentry/utils/useMedia';
import {SecondaryNavigationContextProvider} from 'sentry/views/navigation/secondaryNavigationContext';
+import {UNLIMITED_RESERVED} from 'getsentry/constants';
import {SubscriptionStore} from 'getsentry/stores/subscriptionStore';
import {UsageOverview} from 'getsentry/views/subscriptionPage/usageOverview';
@@ -135,6 +136,182 @@ describe('UsageOverview', () => {
expect(screen.queryByRole('heading', {name: 'Transactions'})).not.toBeInTheDocument();
});
+ it('selects gifted-only product from URL query parameter', async () => {
+ jest
+ .spyOn(useMedia, 'useMedia')
+ .mockImplementation(query => query.includes('min-width'));
+ const originalMonitorSeats = subscription.categories.monitorSeats;
+ subscription.categories.monitorSeats = {
+ ...subscription.categories.monitorSeats!,
+ reserved: 0,
+ free: 1,
+ prepaid: 1,
+ };
+ render(
+ ,
+ {
+ additionalWrapper: SecondaryNavigationContextProvider,
+ initialRouterConfig: {
+ location: {
+ pathname: '/organizations/org-slug/subscription/usage-overview',
+ query: {product: DataCategory.MONITOR_SEATS},
+ },
+ },
+ }
+ );
+
+ await screen.findByRole('heading', {name: 'Cron Monitors'});
+ expect(screen.queryByRole('heading', {name: 'Errors'})).not.toBeInTheDocument();
+ subscription.categories.monitorSeats = originalMonitorSeats;
+ });
+
+ it('does not select product from URL when no quota at all', async () => {
+ jest
+ .spyOn(useMedia, 'useMedia')
+ .mockImplementation(query => query.includes('min-width'));
+ const originalMonitorSeats = subscription.categories.monitorSeats;
+ subscription.categories.monitorSeats = {
+ ...subscription.categories.monitorSeats!,
+ reserved: 0,
+ free: 0,
+ prepaid: 0,
+ };
+ render(
+ ,
+ {
+ additionalWrapper: SecondaryNavigationContextProvider,
+ initialRouterConfig: {
+ location: {
+ pathname: '/organizations/org-slug/subscription/usage-overview',
+ query: {product: DataCategory.MONITOR_SEATS},
+ },
+ },
+ }
+ );
+
+ await screen.findByRole('heading', {name: 'Errors'});
+ expect(
+ screen.queryByRole('heading', {name: 'Cron Monitors'})
+ ).not.toBeInTheDocument();
+ subscription.categories.monitorSeats = originalMonitorSeats;
+ });
+
+ it('selects product with softCapType from URL query parameter', async () => {
+ jest
+ .spyOn(useMedia, 'useMedia')
+ .mockImplementation(query => query.includes('min-width'));
+ const originalMonitorSeats = subscription.categories.monitorSeats;
+ const originalHasSoftCap = subscription.hasSoftCap;
+ subscription.categories.monitorSeats = {
+ ...subscription.categories.monitorSeats!,
+ reserved: 0,
+ free: 0,
+ prepaid: 0,
+ softCapType: 'TRUE_FORWARD',
+ };
+ subscription.hasSoftCap = true;
+ render(
+ ,
+ {
+ additionalWrapper: SecondaryNavigationContextProvider,
+ initialRouterConfig: {
+ location: {
+ pathname: '/organizations/org-slug/subscription/usage-overview',
+ query: {product: DataCategory.MONITOR_SEATS},
+ },
+ },
+ }
+ );
+
+ await screen.findByRole('heading', {name: 'Cron Monitors'});
+ expect(screen.queryByRole('heading', {name: 'Errors'})).not.toBeInTheDocument();
+ subscription.categories.monitorSeats = originalMonitorSeats;
+ subscription.hasSoftCap = originalHasSoftCap;
+ });
+
+ it('selects product from URL when subscription has hasSoftCap=true and category has null softCapType', async () => {
+ jest
+ .spyOn(useMedia, 'useMedia')
+ .mockImplementation(query => query.includes('min-width'));
+ const originalMonitorSeats = subscription.categories.monitorSeats;
+ const originalHasSoftCap = subscription.hasSoftCap;
+ subscription.hasSoftCap = true;
+ subscription.categories.monitorSeats = {
+ ...subscription.categories.monitorSeats!,
+ reserved: 0,
+ free: 0,
+ prepaid: 0,
+ softCapType: null,
+ };
+ render(
+ ,
+ {
+ additionalWrapper: SecondaryNavigationContextProvider,
+ initialRouterConfig: {
+ location: {
+ pathname: '/organizations/org-slug/subscription/usage-overview',
+ query: {product: DataCategory.MONITOR_SEATS},
+ },
+ },
+ }
+ );
+
+ await screen.findByRole('heading', {name: 'Cron Monitors'});
+ expect(screen.queryByRole('heading', {name: 'Errors'})).not.toBeInTheDocument();
+ subscription.categories.monitorSeats = originalMonitorSeats;
+ subscription.hasSoftCap = originalHasSoftCap;
+ });
+
+ it('selects product from URL when category has unlimited prepaid (UNLIMITED_RESERVED sentinel)', async () => {
+ jest
+ .spyOn(useMedia, 'useMedia')
+ .mockImplementation(query => query.includes('min-width'));
+ const originalMonitorSeats = subscription.categories.monitorSeats;
+ subscription.categories.monitorSeats = {
+ ...subscription.categories.monitorSeats!,
+ reserved: UNLIMITED_RESERVED,
+ free: 0,
+ prepaid: UNLIMITED_RESERVED,
+ softCapType: null,
+ };
+ render(
+ ,
+ {
+ additionalWrapper: SecondaryNavigationContextProvider,
+ initialRouterConfig: {
+ location: {
+ pathname: '/organizations/org-slug/subscription/usage-overview',
+ query: {product: DataCategory.MONITOR_SEATS},
+ },
+ },
+ }
+ );
+
+ await screen.findByRole('heading', {name: 'Cron Monitors'});
+ expect(screen.queryByRole('heading', {name: 'Errors'})).not.toBeInTheDocument();
+ subscription.categories.monitorSeats = originalMonitorSeats;
+ });
+
it('can switch panel by clicking table rows', async () => {
jest
.spyOn(useMedia, 'useMedia')
diff --git a/static/gsApp/views/subscriptionPage/usageOverview/index.tsx b/static/gsApp/views/subscriptionPage/usageOverview/index.tsx
index 85c1292591e27d..a69e13bc6b24ab 100644
--- a/static/gsApp/views/subscriptionPage/usageOverview/index.tsx
+++ b/static/gsApp/views/subscriptionPage/usageOverview/index.tsx
@@ -51,6 +51,8 @@ export function UsageOverview({
if (productFromQuery) {
const isAddOn = checkIsAddOn(productFromQuery);
if (selectedProduct !== productFromQuery) {
+ const dataCategory = productFromQuery as DataCategory;
+ const metricHistory = subscription.categories[dataCategory];
const isSelectable = isAddOn
? (subscription.addOns?.[productFromQuery as AddOnCategory]?.enabled ??
false) &&
@@ -59,17 +61,13 @@ export function UsageOverview({
]?.dataCategories.every(category =>
checkIsAddOnChildCategory(subscription, category, true)
)
- : (subscription.categories[productFromQuery as DataCategory]?.reserved ?? 0) >
- 0 ||
- !!getActiveProductTrial(
- subscription.productTrials ?? null,
- productFromQuery as DataCategory
- ) ||
+ : (metricHistory?.prepaid ?? 0) !== 0 ||
+ !!metricHistory?.softCapType ||
+ (!!metricHistory && subscription.hasSoftCap) ||
+ !!getActiveProductTrial(subscription.productTrials ?? null, dataCategory) ||
(subscription.onDemandBudgets?.budgetMode === OnDemandBudgetMode.SHARED
? subscription.onDemandBudgets.sharedMaxBudget
- : (subscription.onDemandBudgets?.budgets?.[
- productFromQuery as DataCategory
- ] ?? 0)) > 0;
+ : (subscription.onDemandBudgets?.budgets?.[dataCategory] ?? 0)) > 0;
if (isSelectable) {
setSelectedProduct(
isAddOn