From 2c302294874605e452e7761c5c7cd7223108b860 Mon Sep 17 00:00:00 2001 From: Alberto Leal Date: Wed, 15 Apr 2026 20:48:13 -0400 Subject: [PATCH 1/6] fix(billing): Account for gifted quantities in productIsEnabled check productIsEnabled() checked only `reserved === 0` to determine if a product was PAYG-only, ignoring the `free` (gifted) field. For orgs on am3_t_ent with legacy soft cap and gifted monitors, this caused Cron Monitors and Uptime Monitors to incorrectly show "Upgrade required" even though they had available quota via gifted quantities. Use `prepaid` (which equals `reserved + free`) instead of `reserved` alone, consistent with how the backend quota logic already works. Also fix the same issue in URL-based product selection on the subscription usage overview page. Co-Authored-By: Claude Opus 4.6 --- static/gsApp/utils/billing.spec.tsx | 37 ++++++++++ static/gsApp/utils/billing.tsx | 2 +- .../usageOverview/components/panel.spec.tsx | 40 +++++++++++ .../usageOverview/components/table.spec.tsx | 38 +++++++++++ .../usageOverview/index.spec.tsx | 68 +++++++++++++++++++ .../subscriptionPage/usageOverview/index.tsx | 2 +- 6 files changed, 185 insertions(+), 2 deletions(-) diff --git a/static/gsApp/utils/billing.spec.tsx b/static/gsApp/utils/billing.spec.tsx index 0412e458b416bd..15836311e0e72b 100644 --- a/static/gsApp/utils/billing.spec.tsx +++ b/static/gsApp/utils/billing.spec.tsx @@ -1348,6 +1348,43 @@ 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); + }); }); describe('getSeerTrialCategory', () => { diff --git a/static/gsApp/utils/billing.tsx b/static/gsApp/utils/billing.tsx index 1284ad0059c27c..e38476f3571e75 100644 --- a/static/gsApp/utils/billing.tsx +++ b/static/gsApp/utils/billing.tsx @@ -1040,7 +1040,7 @@ export function productIsEnabled( if (!metricHistory) { return false; } - const isPaygOnly = metricHistory.reserved === 0; + const isPaygOnly = (metricHistory.prepaid ?? 0) === 0; return ( !isPaygOnly || metricHistory.onDemandBudget > 0 || diff --git a/static/gsApp/views/subscriptionPage/usageOverview/components/panel.spec.tsx b/static/gsApp/views/subscriptionPage/usageOverview/components/panel.spec.tsx index 28ee5d372be356..e0d5edaac1af7d 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview/components/panel.spec.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/components/panel.spec.tsx @@ -669,6 +669,46 @@ 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('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..5d9d6fac03fba9 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview/components/table.spec.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/components/table.spec.tsx @@ -428,6 +428,44 @@ 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 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..047fd562b4a0b6 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview/index.spec.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/index.spec.tsx @@ -135,6 +135,74 @@ 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('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..9ce61ca19a7ed0 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview/index.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/index.tsx @@ -59,7 +59,7 @@ export function UsageOverview({ ]?.dataCategories.every(category => checkIsAddOnChildCategory(subscription, category, true) ) - : (subscription.categories[productFromQuery as DataCategory]?.reserved ?? 0) > + : (subscription.categories[productFromQuery as DataCategory]?.prepaid ?? 0) > 0 || !!getActiveProductTrial( subscription.productTrials ?? null, From 97954f107beacf826cfaba7402c537f850d51d37 Mon Sep 17 00:00:00 2001 From: Alberto Leal Date: Wed, 15 Apr 2026 21:04:36 -0400 Subject: [PATCH 2/6] fix(billing): Also treat softCapType as enabled in productIsEnabled Legacy soft cap orgs with TRUE_FORWARD or ON_DEMAND billing on a category can use that product without prepaid quota. Add softCapType to the productIsEnabled check and URL-based product selection so these orgs don't see "Upgrade required" on the subscription page. Co-Authored-By: Claude Opus 4.6 --- static/gsApp/utils/billing.spec.tsx | 22 ++++++++++ static/gsApp/utils/billing.tsx | 2 +- .../usageOverview/components/panel.spec.tsx | 23 +++++++++++ .../usageOverview/components/table.spec.tsx | 41 +++++++++++++++++++ .../usageOverview/index.spec.tsx | 37 +++++++++++++++++ .../subscriptionPage/usageOverview/index.tsx | 1 + 6 files changed, 125 insertions(+), 1 deletion(-) diff --git a/static/gsApp/utils/billing.spec.tsx b/static/gsApp/utils/billing.spec.tsx index 15836311e0e72b..0396e52df5735e 100644 --- a/static/gsApp/utils/billing.spec.tsx +++ b/static/gsApp/utils/billing.spec.tsx @@ -1385,6 +1385,28 @@ describe('productIsEnabled', () => { }; 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); + }); }); describe('getSeerTrialCategory', () => { diff --git a/static/gsApp/utils/billing.tsx b/static/gsApp/utils/billing.tsx index e38476f3571e75..6ee3b9e5acd850 100644 --- a/static/gsApp/utils/billing.tsx +++ b/static/gsApp/utils/billing.tsx @@ -1040,7 +1040,7 @@ export function productIsEnabled( if (!metricHistory) { return false; } - const isPaygOnly = (metricHistory.prepaid ?? 0) === 0; + const isPaygOnly = (metricHistory.prepaid ?? 0) === 0 && !metricHistory.softCapType; return ( !isPaygOnly || metricHistory.onDemandBudget > 0 || diff --git a/static/gsApp/views/subscriptionPage/usageOverview/components/panel.spec.tsx b/static/gsApp/views/subscriptionPage/usageOverview/components/panel.spec.tsx index e0d5edaac1af7d..69d76e5a346b8a 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview/components/panel.spec.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/components/panel.spec.tsx @@ -709,6 +709,29 @@ describe('ProductBreakdownPanel', () => { 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('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 5d9d6fac03fba9..95723ab9e5c46f 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview/components/table.spec.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/components/table.spec.tsx @@ -466,6 +466,47 @@ describe('UsageOverviewTable', () => { 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 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 047fd562b4a0b6..435ef003d1699b 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview/index.spec.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/index.spec.tsx @@ -203,6 +203,43 @@ describe('UsageOverview', () => { 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('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 9ce61ca19a7ed0..330f84a6a07f5a 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview/index.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/index.tsx @@ -61,6 +61,7 @@ export function UsageOverview({ ) : (subscription.categories[productFromQuery as DataCategory]?.prepaid ?? 0) > 0 || + !!subscription.categories[productFromQuery as DataCategory]?.softCapType || !!getActiveProductTrial( subscription.productTrials ?? null, productFromQuery as DataCategory From 0c0740fc71354a2c8519afd5f5b2b4c726477e52 Mon Sep 17 00:00:00 2001 From: Alberto Leal Date: Thu, 16 Apr 2026 14:40:03 -0400 Subject: [PATCH 3/6] fix(billing): Also check hasSoftCap for legacy soft cap orgs subscription.soft_cap (legacy flag, serialized as hasSoftCap) and metric_history.soft_cap_type (per-category enum) are orthogonal fields in getsentry that are not kept in sync. Legacy soft cap orgs can have hasSoftCap=true with soft_cap_type=null on newer categories like MONITOR_SEAT and UPTIME, because create_new_category_histories does not inherit soft_cap_type from siblings or from the subscription flag. This matches the backend pattern used in getsentry quotas.py, tally_usage.py, seat_policy/monitor.py, and billingseatassignment.py: subscription.soft_cap OR metric_history.soft_cap_type is not None Co-Authored-By: Claude Opus 4.7 --- static/gsApp/utils/billing.spec.tsx | 21 +++++++++ static/gsApp/utils/billing.tsx | 5 ++- .../usageOverview/components/panel.spec.tsx | 27 ++++++++++++ .../usageOverview/components/table.spec.tsx | 44 +++++++++++++++++++ .../usageOverview/index.spec.tsx | 37 ++++++++++++++++ .../subscriptionPage/usageOverview/index.tsx | 2 + 6 files changed, 135 insertions(+), 1 deletion(-) diff --git a/static/gsApp/utils/billing.spec.tsx b/static/gsApp/utils/billing.spec.tsx index 0396e52df5735e..ce9ba053e1e78f 100644 --- a/static/gsApp/utils/billing.spec.tsx +++ b/static/gsApp/utils/billing.spec.tsx @@ -1407,6 +1407,27 @@ describe('productIsEnabled', () => { }; 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 6ee3b9e5acd850..a8b0154e7058fa 100644 --- a/static/gsApp/utils/billing.tsx +++ b/static/gsApp/utils/billing.tsx @@ -1040,7 +1040,10 @@ export function productIsEnabled( if (!metricHistory) { return false; } - const isPaygOnly = (metricHistory.prepaid ?? 0) === 0 && !metricHistory.softCapType; + const isPaygOnly = + (metricHistory.prepaid ?? 0) === 0 && + !metricHistory.softCapType && + !subscription.hasSoftCap; return ( !isPaygOnly || metricHistory.onDemandBudget > 0 || diff --git a/static/gsApp/views/subscriptionPage/usageOverview/components/panel.spec.tsx b/static/gsApp/views/subscriptionPage/usageOverview/components/panel.spec.tsx index 69d76e5a346b8a..cb3c0c084baee4 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview/components/panel.spec.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/components/panel.spec.tsx @@ -732,6 +732,33 @@ describe('ProductBreakdownPanel', () => { 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 95723ab9e5c46f..41906ff86b7772 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview/components/table.spec.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/components/table.spec.tsx @@ -507,6 +507,50 @@ describe('UsageOverviewTable', () => { 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 435ef003d1699b..c1681add202924 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview/index.spec.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/index.spec.tsx @@ -240,6 +240,43 @@ describe('UsageOverview', () => { 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('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 330f84a6a07f5a..43638623b2d4ce 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview/index.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/index.tsx @@ -62,6 +62,8 @@ export function UsageOverview({ : (subscription.categories[productFromQuery as DataCategory]?.prepaid ?? 0) > 0 || !!subscription.categories[productFromQuery as DataCategory]?.softCapType || + (!!subscription.categories[productFromQuery as DataCategory] && + subscription.hasSoftCap) || !!getActiveProductTrial( subscription.productTrials ?? null, productFromQuery as DataCategory From 5ba6e4a18df7a9ec4974084b66bb65616af0e992 Mon Sep 17 00:00:00 2001 From: Alberto Leal Date: Thu, 16 Apr 2026 15:20:38 -0400 Subject: [PATCH 4/6] ref(billing): Extract metricHistory local in URL product selection Pull the repeated subscription.categories[productFromQuery as DataCategory] lookup and the productFromQuery as DataCategory cast into locals. No behavior change. Co-Authored-By: Claude Opus 4.7 --- .../subscriptionPage/usageOverview/index.tsx | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/static/gsApp/views/subscriptionPage/usageOverview/index.tsx b/static/gsApp/views/subscriptionPage/usageOverview/index.tsx index 43638623b2d4ce..9cc3c84002ee13 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,20 +61,13 @@ export function UsageOverview({ ]?.dataCategories.every(category => checkIsAddOnChildCategory(subscription, category, true) ) - : (subscription.categories[productFromQuery as DataCategory]?.prepaid ?? 0) > - 0 || - !!subscription.categories[productFromQuery as DataCategory]?.softCapType || - (!!subscription.categories[productFromQuery as DataCategory] && - subscription.hasSoftCap) || - !!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 From c8d7ebe0a70eb3e56e56d6048ee7937ef4bf4b33 Mon Sep 17 00:00:00 2001 From: Alberto Leal Date: Thu, 16 Apr 2026 15:54:23 -0400 Subject: [PATCH 5/6] ref(billing): Rename isPaygOnly to hasNonPaygAccess/hasPaygBudget The isPaygOnly name read awkwardly through a double negation (!isPaygOnly) and was semantically misleading for the per-category soft cap case (the category is billed via soft cap, not PAYG). Split the condition into two positively-named tiers that mirror the actual access model: - hasNonPaygAccess: prepaid quota, per-category soft cap billing, or subscription-level legacy soft cap - hasPaygBudget: per-category or shared on-demand budget A category is enabled if either tier grants access. Add a regression test for UNLIMITED_RESERVED (-1) prepaid, since the inversion from === 0 to > 0 would silently exclude unlimited (the correct inversion is !== 0). Co-Authored-By: Claude Opus 4.7 --- static/gsApp/utils/billing.spec.tsx | 10 ++++++++++ static/gsApp/utils/billing.tsx | 15 +++++++-------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/static/gsApp/utils/billing.spec.tsx b/static/gsApp/utils/billing.spec.tsx index ce9ba053e1e78f..c13af248ca9b9d 100644 --- a/static/gsApp/utils/billing.spec.tsx +++ b/static/gsApp/utils/billing.spec.tsx @@ -1386,6 +1386,16 @@ describe('productIsEnabled', () => { 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!, diff --git a/static/gsApp/utils/billing.tsx b/static/gsApp/utils/billing.tsx index a8b0154e7058fa..8d77304d868339 100644 --- a/static/gsApp/utils/billing.tsx +++ b/static/gsApp/utils/billing.tsx @@ -1040,16 +1040,15 @@ export function productIsEnabled( if (!metricHistory) { return false; } - const isPaygOnly = - (metricHistory.prepaid ?? 0) === 0 && - !metricHistory.softCapType && - !subscription.hasSoftCap; - 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; } /** From 4bcb6384da01b17953278111b7acd7b58eec7968 Mon Sep 17 00:00:00 2001 From: Alberto Leal Date: Thu, 16 Apr 2026 16:51:51 -0400 Subject: [PATCH 6/6] fix(billing): Make URL product selection recognize UNLIMITED_RESERVED prepaid The prepaid check in index.tsx used `> 0`, which treats the UNLIMITED_RESERVED=-1 sentinel as "no quota" and silently rewrites the URL back to the current selectedProduct. Real paying customers have reserved=-1 on categories like INSTALLABLE_BUILD and SIZE_ANALYSIS (via correct_enterprise_reserved in getsentry/billing/plans/__init__.py), so deep-linking to those products on the subscription page didn't work. Change to `!== 0` to match productIsEnabled in billing.tsx. Add a regression test that fails without the fix. Co-Authored-By: Claude Opus 4.7 --- .../usageOverview/index.spec.tsx | 35 +++++++++++++++++++ .../subscriptionPage/usageOverview/index.tsx | 2 +- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/static/gsApp/views/subscriptionPage/usageOverview/index.spec.tsx b/static/gsApp/views/subscriptionPage/usageOverview/index.spec.tsx index c1681add202924..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'; @@ -277,6 +278,40 @@ describe('UsageOverview', () => { 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 9cc3c84002ee13..a69e13bc6b24ab 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview/index.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/index.tsx @@ -61,7 +61,7 @@ export function UsageOverview({ ]?.dataCategories.every(category => checkIsAddOnChildCategory(subscription, category, true) ) - : (metricHistory?.prepaid ?? 0) > 0 || + : (metricHistory?.prepaid ?? 0) !== 0 || !!metricHistory?.softCapType || (!!metricHistory && subscription.hasSoftCap) || !!getActiveProductTrial(subscription.productTrials ?? null, dataCategory) ||