123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371 |
- import {Fragment, useEffect} from 'react';
- import styled from '@emotion/styled';
- import type {Location} from 'history';
- import type {Client} from 'sentry/api';
- import ErrorBoundary from 'sentry/components/errorBoundary';
- import LoadingError from 'sentry/components/loadingError';
- import LoadingIndicator from 'sentry/components/loadingIndicator';
- import {DATA_CATEGORY_INFO} from 'sentry/constants';
- import {space} from 'sentry/styles/space';
- import {DataCategory} from 'sentry/types/core';
- import type {Organization} from 'sentry/types/organization';
- import {useApiQuery} from 'sentry/utils/queryClient';
- import withApi from 'sentry/utils/withApi';
- import withOrganization from 'sentry/utils/withOrganization';
- import {openCodecovModal} from 'getsentry/actionCreators/modal';
- import withSubscription from 'getsentry/components/withSubscription';
- import type {
- BillingStatTotal,
- CustomerUsage,
- Plan,
- ProductTrial,
- PromotionData,
- ReservedBudgetForCategory,
- Subscription,
- } from 'getsentry/types';
- import {PlanTier} from 'getsentry/types';
- import {hasAccessToSubscriptionOverview, isAm3DsPlan} from 'getsentry/utils/billing';
- import {sortCategories} from 'getsentry/utils/dataCategory';
- import withPromotions from 'getsentry/utils/withPromotions';
- import ContactBillingMembers from 'getsentry/views/contactBillingMembers';
- import {openOnDemandBudgetEditModal} from 'getsentry/views/onDemandBudgets/editOnDemandButton';
- import openPerformanceQuotaCreditsPromoModal from './promotions/performanceQuotaCreditsPromo';
- import openPerformanceReservedTransactionsDiscountModal from './promotions/performanceReservedTransactionsPromo';
- import TrialEnded from './trial/trialEnded';
- import OnDemandDisabled from './ondemandDisabled';
- import {OnDemandSettings} from './onDemandSettings';
- import {DisplayModeToggle} from './overviewDisplayModeToggle';
- import RecurringCredits from './recurringCredits';
- import ReservedUsageChart from './reservedUsageChart';
- import SubscriptionHeader from './subscriptionHeader';
- import UsageAlert from './usageAlert';
- import UsageTotals from './usageTotals';
- import {trackSubscriptionView} from './utils';
- type Props = {
- api: Client;
- location: Location;
- organization: Organization;
- promotionData: PromotionData;
- subscription: Subscription;
- };
- /**
- * Subscription overview page.
- */
- function Overview({api, location, subscription, organization, promotionData}: Props) {
- const displayMode = ['cost', 'usage'].includes(location.query.displayMode as string)
- ? (location.query.displayMode as 'cost' | 'usage')
- : 'usage';
- const hasBillingPerms = organization.access?.includes('org:billing');
- // we fetch an expanded view of the subscription which includes usage
- // data for the current period
- const {
- data: usage,
- refetch: refetchUsage,
- isPending,
- isError,
- } = useApiQuery<CustomerUsage>([`/customers/${organization.slug}/usage/`], {
- staleTime: 60_000,
- });
- const reservedBudgetCategoryInfo: Record<string, ReservedBudgetForCategory> = {};
- subscription.reservedBudgets?.forEach(rb => {
- Object.entries(rb.categories).forEach(([category, rbmh]) => {
- reservedBudgetCategoryInfo[category] = {
- freeBudget: rb.freeBudget,
- totalReservedBudget: rb.reservedBudget,
- reservedSpend: rbmh.reservedSpend,
- reservedCpe: rbmh.reservedCpe,
- prepaidBudget: rb.reservedBudget + rb.freeBudget,
- };
- });
- });
- useEffect(() => {
- if (promotionData) {
- let promotion = promotionData.availablePromotions?.find(
- promo => promo.promptActivityTrigger === 'performance_reserved_txns_discount_v1'
- );
- if (promotion) {
- openPerformanceReservedTransactionsDiscountModal({
- api,
- promotionData,
- organization,
- promptFeature: 'performance_reserved_txns_discount_v1',
- });
- return;
- }
- promotion = promotionData.availablePromotions?.find(
- promo => promo.promptActivityTrigger === 'performance_quota_credits_v1'
- );
- if (promotion) {
- openPerformanceQuotaCreditsPromoModal({api, promotionData, organization});
- return;
- }
- promotion = promotionData.availablePromotions?.find(
- promo => promo.promptActivityTrigger === 'performance_reserved_txns_discount'
- );
- if (promotion) {
- openPerformanceReservedTransactionsDiscountModal({
- api,
- promotionData,
- organization,
- promptFeature: 'performance_reserved_txns_discount',
- });
- return;
- }
- }
- // open the codecov modal if the query param is present
- if (
- location.query?.open_codecov_modal === '1' &&
- // self serve or has billing perms can view it
- hasAccessToSubscriptionOverview(subscription, organization)
- ) {
- openCodecovModal({organization});
- }
- // Open on-demand budget modal if hash fragment present and user has access
- if (
- window.location.hash === '#open-ondemand-modal' &&
- subscription.supportsOnDemand &&
- hasAccessToSubscriptionOverview(subscription, organization)
- ) {
- openOnDemandBudgetEditModal({organization, subscription});
- // Clear hash to prevent modal reopening on refresh
- window.history.replaceState(
- null,
- '',
- window.location.pathname + window.location.search
- );
- }
- }, [organization, location.query, subscription, promotionData, api]);
- useEffect(
- () => void trackSubscriptionView(organization, subscription, 'overview'),
- [subscription, organization]
- );
- // Sales managed accounts do not allow members to view the billing page.
- // Whilst self-serve accounts do.
- if (!hasBillingPerms && !subscription.canSelfServe) {
- return <ContactBillingMembers />;
- }
- function renderUsageChart(usageData: CustomerUsage) {
- const {stats, periodStart, periodEnd} = usageData;
- return (
- <ErrorBoundary mini>
- <ReservedUsageChart
- location={location}
- organization={organization}
- subscription={subscription}
- usagePeriodStart={periodStart}
- usagePeriodEnd={periodEnd}
- usageStats={stats}
- displayMode={displayMode}
- reservedBudgetCategoryInfo={reservedBudgetCategoryInfo}
- />
- </ErrorBoundary>
- );
- }
- function renderUsageCards(usageData: CustomerUsage) {
- const nonPlanProductTrials: ProductTrial[] =
- subscription.productTrials?.filter(
- pt => !Object.keys(subscription.categories).includes(pt.category)
- ) || [];
- const showProductTrialEventBreakdown: boolean =
- nonPlanProductTrials?.filter(pt => pt.category === DataCategory.PROFILES).length >
- 0 || false;
- if (
- !subscription.hadCustomDynamicSampling &&
- isAm3DsPlan(subscription.plan) &&
- !subscription.isEnterpriseTrial
- ) {
- // if the customer has not yet used custom DS in the current period, just show
- // one spans card
- reservedBudgetCategoryInfo[DataCategory.SPANS]!.reservedSpend +=
- reservedBudgetCategoryInfo[DataCategory.SPANS_INDEXED]!.reservedSpend ?? 0;
- }
- return (
- <TotalsWrapper>
- {sortCategories(subscription.categories).map(categoryHistory => {
- const category = categoryHistory.category;
- if (
- category === DATA_CATEGORY_INFO.spanIndexed.plural &&
- !subscription.hadCustomDynamicSampling
- ) {
- return null;
- }
- // The usageData does not include details for seat-based categories.
- // For now we will handle the monitor category specially
- let monitor_usage: number | undefined = 0;
- if (category === DataCategory.MONITOR_SEATS) {
- monitor_usage = subscription.categories.monitorSeats?.usage;
- }
- if (category === DataCategory.UPTIME) {
- monitor_usage = subscription.categories.uptime?.usage;
- }
- const categoryTotals: BillingStatTotal =
- category !== DataCategory.MONITOR_SEATS && category !== DataCategory.UPTIME
- ? usageData.totals[category]!
- : {
- accepted: monitor_usage ?? 0,
- dropped: 0,
- droppedOther: 0,
- droppedOverQuota: 0,
- droppedSpikeProtection: 0,
- filtered: 0,
- projected: 0,
- };
- const eventTotals =
- category !== DataCategory.MONITOR_SEATS && category !== DataCategory.UPTIME
- ? usageData.eventTotals?.[category]
- : undefined;
- const showEventBreakdown =
- organization.features.includes('profiling-billing') &&
- subscription.planTier === PlanTier.AM2;
- return (
- <UsageTotals
- key={category}
- category={category}
- totals={categoryTotals}
- eventTotals={eventTotals}
- showEventBreakdown={showEventBreakdown}
- reservedUnits={categoryHistory.reserved}
- prepaidUnits={categoryHistory.prepaid}
- freeUnits={categoryHistory.free}
- trueForward={categoryHistory.trueForward}
- softCapType={categoryHistory.softCapType}
- disableTable={
- category === DataCategory.MONITOR_SEATS ||
- category === DataCategory.UPTIME ||
- displayMode === 'cost'
- }
- subscription={subscription}
- organization={organization}
- displayMode={displayMode}
- reservedBudget={reservedBudgetCategoryInfo[category]?.totalReservedBudget}
- prepaidBudget={reservedBudgetCategoryInfo[category]?.prepaidBudget}
- reservedSpend={reservedBudgetCategoryInfo[category]?.reservedSpend}
- freeBudget={reservedBudgetCategoryInfo[category]?.freeBudget}
- />
- );
- })}
- {nonPlanProductTrials?.map(pt => {
- const categoryTotals = usageData.totals[pt.category];
- const eventTotals = usageData.eventTotals?.[pt.category];
- return (
- <UsageTotals
- key={pt.category}
- category={pt.category}
- totals={categoryTotals}
- eventTotals={eventTotals}
- showEventBreakdown={showProductTrialEventBreakdown}
- subscription={subscription}
- organization={organization}
- displayMode={displayMode}
- />
- );
- })}
- </TotalsWrapper>
- );
- }
- if (isPending) {
- return (
- <Fragment>
- <SubscriptionHeader subscription={subscription} organization={organization} />
- <LoadingIndicator />
- </Fragment>
- );
- }
- if (isError) {
- return <LoadingError onRetry={refetchUsage} />;
- }
- /**
- * It's important to separate the views for folks with billing permissions (org:billing) and those without.
- * Only owners and billing admins have the billing scope, everyone else including managers, admins, and members lack that scope.
- *
- * Non-billing users should be able to see the following info:
- * - Current Plan information and the date when it ends
- * - Event totals, dropped events, usage charts
- * - Alerts for overages (usage alert, grace period, etc)
- * - CTAs asking the user to request a plan change
- *
- * Non-billing users should NOT see any of the following:
- * - Anything with a dollar amount
- * - Receipts
- * - Credit card on file
- * - Previous usage history
- * - On-demand information
- */
- function contentWithBillingPerms(usageData: CustomerUsage, planDetails: Plan) {
- return (
- <Fragment>
- <RecurringCredits displayType="discount" planDetails={planDetails} />
- <RecurringCredits displayType="data" planDetails={planDetails} />
- <OnDemandDisabled subscription={subscription} />
- <UsageAlert subscription={subscription} usage={usageData} />
- <DisplayModeToggle subscription={subscription} displayMode={displayMode} />
- {renderUsageChart(usageData)}
- {renderUsageCards(usageData)}
- <OnDemandSettings organization={organization} subscription={subscription} />
- <TrialEnded subscription={subscription} />
- </Fragment>
- );
- }
- function contentWithoutBillingPerms(usageData: CustomerUsage) {
- return (
- <Fragment>
- <OnDemandDisabled subscription={subscription} />
- <UsageAlert subscription={subscription} usage={usageData} />
- {renderUsageChart(usageData)}
- {renderUsageCards(usageData)}
- <TrialEnded subscription={subscription} />
- </Fragment>
- );
- }
- return (
- <Fragment>
- <SubscriptionHeader organization={organization} subscription={subscription} />
- <div>
- {hasBillingPerms
- ? contentWithBillingPerms(usage, subscription.planDetails)
- : contentWithoutBillingPerms(usage)}
- </div>
- </Fragment>
- );
- }
- export default withApi(withOrganization(withSubscription(withPromotions(Overview))));
- const TotalsWrapper = styled('div')`
- margin-bottom: ${space(3)};
- `;
|