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;