diff --git a/static/gsApp/hooks/useProductBillingMetadata.tsx b/static/gsApp/hooks/useProductBillingMetadata.tsx new file mode 100644 index 00000000000000..47b5a9969411f8 --- /dev/null +++ b/static/gsApp/hooks/useProductBillingMetadata.tsx @@ -0,0 +1,86 @@ +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 { + activeProductTrial: ProductTrial | null; + billedCategory: DataCategory | null; + displayName: string; + isAddOn: boolean; + isEnabled: boolean; + potentialProductTrial: ProductTrial | null; + usageExceeded: boolean; + 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 ac91933379355d..7e7d3ca5db9e54 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..7722133fd6a65a 100644 --- a/static/gsApp/utils/billing.spec.tsx +++ b/static/gsApp/utils/billing.spec.tsx @@ -185,6 +185,11 @@ describe('formatReservedWithUnits', () => { useUnitScaling: true, }) ).toBe(UNLIMITED); + expect( + formatReservedWithUnits(-1, DataCategory.ATTACHMENTS, { + useUnitScaling: true, + }) + ).toBe(UNLIMITED); }); it('returns correct string for Profile Duration', () => { @@ -254,6 +259,12 @@ describe('formatReservedWithUnits', () => { }) ).toBe(UNLIMITED); + expect( + formatReservedWithUnits(-1, DataCategory.LOG_BYTE, { + useUnitScaling: true, + }) + ).toBe(UNLIMITED); + expect( formatReservedWithUnits(1234, DataCategory.LOG_BYTE, { isAbbreviated: 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.spec.tsx b/static/gsApp/views/subscriptionPage/components/categoryUsageDrawer.spec.tsx deleted file mode 100644 index b7e0754669f0d4..00000000000000 --- a/static/gsApp/views/subscriptionPage/components/categoryUsageDrawer.spec.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import {OrganizationFixture} from 'sentry-fixture/organization'; - -import {BillingStatFixture} from 'getsentry-test/fixtures/billingStat'; -import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription'; -import {UsageTotalFixture} from 'getsentry-test/fixtures/usageTotal'; -import {act, render, screen} from 'sentry-test/reactTestingLibrary'; - -import {DataCategory} from 'sentry/types/core'; -import {OrganizationContext} from 'sentry/views/organizationContext'; - -import SubscriptionStore from 'getsentry/stores/subscriptionStore'; -import {PlanTier} from 'getsentry/types'; - -import CategoryUsageDrawer from './categoryUsageDrawer'; - -describe('CategoryUsageDrawer', () => { - const organization = OrganizationFixture(); - const totals = UsageTotalFixture({ - accepted: 50, - dropped: 10, - droppedOverQuota: 5, - droppedSpikeProtection: 2, - droppedOther: 3, - }); - const stats = [BillingStatFixture()]; - - beforeEach(() => { - organization.features.push('subscriptions-v3'); - }); - - function renderComponent(props: any) { - return render( - - - - ); - } - - it('renders', async () => { - const subscription = SubscriptionFixture({ - organization, - }); - subscription.categories.errors = { - ...subscription.categories.errors!, - usage: 50, - }; - SubscriptionStore.set(organization.slug, subscription); - await act(async () => { - renderComponent({ - subscription, - categoryInfo: subscription.categories.errors, - eventTotals: {[DataCategory.ERRORS]: totals}, - totals, - stats, - periodEnd: '2021-02-01', - periodStart: '2021-01-01', - }); - - // filter values are asynchronously persisted - await tick(); - }); - - expect(screen.getByText('Current Usage Period')).toBeInTheDocument(); - expect(screen.getByRole('heading', {name: 'Total ingested'})).toBeInTheDocument(); - expect(screen.getByRole('row', {name: 'Accepted 50 83%'})).toBeInTheDocument(); - expect(screen.getByRole('row', {name: 'Total Dropped 10 17%'})).toBeInTheDocument(); - expect(screen.getByRole('row', {name: 'Over Quota 5 8%'})).toBeInTheDocument(); - expect(screen.getByRole('row', {name: 'Spike Protection 2 3%'})).toBeInTheDocument(); - expect(screen.getByRole('row', {name: 'Other 3 5%'})).toBeInTheDocument(); - }); - - it('renders event breakdown', async () => { - const subscription = SubscriptionFixture({ - organization, - plan: 'am2_team', - planTier: PlanTier.AM2, - }); - organization.features.push('profiling-billing'); - subscription.categories.transactions = { - ...subscription.categories.transactions!, - usage: 50, - }; - SubscriptionStore.set(organization.slug, subscription); - await act(async () => { - renderComponent({ - subscription, - categoryInfo: subscription.categories.transactions, - eventTotals: { - [DataCategory.TRANSACTIONS]: totals, - [DataCategory.PROFILES]: totals, - }, - totals, - stats, - periodEnd: '2021-02-01', - periodStart: '2021-01-01', - }); - await tick(); - }); - - // only event breakdown tables should have a header for the first column - expect( - screen.queryByRole('columnheader', {name: 'Performance Units'}) - ).not.toBeInTheDocument(); - expect( - screen.queryByRole('columnheader', {name: 'Performance Unit Events'}) - ).not.toBeInTheDocument(); - expect( - screen.getByRole('columnheader', {name: 'Transaction Events'}) - ).toBeInTheDocument(); - expect( - screen.getByRole('columnheader', {name: 'Profile Events'}) - ).toBeInTheDocument(); - - expect( - screen.getByRole('columnheader', {name: '% of Performance Units'}) - ).toBeInTheDocument(); - expect( - screen.getByRole('columnheader', {name: '% of Transactions'}) - ).toBeInTheDocument(); - expect(screen.getByRole('columnheader', {name: '% of Profiles'})).toBeInTheDocument(); - }); -}); diff --git a/static/gsApp/views/subscriptionPage/components/categoryUsageDrawer.tsx b/static/gsApp/views/subscriptionPage/components/categoryUsageDrawer.tsx deleted file mode 100644 index 3557f9eb538b58..00000000000000 --- a/static/gsApp/views/subscriptionPage/components/categoryUsageDrawer.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import OptionSelector from 'sentry/components/charts/optionSelector'; -import {ChartControls, InlineContainer} from 'sentry/components/charts/styles'; -import {Container, Flex} from 'sentry/components/core/layout'; -import {DrawerBody, DrawerHeader} from 'sentry/components/globalDrawer/components'; -import {t} from 'sentry/locale'; -import {DataCategory} from 'sentry/types/core'; -import {useLocation} from 'sentry/utils/useLocation'; -import {useNavigate} from 'sentry/utils/useNavigate'; -import useOrganization from 'sentry/utils/useOrganization'; -import {CHART_OPTIONS_DATA_TRANSFORM} from 'sentry/views/organizationStats/usageChart'; - -import { - PlanTier, - type BillingMetricHistory, - type BillingStats, - type BillingStatTotal, - type Subscription, -} from 'getsentry/types'; -import {addBillingStatTotals, isAm2Plan} from 'getsentry/utils/billing'; -import { - getChunkCategoryFromDuration, - getPlanCategoryName, - isContinuousProfiling, -} from 'getsentry/utils/dataCategory'; -import trackGetsentryAnalytics from 'getsentry/utils/trackGetsentryAnalytics'; -import { - ProductUsageChart, - selectedTransform, -} from 'getsentry/views/subscriptionPage/reservedUsageChart'; -import {EMPTY_STAT_TOTAL} from 'getsentry/views/subscriptionPage/usageTotals'; -import UsageTotalsTable from 'getsentry/views/subscriptionPage/usageTotalsTable'; - -interface CategoryUsageDrawerProps { - categoryInfo: BillingMetricHistory; - eventTotals: Record; - periodEnd: string; - periodStart: string; - stats: BillingStats; - subscription: Subscription; - totals: BillingStatTotal; -} - -function CategoryUsageDrawer({ - categoryInfo, - stats, - totals, - eventTotals, - subscription, - periodStart, - periodEnd, -}: CategoryUsageDrawerProps) { - const organization = useOrganization(); - const navigate = useNavigate(); - const location = useLocation(); - const transform = selectedTransform(location); - const {category, usage: billedUsage} = categoryInfo; - - const displayName = getPlanCategoryName({ - plan: subscription.planDetails, - category, - title: true, - }); - - const usageStats = { - [category]: stats, - }; - - const adjustedTotals = isContinuousProfiling(category) - ? { - ...addBillingStatTotals(totals, [ - eventTotals[getChunkCategoryFromDuration(category)] ?? EMPTY_STAT_TOTAL, - !isAm2Plan(subscription.plan) && category === DataCategory.PROFILE_DURATION - ? (eventTotals[DataCategory.PROFILES] ?? EMPTY_STAT_TOTAL) - : EMPTY_STAT_TOTAL, - ]), - accepted: billedUsage, - } - : {...totals, accepted: billedUsage}; - - const renderFooter = () => { - return ( - - - { - trackGetsentryAnalytics( - 'subscription_page.usage_overview.transform_changed', - { - organization, - subscription, - transform: val, - } - ); - navigate({ - pathname: location.pathname, - query: {...location.query, transform: val}, - }); - }} - /> - - - ); - }; - - const showEventBreakdown = - organization.features.includes('profiling-billing') && - subscription.planTier === PlanTier.AM2 && - category === DataCategory.TRANSACTIONS; - - return ( - - - {displayName} - - - - - - {showEventBreakdown && - Object.entries(eventTotals).map(([key, eventTotal]) => { - return ( - - ); - })} - - - - ); -} - -export default CategoryUsageDrawer; diff --git a/static/gsApp/views/subscriptionPage/overview.tsx b/static/gsApp/views/subscriptionPage/overview.tsx index 79789a1fd6a7c2..afd9658f9b1efe 100644 --- a/static/gsApp/views/subscriptionPage/overview.tsx +++ b/static/gsApp/views/subscriptionPage/overview.tsx @@ -146,7 +146,7 @@ function Overview({location, subscription, promotionData}: Props) { // Whilst self-serve accounts do. if (!hasBillingPerms && !subscription.canSelfServe) { return ( - + ); @@ -311,10 +311,9 @@ function Overview({location, subscription, promotionData}: Props) { {t('Having trouble?')} @@ -410,7 +409,7 @@ function Overview({location, subscription, promotionData}: Props) { return ( ) : ( - + {hasBillingPerms ? contentWithBillingPerms(usage, subscription.planDetails) : contentWithoutBillingPerms(usage)} diff --git a/static/gsApp/views/subscriptionPage/subscriptionHeader.tsx b/static/gsApp/views/subscriptionPage/subscriptionHeader.tsx index 3fecb67e9b2d39..197b5b944414cc 100644 --- a/static/gsApp/views/subscriptionPage/subscriptionHeader.tsx +++ b/static/gsApp/views/subscriptionPage/subscriptionHeader.tsx @@ -229,7 +229,7 @@ function SubscriptionHeader(props: Props) { - + 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(); }); diff --git a/static/gsApp/views/subscriptionPage/usageOverview.spec.tsx b/static/gsApp/views/subscriptionPage/usageOverview.spec.tsx deleted file mode 100644 index bc97d9759fdefe..00000000000000 --- a/static/gsApp/views/subscriptionPage/usageOverview.spec.tsx +++ /dev/null @@ -1,391 +0,0 @@ -import moment from 'moment-timezone'; -import {LocationFixture} from 'sentry-fixture/locationFixture'; -import {OrganizationFixture} from 'sentry-fixture/organization'; - -import {BillingHistoryFixture} from 'getsentry-test/fixtures/billingHistory'; -import {CustomerUsageFixture} from 'getsentry-test/fixtures/customerUsage'; -import { - SubscriptionFixture, - SubscriptionWithLegacySeerFixture, -} from 'getsentry-test/fixtures/subscription'; -import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; -import {resetMockDate, setMockDate} from 'sentry-test/utils'; - -import {DataCategory} from 'sentry/types/core'; - -import {GIGABYTE, UNLIMITED, UNLIMITED_RESERVED} from 'getsentry/constants'; -import SubscriptionStore from 'getsentry/stores/subscriptionStore'; -import Overview from 'getsentry/views/subscriptionPage/overview'; -import UsageOverview from 'getsentry/views/subscriptionPage/usageOverview'; - -describe('UsageOverview', () => { - const organization = OrganizationFixture(); - const subscription = SubscriptionFixture({organization, plan: 'am3_business'}); - const usageData = CustomerUsageFixture(); - - beforeEach(() => { - organization.features = ['subscriptions-v3', 'seer-billing']; - setMockDate(new Date('2021-05-07')); - MockApiClient.clearMockResponses(); - organization.access = ['org:billing']; - SubscriptionStore.set(organization.slug, subscription); - MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/history/current/`, - method: 'GET', - body: BillingHistoryFixture(), - }); - }); - - afterEach(() => { - resetMockDate(); - }); - - it('renders columns and buttons for billing users', () => { - render( - - ); - expect(screen.getAllByRole('columnheader')).toHaveLength(6); - expect(screen.getByRole('columnheader', {name: 'Product'})).toBeInTheDocument(); - expect(screen.getByRole('columnheader', {name: 'Total usage'})).toBeInTheDocument(); - expect(screen.getByRole('columnheader', {name: 'Reserved'})).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(); - expect(screen.getAllByRole('row', {name: /^View .+ usage$/i}).length).toBeGreaterThan( - 0 - ); - }); - - it('renders columns for non-billing users', () => { - organization.access = []; - render( - - ); - expect(screen.getAllByRole('columnheader')).toHaveLength(6); - expect(screen.getByRole('columnheader', {name: 'Product'})).toBeInTheDocument(); - expect(screen.getByRole('columnheader', {name: 'Total usage'})).toBeInTheDocument(); - expect(screen.getByRole('columnheader', {name: 'Reserved'})).toBeInTheDocument(); - expect( - screen.getByRole('columnheader', {name: 'Reserved spend'}) - ).toBeInTheDocument(); - expect( - screen.getByRole('columnheader', {name: 'Pay-as-you-go spend'}) - ).toBeInTheDocument(); - expect( - screen.queryByRole('button', {name: 'View usage history'}) - ).not.toBeInTheDocument(); - expect( - screen.queryByRole('button', {name: 'Download as CSV'}) - ).not.toBeInTheDocument(); - expect(screen.getAllByRole('row', {name: /^View .+ usage$/i}).length).toBeGreaterThan( - 0 - ); - }); - - it('renders some spend columns for non-self-serve with PAYG support', () => { - const newSubscription = SubscriptionFixture({ - organization, - plan: 'am3_business', - supportsOnDemand: true, - canSelfServe: false, - }); - SubscriptionStore.set(organization.slug, newSubscription); - render( - - ); - expect(screen.getAllByRole('columnheader')).toHaveLength(5); - expect( - screen.queryByRole('columnheader', {name: 'Reserved spend'}) - ).not.toBeInTheDocument(); - expect( - screen.getByRole('columnheader', {name: 'Pay-as-you-go spend'}) - ).toBeInTheDocument(); - }); - - it('does not render spend columns for non-self-serve without PAYG support', () => { - const newSubscription = SubscriptionFixture({ - organization, - plan: 'am3_business', - supportsOnDemand: false, - canSelfServe: false, - }); - SubscriptionStore.set(organization.slug, newSubscription); - render( - - ); - expect(screen.getAllByRole('columnheader')).toHaveLength(4); - expect( - screen.queryByRole('columnheader', {name: 'Reserved spend'}) - ).not.toBeInTheDocument(); - expect( - screen.queryByRole('columnheader', {name: 'Pay-as-you-go spend'}) - ).not.toBeInTheDocument(); - }); - - it('renders table based on subscription state', () => { - subscription.onDemandPeriodStart = '2025-05-02'; - subscription.onDemandPeriodEnd = '2025-06-01'; - 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.attachments = { - ...subscription.categories.attachments!, - reserved: 25, // 25 GB - prepaid: 25, // 25 GB - free: 0, - usage: GIGABYTE / 2, - }; - subscription.categories.spans = { - ...subscription.categories.spans!, - reserved: 20_000_000, - }; - subscription.categories.replays = { - ...subscription.categories.replays!, - reserved: UNLIMITED_RESERVED, - usage: 500_000, - }; - - render( - - ); - - expect(screen.getByText('May 2 - Jun 1, 2025')).toBeInTheDocument(); - - // Continuous profile hours product trial available - expect( - screen.getByRole('button', { - name: 'Start 14 day free Continuous Profile Hours trial', - }) - ).toBeInTheDocument(); - expect(screen.queryByText('Trial available')).not.toBeInTheDocument(); - - // Replays product trial active - expect(screen.getByRole('cell', {name: '20 days left'})).toBeInTheDocument(); - - // Errors usage and gifted units - expect(screen.getByRole('cell', {name: '6,000'})).toBeInTheDocument(); - expect(screen.getByRole('cell', {name: '$10.00'})).toBeInTheDocument(); - expect(screen.getByRole('cell', {name: '12% of 51,000'})).toBeInTheDocument(); - - // Attachments usage should be in the correct unit + above platform volume - expect(screen.getByRole('cell', {name: '500 MB'})).toBeInTheDocument(); - expect(screen.getByRole('cell', {name: '2% of 25 GB'})).toBeInTheDocument(); - expect(screen.getByRole('cell', {name: '$6.00'})).toBeInTheDocument(); - - // Reserved spans above platform volume - expect(screen.getAllByRole('cell', {name: '0'}).length).toBeGreaterThan(0); - expect(screen.getByRole('cell', {name: '$32.00'})).toBeInTheDocument(); - - // Unlimited usage for Replays - expect(screen.getAllByRole('cell', {name: UNLIMITED})).toHaveLength(2); - }); - - it('renders table based on add-on state', () => { - organization.features.push('seer-user-billing'); - const subWithSeer = SubscriptionWithLegacySeerFixture({organization}); - SubscriptionStore.set(organization.slug, subWithSeer); - render( - - ); - // Org has Seer user flag but did not buy Seer add on, only legacy add-on - expect(screen.getAllByRole('cell', {name: 'Seer'})).toHaveLength(1); - expect(screen.getAllByRole('row', {name: 'Collapse Seer details'})).toHaveLength(1); - expect(screen.getByRole('cell', {name: 'Issue Fixes'})).toBeInTheDocument(); - expect(screen.getByRole('cell', {name: 'Issue Scans'})).toBeInTheDocument(); - - // We test it this way to ensure we don't show the cell with the proper display name or the raw DataCategory - expect(screen.queryByRole('cell', {name: /Seer*Users/})).not.toBeInTheDocument(); - expect(screen.queryByRole('cell', {name: /Prevent*Reviews/})).not.toBeInTheDocument(); - }); - - it('renders add-on sub-categories if unlimited', () => { - const sub = SubscriptionFixture({organization}); - sub.categories.seerAutofix = { - ...sub.categories.seerAutofix!, - reserved: UNLIMITED_RESERVED, - }; - - render( - - ); - - // issue fixes is unlimited - expect(screen.getByRole('cell', {name: 'Issue Fixes'})).toBeInTheDocument(); - expect(screen.getAllByRole('cell', {name: UNLIMITED})).toHaveLength(2); - // issue scans is 0 - expect(screen.getByRole('cell', {name: 'Issue Scans'})).toBeInTheDocument(); - expect(screen.getAllByRole('cell', {name: '0'}).length).toBeGreaterThan(0); - - // add-on is not rendered since at least one of its sub-categories is unlimited - expect(screen.queryByRole('cell', {name: 'Seer'})).not.toBeInTheDocument(); - expect( - screen.queryByRole('row', {name: 'Expand Seer details'}) - ).not.toBeInTheDocument(); - expect( - screen.queryByRole('row', {name: 'Collapse Seer details'}) - ).not.toBeInTheDocument(); - - expect(screen.getByRole('cell', {name: 'Issue Scans'})).toBeInTheDocument(); - }); - - it('can open drawer for data categories but not add ons', async () => { - MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/usage/`, - method: 'GET', - body: CustomerUsageFixture(), - }); - MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/plan-migrations/`, - query: {scheduled: 1, applied: 0}, - method: 'GET', - body: [], - }); - MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/billing-details/`, - method: 'GET', - }); - MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/subscription/next-bill/`, - method: 'GET', - }); - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/promotions/trigger-check/`, - method: 'POST', - }); - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/prompts-activity/`, - body: {}, - }); - MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/recurring-credits/`, - method: 'GET', - body: [], - }); - MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/history/`, - method: 'GET', - }); - const subWithSeer = SubscriptionWithLegacySeerFixture({organization}); - const mockLocation = LocationFixture(); - SubscriptionStore.set(organization.slug, subWithSeer); - - // use Overview component here so we can test the drawers - render(, {organization}); - - await screen.findByText('Usage Overview'); - expect(screen.queryByRole('complementary')).not.toBeInTheDocument(); - await userEvent.click(screen.getByRole('row', {name: 'View Errors usage'})); - expect( - screen.getByRole('complementary', {name: 'Usage for Errors'}) - ).toBeInTheDocument(); - - // cannot open drawer for seat-based categories - expect( - screen.queryByRole('row', {name: 'View Cron Monitors usage'}) - ).not.toBeInTheDocument(); - expect( - screen.queryByRole('row', {name: 'View Uptime monitors usage'}) - ).not.toBeInTheDocument(); - - // cannot open drawer for add-ons, but can open for an add-on's data categories - expect(screen.queryByRole('row', {name: 'View Seer usage'})).not.toBeInTheDocument(); - await userEvent.click(screen.getByRole('row', {name: 'View Issue Fixes usage'})); - expect( - screen.getByRole('complementary', {name: 'Usage for Issue Fixes'}) - ).toBeInTheDocument(); - expect( - screen.queryByRole('complementary', {name: 'Usage for Errors'}) - ).not.toBeInTheDocument(); - }); - - it('renders PAYG tags only if PAYG is supported', () => { - const sub = SubscriptionFixture({ - plan: 'am3_f', - organization, - supportsOnDemand: true, - }); - - render( - - ); - expect( - screen.getAllByRole('cell', {name: 'Pay-as-you-go only'}).length - ).toBeGreaterThan(0); - }); - - it('does not render PAYG tags if PAYG is not supported', () => { - const sub = SubscriptionFixture({ - plan: 'am3_f', - organization, - supportsOnDemand: false, - }); - - render( - - ); - expect( - screen.queryByRole('cell', {name: 'Pay-as-you-go only'}) - ).not.toBeInTheDocument(); - }); -}); diff --git a/static/gsApp/views/subscriptionPage/usageOverview.tsx b/static/gsApp/views/subscriptionPage/usageOverview.tsx deleted file mode 100644 index b89298e1d4d598..00000000000000 --- a/static/gsApp/views/subscriptionPage/usageOverview.tsx +++ /dev/null @@ -1,895 +0,0 @@ -import React, {useCallback, useEffect, useMemo, useState} from 'react'; -import {useTheme} from '@emotion/react'; -import styled from '@emotion/styled'; -import moment from 'moment-timezone'; - -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 useDrawer from 'sentry/components/globalDrawer'; -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 getDaysSinceDate from 'sentry/utils/getDaysSinceDate'; -import {toTitleCase} from 'sentry/utils/string/toTitleCase'; -import {useLocation} from 'sentry/utils/useLocation'; -import useMedia from 'sentry/utils/useMedia'; -import {useNavigate} from 'sentry/utils/useNavigate'; -import {useNavContext} from 'sentry/views/nav/context'; -import {NavLayout} from 'sentry/views/nav/types'; - -import ProductTrialTag from 'getsentry/components/productTrial/productTrialTag'; -import StartTrialButton from 'getsentry/components/startTrialButton'; -import { - GIGABYTE, - RESERVED_BUDGET_QUOTA, - UNLIMITED, - UNLIMITED_RESERVED, -} from 'getsentry/constants'; -import {useCurrentBillingHistory} from 'getsentry/hooks/useCurrentBillingHistory'; -import { - AddOnCategory, - OnDemandBudgetMode, - type CustomerUsage, - type EventBucket, - type Subscription, -} from 'getsentry/types'; -import { - displayBudgetName, - formatReservedWithUnits, - formatUsageWithUnits, - getActiveProductTrial, - getPercentage, - getPotentialProductTrial, - getReservedBudgetCategoryForAddOn, - MILLISECONDS_IN_HOUR, - supportsPayg, -} from 'getsentry/utils/billing'; -import { - getCategoryInfoFromPlural, - getPlanCategoryName, - isByteCategory, - isContinuousProfiling, - sortCategories, -} from 'getsentry/utils/dataCategory'; -import trackGetsentryAnalytics from 'getsentry/utils/trackGetsentryAnalytics'; -import {displayPriceWithCents, getBucket} from 'getsentry/views/amCheckout/utils'; -import CategoryUsageDrawer from 'getsentry/views/subscriptionPage/components/categoryUsageDrawer'; -import {EMPTY_STAT_TOTAL} from 'getsentry/views/subscriptionPage/usageTotals'; - -interface UsageOverviewProps { - organization: Organization; - subscription: Subscription; - usageData: CustomerUsage; -} - -// XXX: This is a hack to ensure that the grid rows don't change height -// when hovering over the row (due to buttons that appear) -const MIN_CONTENT_HEIGHT = '28px'; - -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, usageData}: UsageOverviewProps) { - const navigate = useNavigate(); - const location = useLocation(); - const [openState, setOpenState] = useState>>({}); - const [hoverState, setHoverState] = useState>>( - {} - ); - const {isDrawerOpen, openDrawer} = useDrawer(); - const [highlightedRow, setHighlightedRow] = useState(undefined); - const [trialButtonBusyState, setTrialButtonBusyState] = useState< - Partial> - >({}); - const theme = useTheme(); - const isXlScreen = useMedia(`(min-width: ${theme.breakpoints.xl})`); - - const handleOpenDrawer = useCallback( - (dataCategory: DataCategory) => { - trackGetsentryAnalytics('subscription_page.usage_overview.row_clicked', { - organization, - subscription, - dataCategory, - }); - navigate( - { - pathname: location.pathname, - query: {...location.query, drawer: dataCategory}, - }, - { - replace: true, - } - ); - }, - [navigate, location.query, location.pathname, organization, subscription] - ); - - const handleCloseDrawer = useCallback(() => { - navigate( - { - pathname: location.pathname, - query: { - ...location.query, - drawer: undefined, - }, - }, - {replace: true} - ); - }, [navigate, location.query, location.pathname]); - - useEffect(() => { - Object.entries(subscription.addOns ?? {}) - .filter( - // only show add-on data categories if the add-on is enabled - ([_, addOnInfo]) => - !addOnInfo.billingFlag || - (organization.features.includes(addOnInfo.billingFlag) && addOnInfo.enabled) - ) - .forEach(([apiName, _]) => { - setOpenState(prev => ({...prev, [apiName]: true})); - }); - }, [subscription.addOns, organization.features]); - - useEffect(() => { - if (!isDrawerOpen && location.query.drawer) { - const dataCategory = location.query.drawer as DataCategory; - const categoryInfo = subscription.categories[dataCategory]; - const productName = getPlanCategoryName({ - plan: subscription.planDetails, - category: dataCategory, - title: true, - }); - if (!categoryInfo) { - handleCloseDrawer(); - return; - } - openDrawer( - () => ( - - ), - { - ariaLabel: t('Usage for %s', productName), - drawerKey: 'usage-overview-drawer', - resizable: false, - onClose: () => handleCloseDrawer(), - drawerWidth: '650px', - } - ); - } - }, [ - isDrawerOpen, - location.query.drawer, - usageData, - subscription, - openDrawer, - handleCloseDrawer, - ]); - - const allAddOnDataCategories = useMemo( - () => - Object.values(subscription.planDetails.addOnCategories) - .filter( - // filter out data categories that are part of add-on that have at least one unlimited sub-category - addOn => - !addOn.dataCategories.some( - category => - subscription.categories[category]?.reserved === UNLIMITED_RESERVED - ) - ) - .flatMap(addOn => addOn.dataCategories), - [subscription.planDetails.addOnCategories, subscription.categories] - ); - - const addOnsToShow = useMemo( - () => - Object.entries(subscription.addOns ?? {}).filter( - // show add-ons regardless of whether they're enabled - // as long as they're launched for the org - // and none of their sub-categories are unlimited - // Also do not show Seer if the legacy Seer add-on is enabled - ([_, addOnInfo]) => - (!addOnInfo.billingFlag || - organization.features.includes(addOnInfo.billingFlag)) && - !addOnInfo.dataCategories.some( - category => subscription.categories[category]?.reserved === UNLIMITED_RESERVED - ) && - (addOnInfo.apiName !== AddOnCategory.SEER || - !subscription.addOns?.[AddOnCategory.LEGACY_SEER]?.enabled) - ), - [subscription.addOns, organization.features, subscription.categories] - ); - - const columnOrder: GridColumnOrder[] = useMemo(() => { - const hasAnyPotentialOrActiveProductTrial = subscription.productTrials?.some( - trial => - !trial.isStarted || - (trial.isStarted && trial.endDate && getDaysSinceDate(trial.endDate) <= 0) - ); - return [ - {key: 'product', name: t('Product'), width: 250}, - {key: 'totalUsage', name: t('Total usage'), width: 200}, - {key: 'reservedUsage', name: t('Reserved'), width: 300}, - {key: 'reservedSpend', name: t('Reserved spend'), width: isXlScreen ? 200 : 150}, - { - key: 'budgetSpend', - name: t('%s spend', displayBudgetName(subscription.planDetails, {title: true})), - width: isXlScreen ? 200 : 150, - }, - { - key: 'trialInfo', - name: '', - width: 200, - }, - { - key: 'drawerButton', - name: '', - }, - ].filter( - column => - (subscription.canSelfServe || - !column.key.endsWith('Spend') || - (supportsPayg(subscription) && column.key === 'budgetSpend')) && - (hasAnyPotentialOrActiveProductTrial || column.key !== 'trialInfo') - ); - }, [subscription, isXlScreen]); - - // TODO(isabella): refactor this to have better types - const productData: Array<{ - budgetSpend: number; - hasAccess: boolean; - isClickable: boolean; - isPaygOnly: boolean; - isUnlimited: boolean; - product: string; - totalUsage: number; - addOnCategory?: AddOnCategory; - ariaLabel?: string; - dataCategory?: DataCategory; - free?: number; - isChildProduct?: boolean; - isOpen?: boolean; - percentUsed?: number; - productTrialCategory?: DataCategory; - reserved?: number; - reservedSpend?: number; - softCapType?: 'ON_DEMAND' | 'TRUE_FORWARD'; - toggleKey?: DataCategory | AddOnCategory; - }> = useMemo(() => { - return [ - ...sortCategories(subscription.categories) - .filter(metricHistory => !allAddOnDataCategories.includes(metricHistory.category)) - .map(metricHistory => { - const category = metricHistory.category; - const categoryInfo = getCategoryInfoFromPlural(category); - const productName = getPlanCategoryName({ - plan: subscription.planDetails, - category, - title: true, - }); - const reserved = metricHistory.reserved ?? 0; - const free = metricHistory.free ?? 0; - const prepaid = metricHistory.prepaid ?? 0; - const total = metricHistory.usage; - const paygTotal = metricHistory.onDemandSpendUsed; - const softCapType = - metricHistory.softCapType ?? - (metricHistory.trueForward ? 'TRUE_FORWARD' : undefined); - const activeProductTrial = getActiveProductTrial( - subscription.productTrials ?? [], - category - ); - - const isPaygOnly = reserved === 0 && subscription.supportsOnDemand; - const hasAccess = activeProductTrial - ? true - : isPaygOnly - ? subscription.onDemandBudgets?.budgetMode === - OnDemandBudgetMode.PER_CATEGORY - ? metricHistory.onDemandBudget > 0 - : subscription.onDemandMaxSpend > 0 - : reserved > 0 || reserved === UNLIMITED_RESERVED; - - const bucket = getBucket({ - events: reserved, // buckets use the converted unit reserved amount (ie. in GB for byte categories) - buckets: subscription.planDetails.planCategories[category], - }); - const recurringReservedSpend = bucket.price ?? 0; - // convert prepaid amount to the same unit as usage to accurately calculate percent used - const reservedTotal = isByteCategory(category) - ? prepaid * GIGABYTE - : isContinuousProfiling(category) - ? prepaid * MILLISECONDS_IN_HOUR - : prepaid; - const percentUsed = reservedTotal - ? getPercentage(total, reservedTotal) - : undefined; - - return { - dataCategory: category, - hasAccess, - isPaygOnly, - free, - reserved, - isUnlimited: !!activeProductTrial || reserved === UNLIMITED_RESERVED, - softCapType: softCapType ?? undefined, - product: productName, - totalUsage: total, - percentUsed, - reservedSpend: recurringReservedSpend, - budgetSpend: paygTotal, - productTrialCategory: category, - isClickable: categoryInfo?.tallyType === 'usage', - }; - }), - ...addOnsToShow.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?.reservedBudget - ? getPercentage( - reservedBudget?.totalReservedSpend, - reservedBudget?.reservedBudget - ) - : undefined; - const activeProductTrial = getActiveProductTrial( - subscription.productTrials ?? [], - addOnDataCategories[0] as DataCategory - ); - const hasAccess = addOnInfo.enabled; - - let bucket: EventBucket | undefined = undefined; - if (hasAccess) { - // NOTE: this only works for reserved budget add-ons, - // returning the first sub-category bucket that has a price - // for the reserved budget tier (RESERVED_BUDGET_QUOTA) - let i = 0; - while (!bucket?.price && i < addOnDataCategories.length) { - bucket = getBucket({ - buckets: subscription.planDetails.planCategories[addOnDataCategories[i]!], - events: RESERVED_BUDGET_QUOTA, - }); - i++; - } - } - const recurringReservedSpend = bucket?.price ?? 0; - - // Only show child categories if the add-on is open and enabled - const childCategoriesData = - openState[apiName as AddOnCategory] && hasAccess - ? addOnInfo.dataCategories.map(addOnDataCategory => { - const categoryInfo = getCategoryInfoFromPlural(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, - }); - const metricHistory = subscription.categories[addOnDataCategory]; - const softCapType = - metricHistory?.softCapType ?? - (metricHistory?.trueForward ? 'TRUE_FORWARD' : undefined); - return { - addOnCategory: apiName as AddOnCategory, - dataCategory: addOnDataCategory, - isChildProduct: true, - isOpen: openState[apiName as AddOnCategory], - hasAccess: true, - isPaygOnly: false, - isUnlimited: !!activeProductTrial, - softCapType: softCapType ?? undefined, - budgetSpend: childPaygTotal, - totalUsage: (childSpend ?? 0) + childPaygTotal, - product: childProductName, - isClickable: categoryInfo?.tallyType === 'usage', - }; - }) - : null; - - return [ - { - addOnCategory: apiName as AddOnCategory, - hasAccess, - free: reservedBudget?.freeBudget ?? 0, - reserved: reservedBudget?.reservedBudget ?? 0, - isPaygOnly: !reservedBudget, - isOpen: openState[apiName as AddOnCategory], - toggleKey: hasAccess ? (apiName as AddOnCategory) : undefined, - isUnlimited: !!activeProductTrial, - productTrialCategory: addOnDataCategories[0] as DataCategory, - product: addOnName, - totalUsage: (reservedBudget?.totalReservedSpend ?? 0) + paygTotal, - percentUsed, - reservedSpend: recurringReservedSpend, - budgetSpend: paygTotal, - isClickable: hasAccess, - }, - ...(childCategoriesData ?? []), - ]; - }), - ]; - }, [subscription, allAddOnDataCategories, openState, addOnsToShow]); - - return ( - { - const isSpendColumn = column.key.toString().toLowerCase().endsWith('spend'); - return ( - - {column.name} - - ); - }, - renderBodyCell: (column, row) => { - const { - totalUsage, - hasAccess, - isPaygOnly, - isUnlimited, - product, - addOnCategory, - dataCategory, - free, - isChildProduct, - isOpen, - reserved, - percentUsed, - softCapType, - toggleKey, - productTrialCategory, - isClickable, - } = row; - - const productTrial = productTrialCategory - ? (getActiveProductTrial( - subscription.productTrials ?? [], - productTrialCategory - ) ?? - getPotentialProductTrial( - subscription.productTrials ?? [], - productTrialCategory - )) - : undefined; - - if (defined(isOpen) && !isOpen && isChildProduct) { - return null; - } - - switch (column.key) { - case 'product': { - const title = ( - - {!hasAccess && } {product} - {softCapType && - ` (${toTitleCase(softCapType.replace(/_/g, ' ').toLocaleLowerCase())})`}{' '} - - ); - - if (toggleKey) { - return ( - - - {title} - - ); - } - return ( - - {title} - - ); - } - case 'totalUsage': { - const formattedTotal = addOnCategory - ? displayPriceWithCents({cents: totalUsage}) - : dataCategory - ? formatUsageWithUnits(totalUsage, dataCategory, { - useUnitScaling: true, - }) - : totalUsage; - - return ( - - - {isUnlimited ? UNLIMITED : formattedTotal}{' '} - - - ); - } - case 'reservedUsage': { - if (isPaygOnly) { - return ( - - - {tct('[budgetTerm] only', { - budgetTerm: displayBudgetName(subscription.planDetails, { - title: true, - }), - })} - - - ); - } - - if (isUnlimited) { - return {UNLIMITED}; - } - - if (defined(percentUsed)) { - const formattedReserved = addOnCategory - ? displayPriceWithCents({cents: reserved ?? 0}) - : dataCategory - ? formatReservedWithUnits(reserved ?? 0, dataCategory, { - useUnitScaling: true, - }) - : (reserved ?? 0); - const formattedFree = addOnCategory - ? displayPriceWithCents({cents: free ?? 0}) - : dataCategory - ? formatReservedWithUnits(free ?? 0, dataCategory, { - useUnitScaling: true, - }) - : (free ?? 0); - const formattedReservedTotal = addOnCategory - ? displayPriceWithCents({cents: (reserved ?? 0) + (free ?? 0)}) - : dataCategory - ? formatReservedWithUnits( - (reserved ?? 0) + (free ?? 0), - dataCategory, - { - useUnitScaling: true, - } - ) - : (reserved ?? 0) + (free ?? 0); - - return ( - - - - {tct('[percent]% of [formattedReservedTotal]', { - percent: percentUsed.toFixed(0), - formattedReservedTotal, - })} - - { - - } - - ); - } - 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 'trialInfo': { - if (productTrial) { - return ( - - {productTrial.isStarted ? ( - - - - ) : ( - { - 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
; - } - case 'drawerButton': { - if (isClickable && dataCategory && hoverState[dataCategory]) { - return ( - - - - )} - - - - ); -} - -export default UsageOverview; - -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/usageOverview/components/actions.tsx b/static/gsApp/views/subscriptionPage/usageOverview/components/actions.tsx new file mode 100644 index 00000000000000..7fde4639f3e0b6 --- /dev/null +++ b/static/gsApp/views/subscriptionPage/usageOverview/components/actions.tsx @@ -0,0 +1,100 @@ +import {Button} from 'sentry/components/core/button'; +import {LinkButton} from 'sentry/components/core/button/linkButton'; +import {Flex} from 'sentry/components/core/layout'; +import {DropdownMenu} from 'sentry/components/dropdownMenu'; +import {IconDownload, IconEllipsis, IconTable} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import type {Organization} from 'sentry/types/organization'; +import {useNavContext} from 'sentry/views/nav/context'; +import {NavLayout} from 'sentry/views/nav/types'; + +import {useCurrentBillingHistory} from 'getsentry/hooks/useCurrentBillingHistory'; +import trackGetsentryAnalytics from 'getsentry/utils/trackGetsentryAnalytics'; + +function UsageOverviewActions({organization}: {organization: Organization}) { + const {layout: navLayout} = useNavContext(); + const isMobile = navLayout === NavLayout.MOBILE; + + const {currentHistory, isPending, isError} = useCurrentBillingHistory(); + const hasBillingPerms = organization.access.includes('org:billing'); + if (!hasBillingPerms) { + return null; + } + + const buttons: Array<{ + icon: React.ReactNode; + label: string; + disabled?: boolean; + onClick?: () => void; + to?: string; + }> = [ + { + label: t('View all usage'), + to: '/settings/billing/usage/', + icon: , + }, + { + label: t('Download as CSV'), + icon: , + onClick: () => { + trackGetsentryAnalytics('subscription_page.download_reports.clicked', { + organization, + reportType: 'summary', + }); + if (currentHistory) { + window.open(currentHistory.links.csv, '_blank'); + } + }, + disabled: isPending || isError, + }, + ]; + + if (isMobile) { + return ( + , + showChevron: false, + size: 'sm', + }} + items={buttons.map(buttonInfo => ({ + key: buttonInfo.label, + label: buttonInfo.label, + onAction: buttonInfo.onClick, + to: buttonInfo.to, + disabled: buttonInfo.disabled, + }))} + /> + ); + } + + return ( + + {buttons.map(buttonInfo => + buttonInfo.to ? ( + + {buttonInfo.label} + + ) : ( + + ) + )} + + ); +} + +export default UsageOverviewActions; 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 new file mode 100644 index 00000000000000..93eb61bee23351 --- /dev/null +++ b/static/gsApp/views/subscriptionPage/usageOverview/components/charts.tsx @@ -0,0 +1,125 @@ +import {Container} from '@sentry/scraps/layout'; + +import OptionSelector from 'sentry/components/charts/optionSelector'; +import {ChartControls, InlineContainer} from 'sentry/components/charts/styles'; +import {t} from 'sentry/locale'; +import {DataCategory} from 'sentry/types/core'; +import {useLocation} from 'sentry/utils/useLocation'; +import {useNavigate} from 'sentry/utils/useNavigate'; +import {CHART_OPTIONS_DATA_TRANSFORM} from 'sentry/views/organizationStats/usageChart'; + +import {addBillingStatTotals, checkIsAddOn, isAm2Plan} from 'getsentry/utils/billing'; +import { + getCategoryInfoFromPlural, + getChunkCategoryFromDuration, + isContinuousProfiling, +} from 'getsentry/utils/dataCategory'; +import trackGetsentryAnalytics from 'getsentry/utils/trackGetsentryAnalytics'; +import { + ProductUsageChart, + selectedTransform, +} from 'getsentry/views/subscriptionPage/reservedUsageChart'; +import type {BreakdownPanelProps} from 'getsentry/views/subscriptionPage/usageOverview/type'; +import {EMPTY_STAT_TOTAL} from 'getsentry/views/subscriptionPage/usageTotals'; +import UsageTotalsTable from 'getsentry/views/subscriptionPage/usageTotalsTable'; + +function UsageCharts({ + selectedProduct, + usageData, + subscription, + organization, +}: BreakdownPanelProps) { + const navigate = useNavigate(); + const location = useLocation(); + const transform = selectedTransform(location); + if (checkIsAddOn(selectedProduct)) { + return null; + } + + const category = selectedProduct as DataCategory; + const metricHistory = subscription.categories[category]; + const categoryInfo = getCategoryInfoFromPlural(category); + + if (!metricHistory || !categoryInfo) { + return null; + } + + const {tallyType} = categoryInfo; + if (tallyType === 'seat') { + return null; + } + + const {usage: billedUsage} = metricHistory; + const stats = usageData.stats[category] ?? []; + const eventTotals = usageData.eventTotals?.[category] ?? {}; + const totals = usageData.totals[category] ?? EMPTY_STAT_TOTAL; + const usageStats = { + [category]: stats, + }; + + const adjustedTotals = isContinuousProfiling(category) + ? { + ...addBillingStatTotals(totals, [ + eventTotals[getChunkCategoryFromDuration(category)] ?? EMPTY_STAT_TOTAL, + !isAm2Plan(subscription.plan) && + selectedProduct === DataCategory.PROFILE_DURATION + ? (eventTotals[DataCategory.PROFILES] ?? EMPTY_STAT_TOTAL) + : EMPTY_STAT_TOTAL, + ]), + accepted: billedUsage, + } + : {...totals, accepted: billedUsage}; + + const renderFooter = () => { + return ( + + + { + trackGetsentryAnalytics( + 'subscription_page.usage_overview.transform_changed', + { + organization, + subscription, + transform: val, + } + ); + navigate({ + pathname: location.pathname, + query: {...location.query, transform: val}, + }); + }} + /> + + + ); + }; + + return ( + + + + + ); +} + +export default UsageCharts; diff --git a/static/gsApp/views/subscriptionPage/usageOverview/components/panel.spec.tsx b/static/gsApp/views/subscriptionPage/usageOverview/components/panel.spec.tsx new file mode 100644 index 00000000000000..c1d16e7ad0d8c1 --- /dev/null +++ b/static/gsApp/views/subscriptionPage/usageOverview/components/panel.spec.tsx @@ -0,0 +1,303 @@ +import moment from 'moment-timezone'; +import {OrganizationFixture} from 'sentry-fixture/organization'; + +import {CustomerUsageFixture} from 'getsentry-test/fixtures/customerUsage'; +import { + SubscriptionFixture, + SubscriptionWithLegacySeerFixture, +} 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 {AddOnCategory, OnDemandBudgetMode, type Subscription} from 'getsentry/types'; +import ProductBreakdownPanel from 'getsentry/views/subscriptionPage/usageOverview/components/panel'; + +describe('ProductBreakdownPanel', () => { + const organization = OrganizationFixture(); + let subscription: Subscription; + const usageData = CustomerUsageFixture(); + + beforeEach(() => { + setMockDate(new Date('2021-05-07')); + organization.features = ['subscriptions-v3', 'seer-billing']; + organization.access = ['org:billing']; + subscription = SubscriptionFixture({organization, plan: 'am3_business'}); + SubscriptionStore.set(organization.slug, subscription); + }); + + afterEach(() => { + 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 = [ + { + category: DataCategory.PROFILE_DURATION, + isStarted: false, + reasonCode: 1001, + startDate: undefined, + endDate: moment().utc().add(20, 'years').format(), + }, + ]; + SubscriptionStore.set(organization.slug, subscription); + render( + + ); + + await screen.findByRole('heading', {name: 'Continuous Profile Hours'}); + expect(screen.getByRole('button', {name: 'Activate free trial'})).toBeInTheDocument(); + }); + + it('renders upgrade CTA', async () => { + render( + + ); + + await screen.findByRole('heading', {name: 'Continuous Profile Hours'}); + expect(screen.getByRole('button', {name: 'Upgrade now'})).toBeInTheDocument(); + }); + + it('renders active product trial status', async () => { + subscription.productTrials = [ + { + category: DataCategory.REPLAYS, + isStarted: true, + reasonCode: 1001, + startDate: moment().utc().subtract(10, 'days').format(), + endDate: moment().utc().add(20, 'days').format(), + }, + ]; + SubscriptionStore.set(organization.slug, subscription); + render( + + ); + + await screen.findByRole('heading', {name: 'Replays'}); + expect(screen.getByText('Trial - 20 days left')).toBeInTheDocument(); + }); + + it('renders usage exceeded status without PAYG set', async () => { + subscription.categories.errors = { + ...subscription.categories.errors!, + usageExceeded: true, + }; + SubscriptionStore.set(organization.slug, subscription); + render( + + ); + + await screen.findByRole('heading', {name: 'Errors'}); + expect(screen.getByText('Usage exceeded')).toBeInTheDocument(); + }); + + it('renders usage exceeded status with PAYG set', async () => { + subscription.onDemandBudgets = { + enabled: true, + budgetMode: OnDemandBudgetMode.SHARED, + sharedMaxBudget: 100_00, + onDemandSpendUsed: 100_00, + }; + subscription.categories.errors = { + ...subscription.categories.errors!, + usageExceeded: true, + }; + SubscriptionStore.set(organization.slug, subscription); + render( + + ); + + await screen.findByRole('heading', {name: 'Errors'}); + expect(screen.getByText('Pay-as-you-go limit reached')).toBeInTheDocument(); + }); +}); diff --git a/static/gsApp/views/subscriptionPage/usageOverview/components/panel.tsx b/static/gsApp/views/subscriptionPage/usageOverview/components/panel.tsx new file mode 100644 index 00000000000000..01509467b12c9f --- /dev/null +++ b/static/gsApp/views/subscriptionPage/usageOverview/components/panel.tsx @@ -0,0 +1,186 @@ +import {Fragment} from 'react'; + +import {Tag} from '@sentry/scraps/badge'; +import {Container, Flex} from '@sentry/scraps/layout'; +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, + 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, + UpgradeCta, +} from 'getsentry/views/subscriptionPage/usageOverview/components/upgradeOrTrialCta'; +import type {BreakdownPanelProps} from 'getsentry/views/subscriptionPage/usageOverview/type'; + +function PanelHeader({ + selectedProduct, + subscription, +}: Pick) { + const {onDemandBudgets: paygBudgets} = subscription; + + const { + displayName, + billedCategory, + isAddOn, + addOnInfo, + usageExceeded, + activeProductTrial, + } = useProductBillingMetadata(subscription, selectedProduct); + + if ( + // special case for seer add-on + selectedProduct === AddOnCategory.SEER || + !billedCategory || + (isAddOn && !addOnInfo) + ) { + return null; + } + + const trialDaysLeft = -1 * getDaysSinceDate(activeProductTrial?.endDate ?? ''); + + const hasPaygAvailable = + supportsPayg(subscription) && + subscription.planDetails.onDemandCategories.includes(billedCategory) && + ((paygBudgets?.budgetMode === OnDemandBudgetMode.SHARED && + paygBudgets.sharedMaxBudget > 0) || + (paygBudgets?.budgetMode === OnDemandBudgetMode.PER_CATEGORY && + (paygBudgets?.budgets[billedCategory] ?? 0) > 0)); + + const status = activeProductTrial ? ( + }> + {tn('Trial - %s day left', 'Trial - %s days left', trialDaysLeft)} + + ) : usageExceeded ? ( + }> + {hasPaygAvailable + ? tct('[budgetTerm] limit reached', { + budgetTerm: displayBudgetName(subscription.planDetails, {title: true}), + }) + : t('Usage exceeded')} + + ) : null; + + return ( + + {displayName} + {status} + + ); +} + +function ProductBreakdownPanel({ + organization, + selectedProduct, + subscription, + usageData, + isInline, +}: BreakdownPanelProps) { + const { + billedCategory, + isAddOn, + isEnabled, + addOnInfo, + activeProductTrial, + potentialProductTrial, + } = useProductBillingMetadata(subscription, selectedProduct); + + if (!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; + + return ( + + + {potentialProductTrial && ( + + )} + {isEnabled && ( + + {breakdownInfo} + + + )} + {isEmpty && } + + ); +} + +export default ProductBreakdownPanel; diff --git a/static/gsApp/views/subscriptionPage/usageOverview/components/productTrialRibbon.tsx b/static/gsApp/views/subscriptionPage/usageOverview/components/productTrialRibbon.tsx new file mode 100644 index 00000000000000..764e3dc06be370 --- /dev/null +++ b/static/gsApp/views/subscriptionPage/usageOverview/components/productTrialRibbon.tsx @@ -0,0 +1,89 @@ +import {useTheme} from '@emotion/react'; +import styled from '@emotion/styled'; + +import {Tooltip} from '@sentry/scraps/tooltip'; + +import {Flex} from 'sentry/components/core/layout'; +import {IconClock, IconLightning} from 'sentry/icons'; +import {t, tn} from 'sentry/locale'; +import getDaysSinceDate from 'sentry/utils/getDaysSinceDate'; + +import {type ProductTrial} from 'getsentry/types'; + +function ProductTrialRibbon({ + activeProductTrial, + potentialProductTrial, +}: { + activeProductTrial: ProductTrial | null; + potentialProductTrial: ProductTrial | null; +}) { + const theme = useTheme(); + const iconProps = { + size: 'xs' as const, + color: 'white' as const, + }; + const ribbonColor = activeProductTrial + ? theme.tokens.graphics.promotion + : theme.tokens.graphics.accent; + + if (!activeProductTrial && !potentialProductTrial) { + return null; + } + + const trialDaysLeft = -1 * getDaysSinceDate(activeProductTrial?.endDate ?? ''); + const tooltipContent = potentialProductTrial + ? t('Trial available') + : tn('%s day left', '%s days left', trialDaysLeft); + + return ( + + + + {activeProductTrial ? ( + + ) : ( + + )} + + + + + + + + ); +} + +export default ProductTrialRibbon; + +const RibbonContainer = styled('td')` + display: flex; + position: absolute; + left: -1px; + top: 14px; + z-index: 1000; +`; + +const RibbonBase = styled('div')<{ribbonColor: string}>` + width: 20px; + height: 22px; + background: ${p => p.ribbonColor}; + padding: ${p => `${p.theme.space['2xs']} ${p.theme.space.xs}`}; +`; + +const BottomRibbonEdge = styled('div')<{ribbonColor: string}>` + position: absolute; + top: auto; + bottom: 0; + width: 0px; + height: 0px; + border-style: solid; + border-color: transparent transparent ${p => p.ribbonColor} transparent; + border-width: 11px 5.5px 11px 0px; +`; + +const TopRibbonEdge = styled(BottomRibbonEdge)` + transform: scaleY(-1); + top: 0; + bottom: auto; +`; diff --git a/static/gsApp/views/subscriptionPage/usageOverview/components/table.spec.tsx b/static/gsApp/views/subscriptionPage/usageOverview/components/table.spec.tsx new file mode 100644 index 00000000000000..65a90d8da25591 --- /dev/null +++ b/static/gsApp/views/subscriptionPage/usageOverview/components/table.spec.tsx @@ -0,0 +1,237 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; + +import {CustomerUsageFixture} from 'getsentry-test/fixtures/customerUsage'; +import { + SubscriptionFixture, + SubscriptionWithLegacySeerFixture, +} from 'getsentry-test/fixtures/subscription'; +import {render, screen} from 'sentry-test/reactTestingLibrary'; + +import {DataCategory} from 'sentry/types/core'; + +import {GIGABYTE, UNLIMITED, UNLIMITED_RESERVED} from 'getsentry/constants'; +import SubscriptionStore from 'getsentry/stores/subscriptionStore'; +import UsageOverviewTable from 'getsentry/views/subscriptionPage/usageOverview/components/table'; + +describe('UsageOverview', () => { + const organization = OrganizationFixture(); + const subscription = SubscriptionFixture({organization, plan: 'am3_business'}); + const usageData = CustomerUsageFixture(); + + beforeEach(() => { + organization.features = ['subscriptions-v3', 'seer-billing']; + organization.access = ['org:billing']; + SubscriptionStore.set(organization.slug, subscription); + }); + + it('renders columns and buttons for billing users', async () => { + render( + + ); + + await screen.findByRole('columnheader', {name: 'Feature'}); + expect(screen.getAllByRole('columnheader')).toHaveLength(3); + expect(screen.getByRole('columnheader', {name: 'Usage'})).toBeInTheDocument(); + expect( + screen.getByRole('columnheader', {name: 'Additional spend'}) + ).toBeInTheDocument(); + expect( + screen.getAllByRole('button', {name: /^View .+ usage$/i}).length + ).toBeGreaterThan(0); + }); + + it('renders columns for non-billing users', async () => { + organization.access = []; + render( + + ); + + await screen.findByRole('columnheader', {name: 'Feature'}); + expect(screen.getAllByRole('columnheader')).toHaveLength(3); + expect(screen.getByRole('columnheader', {name: 'Usage'})).toBeInTheDocument(); + expect( + screen.getByRole('columnheader', {name: 'Additional spend'}) + ).toBeInTheDocument(); + expect( + screen.getAllByRole('button', {name: /^View .+ usage$/i}).length + ).toBeGreaterThan(0); + }); + + it('renders columns for non-self-serve with PAYG support', async () => { + const newSubscription = SubscriptionFixture({ + organization, + plan: 'am3_business', + supportsOnDemand: true, + canSelfServe: false, + }); + SubscriptionStore.set(organization.slug, newSubscription); + render( + + ); + + await screen.findByRole('columnheader', {name: 'Feature'}); + expect(screen.getAllByRole('columnheader')).toHaveLength(3); + expect(screen.getByRole('columnheader', {name: 'Usage'})).toBeInTheDocument(); + expect( + screen.getByRole('columnheader', {name: 'Additional spend'}) + ).toBeInTheDocument(); + }); + + it('does not render spend columns for non-self-serve without PAYG support', async () => { + const newSubscription = SubscriptionFixture({ + organization, + plan: 'am3_business', + supportsOnDemand: false, + canSelfServe: false, + }); + SubscriptionStore.set(organization.slug, newSubscription); + render( + + ); + + await screen.findByRole('columnheader', {name: 'Feature'}); + expect(screen.getAllByRole('columnheader')).toHaveLength(2); + expect(screen.getByRole('columnheader', {name: 'Usage'})).toBeInTheDocument(); + expect( + screen.queryByRole('columnheader', {name: 'Additional spend'}) + ).not.toBeInTheDocument(); + }); + + it('renders table and panel based on subscription state', async () => { + subscription.onDemandMaxSpend = 100_00; + + subscription.categories.errors = { + ...subscription.categories.errors!, + free: 1000, + usage: 6000, + onDemandSpendUsed: 10_00, + prepaid: 51_000, + }; + subscription.categories.attachments = { + ...subscription.categories.attachments!, + prepaid: 25, // 25 GB + reserved: 25, + free: 0, + usage: GIGABYTE / 2, + }; + subscription.categories.spans = { + ...subscription.categories.spans!, + prepaid: 20_000_000, + reserved: 20_000_000, + }; + subscription.categories.replays = { + ...subscription.categories.replays!, + prepaid: UNLIMITED_RESERVED, + usage: 500_000, + }; + SubscriptionStore.set(organization.slug, subscription); + + render( + + ); + + await screen.findByRole('columnheader', {name: 'Feature'}); + + // Errors usage and gifted units + expect(screen.getByRole('cell', {name: '6K / 51K'})).toBeInTheDocument(); + expect(screen.getByRole('cell', {name: '$10.00'})).toBeInTheDocument(); + + // Attachments usage should be in the correct unit + above platform volume + expect(screen.getByRole('cell', {name: '500 MB / 25 GB'})).toBeInTheDocument(); + expect(screen.getByRole('cell', {name: '$6.00'})).toBeInTheDocument(); + + // Reserved spans above platform volume + expect(screen.getByRole('cell', {name: '0 / 20M'})).toBeInTheDocument(); + expect(screen.getByRole('cell', {name: '$32.00'})).toBeInTheDocument(); + + // Unlimited usage for Replays + expect(screen.getByRole('cell', {name: `500K / ${UNLIMITED}`})).toBeInTheDocument(); + }); + + it('renders table based on add-on state', async () => { + organization.features.push('seer-user-billing'); + const subWithSeer = SubscriptionWithLegacySeerFixture({organization}); + SubscriptionStore.set(organization.slug, subWithSeer); + render( + + ); + + await screen.findByRole('columnheader', {name: 'Feature'}); + + // Org has Seer user flag but did not buy Seer add on, only legacy add-on + expect(screen.getAllByRole('cell', {name: 'Seer'})).toHaveLength(1); + expect(screen.getByRole('cell', {name: 'Issue Fixes'})).toBeInTheDocument(); + expect(screen.getByRole('cell', {name: 'Issue Scans'})).toBeInTheDocument(); + + // We test it this way to ensure we don't show the cell with the proper display name or the raw DataCategory + expect(screen.queryByRole('cell', {name: /Seer*Users/})).not.toBeInTheDocument(); + expect(screen.queryByRole('cell', {name: /Prevent*Reviews/})).not.toBeInTheDocument(); + }); + + it('renders add-on sub-categories if unlimited', async () => { + const sub = SubscriptionFixture({organization}); + sub.categories.seerAutofix = { + ...sub.categories.seerAutofix!, + reserved: UNLIMITED_RESERVED, + prepaid: UNLIMITED_RESERVED, + }; + + 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 / ${UNLIMITED}`})).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(); + }); +}); diff --git a/static/gsApp/views/subscriptionPage/usageOverview/components/table.tsx b/static/gsApp/views/subscriptionPage/usageOverview/components/table.tsx new file mode 100644 index 00000000000000..ba159f2c64d97b --- /dev/null +++ b/static/gsApp/views/subscriptionPage/usageOverview/components/table.tsx @@ -0,0 +1,165 @@ +import {Fragment} from 'react'; +import styled from '@emotion/styled'; + +import {Text} from 'sentry/components/core/text'; +import {t} from 'sentry/locale'; + +import {UNLIMITED_RESERVED} from 'getsentry/constants'; +import {AddOnCategory} from 'getsentry/types'; +import {getBilledCategory, 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/type'; + +function UsageOverviewTable({ + organization, + subscription, + onRowClick, + selectedProduct, + usageData, +}: UsageOverviewTableProps) { + const addOnDataCategories = Object.values( + subscription.planDetails.addOnCategories + ).flatMap(addOnInfo => addOnInfo.dataCategories); + const sortedCategories = sortCategories(subscription.categories); + const showAdditionalSpendColumn = + subscription.canSelfServe || supportsPayg(subscription); + + return ( + + + + + + {showAdditionalSpendColumn && ( + + )} + + + + {sortedCategories + .filter( + categoryInfo => + // filter out data categories that are part of add-ons + // unless they are unlimited + !addOnDataCategories.includes(categoryInfo.category) || + categoryInfo.reserved === UNLIMITED_RESERVED + ) + .map(categoryInfo => { + const {category} = categoryInfo; + + return ( + + ); + })} + {Object.values(subscription.planDetails.addOnCategories) + .filter( + // show add-ons regardless of whether they're enabled + // as long as they're launched for the org + // and none of their sub-categories are unlimited + // Also do not show Seer if the legacy Seer add-on is enabled + addOnInfo => + (!addOnInfo.billingFlag || + organization.features.includes(addOnInfo.billingFlag)) && + !addOnInfo.dataCategories.some( + category => + subscription.categories[category]?.reserved === UNLIMITED_RESERVED + ) && + (addOnInfo.apiName !== AddOnCategory.SEER || + !subscription.addOns?.[AddOnCategory.LEGACY_SEER]?.enabled) + ) + .map(addOnInfo => { + const {apiName, dataCategories} = addOnInfo; + const billedCategory = getBilledCategory(subscription, apiName); + if (!billedCategory) { + return null; + } + + return ( + + + {sortedCategories + .filter(categoryInfo => dataCategories.includes(categoryInfo.category)) + .map(categoryInfo => { + const {category} = categoryInfo; + + return ( + + ); + })} + + ); + })} + +
+ + {t('Feature')} + + + + {t('Usage')} + + + + {t('Additional spend')} + +
+ ); +} + +export default UsageOverviewTable; + +const Table = styled('table')` + display: grid; + grid-template-columns: auto max-content auto; + background: ${p => p.theme.background}; + border-top: 1px solid ${p => p.theme.border}; + border-radius: 0 0 ${p => p.theme.borderRadius} ${p => p.theme.borderRadius}; + gap: 0 ${p => p.theme.space['3xl']}; + width: 100%; + margin: 0; + + thead, + tbody, + tr { + display: grid; + grid-template-columns: subgrid; + grid-column: 1 / -1; + } + + @media (max-width: ${p => p.theme.breakpoints.md}) { + grid-template-columns: repeat(3, auto); + } +`; + +const TableHeaderRow = styled('tr')` + background: ${p => p.theme.backgroundSecondary}; + border-bottom: 1px solid ${p => p.theme.border}; + text-transform: uppercase; + padding: ${p => p.theme.space.xl}; +`; diff --git a/static/gsApp/views/subscriptionPage/usageOverview/components/tableRow.tsx b/static/gsApp/views/subscriptionPage/usageOverview/components/tableRow.tsx new file mode 100644 index 00000000000000..acde9653d78b2b --- /dev/null +++ b/static/gsApp/views/subscriptionPage/usageOverview/components/tableRow.tsx @@ -0,0 +1,318 @@ +import {Fragment, useState} from 'react'; +import {css, useTheme} from '@emotion/react'; +import styled from '@emotion/styled'; + +import {Flex} from 'sentry/components/core/layout'; +import {Text} from 'sentry/components/core/text'; +import ProgressRing from 'sentry/components/progressRing'; +import {IconLock, IconWarning} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import {DataCategory} from 'sentry/types/core'; +import useMedia from 'sentry/utils/useMedia'; + +import {GIGABYTE, UNLIMITED_RESERVED} from 'getsentry/constants'; +import {useProductBillingMetadata} from 'getsentry/hooks/useProductBillingMetadata'; +import {AddOnCategory} from 'getsentry/types'; +import { + formatReservedWithUnits, + formatUsageWithUnits, + getPercentage, + getReservedBudgetCategoryForAddOn, + getSoftCapType, + MILLISECONDS_IN_HOUR, + supportsPayg, +} from 'getsentry/utils/billing'; +import {isByteCategory, isContinuousProfiling} from 'getsentry/utils/dataCategory'; +import {displayPriceWithCents, getBucket} from 'getsentry/views/amCheckout/utils'; +import ProductBreakdownPanel from 'getsentry/views/subscriptionPage/usageOverview/components/panel'; +import ProductTrialRibbon from 'getsentry/views/subscriptionPage/usageOverview/components/productTrialRibbon'; +import {SIDE_PANEL_MIN_SCREEN_BREAKPOINT} from 'getsentry/views/subscriptionPage/usageOverview/constants'; +import type {UsageOverviewTableProps} from 'getsentry/views/subscriptionPage/usageOverview/type'; + +interface ChildProductRowProps { + isChildProduct: true; + parentProduct: DataCategory | AddOnCategory; + product: DataCategory; +} + +interface ParentProductRowProps { + product: DataCategory | AddOnCategory; + isChildProduct?: false; + parentProduct?: never; +} + +function UsageOverviewTableRow({ + organization, + product, + selectedProduct, + onRowClick, + subscription, + isChildProduct, + parentProduct, + usageData, +}: UsageOverviewTableProps & (ChildProductRowProps | ParentProductRowProps)) { + const theme = useTheme(); + const showPanelInline = !useMedia( + `(min-width: ${theme.breakpoints[SIDE_PANEL_MIN_SCREEN_BREAKPOINT]})` + ); + const [isHovered, setIsHovered] = useState(false); + const showAdditionalSpendColumn = + subscription.canSelfServe || supportsPayg(subscription); + + const { + displayName, + billedCategory, + isAddOn, + isEnabled, + addOnInfo, + usageExceeded, + activeProductTrial, + potentialProductTrial, + } = useProductBillingMetadata(subscription, product, parentProduct, isChildProduct); + if (!billedCategory) { + return null; + } + + const metricHistory = subscription.categories[billedCategory]; + if (!metricHistory) { + return null; + } + + if (!isEnabled && isChildProduct) { + // don't show child product rows if the parent product is not enabled + return null; + } + + let percentUsed = 0; + let formattedUsage = ''; + let formattedPrepaid = null; + let paygSpend = 0; + let isUnlimited = false; + + if (isAddOn) { + if (!addOnInfo) { + return null; + } + + isUnlimited = !!activeProductTrial; + const reservedBudgetCategory = getReservedBudgetCategoryForAddOn( + (parentProduct ?? product) as AddOnCategory + ); + const reservedBudget = subscription.reservedBudgets?.find( + budget => budget.apiName === reservedBudgetCategory + ); + percentUsed = reservedBudget + ? getPercentage(reservedBudget.totalReservedSpend, reservedBudget.reservedBudget) + : 0; + formattedUsage = reservedBudget + ? displayPriceWithCents({ + cents: isChildProduct + ? (reservedBudget.categories[product]?.reservedSpend ?? 0) + : reservedBudget.totalReservedSpend, + }) + : formatUsageWithUnits(metricHistory.usage, billedCategory, { + isAbbreviated: true, + useUnitScaling: true, + }); + + if (isUnlimited) { + formattedPrepaid = formatReservedWithUnits(UNLIMITED_RESERVED, billedCategory); + } else if (reservedBudget) { + formattedPrepaid = displayPriceWithCents({cents: reservedBudget.reservedBudget}); + } + + paygSpend = isChildProduct + ? (subscription.categories[product]?.onDemandSpendUsed ?? 0) + : addOnInfo.dataCategories.reduce((acc, category) => { + return acc + (subscription.categories[category]?.onDemandSpendUsed ?? 0); + }, 0); + } else { + // convert prepaid amount to the same unit as usage to accurately calculate percent used + const {prepaid} = metricHistory; + isUnlimited = prepaid === UNLIMITED_RESERVED || !!activeProductTrial; + const rawPrepaid = isUnlimited + ? prepaid + : isByteCategory(billedCategory) + ? prepaid * GIGABYTE + : isContinuousProfiling(billedCategory) + ? prepaid * MILLISECONDS_IN_HOUR + : prepaid; + percentUsed = rawPrepaid ? getPercentage(metricHistory.usage, rawPrepaid) : 0; + + formattedUsage = formatUsageWithUnits(metricHistory.usage, billedCategory, { + isAbbreviated: true, + useUnitScaling: true, + }); + formattedPrepaid = formatReservedWithUnits(prepaid, billedCategory, { + useUnitScaling: true, + isAbbreviated: true, + }); + + paygSpend = subscription.categories[billedCategory]?.onDemandSpendUsed ?? 0; + } + + const {reserved} = metricHistory; + const bucket = getBucket({ + events: reserved ?? 0, // buckets use the converted unit reserved amount (ie. in GB for byte categories) + buckets: subscription.planDetails.planCategories[billedCategory], + }); + const recurringReservedSpend = isChildProduct ? 0 : (bucket.price ?? 0); + const additionalSpend = recurringReservedSpend + paygSpend; + + const formattedSoftCapType = + isChildProduct || !isAddOn ? getSoftCapType(metricHistory) : null; + const formattedDisplayName = formattedSoftCapType + ? `${displayName} (${formattedSoftCapType})` + : displayName; + + const isPaygOnly = + !isAddOn && supportsPayg(subscription) && metricHistory.reserved === 0; + + const isClickable = !!potentialProductTrial || isEnabled; + const isSelected = selectedProduct === product; + + return ( + + isClickable && setIsHovered(true)} + onMouseLeave={() => isClickable && setIsHovered(false)} + isClickable={isClickable} + isSelected={isSelected} + onClick={() => (isClickable ? onRowClick(product) : undefined)} + onKeyDown={e => { + if ((e.key === 'Enter' || e.key === ' ') && isClickable) { + onRowClick(product); + } + }} + tabIndex={0} + role="button" + aria-label={t('View %s usage', displayName)} + > + {(activeProductTrial || potentialProductTrial) && ( + + )} + + + + {formattedDisplayName}{' '} + {!isEnabled && ( + + + + )} + + + + {isEnabled && ( + + + + {usageExceeded ? ( + + ) : isPaygOnly || isChildProduct || isUnlimited ? null : ( + + )} + + {isPaygOnly || isChildProduct || !formattedPrepaid + ? formattedUsage + : `${formattedUsage} / ${formattedPrepaid}`} + + + + {showAdditionalSpendColumn && ( + + + {displayPriceWithCents({cents: additionalSpend})} + + + )} + + )} + {(isSelected || isHovered) && } + + {showPanelInline && isSelected && ( + + + + + + )} + + ); +} + +export default UsageOverviewTableRow; + +const Row = styled('tr')<{isClickable: boolean; isSelected: boolean}>` + position: relative; + background: ${p => (p.isSelected ? p.theme.backgroundSecondary : p.theme.background)}; + padding: ${p => p.theme.space.xl}; + cursor: pointer; + + &:not(:last-child) { + border-bottom: 1px solid ${p => p.theme.border}; + } + + &:last-child { + border-radius: 0 0 ${p => p.theme.borderRadius} ${p => p.theme.borderRadius}; + } + + cursor: default; + + ${p => + p.isClickable && + css` + cursor: pointer; + + &:hover { + background: ${p.theme.backgroundSecondary}; + } + `} +`; + +const MobilePanelContainer = styled('td')` + display: grid; + grid-template-columns: subgrid; + grid-column: 1 / -1; +`; + +const SelectedPill = styled('td')<{isSelected: boolean}>` + position: absolute; + right: -1px; + top: 30%; + width: 4px; + height: 22px; + border-radius: 2px; + background: ${p => + p.isSelected ? p.theme.tokens.graphics.accent : p.theme.tokens.graphics.muted}; +`; + +const IconContainer = styled('span')` + display: inline-block; + vertical-align: middle; +`; diff --git a/static/gsApp/views/subscriptionPage/usageOverview/components/upgradeOrTrialCta.tsx b/static/gsApp/views/subscriptionPage/usageOverview/components/upgradeOrTrialCta.tsx new file mode 100644 index 00000000000000..69df6962d2a95d --- /dev/null +++ b/static/gsApp/views/subscriptionPage/usageOverview/components/upgradeOrTrialCta.tsx @@ -0,0 +1,207 @@ +import {Fragment, useState} from 'react'; + +import {LinkButton} from '@sentry/scraps/button/linkButton'; +import {Flex, Grid} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; + +import {IconLightning, IconOpen} from 'sentry/icons'; +import {t, tct} from 'sentry/locale'; +import {DataCategory} from 'sentry/types/core'; +import type {Organization} from 'sentry/types/organization'; +import {toTitleCase} from 'sentry/utils/string/toTitleCase'; +import {useNavContext} from 'sentry/views/nav/context'; +import {NavLayout} from 'sentry/views/nav/types'; + +import StartTrialButton from 'getsentry/components/startTrialButton'; +import { + AddOnCategory, + BillingType, + type ProductTrial, + type Subscription, +} from 'getsentry/types'; +import {checkIsAddOn, getBilledCategory} from 'getsentry/utils/billing'; +import {getPlanCategoryName} from 'getsentry/utils/dataCategory'; + +function Cta({ + title, + subtitle, + buttons, + hasContentBelow, +}: { + hasContentBelow: boolean; + subtitle: React.ReactNode; + title: React.ReactNode; + buttons?: React.ReactNode; +}) { + const {isCollapsed: navIsCollapsed, layout: navLayout} = useNavContext(); + const isMobile = navLayout === NavLayout.MOBILE; + + return ( + + + + {title} + + + {subtitle} + + + {buttons && ( + + {buttons} + + )} + + ); +} + +function FindOutMoreButton({ + href, + to, +}: + | { + href: string; + to?: never; + } + | { + to: string; + href?: never; + }) { + return ( + } priority="link" size="sm" href={href} to={to ?? ''}> + {t('Find out more')} + + ); +} + +function ProductTrialCta({ + organization, + subscription, + selectedProduct, + showBottomBorder, + potentialProductTrial, +}: { + organization: Organization; + potentialProductTrial: ProductTrial; + selectedProduct: DataCategory | AddOnCategory; + showBottomBorder: boolean; + subscription: Subscription; +}) { + const [trialButtonBusy, setTrialButtonBusy] = useState(false); + const billedCategory = getBilledCategory(subscription, selectedProduct); + if (!billedCategory) { + return null; + } + + const isAddOn = checkIsAddOn(selectedProduct); + const addOnInfo = isAddOn + ? subscription.addOns?.[selectedProduct as AddOnCategory] + : null; + if (isAddOn && !addOnInfo) { + return null; + } + + const productName = isAddOn + ? toTitleCase(addOnInfo!.productName, {allowInnerUpperCase: true}) + : getPlanCategoryName({ + plan: subscription.planDetails, + category: billedCategory, + title: true, + }); + + return ( + + } + organization={organization} + source="usage-overview" + requestData={{ + productTrial: { + category: potentialProductTrial.category, + reasonCode: potentialProductTrial.reasonCode, + }, + }} + priority="primary" + handleClick={() => setTrialButtonBusy(true)} + onTrialStarted={() => setTrialButtonBusy(true)} + onTrialFailed={() => setTrialButtonBusy(false)} + busy={trialButtonBusy} + disabled={trialButtonBusy} + > + {t('Activate free trial')} + + + + } + hasContentBelow={showBottomBorder} + /> + ); +} + +function UpgradeCta({ + organization, + subscription, +}: { + organization: Organization; + subscription: Subscription; +}) { + const isSalesAccount = + // Invoiced subscriptions are managed by sales + subscription.type === BillingType.INVOICED || + // Custom-priced subscriptions (price > 0) are managed by sales + (subscription.customPrice !== null && subscription.customPrice > 0); + + return ( + , + }) + : tct('Contact us at [mailto:support@sentry.io] to upgrade.', { + mailto: , + }), + })} + buttons={ + subscription.canSelfServe ? ( + + + {t('Upgrade now')} + + + + ) : undefined + } + hasContentBelow={false} + /> + ); +} + +export {ProductTrialCta, UpgradeCta}; diff --git a/static/gsApp/views/subscriptionPage/usageOverview/constants.tsx b/static/gsApp/views/subscriptionPage/usageOverview/constants.tsx new file mode 100644 index 00000000000000..61d8a301bf88c1 --- /dev/null +++ b/static/gsApp/views/subscriptionPage/usageOverview/constants.tsx @@ -0,0 +1,5 @@ +/** + * The minimum screen breakpoint at which the side panel is shown. + * At smaller screen sizes, the panel is shown inline. + */ +export const SIDE_PANEL_MIN_SCREEN_BREAKPOINT = 'lg'; diff --git a/static/gsApp/views/subscriptionPage/usageOverview/index.spec.tsx b/static/gsApp/views/subscriptionPage/usageOverview/index.spec.tsx new file mode 100644 index 00000000000000..73cb21d5143a96 --- /dev/null +++ b/static/gsApp/views/subscriptionPage/usageOverview/index.spec.tsx @@ -0,0 +1,142 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; + +import {BillingHistoryFixture} from 'getsentry-test/fixtures/billingHistory'; +import {CustomerUsageFixture} from 'getsentry-test/fixtures/customerUsage'; +import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription'; +import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; + +import {DataCategory} from 'sentry/types/core'; +import * as useMedia from 'sentry/utils/useMedia'; + +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', + onDemandPeriodStart: '2021-05-02', + onDemandPeriodEnd: '2021-06-01', + }); + const usageData = CustomerUsageFixture(); + + beforeEach(() => { + jest.restoreAllMocks(); + organization.features = ['subscriptions-v3', 'seer-billing']; + organization.access = ['org:billing']; + SubscriptionStore.set(organization.slug, subscription); + MockApiClient.addMockResponse({ + url: `/customers/${organization.slug}/history/current/`, + method: 'GET', + body: BillingHistoryFixture(), + }); + }); + + it('renders actions for billing users', async () => { + render( + + ); + + await screen.findByRole('heading', {name: 'Usage: May 2 - Jun 1, 2021'}); + expect(screen.getByRole('button', {name: 'View all usage'})).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Download as CSV'})).toBeInTheDocument(); + }); + + it('does not render actions for non-billing users', async () => { + organization.access = []; + render( + + ); + + await screen.findByRole('heading', {name: 'Usage: May 2 - Jun 1, 2021'}); + expect( + screen.queryByRole('button', {name: 'View all usage'}) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', {name: 'Download as CSV'}) + ).not.toBeInTheDocument(); + }); + + it('opens panel based with no query params', async () => { + jest.spyOn(useMedia, 'default').mockReturnValue(true); + render( + + ); + + await screen.findByRole('heading', {name: 'Errors'}); + }); + + it('opens panel based on query params', async () => { + jest.spyOn(useMedia, 'default').mockReturnValue(true); + render( + , + { + initialRouterConfig: { + location: { + pathname: '/organizations/org-slug/subscription/usage-overview', + query: {product: DataCategory.REPLAYS}, + }, + }, + } + ); + + await screen.findByRole('heading', {name: 'Replays'}); + expect(screen.queryByRole('heading', {name: 'Errors'})).not.toBeInTheDocument(); + }); + + it('defaults to last selected when query param is invalid', async () => { + jest.spyOn(useMedia, 'default').mockReturnValue(true); + render( + , + { + initialRouterConfig: { + location: { + pathname: '/organizations/org-slug/subscription/usage-overview', + query: {product: 'transactions'}, + }, + }, + } + ); + + await screen.findByRole('heading', {name: 'Errors'}); + expect(screen.queryByRole('heading', {name: 'Transactions'})).not.toBeInTheDocument(); + }); + + it('can switch panel by clicking table rows', async () => { + jest.spyOn(useMedia, 'default').mockReturnValue(true); + render( + + ); + + await screen.findByRole('heading', {name: 'Errors'}); + expect(screen.queryByRole('heading', {name: 'Replays'})).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', {name: 'View Replays usage'})); + await screen.findByRole('heading', {name: 'Replays'}); + expect(screen.queryByRole('heading', {name: 'Errors'})).not.toBeInTheDocument(); + }); +}); diff --git a/static/gsApp/views/subscriptionPage/usageOverview/index.tsx b/static/gsApp/views/subscriptionPage/usageOverview/index.tsx new file mode 100644 index 00000000000000..ce74802e1f3921 --- /dev/null +++ b/static/gsApp/views/subscriptionPage/usageOverview/index.tsx @@ -0,0 +1,144 @@ +import {useEffect, useState} from 'react'; +import {useTheme} from '@emotion/react'; +import moment from 'moment-timezone'; + +import {Container, Flex, Grid} from 'sentry/components/core/layout'; +import {Heading} from 'sentry/components/core/text'; +import {tct} from 'sentry/locale'; +import {DataCategory} from 'sentry/types/core'; +import {useLocation} from 'sentry/utils/useLocation'; +import useMedia from 'sentry/utils/useMedia'; +import {useNavigate} from 'sentry/utils/useNavigate'; + +import {AddOnCategory} from 'getsentry/types'; +import {checkIsAddOn} 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'; +import UsageOverviewTable from 'getsentry/views/subscriptionPage/usageOverview/components/table'; +import {SIDE_PANEL_MIN_SCREEN_BREAKPOINT} from 'getsentry/views/subscriptionPage/usageOverview/constants'; +import type {UsageOverviewProps} from 'getsentry/views/subscriptionPage/usageOverview/type'; + +function UsageOverview({subscription, organization, usageData}: UsageOverviewProps) { + const [selectedProduct, setSelectedProduct] = useState( + DataCategory.ERRORS + ); + const navigate = useNavigate(); + const location = useLocation(); + const theme = useTheme(); + const showSidePanel = useMedia( + `(min-width: ${theme.breakpoints[SIDE_PANEL_MIN_SCREEN_BREAKPOINT]})` + ); + + const startDate = moment(subscription.onDemandPeriodStart); + const endDate = moment(subscription.onDemandPeriodEnd); + const startsAndEndsSameYear = startDate.year() === endDate.year(); + + useEffect(() => { + const productFromQuery = location.query.product as string; + if (productFromQuery) { + const isAddOn = checkIsAddOn(productFromQuery); + if (selectedProduct !== productFromQuery) { + const isSelectable = isAddOn + ? Object.keys(subscription.planDetails.addOnCategories).includes( + productFromQuery + ) + : subscription.planDetails.categories.includes( + productFromQuery as DataCategory + ); + if (isSelectable) { + setSelectedProduct( + isAddOn + ? (productFromQuery as AddOnCategory) + : (productFromQuery as DataCategory) + ); + } else { + // if the query is an unselectable product, reset the query to the existing selected product + navigate( + { + pathname: location.pathname, + query: { + ...location.query, + product: selectedProduct, + }, + }, + { + replace: true, + } + ); + } + } + } + }, [ + location.query.product, + selectedProduct, + location.pathname, + location.query, + navigate, + subscription.planDetails.addOnCategories, + subscription.planDetails.categories, + ]); + + return ( + + + + + + {tct('Usage: [period]', { + period: `${startDate.format(startsAndEndsSameYear ? 'MMM D' : 'MMM D, YYYY')} - ${endDate.format('MMM D, YYYY')}`, + })} + + + + + { + setSelectedProduct(product); + trackGetsentryAnalytics('subscription_page.usage_overview.row_clicked', { + organization, + subscription, + ...(Object.values(AddOnCategory).includes(product as AddOnCategory) + ? {addOnCategory: product as AddOnCategory} + : {dataCategory: product as DataCategory}), + }); + navigate( + { + pathname: location.pathname, + query: { + ...location.query, + product, + }, + }, + {replace: true} + ); + }} + selectedProduct={selectedProduct} + usageData={usageData} + /> + + {showSidePanel && ( + + )} + + ); +} + +export default UsageOverview; diff --git a/static/gsApp/views/subscriptionPage/usageOverview/type.tsx b/static/gsApp/views/subscriptionPage/usageOverview/type.tsx new file mode 100644 index 00000000000000..06d9f2199824b3 --- /dev/null +++ b/static/gsApp/views/subscriptionPage/usageOverview/type.tsx @@ -0,0 +1,19 @@ +import type {DataCategory} from 'sentry/types/core'; +import type {Organization} from 'sentry/types/organization'; + +import type {AddOnCategory, CustomerUsage, Subscription} from 'getsentry/types'; + +export interface UsageOverviewProps { + organization: Organization; + subscription: Subscription; + usageData: CustomerUsage; +} + +export interface BreakdownPanelProps extends UsageOverviewProps { + selectedProduct: DataCategory | AddOnCategory; + isInline?: boolean; +} + +export interface UsageOverviewTableProps extends Omit { + onRowClick: (category: DataCategory | AddOnCategory) => void; +} diff --git a/static/gsApp/views/subscriptionPage/usageTotalsTable.tsx b/static/gsApp/views/subscriptionPage/usageTotalsTable.tsx index 04d08c2ab1d7cf..2fcb7ab6c62427 100644 --- a/static/gsApp/views/subscriptionPage/usageTotalsTable.tsx +++ b/static/gsApp/views/subscriptionPage/usageTotalsTable.tsx @@ -310,12 +310,7 @@ function UsageTotalsTable({category, isEventBreakdown, totals, subscription}: Pr const hasSpikeProtection = categoryInfo?.hasSpikeProtection ?? false; return ( - + {isNewBillingUI && (