diff --git a/static/gsApp/utils/billing.spec.tsx b/static/gsApp/utils/billing.spec.tsx index 880cd0b2dd0492..7cfbd978fa6989 100644 --- a/static/gsApp/utils/billing.spec.tsx +++ b/static/gsApp/utils/billing.spec.tsx @@ -7,11 +7,19 @@ import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription'; import {DataCategory} from 'sentry/types/core'; -import {BILLION, GIGABYTE, MILLION, UNLIMITED} from 'getsentry/constants'; +import { + BILLION, + GIGABYTE, + MILLION, + RESERVED_BUDGET_QUOTA, + UNLIMITED, + UNLIMITED_RESERVED, +} from 'getsentry/constants'; import {AddOnCategory, OnDemandBudgetMode} from 'getsentry/types'; import type {ProductTrial, Subscription} from 'getsentry/types'; import { checkIsAddOn, + checkIsAddOnChildCategory, convertUsageToReservedUnit, formatReservedWithUnits, formatUsageWithUnits, @@ -1155,6 +1163,65 @@ describe('checkIsAddOn', () => { }); }); +describe('checkIsAddOnChildCategory', () => { + const organization = OrganizationFixture(); + let subscription: Subscription; + + beforeEach(() => { + subscription = SubscriptionFixture({organization, plan: 'am3_team'}); + }); + + it('returns false when parent add-on is unavailable', () => { + subscription.addOns!.seer = { + ...subscription.addOns?.seer!, + isAvailable: false, + }; + expect(checkIsAddOnChildCategory(subscription, DataCategory.SEER_USER, true)).toBe( + false + ); + }); + + it('returns true for zero reserved volume', () => { + subscription.categories.seerUsers = { + ...subscription.categories.seerUsers!, + reserved: 0, + }; + expect(checkIsAddOnChildCategory(subscription, DataCategory.SEER_USER, true)).toBe( + true + ); + }); + + it('returns true for RESERVED_BUDGET_QUOTA reserved volume', () => { + subscription.categories.seerAutofix = { + ...subscription.categories.seerAutofix!, + reserved: RESERVED_BUDGET_QUOTA, + }; + expect(checkIsAddOnChildCategory(subscription, DataCategory.SEER_AUTOFIX, true)).toBe( + true + ); + }); + + it('returns true for sub-categories regardless of reserved volume if not checking', () => { + subscription.categories.seerAutofix = { + ...subscription.categories.seerAutofix!, + reserved: UNLIMITED_RESERVED, + }; + expect( + checkIsAddOnChildCategory(subscription, DataCategory.SEER_AUTOFIX, false) + ).toBe(true); + }); + + it('returns false for sub-categories with non-zero reserved volume if checking', () => { + subscription.categories.seerAutofix = { + ...subscription.categories.seerAutofix!, + reserved: UNLIMITED_RESERVED, + }; + expect(checkIsAddOnChildCategory(subscription, DataCategory.SEER_AUTOFIX, true)).toBe( + false + ); + }); +}); + describe('getBilledCategory', () => { const organization = OrganizationFixture(); const subscription = SubscriptionFixture({organization, plan: 'am3_team'}); diff --git a/static/gsApp/utils/billing.tsx b/static/gsApp/utils/billing.tsx index 443df350734d85..cbdd069edcf3eb 100644 --- a/static/gsApp/utils/billing.tsx +++ b/static/gsApp/utils/billing.tsx @@ -897,6 +897,34 @@ export function checkIsAddOn( return Object.values(AddOnCategory).includes(selectedProduct as AddOnCategory); } +/** + * Check if a data category is a child category of an add-on. + * If `checkReserved` is true, the category is only considered a child if it has a reserved volume of 0 or RESERVED_BUDGET_QUOTA. + * If `checkReserved` is false, the category is considered a child if it is included in `dataCategories` for any available add-on. + */ +export function checkIsAddOnChildCategory( + subscription: Subscription, + category: DataCategory, + checkReserved: boolean +) { + const parentAddOn = Object.values(subscription.addOns ?? {}) + .filter(addOn => addOn.isAvailable) + .find(addOn => addOn.dataCategories.includes(category)); + if (!parentAddOn) { + return false; + } + + if (checkReserved) { + const metricHistory = subscription.categories[category]; + if (!metricHistory) { + return false; + } + return [RESERVED_BUDGET_QUOTA, 0].includes(metricHistory.reserved ?? 0); + } + + return true; +} + /** * Get the billed DataCategory for an add-on or DataCategory. */ diff --git a/static/gsApp/utils/dataCategory.spec.tsx b/static/gsApp/utils/dataCategory.spec.tsx index 9bb6e5ca44ee41..41018348d68301 100644 --- a/static/gsApp/utils/dataCategory.spec.tsx +++ b/static/gsApp/utils/dataCategory.spec.tsx @@ -16,6 +16,7 @@ import {DataCategory} from 'sentry/types/core'; import { getPlanCategoryName, getReservedBudgetDisplayName, + getSingularCategoryName, hasCategoryFeature, isByteCategory, listDisplayNames, @@ -259,6 +260,50 @@ describe('getPlanCategoryName', () => { ); }); + it('should title case category if specified', () => { + expect( + getPlanCategoryName({plan, category: DataCategory.MONITOR_SEATS, title: true}) + ).toBe('Cron Monitors'); + expect(getPlanCategoryName({plan, category: DataCategory.ERRORS, title: true})).toBe( + 'Errors' + ); + }); + + it('should display spans as accepted spans for DS', () => { + expect( + getPlanCategoryName({ + plan, + category: DataCategory.SPANS, + hadCustomDynamicSampling: true, + }) + ).toBe('Accepted spans'); + }); +}); + +describe('getSingularCategoryName', () => { + const plan = PlanDetailsLookupFixture('am3_team'); + + it('should capitalize category', () => { + expect(getSingularCategoryName({plan, category: DataCategory.TRANSACTIONS})).toBe( + 'Transaction' + ); + expect(getSingularCategoryName({plan, category: DataCategory.PROFILE_DURATION})).toBe( + 'Continuous profile hour' + ); + expect(getSingularCategoryName({plan, category: DataCategory.MONITOR_SEATS})).toBe( + 'Cron monitor' + ); + }); + + it('should title case category if specified', () => { + expect( + getSingularCategoryName({plan, category: DataCategory.MONITOR_SEATS, title: true}) + ).toBe('Cron Monitor'); + expect( + getSingularCategoryName({plan, category: DataCategory.ERRORS, title: true}) + ).toBe('Error'); + }); + it('should display spans as accepted spans for DS', () => { expect( getPlanCategoryName({ diff --git a/static/gsApp/utils/dataCategory.tsx b/static/gsApp/utils/dataCategory.tsx index 7e1ac92855bc81..acff61129390cc 100644 --- a/static/gsApp/utils/dataCategory.tsx +++ b/static/gsApp/utils/dataCategory.tsx @@ -1,6 +1,7 @@ import upperFirst from 'lodash/upperFirst'; import {DATA_CATEGORY_INFO} from 'sentry/constants'; +import {t} from 'sentry/locale'; import {DataCategory, DataCategoryExact} from 'sentry/types/core'; import type {Organization} from 'sentry/types/organization'; import oxfordizeArray from 'sentry/utils/oxfordizeArray'; @@ -68,13 +69,11 @@ export function getPlanCategoryName({ }: CategoryNameProps) { const displayNames = plan?.categoryDisplayNames?.[category]; const categoryName = - category === DataCategory.LOG_BYTE - ? 'logs' - : category === DataCategory.SPANS && hadCustomDynamicSampling - ? 'accepted spans' - : displayNames - ? displayNames.plural - : category; + category === DataCategory.SPANS && hadCustomDynamicSampling + ? t('accepted spans') + : displayNames + ? displayNames.plural + : category; return title ? toTitleCase(categoryName, {allowInnerUpperCase: true}) : capitalize @@ -95,7 +94,7 @@ export function getSingularCategoryName({ const displayNames = plan?.categoryDisplayNames?.[category]; const categoryName = category === DataCategory.SPANS && hadCustomDynamicSampling - ? 'accepted span' + ? t('accepted span') : displayNames ? displayNames.singular : category.substring(0, category.length - 1); diff --git a/static/gsApp/views/amCheckout/index.spec.tsx b/static/gsApp/views/amCheckout/index.spec.tsx index f838e7eda9aec8..118c66b39a3f17 100644 --- a/static/gsApp/views/amCheckout/index.spec.tsx +++ b/static/gsApp/views/amCheckout/index.spec.tsx @@ -4,10 +4,7 @@ import {RouteComponentPropsFixture} from 'sentry-fixture/routeComponentPropsFixt import {BillingConfigFixture} from 'getsentry-test/fixtures/billingConfig'; import {MetricHistoryFixture} from 'getsentry-test/fixtures/metricHistory'; -import { - SubscriptionFixture, - SubscriptionWithLegacySeerFixture, -} from 'getsentry-test/fixtures/subscription'; +import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription'; import { act, render, @@ -19,7 +16,7 @@ import { import SubscriptionStore from 'getsentry/stores/subscriptionStore'; import type {Subscription as SubscriptionType} from 'getsentry/types'; -import {OnDemandBudgetMode, PlanTier} from 'getsentry/types'; +import {AddOnCategory, OnDemandBudgetMode, PlanTier} from 'getsentry/types'; import AMCheckout from 'getsentry/views/amCheckout'; import {getCheckoutAPIData} from 'getsentry/views/amCheckout/utils'; import {hasOnDemandBudgetsFeature} from 'getsentry/views/onDemandBudgets/utils'; @@ -696,26 +693,20 @@ describe('AM2 Checkout', () => { method: 'GET', body: {}, }); - mockResponse = MockApiClient.addMockResponse({ url: `/customers/${organization.slug}/billing-config/`, method: 'GET', body: BillingConfigFixture(PlanTier.AM2), }); - MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/promotions/trigger-check/`, method: 'POST', }); - MockApiClient.addMockResponse({ url: `/customers/${organization.slug}/plan-migrations/?applied=0`, method: 'GET', body: {}, }); - }); - - it('renders for checkout v3', async () => { MockApiClient.addMockResponse({ url: `/customers/${organization.slug}/billing-details/`, method: 'GET', @@ -724,7 +715,9 @@ describe('AM2 Checkout', () => { url: `/customers/${organization.slug}/subscription/preview/`, method: 'GET', }); + }); + it('renders for checkout v3', async () => { render( { expect(screen.getAllByText('53.40')).toHaveLength(2); }); - it('skips step 1 for business plan in same tier', async () => { - const am2BizSubscription = SubscriptionFixture({ - organization, - plan: 'am2_business', - planTier: 'am2', - categories: { - errors: MetricHistoryFixture({reserved: 100_000}), - transactions: MetricHistoryFixture({reserved: 20_000_000}), - attachments: MetricHistoryFixture({reserved: 1}), - monitorSeats: MetricHistoryFixture({reserved: 1}), - profileDuration: MetricHistoryFixture({reserved: 1}), - replays: MetricHistoryFixture({reserved: 10_000}), - }, - onDemandMaxSpend: 2000, - }); - - SubscriptionStore.set(organization.slug, am2BizSubscription); - - render( - , - {organization} - ); - await screen.findByText('Choose Your Plan'); - expect(screen.queryByTestId('body-choose-your-plan')).not.toBeInTheDocument(); - expect(screen.getByTestId('errors-volume-item')).toBeInTheDocument(); - }); - it('does not skip step 1 for business plan pre-backfill', async () => { const launchOrg = OrganizationFixture({features: ['seer-billing']}); const am2BizSubscription = SubscriptionFixture({ @@ -1117,31 +1077,6 @@ describe('AM2 Checkout', () => { expect(screen.queryByTestId('errors-volume-item')).not.toBeInTheDocument(); }); - it('skips step 1 for business plan with seer', async () => { - const seerOrg = OrganizationFixture({features: ['seer-billing']}); - const seerSubscription = SubscriptionWithLegacySeerFixture({ - organization: seerOrg, - planTier: 'am2', - plan: 'am2_business', - }); - - SubscriptionStore.set(organization.slug, seerSubscription); - - render( - , - {organization: seerOrg} - ); - await screen.findByText('Choose Your Plan'); - expect(screen.queryByTestId('body-choose-your-plan')).not.toBeInTheDocument(); - expect(screen.getByTestId('errors-volume-item')).toBeInTheDocument(); - }); - it('does not skip step 1 for business plan without seer', async () => { const nonSeerOrg = OrganizationFixture({features: ['seer-billing']}); const nonSeerSubscription = SubscriptionFixture({ @@ -1167,7 +1102,7 @@ describe('AM2 Checkout', () => { expect(screen.queryByTestId('errors-volume-item')).not.toBeInTheDocument(); }); - it('test business bundle standard checkout', async () => { + it('renders standard checkout for business bundle', async () => { const am2BizSubscription = SubscriptionFixture({ organization, plan: 'am2_business_bundle', @@ -1191,20 +1126,24 @@ describe('AM2 Checkout', () => { api={api} onToggleLegacy={jest.fn()} checkoutTier={PlanTier.AM2} + isNewCheckout />, {organization} ); - // wait for page load - await screen.findByText('Choose Your Plan'); + await waitFor(() => { + expect(mockResponse).toHaveBeenCalledWith( + `/customers/${organization.slug}/billing-config/`, + expect.objectContaining({ + method: 'GET', + data: {tier: 'am2'}, + }) + ); + }); - // "Choose Your Plan" should be skipped and "Reserved Volumes" should be visible - // This is existing behavior to skip "Choose Your Plan" step for existing business customers - expect(screen.queryByTestId('body-choose-your-plan')).not.toBeInTheDocument(); - expect(screen.getByTestId('errors-volume-item')).toBeInTheDocument(); + assertCheckoutV3Steps(PlanTier.AM2); - // Click on "Choose Your Plan" and verify that Business is selected - await userEvent.click(screen.getByText('Choose Your Plan')); + // Verify that Business is preselected expect(screen.getByRole('radio', {name: 'Business'})).toBeChecked(); }); @@ -1227,7 +1166,18 @@ describe('AM2 Checkout', () => { }, onDemandMaxSpend: 2000, }); - + // set all add-ons as unavailable so we don't check for them in order to skip the step + sub.addOns = { + ...sub.addOns, + [AddOnCategory.SEER]: { + ...sub.addOns?.[AddOnCategory.SEER]!, + isAvailable: false, + }, + [AddOnCategory.LEGACY_SEER]: { + ...sub.addOns?.[AddOnCategory.LEGACY_SEER]!, + isAvailable: false, + }, + }; SubscriptionStore.set(organization.slug, sub); render( @@ -1846,6 +1796,19 @@ describe('AM3 Checkout', () => { isFree: false, }); + // set all add-ons as unavailable so we don't check for them in order to skip the step + sub.addOns = { + ...sub.addOns, + [AddOnCategory.SEER]: { + ...sub.addOns?.[AddOnCategory.SEER]!, + isAvailable: false, + }, + [AddOnCategory.LEGACY_SEER]: { + ...sub.addOns?.[AddOnCategory.LEGACY_SEER]!, + isAvailable: false, + }, + }; + SubscriptionStore.set(organization.slug, sub); render( @@ -1923,6 +1886,19 @@ describe('AM3 Checkout', () => { isTrial: true, // isTrial is true for both subscription trials and plan trials }); + // set all add-ons as unavailable so we don't check for them in order to skip the step + sub.addOns = { + ...sub.addOns, + [AddOnCategory.SEER]: { + ...sub.addOns?.[AddOnCategory.SEER]!, + isAvailable: false, + }, + [AddOnCategory.LEGACY_SEER]: { + ...sub.addOns?.[AddOnCategory.LEGACY_SEER]!, + isAvailable: false, + }, + }; + SubscriptionStore.set(organization.slug, sub); render( @@ -2052,6 +2028,19 @@ describe('AM3 Checkout', () => { isFree: false, }); + // set all add-ons as unavailable so we don't check for them in order to skip the step + sub.addOns = { + ...sub.addOns, + [AddOnCategory.SEER]: { + ...sub.addOns?.[AddOnCategory.SEER]!, + isAvailable: false, + }, + [AddOnCategory.LEGACY_SEER]: { + ...sub.addOns?.[AddOnCategory.LEGACY_SEER]!, + isAvailable: false, + }, + }; + SubscriptionStore.set(organization.slug, sub); render( diff --git a/static/gsApp/views/amCheckout/index.tsx b/static/gsApp/views/amCheckout/index.tsx index 4678ea8e04c4b7..4734f18357a6d4 100644 --- a/static/gsApp/views/amCheckout/index.tsx +++ b/static/gsApp/views/amCheckout/index.tsx @@ -159,9 +159,7 @@ class AMCheckout extends Component { const selectedAll = Object.values(props.subscription.addOns ?? {}).every( addOn => // add-on is enabled or not launched yet - // if there's no billing flag, we assume it's launched - addOn.enabled || - (addOn.billingFlag && !props.organization.features.includes(addOn.billingFlag)) + addOn.enabled || !addOn.isAvailable ); if (selectedAll) { @@ -535,7 +533,7 @@ class AMCheckout extends Component { addOns: Object.values(subscription.addOns ?? {}) .filter( // only populate add-ons that are launched - addOn => !addOn.billingFlag || organization.features.includes(addOn.billingFlag) + addOn => addOn.isAvailable ) .reduce((acc, addOn) => { acc[addOn.apiName] = { diff --git a/static/gsApp/views/amCheckout/steps/onDemandBudgets.spec.tsx b/static/gsApp/views/amCheckout/steps/onDemandBudgets.spec.tsx index 267c766b4e5a75..fe79e49c88c862 100644 --- a/static/gsApp/views/amCheckout/steps/onDemandBudgets.spec.tsx +++ b/static/gsApp/views/amCheckout/steps/onDemandBudgets.spec.tsx @@ -10,7 +10,7 @@ import {fireEvent, render, screen, userEvent} from 'sentry-test/reactTestingLibr import SubscriptionStore from 'getsentry/stores/subscriptionStore'; import type {Subscription as SubscriptionType} from 'getsentry/types'; -import {OnDemandBudgetMode, PlanTier} from 'getsentry/types'; +import {AddOnCategory, OnDemandBudgetMode, PlanTier} from 'getsentry/types'; import AMCheckout from 'getsentry/views/amCheckout'; describe('OnDemandBudgets AM Checkout', () => { @@ -162,6 +162,19 @@ describe('OnDemandBudgets AM Checkout', () => { body: {}, }); + // set all add-ons as unavailable so we don't include them in API call + subscription.addOns = { + ...subscription.addOns, + [AddOnCategory.SEER]: { + ...subscription.addOns?.[AddOnCategory.SEER]!, + isAvailable: false, + }, + [AddOnCategory.LEGACY_SEER]: { + ...subscription.addOns?.[AddOnCategory.LEGACY_SEER]!, + isAvailable: false, + }, + }; + createWrapper({subscription}); expect(await screen.findByText('On-Demand Budgets')).toBeInTheDocument(); diff --git a/static/gsApp/views/amCheckout/steps/setPayAsYouGo.tsx b/static/gsApp/views/amCheckout/steps/setPayAsYouGo.tsx index 07fdfcc1c454d4..fcff827a7dc991 100644 --- a/static/gsApp/views/amCheckout/steps/setPayAsYouGo.tsx +++ b/static/gsApp/views/amCheckout/steps/setPayAsYouGo.tsx @@ -60,10 +60,9 @@ function SetPayAsYouGo({ const addOnCategories = useMemo(() => { return Object.values(activePlan.addOnCategories).filter( - addOnInfo => - !addOnInfo.billingFlag || organization.features.includes(addOnInfo.billingFlag) + addOnInfo => subscription.addOns?.[addOnInfo.apiName]?.isAvailable ?? false ); - }, [activePlan, organization.features]); + }, [activePlan, subscription.addOns]); const paygOnlyCategories = useMemo(() => { return activePlan.categories.filter( diff --git a/static/gsApp/views/onDemandBudgets/onDemandBudgetEdit.tsx b/static/gsApp/views/onDemandBudgets/onDemandBudgetEdit.tsx index 47ef33911b58f1..b826ba6d83a135 100644 --- a/static/gsApp/views/onDemandBudgets/onDemandBudgetEdit.tsx +++ b/static/gsApp/views/onDemandBudgets/onDemandBudgetEdit.tsx @@ -147,9 +147,7 @@ class OnDemandBudgetEdit extends Component { ), ...Object.values(activePlan.addOnCategories) .filter( - addOnInfo => - !addOnInfo.billingFlag || - organization.features.includes(addOnInfo.billingFlag) + addOnInfo => subscription.addOns?.[addOnInfo.apiName]?.isAvailable ?? false ) .map(addOnInfo => toTitleCase(addOnInfo.productName, {allowInnerUpperCase: true}) diff --git a/static/gsApp/views/onDemandBudgets/onDemandBudgets.spec.tsx b/static/gsApp/views/onDemandBudgets/onDemandBudgets.spec.tsx index 0020a8f7044775..65f3cb2f2e67e2 100644 --- a/static/gsApp/views/onDemandBudgets/onDemandBudgets.spec.tsx +++ b/static/gsApp/views/onDemandBudgets/onDemandBudgets.spec.tsx @@ -12,7 +12,7 @@ import { import SubscriptionStore from 'getsentry/stores/subscriptionStore'; import type {Subscription as TSubscription} from 'getsentry/types'; -import {OnDemandBudgetMode, PlanTier} from 'getsentry/types'; +import {AddOnCategory, OnDemandBudgetMode, PlanTier} from 'getsentry/types'; import OnDemandBudgets from 'getsentry/views/onDemandBudgets'; import OnDemandBudgetEdit from 'getsentry/views/onDemandBudgets/onDemandBudgetEdit'; @@ -570,6 +570,14 @@ describe('OnDemandBudgets', () => { usedSpends: {}, }, }); + // set legacy Seer add-on as unavailable so we don't include warning for both types of Seer + subscription.addOns = { + ...subscription.addOns, + [AddOnCategory.LEGACY_SEER]: { + ...subscription.addOns?.[AddOnCategory.LEGACY_SEER]!, + isAvailable: false, + }, + }; const activePlan = subscription.planDetails; @@ -606,8 +614,6 @@ describe('OnDemandBudgets', () => { }); it('displays per-category warning for multiple categories', () => { - // Test with logs-billing only - organization.features = ['logs-billing']; const subscription = SubscriptionFixture({ plan: 'am2_business', planTier: PlanTier.AM2, @@ -630,6 +636,14 @@ describe('OnDemandBudgets', () => { usedSpends: {}, }, }); + // set legacy Seer add-on as unavailable so we don't include warning for both types of Seer + subscription.addOns = { + ...subscription.addOns, + [AddOnCategory.LEGACY_SEER]: { + ...subscription.addOns?.[AddOnCategory.LEGACY_SEER]!, + isAvailable: false, + }, + }; const activePlan = subscription.planDetails; @@ -643,7 +657,7 @@ describe('OnDemandBudgets', () => { }, }; - const {rerender} = render( + render( { expect(screen.getByTestId('per-category-budget-radio')).toBeInTheDocument(); expect(screen.getByTestId('per-category-budget-radio')).toBeChecked(); - // When logs-billing is enabled, should show the logs warning - expect( - screen.getByText( - 'Additional logs usage is only available through a shared on-demand budget. To enable on-demand usage switch to a shared on-demand budget.' - ) - ).toBeInTheDocument(); - - // Test with both seer-billing and logs-billing - organization.features = ['seer-billing', 'logs-billing']; - - rerender( - - ); - - // When both features are enabled expect( screen.getByText( 'Additional logs and Seer usage are only available through a shared on-demand budget. To enable on-demand usage switch to a shared on-demand budget.' diff --git a/static/gsApp/views/subscriptionPage/usageOverview/components/breakdownInfo.tsx b/static/gsApp/views/subscriptionPage/usageOverview/components/breakdownInfo.tsx index 82e11d155dfb3f..a248f48d1451a5 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview/components/breakdownInfo.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/components/breakdownInfo.tsx @@ -18,6 +18,7 @@ import type { Subscription, } from 'getsentry/types'; import { + checkIsAddOnChildCategory, displayBudgetName, formatReservedWithUnits, getSoftCapType, @@ -199,11 +200,7 @@ function DataCategoryUsageBreakdownInfo({ const platformReservedField = tct('[planName] plan', {planName: plan.name}); const reserved = metricHistory.reserved ?? 0; const isUnlimited = reserved === UNLIMITED_RESERVED; - - const addOnDataCategories = Object.values(plan.addOnCategories).flatMap( - addOn => addOn.dataCategories - ); - const isAddOnChildCategory = addOnDataCategories.includes(category) && !isUnlimited; + const isAddOnChildCategory = checkIsAddOnChildCategory(subscription, category, true); const additionalReserved = Math.max(0, reserved - platformReserved); const shouldShowAdditionalReserved = diff --git a/static/gsApp/views/subscriptionPage/usageOverview/components/table.spec.tsx b/static/gsApp/views/subscriptionPage/usageOverview/components/table.spec.tsx index bec366a4f5a2c5..21cd234557505f 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview/components/table.spec.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/components/table.spec.tsx @@ -234,4 +234,35 @@ describe('UsageOverviewTable', () => { // add-on is not rendered since at least one of its sub-categories is unlimited expect(screen.queryByRole('cell', {name: 'Seer'})).not.toBeInTheDocument(); }); + + it('renders add-on sub-categories if non-zero non-unlimited reserved volume', async () => { + const sub = SubscriptionFixture({organization}); + sub.categories.seerAutofix = { + ...sub.categories.seerAutofix!, + reserved: 100, + prepaid: 100, + }; + + render( + + ); + + await screen.findByRole('columnheader', {name: 'Feature'}); + + // issue fixes is unlimited + expect(screen.getByRole('cell', {name: 'Issue Fixes'})).toBeInTheDocument(); + expect(screen.getByRole('cell', {name: '0 / 100'})).toBeInTheDocument(); + + // issue scans is 0 so is not rendered + expect(screen.queryByRole('cell', {name: 'Issue Scans'})).not.toBeInTheDocument(); + + // add-on is not rendered since at least one of its sub-categories is unlimited + expect(screen.queryByRole('cell', {name: 'Seer'})).not.toBeInTheDocument(); + }); }); diff --git a/static/gsApp/views/subscriptionPage/usageOverview/components/table.tsx b/static/gsApp/views/subscriptionPage/usageOverview/components/table.tsx index 3e56526b97656c..060e26d28ae8e2 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview/components/table.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/components/table.tsx @@ -4,8 +4,12 @@ import styled from '@emotion/styled'; import {Text} from 'sentry/components/core/text'; import {t} from 'sentry/locale'; -import {UNLIMITED_RESERVED} from 'getsentry/constants'; -import {getBilledCategory, supportsPayg} from 'getsentry/utils/billing'; +import { + checkIsAddOnChildCategory, + getBilledCategory, + getReservedBudgetCategoryForAddOn, + supportsPayg, +} from 'getsentry/utils/billing'; import {sortCategories} from 'getsentry/utils/dataCategory'; import UsageOverviewTableRow from 'getsentry/views/subscriptionPage/usageOverview/components/tableRow'; import type {UsageOverviewTableProps} from 'getsentry/views/subscriptionPage/usageOverview/types'; @@ -17,9 +21,9 @@ function UsageOverviewTable({ selectedProduct, usageData, }: UsageOverviewTableProps) { - const addOnDataCategories = Object.values( - subscription.planDetails.addOnCategories - ).flatMap(addOnInfo => addOnInfo.dataCategories); + const addOnDataCategories = Object.values(subscription.planDetails.addOnCategories) + .flatMap(addOnInfo => addOnInfo.dataCategories) + .filter(category => checkIsAddOnChildCategory(subscription, category, true)); const sortedCategories = sortCategories(subscription.categories); const showAdditionalSpendColumn = subscription.canSelfServe || supportsPayg(subscription); @@ -52,9 +56,7 @@ function UsageOverviewTable({ .filter( categoryInfo => // filter out data categories that are part of add-ons - // unless they are unlimited - !addOnDataCategories.includes(categoryInfo.category) || - categoryInfo.reserved === UNLIMITED_RESERVED + !addOnDataCategories.includes(categoryInfo.category) ) .map(categoryInfo => { const {category} = categoryInfo; @@ -75,13 +77,7 @@ function UsageOverviewTable({ .filter( // show add-ons regardless of whether they're enabled // as long as they're available - // and none of their sub-categories are unlimited - addOnInfo => - (subscription.addOns?.[addOnInfo.apiName]?.isAvailable ?? false) && - !addOnInfo.dataCategories.some( - category => - subscription.categories[category]?.reserved === UNLIMITED_RESERVED - ) + addOnInfo => subscription.addOns?.[addOnInfo.apiName]?.isAvailable ?? false ) .map(addOnInfo => { const {apiName, dataCategories} = addOnInfo; @@ -90,6 +86,19 @@ function UsageOverviewTable({ return null; } + // if any sub-category has non-zero or non-reserved budget reserved volume, don't show the add-on + // we will render the individual sub-categories alone as part of `sortedCategories` + // NOTE: this assumes that the same is true for all sibling sub-categories of the add-on + if ( + dataCategories.some( + category => !checkIsAddOnChildCategory(subscription, category, true) + ) + ) { + return null; + } + + const reservedBudgetCategory = getReservedBudgetCategoryForAddOn(apiName); + return ( - {sortedCategories - .filter(categoryInfo => dataCategories.includes(categoryInfo.category)) - .map(categoryInfo => { - const {category} = categoryInfo; + {/* Only show sub-categories if it's a reserved budget add-on */} + {reservedBudgetCategory + ? sortedCategories + .filter(categoryInfo => + dataCategories.includes(categoryInfo.category) + ) + .map(categoryInfo => { + const {category} = categoryInfo; - return ( - - ); - })} + return ( + + ); + }) + : null} ); })} diff --git a/static/gsApp/views/subscriptionPage/usageOverview/components/tableRow.tsx b/static/gsApp/views/subscriptionPage/usageOverview/components/tableRow.tsx index e082eb85b3d8a1..5412869f4e6981 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview/components/tableRow.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/components/tableRow.tsx @@ -181,6 +181,21 @@ function UsageOverviewTableRow({ const isSelected = selectedProduct === product; + /** + * Only show the progress ring if: + * - the usage is not exceeded + * - the product is not PAYG only + * - the product is not a child product (ie. sub-categories of an add-on) + * - prepaid volume is not unlimited + * - the product is not an add-on or the product is an add-on with a prepaid volume + */ + const showProgressRing = + !usageExceeded && + !isPaygOnly && + !isChildProduct && + !isUnlimited && + (!isAddOn || formattedPrepaid); + return ( - ) : isPaygOnly || isChildProduct || isUnlimited ? null : ( + ) : showProgressRing ? ( - )} + ) : null} {isUnlimited ? ( {t('Unlimited')} diff --git a/static/gsApp/views/subscriptionPage/usageOverview/index.tsx b/static/gsApp/views/subscriptionPage/usageOverview/index.tsx index 638bce9c2b4bbb..49f810c6b51ff5 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview/index.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/index.tsx @@ -11,7 +11,11 @@ import useMedia from 'sentry/utils/useMedia'; import {useNavigate} from 'sentry/utils/useNavigate'; import {AddOnCategory, OnDemandBudgetMode} from 'getsentry/types'; -import {checkIsAddOn, getActiveProductTrial} from 'getsentry/utils/billing'; +import { + checkIsAddOn, + checkIsAddOnChildCategory, + getActiveProductTrial, +} from 'getsentry/utils/billing'; import trackGetsentryAnalytics from 'getsentry/utils/trackGetsentryAnalytics'; import UsageOverviewActions from 'getsentry/views/subscriptionPage/usageOverview/components/actions'; import ProductBreakdownPanel from 'getsentry/views/subscriptionPage/usageOverview/components/panel'; @@ -43,7 +47,13 @@ function UsageOverview({subscription, organization, usageData}: UsageOverviewPro const isAddOn = checkIsAddOn(productFromQuery); if (selectedProduct !== productFromQuery) { const isSelectable = isAddOn - ? (subscription.addOns?.[productFromQuery as AddOnCategory]?.enabled ?? false) + ? (subscription.addOns?.[productFromQuery as AddOnCategory]?.enabled ?? + false) && + subscription.addOns?.[ + productFromQuery as AddOnCategory + ]?.dataCategories.every(category => + checkIsAddOnChildCategory(subscription, category, true) + ) : (subscription.categories[productFromQuery as DataCategory]?.reserved ?? 0) > 0 || !!getActiveProductTrial( @@ -84,10 +94,7 @@ function UsageOverview({subscription, organization, usageData}: UsageOverviewPro location.pathname, location.query, navigate, - subscription.addOns, - subscription.categories, - subscription.onDemandBudgets, - subscription.productTrials, + subscription, ]); return ( diff --git a/tests/js/getsentry-test/fixtures/am1Plans.ts b/tests/js/getsentry-test/fixtures/am1Plans.ts index d3b32b0515a867..8775ebea896b80 100644 --- a/tests/js/getsentry-test/fixtures/am1Plans.ts +++ b/tests/js/getsentry-test/fixtures/am1Plans.ts @@ -38,6 +38,7 @@ const AM1_CATEGORY_DISPLAY_NAMES = { uptime: {singular: 'uptime monitor', plural: 'uptime monitors'}, seerAutofix: {singular: 'issue fix', plural: 'issue fixes'}, seerScanner: {singular: 'issue scan', plural: 'issue scans'}, + logBytes: {singular: 'log', plural: 'logs'}, }; const AM1_AVAILABLE_RESERVED_BUDGET_TYPES = {