diff --git a/static/gsApp/components/productTrial/productTrialTag.tsx b/static/gsApp/components/productTrial/productTrialTag.tsx
index a105ddce8bcb81..4b48b683f85b5b 100644
--- a/static/gsApp/components/productTrial/productTrialTag.tsx
+++ b/static/gsApp/components/productTrial/productTrialTag.tsx
@@ -25,21 +25,21 @@ function ProductTrialTag({trial, type, showTrialEnded = false}: ProductTrialTagP
return (
} type={type ?? 'default'}>
- {t('Trial Ended')}
+ {t('Trial ended')}
);
}
if (!trial.isStarted) {
return (
- } type={type ?? 'info'}>
- {t('Trial Available')}
+ } type={type ?? 'promotion'}>
+ {t('Trial available')}
);
}
const daysLeft = -1 * getDaysSinceDate(trial.endDate ?? '');
- const tagType = type ?? (daysLeft <= 7 ? 'promotion' : 'highlight');
+ const tagType = type ?? (daysLeft <= 7 ? 'warning' : 'highlight');
return (
} type={tagType}>
diff --git a/static/gsApp/components/subscriptionSettingsLayout.tsx b/static/gsApp/components/subscriptionSettingsLayout.tsx
index e38497e2be4901..f231cba9e7b51f 100644
--- a/static/gsApp/components/subscriptionSettingsLayout.tsx
+++ b/static/gsApp/components/subscriptionSettingsLayout.tsx
@@ -49,7 +49,6 @@ function SubscriptionSettingsLayout(props: Props) {
-
{children}
diff --git a/static/gsApp/hooks/useCurrentBillingHistory.tsx b/static/gsApp/hooks/useCurrentBillingHistory.tsx
new file mode 100644
index 00000000000000..bff97880d2b65e
--- /dev/null
+++ b/static/gsApp/hooks/useCurrentBillingHistory.tsx
@@ -0,0 +1,25 @@
+import {useMemo} from 'react';
+
+import {useApiQuery} from 'sentry/utils/queryClient';
+import useOrganization from 'sentry/utils/useOrganization';
+
+import type {BillingHistory} from 'getsentry/types';
+
+export function useCurrentBillingHistory() {
+ const organization = useOrganization();
+
+ const {
+ data: histories,
+ isPending,
+ isError,
+ } = useApiQuery([`/customers/${organization.slug}/history/`], {
+ staleTime: 0, // TODO(billing): Create an endpoint that returns the current history
+ });
+
+ const currentHistory: BillingHistory | null = useMemo(() => {
+ if (!histories) return null;
+ return histories.find((history: BillingHistory) => history.isCurrent) ?? null;
+ }, [histories]);
+
+ return {currentHistory, isPending, isError};
+}
diff --git a/static/gsApp/utils/billing.tsx b/static/gsApp/utils/billing.tsx
index 314651084b8206..ff1e572a843b7c 100644
--- a/static/gsApp/utils/billing.tsx
+++ b/static/gsApp/utils/billing.tsx
@@ -726,6 +726,18 @@ export function partnerPlanEndingModalIsDismissed(
}
}
+export function getPercentage(quantity: number, total: number | null) {
+ if (typeof total === 'number' && total > 0) {
+ return (Math.min(quantity, total) / total) * 100;
+ }
+ return 0;
+}
+
+export function displayPercentage(quantity: number, total: number | null) {
+ const percentage = getPercentage(quantity, total);
+ return percentage.toFixed(0) + '%';
+}
+
/**
* Returns true if some billing details are set.
*/
diff --git a/static/gsApp/views/subscriptionPage/overview.spec.tsx b/static/gsApp/views/subscriptionPage/overview.spec.tsx
index cddf584a470ec7..114ae463e0f266 100644
--- a/static/gsApp/views/subscriptionPage/overview.spec.tsx
+++ b/static/gsApp/views/subscriptionPage/overview.spec.tsx
@@ -88,6 +88,11 @@ describe('Subscription > Overview', () => {
method: 'GET',
});
+ MockApiClient.addMockResponse({
+ url: `/customers/${organization.slug}/history/`,
+ method: 'GET',
+ });
+
SubscriptionStore.set(organization.slug, {});
});
diff --git a/static/gsApp/views/subscriptionPage/overview.tsx b/static/gsApp/views/subscriptionPage/overview.tsx
index 15ab311b113233..85ea883c5b837b 100644
--- a/static/gsApp/views/subscriptionPage/overview.tsx
+++ b/static/gsApp/views/subscriptionPage/overview.tsx
@@ -34,6 +34,7 @@ import withPromotions from 'getsentry/utils/withPromotions';
import ContactBillingMembers from 'getsentry/views/contactBillingMembers';
import {openOnDemandBudgetEditModal} from 'getsentry/views/onDemandBudgets/editOnDemandButton';
import SubscriptionPageContainer from 'getsentry/views/subscriptionPage/components/subscriptionPageContainer';
+import UsageOverview from 'getsentry/views/subscriptionPage/usageOverview';
import openPerformanceQuotaCreditsPromoModal from './promotions/performanceQuotaCreditsPromo';
import openPerformanceReservedTransactionsDiscountModal from './promotions/performanceReservedTransactionsPromo';
@@ -60,8 +61,8 @@ type Props = {
function Overview({location, subscription, promotionData}: Props) {
const api = useApi();
const organization = useOrganization();
- const navigate = useNavigate();
const isNewBillingUI = hasNewBillingUI(organization);
+ const navigate = useNavigate();
const displayMode = ['cost', 'usage'].includes(location.query.displayMode as string)
? (location.query.displayMode as 'cost' | 'usage')
@@ -335,14 +336,20 @@ function Overview({location, subscription, promotionData}: Props) {
-
- {renderUsageChart(usageData)}
- {renderUsageCards(usageData)}
-
+ {isNewBillingUI ? (
+
+ ) : (
+
+
+ {renderUsageChart(usageData)}
+ {renderUsageCards(usageData)}
+
+
+ )}
);
@@ -353,8 +360,14 @@ function Overview({location, subscription, promotionData}: Props) {
- {renderUsageChart(usageData)}
- {renderUsageCards(usageData)}
+ {isNewBillingUI ? (
+
+ ) : (
+
+ {renderUsageChart(usageData)}
+ {renderUsageCards(usageData)}
+
+ )}
);
diff --git a/static/gsApp/views/subscriptionPage/usageOverview.spec.tsx b/static/gsApp/views/subscriptionPage/usageOverview.spec.tsx
new file mode 100644
index 00000000000000..5d02908948dc8d
--- /dev/null
+++ b/static/gsApp/views/subscriptionPage/usageOverview.spec.tsx
@@ -0,0 +1,119 @@
+import moment from 'moment-timezone';
+import {OrganizationFixture} from 'sentry-fixture/organization';
+
+import {BillingHistoryFixture} from 'getsentry-test/fixtures/billingHistory';
+import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription';
+import {render, screen} from 'sentry-test/reactTestingLibrary';
+import {resetMockDate, setMockDate} from 'sentry-test/utils';
+
+import {DataCategory} from 'sentry/types/core';
+
+import SubscriptionStore from 'getsentry/stores/subscriptionStore';
+import UsageOverview from 'getsentry/views/subscriptionPage/usageOverview';
+
+describe('UsageOverview', () => {
+ const organization = OrganizationFixture();
+ const subscription = SubscriptionFixture({organization, plan: 'am3_business'});
+
+ beforeEach(() => {
+ setMockDate(new Date('2021-05-07'));
+ MockApiClient.clearMockResponses();
+ organization.access = ['org:billing'];
+ SubscriptionStore.set(organization.slug, subscription);
+ MockApiClient.addMockResponse({
+ url: `/customers/${organization.slug}/history/`,
+ method: 'GET',
+ body: BillingHistoryFixture(),
+ });
+ });
+
+ afterEach(() => {
+ resetMockDate();
+ });
+
+ it('renders columns and buttons for billing users', () => {
+ render();
+ expect(screen.getByRole('columnheader', {name: 'Product'})).toBeInTheDocument();
+ expect(screen.getByRole('columnheader', {name: 'Current usage'})).toBeInTheDocument();
+ expect(
+ screen.getByRole('columnheader', {name: 'Reserved usage'})
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('columnheader', {name: 'Reserved spend'})
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('columnheader', {name: 'Pay-as-you-go spend'})
+ ).toBeInTheDocument();
+ expect(screen.getByRole('button', {name: 'View usage history'})).toBeInTheDocument();
+ expect(screen.getByRole('button', {name: 'Download as CSV'})).toBeInTheDocument();
+ });
+
+ it('renders columns for non-billing users', () => {
+ organization.access = [];
+ render();
+ expect(screen.getByRole('columnheader', {name: 'Product'})).toBeInTheDocument();
+ expect(screen.getByRole('columnheader', {name: 'Current usage'})).toBeInTheDocument();
+ expect(
+ screen.getByRole('columnheader', {name: 'Reserved usage'})
+ ).toBeInTheDocument();
+ expect(screen.queryByText('Reserved spend')).not.toBeInTheDocument();
+ expect(screen.queryByText('Pay-as-you-go spend')).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', {name: 'View usage history'})
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', {name: 'Download as CSV'})
+ ).not.toBeInTheDocument();
+ });
+
+ it('renders table based on subscription state', () => {
+ subscription.onDemandMaxSpend = 100_00;
+ subscription.productTrials = [
+ {
+ category: DataCategory.PROFILE_DURATION,
+ isStarted: false,
+ reasonCode: 1001,
+ startDate: undefined,
+ endDate: moment().utc().add(20, 'years').format(),
+ },
+ {
+ category: DataCategory.REPLAYS,
+ isStarted: true,
+ reasonCode: 1001,
+ startDate: moment().utc().subtract(10, 'days').format(),
+ endDate: moment().utc().add(20, 'days').format(),
+ },
+ ];
+ subscription.categories.errors = {
+ ...subscription.categories.errors!,
+ free: 1000,
+ usage: 6000,
+ onDemandSpendUsed: 10_00,
+ };
+ subscription.categories.spans = {
+ ...subscription.categories.spans!,
+ reserved: 20_000_000,
+ };
+
+ render();
+
+ // Continuous profile hours product trial available
+ expect(
+ screen.getByRole('button', {
+ name: 'Start 14 day free Continuous Profile Hours trial',
+ })
+ ).toBeInTheDocument();
+ expect(screen.getByText('Trial available')).toBeInTheDocument();
+
+ // Replays product trial active
+ expect(screen.getByText('20 days left')).toBeInTheDocument();
+
+ // Errors usage and gifted units
+ expect(screen.getByText('6,000 / 51,000')).toBeInTheDocument();
+ expect(screen.getByText('$10.00')).toBeInTheDocument();
+
+ // Reserved spans above platform volume
+ expect(screen.getByText('0 / 20,000,000')).toBeInTheDocument();
+ expect(screen.getByText('$32.00')).toBeInTheDocument();
+ });
+});
diff --git a/static/gsApp/views/subscriptionPage/usageOverview.tsx b/static/gsApp/views/subscriptionPage/usageOverview.tsx
new file mode 100644
index 00000000000000..9eb1b985af1d67
--- /dev/null
+++ b/static/gsApp/views/subscriptionPage/usageOverview.tsx
@@ -0,0 +1,621 @@
+import React, {useEffect, useMemo, useState} from 'react';
+import styled from '@emotion/styled';
+
+import {Tag} from 'sentry/components/core/badge/tag';
+import {Button} from 'sentry/components/core/button';
+import {LinkButton} from 'sentry/components/core/button/linkButton';
+import {Container, Flex} from 'sentry/components/core/layout';
+import {Heading, Text} from 'sentry/components/core/text';
+import type {TextProps} from 'sentry/components/core/text/text';
+import QuestionTooltip from 'sentry/components/questionTooltip';
+import type {GridColumnOrder} from 'sentry/components/tables/gridEditable';
+import GridEditable from 'sentry/components/tables/gridEditable';
+import {
+ IconChevron,
+ IconDownload,
+ IconGraph,
+ IconLightning,
+ IconLock,
+} from 'sentry/icons';
+import {t, tct} from 'sentry/locale';
+import type {DataCategory} from 'sentry/types/core';
+import type {Organization} from 'sentry/types/organization';
+import {defined} from 'sentry/utils';
+import {toTitleCase} from 'sentry/utils/string/toTitleCase';
+import {useNavContext} from 'sentry/views/nav/context';
+
+import ProductTrialTag from 'getsentry/components/productTrial/productTrialTag';
+import StartTrialButton from 'getsentry/components/startTrialButton';
+import {RESERVED_BUDGET_QUOTA} from 'getsentry/constants';
+import {useCurrentBillingHistory} from 'getsentry/hooks/useCurrentBillingHistory';
+import {
+ AddOnCategory,
+ OnDemandBudgetMode,
+ type ProductTrial,
+ type Subscription,
+} from 'getsentry/types';
+import {
+ displayBudgetName,
+ formatReservedWithUnits,
+ formatUsageWithUnits,
+ getActiveProductTrial,
+ getPercentage,
+ getPotentialProductTrial,
+ getReservedBudgetCategoryForAddOn,
+} from 'getsentry/utils/billing';
+import {getPlanCategoryName, sortCategories} from 'getsentry/utils/dataCategory';
+import {displayPriceWithCents, getBucket} from 'getsentry/views/amCheckout/utils';
+
+interface UsageOverviewProps {
+ organization: Organization;
+ subscription: Subscription;
+}
+
+function CurrencyCell({
+ children,
+ bold,
+ variant,
+}: {
+ children: React.ReactNode;
+ bold?: boolean;
+ variant?: TextProps<'span'>['variant'];
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+function ReservedUsageBar({percentUsed}: {percentUsed: number}) {
+ if (percentUsed === 0 || percentUsed === 1) {
+ return ;
+ }
+ const filledWidth = percentUsed * 90;
+ const unfilledWidth = 90 - filledWidth;
+ return (
+
+
+
+
+ );
+}
+
+function UsageOverviewTable({subscription, organization}: UsageOverviewProps) {
+ const hasBillingPerms = organization.access.includes('org:billing');
+ const [openState, setOpenState] = useState>({});
+ const [trialButtonBusyState, setTrialButtonBusyState] = useState<
+ Partial>
+ >({});
+
+ useEffect(() => {
+ Object.entries(subscription.addOns ?? {})
+ .filter(
+ ([_, addOnInfo]) =>
+ !addOnInfo.billingFlag || organization.features.includes(addOnInfo.billingFlag)
+ )
+ .forEach(([apiName, _]) => {
+ setOpenState(prev => ({...prev, [apiName]: true}));
+ });
+ }, [subscription.addOns, organization.features]);
+
+ const allAddOnDataCategories = Object.values(
+ subscription.planDetails.addOnCategories
+ ).flatMap(addOn => addOn.dataCategories);
+
+ const columnOrder: GridColumnOrder[] = useMemo(() => {
+ const hasAnyPotentialProductTrial = subscription.productTrials?.some(
+ trial => !trial.isStarted
+ );
+ return [
+ {key: 'product', name: t('Product'), width: 300},
+ {key: 'currentUsage', name: t('Current usage'), width: 200},
+ {key: 'reservedUsage', name: t('Reserved usage'), width: 200},
+ {key: 'reservedSpend', name: t('Reserved spend'), width: 200},
+ {
+ key: 'budgetSpend',
+ name: t('%s spend', displayBudgetName(subscription.planDetails, {title: true})),
+ width: 200,
+ },
+ {
+ key: 'cta',
+ name: '',
+ width: 50,
+ },
+ ].filter(
+ column =>
+ (hasBillingPerms || !column.key.endsWith('Spend')) &&
+ (hasAnyPotentialProductTrial || column.key !== 'cta')
+ );
+ }, [hasBillingPerms, subscription.planDetails, subscription.productTrials]);
+
+ // TODO(isabella): refactor this to have better types
+ const productData: Array<{
+ attrs: {
+ hasAccess: boolean;
+ isPaygOnly: boolean;
+ addOnCategory?: AddOnCategory;
+ ariaLabel?: string;
+ dataCategory?: DataCategory;
+ free?: number;
+ hasToggle?: boolean;
+ isChildProduct?: boolean;
+ isOpen?: boolean;
+ onToggle?: () => void;
+ productTrial?: ProductTrial;
+ reserved?: number;
+ };
+ gridData: {
+ budgetSpend: number;
+ currentUsage: number;
+ product: string;
+ reservedSpend?: number;
+ reservedUsage?: number;
+ };
+ }> = useMemo(() => {
+ return [
+ ...sortCategories(subscription.categories)
+ .filter(metricHistory => !allAddOnDataCategories.includes(metricHistory.category))
+ .map(metricHistory => {
+ const category = metricHistory.category;
+ const productName = getPlanCategoryName({
+ plan: subscription.planDetails,
+ category,
+ title: true,
+ });
+ const reserved = metricHistory.reserved ?? 0;
+ const free = metricHistory.free;
+ const total = metricHistory.usage;
+ const paygTotal = metricHistory.onDemandSpendUsed;
+ const activeProductTrial = getActiveProductTrial(
+ subscription.productTrials ?? [],
+ category
+ );
+ const potentialProductTrial = getPotentialProductTrial(
+ subscription.productTrials ?? [],
+ category
+ );
+
+ const isPaygOnly = reserved === 0;
+ const hasAccess = activeProductTrial
+ ? true
+ : isPaygOnly
+ ? subscription.onDemandBudgets?.budgetMode ===
+ OnDemandBudgetMode.PER_CATEGORY
+ ? metricHistory.onDemandBudget > 0
+ : subscription.onDemandMaxSpend > 0
+ : reserved > 0;
+
+ const bucket = getBucket({
+ events: reserved,
+ buckets: subscription.planDetails.planCategories[category],
+ });
+ const recurringReservedSpend = bucket.price ?? 0;
+ const reservedTotal = (reserved ?? 0) + (free ?? 0);
+ const percentUsed = reservedTotal
+ ? getPercentage(total, reservedTotal)
+ : undefined;
+
+ return {
+ attrs: {
+ dataCategory: category,
+ hasAccess,
+ isPaygOnly,
+ free,
+ reserved,
+ productTrial: activeProductTrial ?? potentialProductTrial ?? undefined,
+ },
+ gridData: {
+ product: productName,
+ currentUsage: total,
+ reservedUsage: percentUsed,
+ reservedSpend: recurringReservedSpend,
+ budgetSpend: paygTotal,
+ },
+ };
+ }),
+ ...Object.entries(subscription.addOns ?? {})
+ .filter(
+ ([_, addOnInfo]) =>
+ !addOnInfo.billingFlag ||
+ organization.features.includes(addOnInfo.billingFlag)
+ )
+ .flatMap(([apiName, addOnInfo]) => {
+ const addOnName = toTitleCase(addOnInfo.productName, {
+ allowInnerUpperCase: true,
+ });
+
+ const paygTotal = addOnInfo.dataCategories.reduce((acc, category) => {
+ return acc + (subscription.categories[category]?.onDemandSpendUsed ?? 0);
+ }, 0);
+ const addOnDataCategories = addOnInfo.dataCategories;
+ const reservedBudgetCategory = getReservedBudgetCategoryForAddOn(
+ apiName as AddOnCategory
+ );
+ const reservedBudget = reservedBudgetCategory
+ ? subscription.reservedBudgets?.find(
+ budget => (budget.apiName as string) === reservedBudgetCategory
+ )
+ : undefined;
+ const percentUsed = reservedBudget?.totalReservedSpend
+ ? getPercentage(
+ reservedBudget?.totalReservedSpend,
+ reservedBudget?.reservedBudget
+ )
+ : undefined;
+ const activeProductTrial = getActiveProductTrial(
+ subscription.productTrials ?? [],
+ addOnDataCategories[0] as DataCategory
+ );
+ const potentialProductTrial = getPotentialProductTrial(
+ subscription.productTrials ?? [],
+ addOnDataCategories[0] as DataCategory
+ );
+ const hasAccess = addOnInfo.enabled;
+
+ // TODO(isabella): fix this for add-ons with multiple data categories
+ const bucket = hasAccess
+ ? getBucket({
+ buckets:
+ subscription.planDetails.planCategories[
+ addOnDataCategories[0] as DataCategory
+ ],
+ events: RESERVED_BUDGET_QUOTA,
+ })
+ : undefined;
+ const recurringReservedSpend = bucket?.price ?? 0;
+
+ const childCategoriesData = openState[apiName]
+ ? addOnInfo.dataCategories.map(addOnDataCategory => {
+ const childSpend =
+ reservedBudget?.categories[addOnDataCategory]?.reservedSpend ?? 0;
+ const childPaygTotal =
+ subscription.categories[addOnDataCategory]?.onDemandSpendUsed ?? 0;
+ const childProductName = getPlanCategoryName({
+ plan: subscription.planDetails,
+ category: addOnDataCategory,
+ title: true,
+ });
+ return {
+ attrs: {
+ addOnCategory: apiName as AddOnCategory,
+ isChildProduct: true,
+ isOpen: openState[apiName],
+ hasAccess: true,
+ isPaygOnly: false,
+ },
+ gridData: {
+ budgetSpend: childPaygTotal,
+ currentUsage: (childSpend ?? 0) + childPaygTotal,
+ product: childProductName,
+ },
+ };
+ })
+ : null;
+
+ return [
+ {
+ attrs: {
+ addOnCategory: apiName as AddOnCategory,
+ hasAccess,
+ free: reservedBudget?.freeBudget ?? 0,
+ reserved: reservedBudget?.reservedBudget ?? 0,
+ isPaygOnly: !reservedBudget,
+ productTrial: activeProductTrial ?? potentialProductTrial ?? undefined,
+ hasToggle: true,
+ isOpen: openState[apiName],
+ onToggle: () => {
+ setOpenState(prev => ({...prev, [apiName]: !prev[apiName]}));
+ },
+ ariaLabel: openState[apiName]
+ ? t('Collapse %s info', addOnName)
+ : t('Expand %s info', addOnName),
+ },
+ gridData: {
+ product: addOnName,
+ currentUsage: (reservedBudget?.totalReservedSpend ?? 0) + paygTotal,
+ reservedUsage: percentUsed,
+ reservedSpend: recurringReservedSpend,
+ budgetSpend: paygTotal,
+ },
+ },
+ ...(childCategoriesData ?? []),
+ ];
+ }),
+ ];
+ }, [subscription, allAddOnDataCategories, organization.features, openState]);
+
+ return (
+ product.gridData)}
+ columnSortBy={[]}
+ grid={{
+ renderHeadCell: column => {
+ return {column.name};
+ },
+ renderBodyCell: (column, row) => {
+ const attrs = productData.find(
+ product => product.gridData.product === row.product
+ )?.attrs;
+ if (!attrs) {
+ return row[column.key as keyof typeof row];
+ }
+ const {
+ dataCategory,
+ hasAccess,
+ isPaygOnly,
+ free,
+ reserved,
+ isOpen,
+ isChildProduct,
+ } = attrs;
+
+ if (defined(isOpen) && !isOpen && isChildProduct) {
+ return null;
+ }
+
+ switch (column.key) {
+ case 'product': {
+ const {hasToggle, onToggle, ariaLabel, productTrial} = attrs;
+ const title = (
+
+
+ {!hasAccess && } {row.product}{' '}
+ {' '}
+ {productTrial && }
+
+ );
+
+ if (hasToggle) {
+ return (
+
+
+ ) : (
+
+ )
+ }
+ aria-label={ariaLabel}
+ onClick={() => onToggle?.()}
+ >
+ {title}
+
+
+ );
+ }
+ return (
+
+ {title}
+
+ );
+ }
+ case 'currentUsage': {
+ const formattedTotal = dataCategory
+ ? formatUsageWithUnits(row.currentUsage, dataCategory, {
+ useUnitScaling: true,
+ })
+ : displayPriceWithCents({cents: row.currentUsage});
+ const formattedReserved = dataCategory
+ ? formatReservedWithUnits(reserved ?? 0, dataCategory, {
+ useUnitScaling: true,
+ })
+ : displayPriceWithCents({cents: reserved ?? 0});
+ const formattedFree = dataCategory
+ ? formatReservedWithUnits(free ?? 0, dataCategory, {
+ useUnitScaling: true,
+ })
+ : displayPriceWithCents({cents: free ?? 0});
+ const formattedReservedTotal = dataCategory
+ ? formatReservedWithUnits((reserved ?? 0) + (free ?? 0), dataCategory, {
+ useUnitScaling: true,
+ })
+ : displayPriceWithCents({cents: (reserved ?? 0) + (free ?? 0)});
+ const formattedCurrentUsage =
+ isPaygOnly || isChildProduct
+ ? formattedTotal
+ : `${formattedTotal} / ${formattedReservedTotal}`;
+ return (
+
+ {formattedCurrentUsage}{' '}
+ {!(isPaygOnly || isChildProduct) && (
+
+ )}
+
+ );
+ }
+ case 'reservedUsage': {
+ if (isPaygOnly) {
+ return (
+
+
+ {tct('[budgetTerm] only', {
+ budgetTerm: displayBudgetName(subscription.planDetails, {
+ title: true,
+ }),
+ })}
+
+
+ );
+ }
+ const percentUsed = row.reservedUsage;
+ if (defined(percentUsed)) {
+ return (
+
+
+ {percentUsed.toFixed(0) + '%'}
+
+ );
+ }
+ return ;
+ }
+ case 'reservedSpend':
+ case 'budgetSpend': {
+ const spend = row[column.key as keyof typeof row];
+ const formattedSpend = spend
+ ? displayPriceWithCents({cents: spend as number})
+ : '-';
+ return {formattedSpend};
+ }
+ case 'cta': {
+ const productTrial = attrs.productTrial;
+ if (productTrial && !productTrial.isStarted) {
+ return (
+
+ {
+ setTrialButtonBusyState(prev => ({
+ ...prev,
+ [productTrial.category]: true,
+ }));
+ }}
+ onTrialStarted={() => {
+ setTrialButtonBusyState(prev => ({
+ ...prev,
+ [productTrial.category]: true,
+ }));
+ }}
+ onTrialFailed={() => {
+ setTrialButtonBusyState(prev => ({
+ ...prev,
+ [productTrial.category]: false,
+ }));
+ }}
+ busy={trialButtonBusyState[productTrial.category]}
+ disabled={trialButtonBusyState[productTrial.category]}
+ size="xs"
+ >
+
+
+ {t('Start 14 day free trial')}
+
+
+
+ );
+ }
+ return ;
+ }
+ default:
+ return row[column.key as keyof typeof row];
+ }
+ },
+ }}
+ />
+ );
+}
+
+function UsageOverview({subscription, organization}: UsageOverviewProps) {
+ const hasBillingPerms = organization.access.includes('org:billing');
+ const {isCollapsed: navIsCollapsed} = useNavContext();
+ const {currentHistory, isPending, isError} = useCurrentBillingHistory();
+ return (
+
+
+
+ {t('Usage Overview')}
+
+ {hasBillingPerms && (
+
+ }
+ aria-label={t('View usage history')}
+ priority="link"
+ to="/settings/billing/usage/"
+ >
+ {t('View usage history')}
+
+ }
+ aria-label={t('Download as CSV')}
+ disabled={isPending || isError}
+ onClick={() => {
+ if (currentHistory) {
+ window.open(currentHistory.links.csv, '_blank');
+ }
+ }}
+ >
+ {t('Download as CSV')}
+
+
+ )}
+
+
+
+ );
+}
+
+export default UsageOverview;
+
+const StyledButton = styled(Button)`
+ padding: 0;
+`;
+
+const Bar = styled('div')<{
+ fillPercentage: number;
+ hasLeftBorderRadius?: boolean;
+ hasRightBorderRadius?: boolean;
+ width?: string;
+}>`
+ display: block;
+ width: ${p => (p.width ? p.width : '90px')};
+ height: 6px;
+ background: ${p =>
+ p.fillPercentage === 1
+ ? p.theme.danger
+ : p.fillPercentage > 0
+ ? p.theme.active
+ : p.theme.gray200};
+ border-top-left-radius: ${p => (p.hasLeftBorderRadius ? p.theme.borderRadius : 0)};
+ border-bottom-left-radius: ${p => (p.hasLeftBorderRadius ? p.theme.borderRadius : 0)};
+ border-top-right-radius: ${p => (p.hasRightBorderRadius ? p.theme.borderRadius : 0)};
+ border-bottom-right-radius: ${p => (p.hasRightBorderRadius ? p.theme.borderRadius : 0)};
+`;
diff --git a/static/gsApp/views/subscriptionPage/usageTotals.spec.tsx b/static/gsApp/views/subscriptionPage/usageTotals.spec.tsx
index 1806cb078ef9bb..ec04db59693bff 100644
--- a/static/gsApp/views/subscriptionPage/usageTotals.spec.tsx
+++ b/static/gsApp/views/subscriptionPage/usageTotals.spec.tsx
@@ -1356,7 +1356,7 @@ describe('Subscription > CombinedUsageTotals', () => {
expect(screen.getByTestId('locked-product-message-seer')).toHaveTextContent(
'Start your Seer trial to view usage'
);
- expect(screen.getByText('Trial Available')).toBeInTheDocument();
+ expect(screen.getByText('Trial available')).toBeInTheDocument();
expect(screen.getByRole('button', {name: 'Start trial'})).toBeInTheDocument();
expect(screen.queryByRole('button', {name: 'Enable Seer'})).not.toBeInTheDocument();
});
diff --git a/static/gsApp/views/subscriptionPage/usageTotals.tsx b/static/gsApp/views/subscriptionPage/usageTotals.tsx
index fd989f3ab4d0f8..27f2d68c18712a 100644
--- a/static/gsApp/views/subscriptionPage/usageTotals.tsx
+++ b/static/gsApp/views/subscriptionPage/usageTotals.tsx
@@ -34,6 +34,7 @@ import {
formatReservedWithUnits,
formatUsageWithUnits,
getActiveProductTrial,
+ getPercentage,
getPotentialProductTrial,
isAm2Plan,
isUnlimitedReserved,
@@ -71,18 +72,6 @@ const EMPTY_STAT_TOTAL = {
projected: 0,
};
-function getPercentage(quantity: number, total: number | null) {
- if (typeof total === 'number' && total > 0) {
- return (Math.min(quantity, total) / total) * 100;
- }
- return 0;
-}
-
-export function displayPercentage(quantity: number, total: number | null) {
- const percentage = getPercentage(quantity, total);
- return percentage.toFixed(0) + '%';
-}
-
type UsageProps = {
/**
* The data category to display
diff --git a/static/gsApp/views/subscriptionPage/usageTotalsTable.tsx b/static/gsApp/views/subscriptionPage/usageTotalsTable.tsx
index 6bd4371fdb9745..49cd3c594a63d0 100644
--- a/static/gsApp/views/subscriptionPage/usageTotalsTable.tsx
+++ b/static/gsApp/views/subscriptionPage/usageTotalsTable.tsx
@@ -13,14 +13,13 @@ import type {DataCategory} from 'sentry/types/core';
import {toTitleCase} from 'sentry/utils/string/toTitleCase';
import type {BillingStatTotal, Subscription} from 'getsentry/types';
-import {formatUsageWithUnits} from 'getsentry/utils/billing';
+import {displayPercentage, formatUsageWithUnits} from 'getsentry/utils/billing';
import {
getCategoryInfoFromPlural,
getPlanCategoryName,
isContinuousProfiling,
} from 'getsentry/utils/dataCategory';
import {StripedTable} from 'getsentry/views/subscriptionPage/styles';
-import {displayPercentage} from 'getsentry/views/subscriptionPage/usageTotals';
type RowProps = {
category: DataCategory;