diff --git a/static/gsApp/hooks/useProductBillingMetadata.tsx b/static/gsApp/hooks/useProductBillingMetadata.tsx new file mode 100644 index 00000000000000..a7ebf64b465913 --- /dev/null +++ b/static/gsApp/hooks/useProductBillingMetadata.tsx @@ -0,0 +1,116 @@ +import type {DataCategory} from 'sentry/types/core'; +import {toTitleCase} from 'sentry/utils/string/toTitleCase'; + +import type {AddOn, AddOnCategory, ProductTrial, Subscription} from 'getsentry/types'; +import { + checkIsAddOn, + getActiveProductTrial, + getBilledCategory, + getPotentialProductTrial, + productIsEnabled, +} from 'getsentry/utils/billing'; +import {getPlanCategoryName} from 'getsentry/utils/dataCategory'; + +interface ProductBillingMetadata { + /** + * The active product trial for the given product, if any. + * Always null when excludeProductTrials is true. + */ + activeProductTrial: ProductTrial | null; + /** + * The billed category for the given product. + * When product is a DataCategory, we just return product. + * When product is an AddOnCategory, we return the billed category for the + * add-on. + */ + billedCategory: DataCategory | null; + /** + * The display name for the given product in title case. + */ + displayName: string; + /** + * Whether the product is an add-on. + */ + isAddOn: boolean; + /** + * Whether the product is enabled for the subscription. + */ + isEnabled: boolean; + /** + * The longest available product trial for the given product, if any. + * Always null when excludeProductTrials is true. + */ + potentialProductTrial: ProductTrial | null; + /** + * Whether the usage for the given product has exceeded the limit. + */ + usageExceeded: boolean; + /** + * The add-on information for the given product from the subscription, + * if any. + */ + addOnInfo?: AddOn; +} + +const EMPTY_PRODUCT_BILLING_METADATA: ProductBillingMetadata = { + billedCategory: null, + displayName: '', + isAddOn: false, + isEnabled: false, + usageExceeded: false, + activeProductTrial: null, + potentialProductTrial: null, +}; + +export function useProductBillingMetadata( + subscription: Subscription, + product: DataCategory | AddOnCategory, + parentProduct?: DataCategory | AddOnCategory, + excludeProductTrials?: boolean +): ProductBillingMetadata { + const isAddOn = checkIsAddOn(parentProduct ?? product); + const billedCategory = getBilledCategory(subscription, product); + + if (!billedCategory) { + return EMPTY_PRODUCT_BILLING_METADATA; + } + + let displayName = ''; + let addOnInfo = undefined; + + if (isAddOn) { + const category = (parentProduct ?? product) as AddOnCategory; + addOnInfo = subscription.addOns?.[category]; + if (!addOnInfo) { + return EMPTY_PRODUCT_BILLING_METADATA; + } + displayName = parentProduct + ? getPlanCategoryName({ + plan: subscription.planDetails, + category: billedCategory, + title: true, + }) + : toTitleCase(addOnInfo.productName, {allowInnerUpperCase: true}); + } else { + displayName = getPlanCategoryName({ + plan: subscription.planDetails, + category: billedCategory, + title: true, + }); + } + + return { + displayName, + billedCategory, + isAddOn, + isEnabled: productIsEnabled(subscription, parentProduct ?? product), + addOnInfo, + usageExceeded: subscription.categories[billedCategory]?.usageExceeded ?? false, + activeProductTrial: excludeProductTrials + ? null + : getActiveProductTrial(subscription.productTrials ?? null, billedCategory), + potentialProductTrial: excludeProductTrials + ? null + : getPotentialProductTrial(subscription.productTrials ?? null, billedCategory), + }; +} diff --git a/static/gsApp/types/index.tsx b/static/gsApp/types/index.tsx index 31429dc54e30a5..a0e6b35625b560 100644 --- a/static/gsApp/types/index.tsx +++ b/static/gsApp/types/index.tsx @@ -149,7 +149,7 @@ export type AddOnCategoryInfo = { productName: string; }; -type AddOn = AddOnCategoryInfo & { +export type AddOn = AddOnCategoryInfo & { enabled: boolean; }; diff --git a/static/gsApp/utils/billing.spec.tsx b/static/gsApp/utils/billing.spec.tsx index 0fcf27e501d82c..880cd0b2dd0492 100644 --- a/static/gsApp/utils/billing.spec.tsx +++ b/static/gsApp/utils/billing.spec.tsx @@ -8,14 +8,16 @@ import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription'; import {DataCategory} from 'sentry/types/core'; import {BILLION, GIGABYTE, MILLION, UNLIMITED} from 'getsentry/constants'; -import {OnDemandBudgetMode} from 'getsentry/types'; -import type {ProductTrial} from 'getsentry/types'; +import {AddOnCategory, OnDemandBudgetMode} from 'getsentry/types'; +import type {ProductTrial, Subscription} from 'getsentry/types'; import { + checkIsAddOn, convertUsageToReservedUnit, formatReservedWithUnits, formatUsageWithUnits, getActiveProductTrial, getBestActionToIncreaseEventLimits, + getBilledCategory, getCreditApplied, getOnDemandCategories, getProductTrial, @@ -27,6 +29,7 @@ import { isNewPayingCustomer, isTeamPlanFamily, MILLISECONDS_IN_HOUR, + productIsEnabled, trialPromptIsDismissed, UsageAction, } from 'getsentry/utils/billing'; @@ -185,6 +188,11 @@ describe('formatReservedWithUnits', () => { useUnitScaling: true, }) ).toBe(UNLIMITED); + expect( + formatReservedWithUnits(-1, DataCategory.ATTACHMENTS, { + useUnitScaling: true, + }) + ).toBe(UNLIMITED); }); it('returns correct string for Profile Duration', () => { @@ -253,6 +261,11 @@ describe('formatReservedWithUnits', () => { useUnitScaling: true, }) ).toBe(UNLIMITED); + expect( + formatReservedWithUnits(-1, DataCategory.LOG_BYTE, { + useUnitScaling: true, + }) + ).toBe(UNLIMITED); expect( formatReservedWithUnits(1234, DataCategory.LOG_BYTE, { @@ -1129,3 +1142,126 @@ describe('getCreditApplied', () => { ).toBe(0); }); }); + +describe('checkIsAddOn', () => { + it('returns true for add-on', () => { + expect(checkIsAddOn(AddOnCategory.LEGACY_SEER)).toBe(true); + expect(checkIsAddOn(AddOnCategory.SEER)).toBe(true); + }); + + it('returns false for data category', () => { + expect(checkIsAddOn(DataCategory.ERRORS)).toBe(false); + expect(checkIsAddOn(DataCategory.SEER_AUTOFIX)).toBe(false); + }); +}); + +describe('getBilledCategory', () => { + const organization = OrganizationFixture(); + const subscription = SubscriptionFixture({organization, plan: 'am3_team'}); + + it('returns correct billed category for data category', () => { + subscription.planDetails.categories.forEach(category => { + expect(getBilledCategory(subscription, category)).toBe(category); + }); + }); + + it('returns correct billed category for add-on', () => { + expect(getBilledCategory(subscription, AddOnCategory.LEGACY_SEER)).toBe( + DataCategory.SEER_AUTOFIX + ); + expect(getBilledCategory(subscription, AddOnCategory.SEER)).toBe( + DataCategory.SEER_USER + ); + }); +}); + +describe('productIsEnabled', () => { + const organization = OrganizationFixture(); + let subscription: Subscription; + + beforeEach(() => { + subscription = SubscriptionFixture({organization, plan: 'am3_team'}); + }); + + it('returns true for active product trial', () => { + subscription.productTrials = [ + { + // not started + category: DataCategory.PROFILE_DURATION, + isStarted: false, + reasonCode: 1001, + startDate: undefined, + endDate: moment().utc().add(20, 'years').format(), + }, + { + // started + category: DataCategory.REPLAYS, + isStarted: true, + reasonCode: 1001, + startDate: moment().utc().subtract(10, 'days').format(), + endDate: moment().utc().add(20, 'days').format(), + }, + { + // started + category: DataCategory.SEER_AUTOFIX, + isStarted: true, + reasonCode: 1001, + startDate: moment().utc().subtract(10, 'days').format(), + endDate: moment().utc().add(20, 'days').format(), + }, + ]; + + expect(productIsEnabled(subscription, DataCategory.PROFILE_DURATION)).toBe(false); + expect(productIsEnabled(subscription, DataCategory.REPLAYS)).toBe(true); + expect(productIsEnabled(subscription, DataCategory.SEER_AUTOFIX)).toBe(true); + expect(productIsEnabled(subscription, AddOnCategory.LEGACY_SEER)).toBe(true); // because there is a product trial for the billed category + }); + + it('uses subscription add-on info for add-on', () => { + subscription.addOns!.seer = { + ...subscription.addOns?.seer!, + enabled: true, + }; + + expect(productIsEnabled(subscription, AddOnCategory.SEER)).toBe(true); + expect(productIsEnabled(subscription, AddOnCategory.LEGACY_SEER)).toBe(false); + }); + + it('returns true for non-PAYG-only data categories', () => { + expect(productIsEnabled(subscription, DataCategory.ERRORS)).toBe(true); + }); + + it('uses PAYG budgets for PAYG-only data categories', () => { + expect(productIsEnabled(subscription, DataCategory.PROFILE_DURATION)).toBe(false); + + // shared PAYG + subscription.onDemandBudgets = { + budgetMode: OnDemandBudgetMode.SHARED, + sharedMaxBudget: 1000, + enabled: true, + onDemandSpendUsed: 0, + }; + expect(productIsEnabled(subscription, DataCategory.PROFILE_DURATION)).toBe(true); + + // per-category PAYG + subscription.onDemandBudgets = { + budgetMode: OnDemandBudgetMode.PER_CATEGORY, + enabled: true, + budgets: { + errors: 1000, + }, + usedSpends: {}, + }; + expect(productIsEnabled(subscription, DataCategory.PROFILE_DURATION)).toBe(false); + + subscription.onDemandBudgets.budgets = { + ...subscription.onDemandBudgets.budgets, + profileDuration: 1000, + }; + subscription.categories.profileDuration = { + ...subscription.categories.profileDuration!, + onDemandBudget: 1000, + }; + expect(productIsEnabled(subscription, DataCategory.PROFILE_DURATION)).toBe(true); + }); +}); diff --git a/static/gsApp/utils/billing.tsx b/static/gsApp/utils/billing.tsx index 48da32b511e1f8..332ae53c3ed757 100644 --- a/static/gsApp/utils/billing.tsx +++ b/static/gsApp/utils/billing.tsx @@ -6,6 +6,7 @@ import {DataCategory} from 'sentry/types/core'; import type {Organization} from 'sentry/types/organization'; import {defined} from 'sentry/utils'; import getDaysSinceDate from 'sentry/utils/getDaysSinceDate'; +import {toTitleCase} from 'sentry/utils/string/toTitleCase'; import type {IconSize} from 'sentry/utils/theme'; import { @@ -158,11 +159,17 @@ export function formatReservedWithUnits( if (!isByteCategory(dataCategory)) { return formatReservedNumberToString(reservedQuantity, options); } + // convert reservedQuantity to BYTES to check for unlimited - const usageGb = reservedQuantity ? reservedQuantity * GIGABYTE : reservedQuantity; + // unless it's already unlimited + const usageGb = + reservedQuantity && !isUnlimitedReserved(reservedQuantity) + ? reservedQuantity * GIGABYTE + : reservedQuantity; if (isUnlimitedReserved(usageGb)) { return options.isGifted ? '0 GB' : UNLIMITED; } + if (!options.useUnitScaling) { const byteOptions = dataCategory === DataCategory.LOG_BYTE @@ -622,7 +629,9 @@ export function hasAccessToSubscriptionOverview( */ export function getSoftCapType(metricHistory: BillingMetricHistory): string | null { if (metricHistory.softCapType) { - return titleCase(metricHistory.softCapType.replace(/_/g, ' ')); + return toTitleCase(metricHistory.softCapType.replace(/_/g, ' ').toLowerCase(), { + allowInnerUpperCase: true, + }).replace(' ', metricHistory.softCapType === 'ON_DEMAND' ? '-' : ' '); } if (metricHistory.trueForward) { return 'True Forward'; @@ -861,3 +870,81 @@ export function formatOnDemandDescription( const budgetTerm = displayBudgetName(plan, {title: false}).toLowerCase(); return description.replace(new RegExp(`\\s*${budgetTerm}\\s*`, 'gi'), ' ').trim(); } + +/** + * Given a DataCategory or AddOnCategory, returns true if it is an add-on, false otherwise. + */ +export function checkIsAddOn( + selectedProduct: DataCategory | AddOnCategory | string +): boolean { + return Object.values(AddOnCategory).includes(selectedProduct as AddOnCategory); +} + +/** + * Get the billed DataCategory for an add-on or DataCategory. + */ +export function getBilledCategory( + subscription: Subscription, + selectedProduct: DataCategory | AddOnCategory +): DataCategory | null { + if (checkIsAddOn(selectedProduct)) { + const category = selectedProduct as AddOnCategory; + const addOnInfo = subscription.addOns?.[category]; + if (!addOnInfo) { + return null; + } + + const {dataCategories, apiName} = addOnInfo; + const reservedBudgetCategory = getReservedBudgetCategoryForAddOn(apiName); + const reservedBudget = subscription.reservedBudgets?.find( + budget => budget.apiName === reservedBudgetCategory + ); + return reservedBudget + ? (dataCategories.find(dataCategory => + subscription.planDetails.planCategories[dataCategory]?.find( + bucket => bucket.events === RESERVED_BUDGET_QUOTA + ) + ) ?? dataCategories[0]!) + : dataCategories[0]!; + } + + return selectedProduct as DataCategory; +} + +export function productIsEnabled( + subscription: Subscription, + selectedProduct: DataCategory | AddOnCategory +): boolean { + const billedCategory = getBilledCategory(subscription, selectedProduct); + if (!billedCategory) { + return false; + } + + const activeProductTrial = getActiveProductTrial( + subscription.productTrials ?? null, + billedCategory + ); + if (activeProductTrial) { + return true; + } + + if (checkIsAddOn(selectedProduct)) { + const addOnInfo = subscription.addOns?.[selectedProduct as AddOnCategory]; + if (!addOnInfo) { + return false; + } + return addOnInfo.enabled; + } + + const metricHistory = subscription.categories[billedCategory]; + if (!metricHistory) { + return false; + } + const isPaygOnly = metricHistory.reserved === 0; + return ( + !isPaygOnly || + metricHistory.onDemandBudget > 0 || + (subscription.onDemandBudgets?.budgetMode === OnDemandBudgetMode.SHARED && + subscription.onDemandBudgets.sharedMaxBudget > 0) + ); +} diff --git a/static/gsApp/utils/trackGetsentryAnalytics.tsx b/static/gsApp/utils/trackGetsentryAnalytics.tsx index 2fc444f9fda891..719607205494ef 100644 --- a/static/gsApp/utils/trackGetsentryAnalytics.tsx +++ b/static/gsApp/utils/trackGetsentryAnalytics.tsx @@ -203,9 +203,13 @@ type GetsentryEventParameters = { addOnCategory: AddOnCategory; isOpen: boolean; } & HasSub; - 'subscription_page.usage_overview.row_clicked': { - dataCategory: DataCategory; - } & HasSub; + 'subscription_page.usage_overview.row_clicked': ( + | { + dataCategory: DataCategory; + } + | {addOnCategory: AddOnCategory} + ) & + HasSub; 'subscription_page.usage_overview.transform_changed': { transform: string; } & HasSub; diff --git a/static/gsApp/views/subscriptionPage/components/categoryUsageDrawer.tsx b/static/gsApp/views/subscriptionPage/components/categoryUsageDrawer.tsx index 3557f9eb538b58..c240cd0d961f0b 100644 --- a/static/gsApp/views/subscriptionPage/components/categoryUsageDrawer.tsx +++ b/static/gsApp/views/subscriptionPage/components/categoryUsageDrawer.tsx @@ -9,6 +9,7 @@ import {useNavigate} from 'sentry/utils/useNavigate'; import useOrganization from 'sentry/utils/useOrganization'; import {CHART_OPTIONS_DATA_TRANSFORM} from 'sentry/views/organizationStats/usageChart'; +import {useProductBillingMetadata} from 'getsentry/hooks/useProductBillingMetadata'; import { PlanTier, type BillingMetricHistory, @@ -19,7 +20,6 @@ import { import {addBillingStatTotals, isAm2Plan} from 'getsentry/utils/billing'; import { getChunkCategoryFromDuration, - getPlanCategoryName, isContinuousProfiling, } from 'getsentry/utils/dataCategory'; import trackGetsentryAnalytics from 'getsentry/utils/trackGetsentryAnalytics'; @@ -55,12 +55,8 @@ function CategoryUsageDrawer({ const transform = selectedTransform(location); const {category, usage: billedUsage} = categoryInfo; - const displayName = getPlanCategoryName({ - plan: subscription.planDetails, - category, - title: true, - }); - + // XXX(isabella): using this to make knip happy til the hook is used in other places + const {displayName} = useProductBillingMetadata(subscription, category); const usageStats = { [category]: stats, }; diff --git a/static/gsApp/views/subscriptionPage/usageHistory.spec.tsx b/static/gsApp/views/subscriptionPage/usageHistory.spec.tsx index 49aa93100105a7..379a21dc050c02 100644 --- a/static/gsApp/views/subscriptionPage/usageHistory.spec.tsx +++ b/static/gsApp/views/subscriptionPage/usageHistory.spec.tsx @@ -435,9 +435,9 @@ describe('Subscription > UsageHistory', () => { render(, {organization}); // Per-category Soft Cap On-demand should show up - expect(await screen.findAllByText('Errors (On Demand)')).toHaveLength(2); - expect(screen.getAllByText('Transactions (On Demand)')).toHaveLength(2); - expect(screen.getAllByText('Attachments (On Demand)')).toHaveLength(2); + expect(await screen.findAllByText('Errors (On-Demand)')).toHaveLength(2); + expect(screen.getAllByText('Transactions (On-Demand)')).toHaveLength(2); + expect(screen.getAllByText('Attachments (On-Demand)')).toHaveLength(2); expect(mockCall).toHaveBeenCalled(); });