diff --git a/static/gsApp/views/subscriptionPage/usageOverview/components/breakdownInfo.tsx b/static/gsApp/views/subscriptionPage/usageOverview/components/breakdownInfo.tsx new file mode 100644 index 00000000000000..638d9ba7032905 --- /dev/null +++ b/static/gsApp/views/subscriptionPage/usageOverview/components/breakdownInfo.tsx @@ -0,0 +1,301 @@ +import {Fragment} from 'react'; + +import {Flex, Grid} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; + +import QuestionTooltip from 'sentry/components/questionTooltip'; +import {t, tct} from 'sentry/locale'; +import type {DataCategory} from 'sentry/types/core'; +import {defined} from 'sentry/utils'; +import {toTitleCase} from 'sentry/utils/string/toTitleCase'; + +import {UNLIMITED, UNLIMITED_RESERVED} from 'getsentry/constants'; +import type { + BillingMetricHistory, + Plan, + ProductTrial, + ReservedBudget, + Subscription, +} from 'getsentry/types'; +import { + displayBudgetName, + formatReservedWithUnits, + getSoftCapType, + isTrialPlan, + supportsPayg, +} from 'getsentry/utils/billing'; +import {displayPriceWithCents} from 'getsentry/views/amCheckout/utils'; + +interface BaseProps { + activeProductTrial: ProductTrial | null; + subscription: Subscription; +} + +interface UsageBreakdownInfoProps extends BaseProps { + formattedAdditionalReserved: React.ReactNode | null; + formattedGifted: React.ReactNode | null; + formattedPlatformReserved: React.ReactNode | null; + formattedSoftCapType: React.ReactNode | null; + paygCategoryBudget: number | null; + paygSpend: number; + plan: Plan; + platformReservedField: React.ReactNode; + productCanUsePayg: boolean; + recurringReservedSpend: number | null; +} + +interface DataCategoryUsageBreakdownInfoProps extends BaseProps { + category: DataCategory; + metricHistory: BillingMetricHistory; +} + +interface ReservedBudgetUsageBreakdownInfoProps extends BaseProps { + reservedBudget: ReservedBudget; +} + +function UsageBreakdownField({ + field, + value, + help, +}: { + field: React.ReactNode; + value: React.ReactNode; + help?: React.ReactNode; +}) { + return ( + + + + {field} + + {help && } + + {value} + + ); +} + +function UsageBreakdownInfo({ + subscription, + plan, + platformReservedField, + formattedPlatformReserved, + formattedAdditionalReserved, + formattedGifted, + paygSpend, + paygCategoryBudget, + recurringReservedSpend, + productCanUsePayg, + activeProductTrial, + formattedSoftCapType, +}: UsageBreakdownInfoProps) { + const canUsePayg = productCanUsePayg && supportsPayg(subscription); + const shouldShowIncludedVolume = + !!activeProductTrial || + !!formattedPlatformReserved || + !!formattedAdditionalReserved || + !!formattedGifted; + const shouldShowReservedSpend = + defined(recurringReservedSpend) && subscription.canSelfServe; + const shouldShowAdditionalSpend = + shouldShowReservedSpend || canUsePayg || defined(formattedSoftCapType); + + if (!shouldShowIncludedVolume && !shouldShowAdditionalSpend) { + return null; + } + + return ( + + {shouldShowIncludedVolume && ( + + {t('Included volume')} + {activeProductTrial && ( + + )} + {formattedPlatformReserved && ( + + )} + {formattedAdditionalReserved && ( + + )} + {formattedGifted && ( + + )} + + )} + {shouldShowAdditionalSpend && ( + + {t('Additional spend')} + {formattedSoftCapType && ( + + )} + {canUsePayg && ( + + {displayPriceWithCents({cents: paygSpend})} + {!!paygCategoryBudget && ( + + {' / '} + + {displayPriceWithCents({cents: paygCategoryBudget})} + + + )} + + } + help={tct( + "The amount of [budgetTerm] you've used so far on this product in the current month.", + { + budgetTerm: displayBudgetName(plan), + } + )} + /> + )} + {shouldShowReservedSpend && ( + + )} + + )} + + ); +} + +function DataCategoryUsageBreakdownInfo({ + subscription, + category, + metricHistory, + activeProductTrial, +}: DataCategoryUsageBreakdownInfoProps) { + const {planDetails: plan} = subscription; + const productCanUsePayg = plan.onDemandCategories.includes(category); + const platformReserved = + plan.planCategories[category]?.find( + bucket => bucket.price === 0 && bucket.events >= 0 + )?.events ?? 0; + 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 shouldShowAdditionalReserved = + !isAddOnChildCategory && !isUnlimited && subscription.canSelfServe; + const formattedPlatformReserved = isAddOnChildCategory + ? null + : formatReservedWithUnits( + shouldShowAdditionalReserved ? platformReserved : reserved, + category + ); + const additionalReserved = Math.max(0, reserved - platformReserved); + const formattedAdditionalReserved = shouldShowAdditionalReserved + ? formatReservedWithUnits(additionalReserved, category) + : null; + + const gifted = metricHistory.free ?? 0; + const formattedGifted = isAddOnChildCategory + ? null + : formatReservedWithUnits(gifted, category); + + const paygSpend = metricHistory.onDemandSpendUsed ?? 0; + const paygCategoryBudget = metricHistory.onDemandBudget ?? 0; + + const recurringReservedSpend = isAddOnChildCategory + ? null + : (plan.planCategories[category]?.find(bucket => bucket.events === reserved)?.price ?? + 0); + + return ( + + ); +} + +function ReservedBudgetUsageBreakdownInfo({ + subscription, + reservedBudget, + activeProductTrial, +}: ReservedBudgetUsageBreakdownInfoProps) { + const {planDetails: plan, categories: metricHistories} = subscription; + const productCanUsePayg = reservedBudget.dataCategories.every(category => + plan.onDemandCategories.includes(category) + ); + const onTrialOrSponsored = isTrialPlan(subscription.plan) || subscription.isSponsored; + + const platformReservedField = onTrialOrSponsored + ? tct('[planName] plan', {planName: plan.name}) + : tct('[productName] monthly credits', { + productName: toTitleCase(reservedBudget.productName, { + allowInnerUpperCase: true, + }), + }); + const formattedPlatformReserved = displayPriceWithCents({ + cents: reservedBudget.reservedBudget, + }); + + const formattedAdditionalReserved = null; + const formattedGifted = displayPriceWithCents({cents: reservedBudget.freeBudget}); + const paygSpend = reservedBudget.dataCategories.reduce((acc, category) => { + return acc + (metricHistories[category]?.onDemandSpendUsed ?? 0); + }, 0); + + const billedCategory = reservedBudget.dataCategories[0]!; + const metricHistory = subscription.categories[billedCategory]; + if (!metricHistory) { + return null; + } + const recurringReservedSpend = + plan.planCategories[billedCategory]?.find( + bucket => bucket.events === metricHistory.reserved + )?.price ?? 0; + + return ( + + ); +} + +export {DataCategoryUsageBreakdownInfo, ReservedBudgetUsageBreakdownInfo}; diff --git a/static/gsApp/views/subscriptionPage/usageOverview/components/charts.tsx b/static/gsApp/views/subscriptionPage/usageOverview/components/charts.tsx index 05315e70fc0f8f..6be7d0143bc605 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview/components/charts.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/components/charts.tsx @@ -105,7 +105,7 @@ function UsageCharts({ }; return ( - + { @@ -29,6 +32,169 @@ describe('ProductBreakdownPanel', () => { resetMockDate(); }); + it('renders for data category with shared PAYG set', async () => { + subscription.categories.errors = { + ...subscription.categories.errors!, + reserved: 500_000, + prepaid: 505_000, + free: 5_000, + onDemandSpendUsed: 10_00, + }; + subscription.onDemandBudgets = { + enabled: true, + budgetMode: OnDemandBudgetMode.SHARED, + sharedMaxBudget: 100_00, + onDemandSpendUsed: 0, + }; + render( + + ); + + await screen.findByRole('heading', {name: 'Errors'}); + expect(screen.getByText('Included volume')).toBeInTheDocument(); + expect(screen.getByText('Business plan')).toBeInTheDocument(); + expect(screen.getByText('50,000')).toBeInTheDocument(); + expect(screen.getByText('Additional reserved')).toBeInTheDocument(); + expect(screen.getByText('450,000')).toBeInTheDocument(); + expect(screen.getByText('Gifted')).toBeInTheDocument(); + expect(screen.getByText('5,000')).toBeInTheDocument(); + expect(screen.getByText('Additional spend')).toBeInTheDocument(); + expect(screen.getByText('Pay-as-you-go')).toBeInTheDocument(); + expect(screen.getByText('$10.00')).toBeInTheDocument(); + expect(screen.getByText('Reserved spend')).toBeInTheDocument(); + expect(screen.getByText('$245.00')).toBeInTheDocument(); + }); + + it('renders for data category with per-category PAYG set', async () => { + subscription.categories.errors = { + ...subscription.categories.errors!, + reserved: 500_000, + prepaid: 505_000, + free: 5_000, + onDemandSpendUsed: 10_00, + onDemandBudget: 100_00, + }; + subscription.onDemandBudgets = { + enabled: true, + budgetMode: OnDemandBudgetMode.PER_CATEGORY, + budgets: { + errors: 100_00, + }, + usedSpends: { + errors: 10_00, + }, + }; + render( + + ); + + await screen.findByRole('heading', {name: 'Errors'}); + expect(screen.getByText('Included volume')).toBeInTheDocument(); + expect(screen.getByText('Business plan')).toBeInTheDocument(); + expect(screen.getByText('50,000')).toBeInTheDocument(); + expect(screen.getByText('Additional reserved')).toBeInTheDocument(); + expect(screen.getByText('450,000')).toBeInTheDocument(); + expect(screen.getByText('Gifted')).toBeInTheDocument(); + expect(screen.getByText('5,000')).toBeInTheDocument(); + expect(screen.getByText('Additional spend')).toBeInTheDocument(); + expect(screen.getByText('Pay-as-you-go')).toBeInTheDocument(); + expect(screen.getByText('$10.00 /')).toBeInTheDocument(); + expect(screen.getByText('$100.00')).toBeInTheDocument(); // shows per-category individual budget + expect(screen.getByText('Reserved spend')).toBeInTheDocument(); + expect(screen.getByText('$245.00')).toBeInTheDocument(); + }); + + it('renders for reserved budget add-on', async () => { + const legacySeerSubscription = SubscriptionWithLegacySeerFixture({ + organization, + plan: 'am3_business', + }); + legacySeerSubscription.reservedBudgets![0] = { + ...legacySeerSubscription.reservedBudgets![0]!, + freeBudget: 1_00, + }; + legacySeerSubscription.categories.seerAutofix = { + ...legacySeerSubscription.categories.seerAutofix!, + onDemandSpendUsed: 1_00, + }; + legacySeerSubscription.categories.seerScanner = { + ...legacySeerSubscription.categories.seerScanner!, + onDemandSpendUsed: 1_00, + }; + SubscriptionStore.set(organization.slug, legacySeerSubscription); + render( + + ); + + await screen.findByRole('heading', {name: 'Seer'}); + expect(screen.getByText('Included volume')).toBeInTheDocument(); + expect(screen.queryByText('Business plan')).not.toBeInTheDocument(); + expect(screen.queryByText('Additional reserved')).not.toBeInTheDocument(); + expect(screen.getByText('Seer monthly credits')).toBeInTheDocument(); + expect(screen.getByText('$25.00')).toBeInTheDocument(); + expect(screen.getByText('Gifted')).toBeInTheDocument(); + expect(screen.getByText('$1.00')).toBeInTheDocument(); + expect(screen.getByText('Additional spend')).toBeInTheDocument(); + expect(screen.getByText('Pay-as-you-go')).toBeInTheDocument(); + expect(screen.getByText('$2.00')).toBeInTheDocument(); + expect(screen.getByText('Reserved spend')).toBeInTheDocument(); + expect(screen.getByText('$20.00')).toBeInTheDocument(); + }); + + it('renders for reserved budget add-on data category', async () => { + const legacySeerSubscription = SubscriptionWithLegacySeerFixture({ + organization, + plan: 'am3_business', + }); + legacySeerSubscription.reservedBudgets![0] = { + ...legacySeerSubscription.reservedBudgets![0]!, + freeBudget: 1_00, + }; + legacySeerSubscription.categories.seerAutofix = { + ...legacySeerSubscription.categories.seerAutofix!, + onDemandSpendUsed: 1_00, + }; + legacySeerSubscription.categories.seerScanner = { + ...legacySeerSubscription.categories.seerScanner!, + onDemandSpendUsed: 1_00, + }; + SubscriptionStore.set(organization.slug, legacySeerSubscription); + render( + + ); + + await screen.findByRole('heading', {name: 'Issue Fixes'}); + expect(screen.queryByText('Included volume')).not.toBeInTheDocument(); + expect(screen.queryByText('Business plan')).not.toBeInTheDocument(); + expect(screen.queryByText('Additional reserved')).not.toBeInTheDocument(); + expect(screen.queryByText('Seer monthly credits')).not.toBeInTheDocument(); + expect(screen.queryByText('Gifted')).not.toBeInTheDocument(); + expect(screen.getByText('Additional spend')).toBeInTheDocument(); + expect(screen.getByText('Pay-as-you-go')).toBeInTheDocument(); + expect(screen.getByText('$1.00')).toBeInTheDocument(); + expect(screen.queryByText('Reserved spend')).not.toBeInTheDocument(); + }); + it('renders product trial CTA', async () => { subscription.productTrials = [ { diff --git a/static/gsApp/views/subscriptionPage/usageOverview/components/panel.tsx b/static/gsApp/views/subscriptionPage/usageOverview/components/panel.tsx index b38fcb5ec43b6e..051709b4a679ea 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview/components/panel.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/components/panel.tsx @@ -6,11 +6,20 @@ import {Heading} from '@sentry/scraps/text'; import {IconClock, IconWarning} from 'sentry/icons'; import {t, tct, tn} from 'sentry/locale'; +import {DataCategory} from 'sentry/types/core'; import getDaysSinceDate from 'sentry/utils/getDaysSinceDate'; import {useProductBillingMetadata} from 'getsentry/hooks/useProductBillingMetadata'; import {AddOnCategory, OnDemandBudgetMode} from 'getsentry/types'; -import {displayBudgetName, supportsPayg} from 'getsentry/utils/billing'; +import { + displayBudgetName, + getReservedBudgetCategoryForAddOn, + supportsPayg, +} from 'getsentry/utils/billing'; +import { + DataCategoryUsageBreakdownInfo, + ReservedBudgetUsageBreakdownInfo, +} from 'getsentry/views/subscriptionPage/usageOverview/components/breakdownInfo'; import UsageCharts from 'getsentry/views/subscriptionPage/usageOverview/components/charts'; import { ProductTrialCta, @@ -81,17 +90,55 @@ function ProductBreakdownPanel({ usageData, isInline, }: BreakdownPanelProps) { - const {billedCategory, isAddOn, isEnabled, addOnInfo, potentialProductTrial} = - useProductBillingMetadata(subscription, selectedProduct); + const { + billedCategory, + isAddOn, + isEnabled, + addOnInfo, + activeProductTrial, + potentialProductTrial, + } = useProductBillingMetadata(subscription, selectedProduct); if (!billedCategory) { return null; } - const breakdownInfo = null; - - if ((isAddOn && !addOnInfo) || (!isAddOn && !subscription.categories[billedCategory])) { - return null; + let breakdownInfo = null; + + if (isAddOn) { + if (!addOnInfo) { + return null; + } + const {apiName} = addOnInfo; + const reservedBudgetCategory = getReservedBudgetCategoryForAddOn(apiName); + const reservedBudget = subscription.reservedBudgets?.find( + budget => budget.apiName === reservedBudgetCategory + ); + + if (reservedBudget) { + breakdownInfo = ( + + ); + } + } else { + const category = selectedProduct as DataCategory; + const metricHistory = subscription.categories[category]; + if (!metricHistory) { + return null; + } + + breakdownInfo = ( + + ); } const isEmpty = !potentialProductTrial && !isEnabled;