From dc25142279ad4bb2d998822b921bcdb8d3188b3b Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Fri, 5 Dec 2025 11:04:22 -0800 Subject: [PATCH 1/8] fix(usage overview): Display add-on categories with reserved volume --- static/gsApp/utils/billing.tsx | 49 +++++++++++- static/gsApp/views/amCheckout/index.tsx | 6 +- .../views/amCheckout/steps/setPayAsYouGo.tsx | 5 +- .../onDemandBudgets/onDemandBudgetEdit.tsx | 4 +- .../components/breakdownInfo.tsx | 7 +- .../usageOverview/components/table.tsx | 79 +++++++++++-------- .../usageOverview/components/tableRow.tsx | 19 ++++- .../subscriptionPage/usageOverview/index.tsx | 19 +++-- 8 files changed, 129 insertions(+), 59 deletions(-) diff --git a/static/gsApp/utils/billing.tsx b/static/gsApp/utils/billing.tsx index 443df350734d85..f0dcc0c81a6ebe 100644 --- a/static/gsApp/utils/billing.tsx +++ b/static/gsApp/utils/billing.tsx @@ -2,6 +2,7 @@ import moment from 'moment-timezone'; import type {PromptData} from 'sentry/actionCreators/prompts'; import {IconBuilding, IconGroup, IconSeer, IconUser} from 'sentry/icons'; +import {t, tn} from 'sentry/locale'; import {DataCategory} from 'sentry/types/core'; import type {Organization} from 'sentry/types/organization'; import {defined} from 'sentry/utils'; @@ -206,12 +207,26 @@ export function formatUsageWithUnits( if (isContinuousProfiling(dataCategory)) { const usageProfileHours = usageQuantity / MILLISECONDS_IN_HOUR; if (usageProfileHours === 0) { - return '0'; + return t('0 hours'); } return options.isAbbreviated - ? displayNumber(usageProfileHours, 1) - : usageProfileHours.toLocaleString(undefined, {maximumFractionDigits: 1}); + ? tn('%s hour', '%s hours', displayNumber(usageProfileHours, 1)) + : tn( + '%s hour', + '%s hours', + usageProfileHours.toLocaleString(undefined, {maximumFractionDigits: 1}) + ); + } + if (dataCategory === DataCategory.SEER_USER) { + const categoryInfo = getCategoryInfoFromPlural(dataCategory); + if (categoryInfo) { + if (usageQuantity === 1) { + return `${usageQuantity} ${categoryInfo.displayName}`; + } + return `${usageQuantity} ${categoryInfo.titleName.toLowerCase()}`; + } } + return options.isAbbreviated ? displayNumber(usageQuantity, 0) : usageQuantity.toLocaleString(); @@ -897,6 +912,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/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/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/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.tsx b/static/gsApp/views/subscriptionPage/usageOverview/components/table.tsx index 3e56526b97656c..0cd6653f2f7528 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,18 @@ 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` + 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 ( From fa2a4fd31fe9a0a1de38baca73bd8c09b267359a Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Fri, 5 Dec 2025 14:40:38 -0800 Subject: [PATCH 2/8] fix tests --- static/gsApp/utils/billing.spec.tsx | 16 +- static/gsApp/utils/billing.tsx | 16 +- static/gsApp/views/amCheckout/index.spec.tsx | 147 ++++++++---------- .../amCheckout/steps/onDemandBudgets.spec.tsx | 15 +- .../onDemandBudgets/onDemandBudgets.spec.tsx | 42 +++-- .../subscriptionPage/usageTotals.spec.tsx | 24 +-- 6 files changed, 129 insertions(+), 131 deletions(-) diff --git a/static/gsApp/utils/billing.spec.tsx b/static/gsApp/utils/billing.spec.tsx index 880cd0b2dd0492..a5fdfc3bf93ca2 100644 --- a/static/gsApp/utils/billing.spec.tsx +++ b/static/gsApp/utils/billing.spec.tsx @@ -416,17 +416,19 @@ describe('formatUsageWithUnits', () => { it('returns correct string for continuous profiling', () => { [DataCategory.PROFILE_DURATION, DataCategory.PROFILE_DURATION_UI].forEach( (cat: DataCategory) => { - expect(formatUsageWithUnits(0, cat)).toBe('0'); - expect(formatUsageWithUnits(1, cat)).toBe('0'); - expect(formatUsageWithUnits(360000, cat)).toBe('0.1'); - expect(formatUsageWithUnits(MILLISECONDS_IN_HOUR, cat)).toBe('1'); - expect(formatUsageWithUnits(5.23 * MILLISECONDS_IN_HOUR, cat)).toBe('5.2'); - expect(formatUsageWithUnits(1000 * MILLISECONDS_IN_HOUR, cat)).toBe('1,000'); + expect(formatUsageWithUnits(0, cat)).toBe('0 hours'); + expect(formatUsageWithUnits(1, cat)).toBe('0 hours'); + expect(formatUsageWithUnits(360000, cat)).toBe('0.1 hours'); + expect(formatUsageWithUnits(MILLISECONDS_IN_HOUR, cat)).toBe('1 hour'); + expect(formatUsageWithUnits(5.23 * MILLISECONDS_IN_HOUR, cat)).toBe('5.2 hours'); + expect(formatUsageWithUnits(1000 * MILLISECONDS_IN_HOUR, cat)).toBe( + '1,000 hours' + ); expect( formatUsageWithUnits(1000 * MILLISECONDS_IN_HOUR, cat, { isAbbreviated: true, }) - ).toBe('1K'); + ).toBe('1K hours'); } ); }); diff --git a/static/gsApp/utils/billing.tsx b/static/gsApp/utils/billing.tsx index f0dcc0c81a6ebe..179fff8c8c2b8f 100644 --- a/static/gsApp/utils/billing.tsx +++ b/static/gsApp/utils/billing.tsx @@ -2,7 +2,7 @@ import moment from 'moment-timezone'; import type {PromptData} from 'sentry/actionCreators/prompts'; import {IconBuilding, IconGroup, IconSeer, IconUser} from 'sentry/icons'; -import {t, tn} from 'sentry/locale'; +import {tn} from 'sentry/locale'; import {DataCategory} from 'sentry/types/core'; import type {Organization} from 'sentry/types/organization'; import {defined} from 'sentry/utils'; @@ -206,14 +206,10 @@ export function formatUsageWithUnits( } if (isContinuousProfiling(dataCategory)) { const usageProfileHours = usageQuantity / MILLISECONDS_IN_HOUR; - if (usageProfileHours === 0) { - return t('0 hours'); - } return options.isAbbreviated - ? tn('%s hour', '%s hours', displayNumber(usageProfileHours, 1)) - : tn( - '%s hour', - '%s hours', + ? formatWithHours(usageProfileHours, displayNumber(usageProfileHours, 1)) + : formatWithHours( + usageProfileHours, usageProfileHours.toLocaleString(undefined, {maximumFractionDigits: 1}) ); } @@ -232,6 +228,10 @@ export function formatUsageWithUnits( : usageQuantity.toLocaleString(); } +function formatWithHours(quantityInHours: number, formattedHours: string) { + return `${formattedHours} ${tn('hour', 'hours', quantityInHours)}`; +} + export function convertUsageToReservedUnit( usage: number, category: DataCategory | string 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/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/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/usageTotals.spec.tsx b/static/gsApp/views/subscriptionPage/usageTotals.spec.tsx index dbdf047c99fefb..9e8dae604a7e89 100644 --- a/static/gsApp/views/subscriptionPage/usageTotals.spec.tsx +++ b/static/gsApp/views/subscriptionPage/usageTotals.spec.tsx @@ -166,7 +166,7 @@ describe('Subscription > UsageTotals', () => { expect( screen.getByText('Continuous profile hours usage this period') ).toBeInTheDocument(); - expect(screen.getByText('15')).toBeInTheDocument(); + expect(screen.getByText('15 hours')).toBeInTheDocument(); // Expand usage table await userEvent.click(screen.getByRole('button')); @@ -176,15 +176,15 @@ describe('Subscription > UsageTotals', () => { name: 'Continuous Profile Hours Quantity % of Continuous Profile Hours', }) ).toBeInTheDocument(); - expect(screen.getByRole('row', {name: 'Accepted 15 60%'})).toBeInTheDocument(); + expect(screen.getByRole('row', {name: 'Accepted 15 hours 60%'})).toBeInTheDocument(); expect( - screen.getByRole('row', {name: 'Total Dropped (estimated) 10 40%'}) + screen.getByRole('row', {name: 'Total Dropped (estimated) 10 hours 40%'}) ).toBeInTheDocument(); - expect(screen.getByRole('row', {name: 'Over Quota 0 0%'})).toBeInTheDocument(); + expect(screen.getByRole('row', {name: 'Over Quota 0 hours 0%'})).toBeInTheDocument(); expect( - screen.queryByRole('row', {name: 'Spike Protection 0 0%'}) + screen.queryByRole('row', {name: 'Spike Protection 0 hours 0%'}) ).not.toBeInTheDocument(); - expect(screen.getByRole('row', {name: 'Other 0 0%'})).toBeInTheDocument(); + expect(screen.getByRole('row', {name: 'Other 0 hours 0%'})).toBeInTheDocument(); }); it('does not include profiles for estimates on non-AM3 plans', async () => { @@ -235,7 +235,7 @@ describe('Subscription > UsageTotals', () => { expect( screen.getByText('Continuous profile hours usage this period') ).toBeInTheDocument(); - expect(screen.getByText('15')).toBeInTheDocument(); + expect(screen.getByText('15 hours')).toBeInTheDocument(); // Expand usage table await userEvent.click(screen.getByRole('button')); @@ -245,15 +245,15 @@ describe('Subscription > UsageTotals', () => { name: 'Continuous Profile Hours Quantity % of Continuous Profile Hours', }) ).toBeInTheDocument(); - expect(screen.getByRole('row', {name: 'Accepted 15 75%'})).toBeInTheDocument(); + expect(screen.getByRole('row', {name: 'Accepted 15 hours 75%'})).toBeInTheDocument(); expect( - screen.getByRole('row', {name: 'Total Dropped (estimated) 5 25%'}) + screen.getByRole('row', {name: 'Total Dropped (estimated) 5 hours 25%'}) ).toBeInTheDocument(); - expect(screen.getByRole('row', {name: 'Over Quota 0 0%'})).toBeInTheDocument(); + expect(screen.getByRole('row', {name: 'Over Quota 0 hours 0%'})).toBeInTheDocument(); expect( - screen.queryByRole('row', {name: 'Spike Protection 0 0%'}) + screen.queryByRole('row', {name: 'Spike Protection 0 hours 0%'}) ).not.toBeInTheDocument(); - expect(screen.getByRole('row', {name: 'Other 0 0%'})).toBeInTheDocument(); + expect(screen.getByRole('row', {name: 'Other 0 hours 0%'})).toBeInTheDocument(); }); it('does not render transaction event totals without feature', async () => { From 5018c1f23c159c6335fd503ee13de461fc4a2c3f Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Sat, 6 Dec 2025 12:43:01 -0800 Subject: [PATCH 3/8] move to other pr --- static/gsApp/utils/billing.tsx | 9 --------- 1 file changed, 9 deletions(-) diff --git a/static/gsApp/utils/billing.tsx b/static/gsApp/utils/billing.tsx index 179fff8c8c2b8f..b26871470336b0 100644 --- a/static/gsApp/utils/billing.tsx +++ b/static/gsApp/utils/billing.tsx @@ -213,15 +213,6 @@ export function formatUsageWithUnits( usageProfileHours.toLocaleString(undefined, {maximumFractionDigits: 1}) ); } - if (dataCategory === DataCategory.SEER_USER) { - const categoryInfo = getCategoryInfoFromPlural(dataCategory); - if (categoryInfo) { - if (usageQuantity === 1) { - return `${usageQuantity} ${categoryInfo.displayName}`; - } - return `${usageQuantity} ${categoryInfo.titleName.toLowerCase()}`; - } - } return options.isAbbreviated ? displayNumber(usageQuantity, 0) From 136a7712a50c84f0452b5e36c6bffc938a2db627 Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Sat, 6 Dec 2025 12:45:26 -0800 Subject: [PATCH 4/8] document assumption --- .../views/subscriptionPage/usageOverview/components/table.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/static/gsApp/views/subscriptionPage/usageOverview/components/table.tsx b/static/gsApp/views/subscriptionPage/usageOverview/components/table.tsx index 0cd6653f2f7528..060e26d28ae8e2 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview/components/table.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/components/table.tsx @@ -88,6 +88,7 @@ function UsageOverviewTable({ // 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) From ce02002fae77b5c5ccad0a94265ace93dd3ad91f Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Sat, 6 Dec 2025 12:54:52 -0800 Subject: [PATCH 5/8] tests --- static/gsApp/utils/billing.spec.tsx | 69 ++++++++++++++++++- .../usageOverview/components/table.spec.tsx | 31 +++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/static/gsApp/utils/billing.spec.tsx b/static/gsApp/utils/billing.spec.tsx index a5fdfc3bf93ca2..df0d9bd17b856e 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, @@ -1157,6 +1165,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/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(); + }); }); From a1db40fe8a9f643e1445e818f501aa0addc9d86e Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Mon, 8 Dec 2025 10:29:43 -0800 Subject: [PATCH 6/8] undo some things --- static/gsApp/utils/billing.spec.tsx | 16 +++---- static/gsApp/utils/billing.tsx | 9 ++-- static/gsApp/utils/dataCategory.spec.tsx | 45 +++++++++++++++++++ static/gsApp/utils/dataCategory.tsx | 17 ++++--- .../subscriptionPage/usageTotals.spec.tsx | 24 +++++----- tests/js/getsentry-test/fixtures/am1Plans.ts | 1 + 6 files changed, 76 insertions(+), 36 deletions(-) diff --git a/static/gsApp/utils/billing.spec.tsx b/static/gsApp/utils/billing.spec.tsx index df0d9bd17b856e..7cfbd978fa6989 100644 --- a/static/gsApp/utils/billing.spec.tsx +++ b/static/gsApp/utils/billing.spec.tsx @@ -424,19 +424,17 @@ describe('formatUsageWithUnits', () => { it('returns correct string for continuous profiling', () => { [DataCategory.PROFILE_DURATION, DataCategory.PROFILE_DURATION_UI].forEach( (cat: DataCategory) => { - expect(formatUsageWithUnits(0, cat)).toBe('0 hours'); - expect(formatUsageWithUnits(1, cat)).toBe('0 hours'); - expect(formatUsageWithUnits(360000, cat)).toBe('0.1 hours'); - expect(formatUsageWithUnits(MILLISECONDS_IN_HOUR, cat)).toBe('1 hour'); - expect(formatUsageWithUnits(5.23 * MILLISECONDS_IN_HOUR, cat)).toBe('5.2 hours'); - expect(formatUsageWithUnits(1000 * MILLISECONDS_IN_HOUR, cat)).toBe( - '1,000 hours' - ); + expect(formatUsageWithUnits(0, cat)).toBe('0'); + expect(formatUsageWithUnits(1, cat)).toBe('0'); + expect(formatUsageWithUnits(360000, cat)).toBe('0.1'); + expect(formatUsageWithUnits(MILLISECONDS_IN_HOUR, cat)).toBe('1'); + expect(formatUsageWithUnits(5.23 * MILLISECONDS_IN_HOUR, cat)).toBe('5.2'); + expect(formatUsageWithUnits(1000 * MILLISECONDS_IN_HOUR, cat)).toBe('1,000'); expect( formatUsageWithUnits(1000 * MILLISECONDS_IN_HOUR, cat, { isAbbreviated: true, }) - ).toBe('1K hours'); + ).toBe('1K'); } ); }); diff --git a/static/gsApp/utils/billing.tsx b/static/gsApp/utils/billing.tsx index b26871470336b0..aca80a65562cf7 100644 --- a/static/gsApp/utils/billing.tsx +++ b/static/gsApp/utils/billing.tsx @@ -207,11 +207,8 @@ export function formatUsageWithUnits( if (isContinuousProfiling(dataCategory)) { const usageProfileHours = usageQuantity / MILLISECONDS_IN_HOUR; return options.isAbbreviated - ? formatWithHours(usageProfileHours, displayNumber(usageProfileHours, 1)) - : formatWithHours( - usageProfileHours, - usageProfileHours.toLocaleString(undefined, {maximumFractionDigits: 1}) - ); + ? displayNumber(usageProfileHours, 1) + : usageProfileHours.toLocaleString(undefined, {maximumFractionDigits: 1}); } return options.isAbbreviated @@ -219,7 +216,7 @@ export function formatUsageWithUnits( : usageQuantity.toLocaleString(); } -function formatWithHours(quantityInHours: number, formattedHours: string) { +export function formatWithHours(quantityInHours: number, formattedHours: string) { return `${formattedHours} ${tn('hour', 'hours', quantityInHours)}`; } 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..a5a3a0f3cacd96 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'; @@ -48,7 +49,7 @@ export function getCreditDataCategory(credit: RecurringCredit): DataCategory | n return category; } -type CategoryNameProps = { +export type CategoryNameProps = { category: DataCategory; capitalize?: boolean; hadCustomDynamicSampling?: boolean; @@ -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/subscriptionPage/usageTotals.spec.tsx b/static/gsApp/views/subscriptionPage/usageTotals.spec.tsx index 9e8dae604a7e89..dbdf047c99fefb 100644 --- a/static/gsApp/views/subscriptionPage/usageTotals.spec.tsx +++ b/static/gsApp/views/subscriptionPage/usageTotals.spec.tsx @@ -166,7 +166,7 @@ describe('Subscription > UsageTotals', () => { expect( screen.getByText('Continuous profile hours usage this period') ).toBeInTheDocument(); - expect(screen.getByText('15 hours')).toBeInTheDocument(); + expect(screen.getByText('15')).toBeInTheDocument(); // Expand usage table await userEvent.click(screen.getByRole('button')); @@ -176,15 +176,15 @@ describe('Subscription > UsageTotals', () => { name: 'Continuous Profile Hours Quantity % of Continuous Profile Hours', }) ).toBeInTheDocument(); - expect(screen.getByRole('row', {name: 'Accepted 15 hours 60%'})).toBeInTheDocument(); + expect(screen.getByRole('row', {name: 'Accepted 15 60%'})).toBeInTheDocument(); expect( - screen.getByRole('row', {name: 'Total Dropped (estimated) 10 hours 40%'}) + screen.getByRole('row', {name: 'Total Dropped (estimated) 10 40%'}) ).toBeInTheDocument(); - expect(screen.getByRole('row', {name: 'Over Quota 0 hours 0%'})).toBeInTheDocument(); + expect(screen.getByRole('row', {name: 'Over Quota 0 0%'})).toBeInTheDocument(); expect( - screen.queryByRole('row', {name: 'Spike Protection 0 hours 0%'}) + screen.queryByRole('row', {name: 'Spike Protection 0 0%'}) ).not.toBeInTheDocument(); - expect(screen.getByRole('row', {name: 'Other 0 hours 0%'})).toBeInTheDocument(); + expect(screen.getByRole('row', {name: 'Other 0 0%'})).toBeInTheDocument(); }); it('does not include profiles for estimates on non-AM3 plans', async () => { @@ -235,7 +235,7 @@ describe('Subscription > UsageTotals', () => { expect( screen.getByText('Continuous profile hours usage this period') ).toBeInTheDocument(); - expect(screen.getByText('15 hours')).toBeInTheDocument(); + expect(screen.getByText('15')).toBeInTheDocument(); // Expand usage table await userEvent.click(screen.getByRole('button')); @@ -245,15 +245,15 @@ describe('Subscription > UsageTotals', () => { name: 'Continuous Profile Hours Quantity % of Continuous Profile Hours', }) ).toBeInTheDocument(); - expect(screen.getByRole('row', {name: 'Accepted 15 hours 75%'})).toBeInTheDocument(); + expect(screen.getByRole('row', {name: 'Accepted 15 75%'})).toBeInTheDocument(); expect( - screen.getByRole('row', {name: 'Total Dropped (estimated) 5 hours 25%'}) + screen.getByRole('row', {name: 'Total Dropped (estimated) 5 25%'}) ).toBeInTheDocument(); - expect(screen.getByRole('row', {name: 'Over Quota 0 hours 0%'})).toBeInTheDocument(); + expect(screen.getByRole('row', {name: 'Over Quota 0 0%'})).toBeInTheDocument(); expect( - screen.queryByRole('row', {name: 'Spike Protection 0 hours 0%'}) + screen.queryByRole('row', {name: 'Spike Protection 0 0%'}) ).not.toBeInTheDocument(); - expect(screen.getByRole('row', {name: 'Other 0 hours 0%'})).toBeInTheDocument(); + expect(screen.getByRole('row', {name: 'Other 0 0%'})).toBeInTheDocument(); }); it('does not render transaction event totals without feature', async () => { 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 = { From bf267c4cd3e04f051df976ec03536f18f1550fa1 Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Mon, 8 Dec 2025 10:31:49 -0800 Subject: [PATCH 7/8] this too --- static/gsApp/utils/billing.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/static/gsApp/utils/billing.tsx b/static/gsApp/utils/billing.tsx index aca80a65562cf7..cbdd069edcf3eb 100644 --- a/static/gsApp/utils/billing.tsx +++ b/static/gsApp/utils/billing.tsx @@ -2,7 +2,6 @@ import moment from 'moment-timezone'; import type {PromptData} from 'sentry/actionCreators/prompts'; import {IconBuilding, IconGroup, IconSeer, IconUser} from 'sentry/icons'; -import {tn} from 'sentry/locale'; import {DataCategory} from 'sentry/types/core'; import type {Organization} from 'sentry/types/organization'; import {defined} from 'sentry/utils'; @@ -206,20 +205,18 @@ export function formatUsageWithUnits( } if (isContinuousProfiling(dataCategory)) { const usageProfileHours = usageQuantity / MILLISECONDS_IN_HOUR; + if (usageProfileHours === 0) { + return '0'; + } return options.isAbbreviated ? displayNumber(usageProfileHours, 1) : usageProfileHours.toLocaleString(undefined, {maximumFractionDigits: 1}); } - return options.isAbbreviated ? displayNumber(usageQuantity, 0) : usageQuantity.toLocaleString(); } -export function formatWithHours(quantityInHours: number, formattedHours: string) { - return `${formattedHours} ${tn('hour', 'hours', quantityInHours)}`; -} - export function convertUsageToReservedUnit( usage: number, category: DataCategory | string From 73ae0478cf35a64bbc6e80b9648c686a7de8e6f3 Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Mon, 8 Dec 2025 10:33:30 -0800 Subject: [PATCH 8/8] rm unused export --- static/gsApp/utils/dataCategory.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/gsApp/utils/dataCategory.tsx b/static/gsApp/utils/dataCategory.tsx index a5a3a0f3cacd96..acff61129390cc 100644 --- a/static/gsApp/utils/dataCategory.tsx +++ b/static/gsApp/utils/dataCategory.tsx @@ -49,7 +49,7 @@ export function getCreditDataCategory(credit: RecurringCredit): DataCategory | n return category; } -export type CategoryNameProps = { +type CategoryNameProps = { category: DataCategory; capitalize?: boolean; hadCustomDynamicSampling?: boolean;