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