overview.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
  1. import {Fragment, useEffect} from 'react';
  2. import styled from '@emotion/styled';
  3. import type {Location} from 'history';
  4. import type {Client} from 'sentry/api';
  5. import ErrorBoundary from 'sentry/components/errorBoundary';
  6. import LoadingError from 'sentry/components/loadingError';
  7. import LoadingIndicator from 'sentry/components/loadingIndicator';
  8. import {DATA_CATEGORY_INFO} from 'sentry/constants';
  9. import {space} from 'sentry/styles/space';
  10. import {DataCategory} from 'sentry/types/core';
  11. import type {Organization} from 'sentry/types/organization';
  12. import {useApiQuery} from 'sentry/utils/queryClient';
  13. import withApi from 'sentry/utils/withApi';
  14. import withOrganization from 'sentry/utils/withOrganization';
  15. import {openCodecovModal} from 'getsentry/actionCreators/modal';
  16. import withSubscription from 'getsentry/components/withSubscription';
  17. import type {
  18. BillingStatTotal,
  19. CustomerUsage,
  20. Plan,
  21. ProductTrial,
  22. PromotionData,
  23. ReservedBudgetForCategory,
  24. Subscription,
  25. } from 'getsentry/types';
  26. import {PlanTier} from 'getsentry/types';
  27. import {hasAccessToSubscriptionOverview, isAm3DsPlan} from 'getsentry/utils/billing';
  28. import {sortCategories} from 'getsentry/utils/dataCategory';
  29. import withPromotions from 'getsentry/utils/withPromotions';
  30. import ContactBillingMembers from 'getsentry/views/contactBillingMembers';
  31. import {openOnDemandBudgetEditModal} from 'getsentry/views/onDemandBudgets/editOnDemandButton';
  32. import openPerformanceQuotaCreditsPromoModal from './promotions/performanceQuotaCreditsPromo';
  33. import openPerformanceReservedTransactionsDiscountModal from './promotions/performanceReservedTransactionsPromo';
  34. import TrialEnded from './trial/trialEnded';
  35. import OnDemandDisabled from './ondemandDisabled';
  36. import {OnDemandSettings} from './onDemandSettings';
  37. import {DisplayModeToggle} from './overviewDisplayModeToggle';
  38. import RecurringCredits from './recurringCredits';
  39. import ReservedUsageChart from './reservedUsageChart';
  40. import SubscriptionHeader from './subscriptionHeader';
  41. import UsageAlert from './usageAlert';
  42. import UsageTotals from './usageTotals';
  43. import {trackSubscriptionView} from './utils';
  44. type Props = {
  45. api: Client;
  46. location: Location;
  47. organization: Organization;
  48. promotionData: PromotionData;
  49. subscription: Subscription;
  50. };
  51. /**
  52. * Subscription overview page.
  53. */
  54. function Overview({api, location, subscription, organization, promotionData}: Props) {
  55. const displayMode = ['cost', 'usage'].includes(location.query.displayMode as string)
  56. ? (location.query.displayMode as 'cost' | 'usage')
  57. : 'usage';
  58. const hasBillingPerms = organization.access?.includes('org:billing');
  59. // we fetch an expanded view of the subscription which includes usage
  60. // data for the current period
  61. const {
  62. data: usage,
  63. refetch: refetchUsage,
  64. isPending,
  65. isError,
  66. } = useApiQuery<CustomerUsage>([`/customers/${organization.slug}/usage/`], {
  67. staleTime: 60_000,
  68. });
  69. const reservedBudgetCategoryInfo: Record<string, ReservedBudgetForCategory> = {};
  70. subscription.reservedBudgets?.forEach(rb => {
  71. Object.entries(rb.categories).forEach(([category, rbmh]) => {
  72. reservedBudgetCategoryInfo[category] = {
  73. freeBudget: rb.freeBudget,
  74. totalReservedBudget: rb.reservedBudget,
  75. reservedSpend: rbmh.reservedSpend,
  76. reservedCpe: rbmh.reservedCpe,
  77. prepaidBudget: rb.reservedBudget + rb.freeBudget,
  78. };
  79. });
  80. });
  81. useEffect(() => {
  82. if (promotionData) {
  83. let promotion = promotionData.availablePromotions?.find(
  84. promo => promo.promptActivityTrigger === 'performance_reserved_txns_discount_v1'
  85. );
  86. if (promotion) {
  87. openPerformanceReservedTransactionsDiscountModal({
  88. api,
  89. promotionData,
  90. organization,
  91. promptFeature: 'performance_reserved_txns_discount_v1',
  92. });
  93. return;
  94. }
  95. promotion = promotionData.availablePromotions?.find(
  96. promo => promo.promptActivityTrigger === 'performance_quota_credits_v1'
  97. );
  98. if (promotion) {
  99. openPerformanceQuotaCreditsPromoModal({api, promotionData, organization});
  100. return;
  101. }
  102. promotion = promotionData.availablePromotions?.find(
  103. promo => promo.promptActivityTrigger === 'performance_reserved_txns_discount'
  104. );
  105. if (promotion) {
  106. openPerformanceReservedTransactionsDiscountModal({
  107. api,
  108. promotionData,
  109. organization,
  110. promptFeature: 'performance_reserved_txns_discount',
  111. });
  112. return;
  113. }
  114. }
  115. // open the codecov modal if the query param is present
  116. if (
  117. location.query?.open_codecov_modal === '1' &&
  118. // self serve or has billing perms can view it
  119. hasAccessToSubscriptionOverview(subscription, organization)
  120. ) {
  121. openCodecovModal({organization});
  122. }
  123. // Open on-demand budget modal if hash fragment present and user has access
  124. if (
  125. window.location.hash === '#open-ondemand-modal' &&
  126. subscription.supportsOnDemand &&
  127. hasAccessToSubscriptionOverview(subscription, organization)
  128. ) {
  129. openOnDemandBudgetEditModal({organization, subscription});
  130. // Clear hash to prevent modal reopening on refresh
  131. window.history.replaceState(
  132. null,
  133. '',
  134. window.location.pathname + window.location.search
  135. );
  136. }
  137. }, [organization, location.query, subscription, promotionData, api]);
  138. useEffect(
  139. () => void trackSubscriptionView(organization, subscription, 'overview'),
  140. [subscription, organization]
  141. );
  142. // Sales managed accounts do not allow members to view the billing page.
  143. // Whilst self-serve accounts do.
  144. if (!hasBillingPerms && !subscription.canSelfServe) {
  145. return <ContactBillingMembers />;
  146. }
  147. function renderUsageChart(usageData: CustomerUsage) {
  148. const {stats, periodStart, periodEnd} = usageData;
  149. return (
  150. <ErrorBoundary mini>
  151. <ReservedUsageChart
  152. location={location}
  153. organization={organization}
  154. subscription={subscription}
  155. usagePeriodStart={periodStart}
  156. usagePeriodEnd={periodEnd}
  157. usageStats={stats}
  158. displayMode={displayMode}
  159. reservedBudgetCategoryInfo={reservedBudgetCategoryInfo}
  160. />
  161. </ErrorBoundary>
  162. );
  163. }
  164. function renderUsageCards(usageData: CustomerUsage) {
  165. const nonPlanProductTrials: ProductTrial[] =
  166. subscription.productTrials?.filter(
  167. pt => !Object.keys(subscription.categories).includes(pt.category)
  168. ) || [];
  169. const showProductTrialEventBreakdown: boolean =
  170. nonPlanProductTrials?.filter(pt => pt.category === DataCategory.PROFILES).length >
  171. 0 || false;
  172. if (
  173. !subscription.hadCustomDynamicSampling &&
  174. isAm3DsPlan(subscription.plan) &&
  175. !subscription.isEnterpriseTrial
  176. ) {
  177. // if the customer has not yet used custom DS in the current period, just show
  178. // one spans card
  179. reservedBudgetCategoryInfo[DataCategory.SPANS]!.reservedSpend +=
  180. reservedBudgetCategoryInfo[DataCategory.SPANS_INDEXED]!.reservedSpend ?? 0;
  181. }
  182. return (
  183. <TotalsWrapper>
  184. {sortCategories(subscription.categories).map(categoryHistory => {
  185. const category = categoryHistory.category;
  186. if (
  187. category === DATA_CATEGORY_INFO.spanIndexed.plural &&
  188. !subscription.hadCustomDynamicSampling
  189. ) {
  190. return null;
  191. }
  192. // The usageData does not include details for seat-based categories.
  193. // For now we will handle the monitor category specially
  194. let monitor_usage: number | undefined = 0;
  195. if (category === DataCategory.MONITOR_SEATS) {
  196. monitor_usage = subscription.categories.monitorSeats?.usage;
  197. }
  198. if (category === DataCategory.UPTIME) {
  199. monitor_usage = subscription.categories.uptime?.usage;
  200. }
  201. const categoryTotals: BillingStatTotal =
  202. category !== DataCategory.MONITOR_SEATS && category !== DataCategory.UPTIME
  203. ? usageData.totals[category]!
  204. : {
  205. accepted: monitor_usage ?? 0,
  206. dropped: 0,
  207. droppedOther: 0,
  208. droppedOverQuota: 0,
  209. droppedSpikeProtection: 0,
  210. filtered: 0,
  211. projected: 0,
  212. };
  213. const eventTotals =
  214. category !== DataCategory.MONITOR_SEATS && category !== DataCategory.UPTIME
  215. ? usageData.eventTotals?.[category]
  216. : undefined;
  217. const showEventBreakdown =
  218. organization.features.includes('profiling-billing') &&
  219. subscription.planTier === PlanTier.AM2;
  220. return (
  221. <UsageTotals
  222. key={category}
  223. category={category}
  224. totals={categoryTotals}
  225. eventTotals={eventTotals}
  226. showEventBreakdown={showEventBreakdown}
  227. reservedUnits={categoryHistory.reserved}
  228. prepaidUnits={categoryHistory.prepaid}
  229. freeUnits={categoryHistory.free}
  230. trueForward={categoryHistory.trueForward}
  231. softCapType={categoryHistory.softCapType}
  232. disableTable={
  233. category === DataCategory.MONITOR_SEATS ||
  234. category === DataCategory.UPTIME ||
  235. displayMode === 'cost'
  236. }
  237. subscription={subscription}
  238. organization={organization}
  239. displayMode={displayMode}
  240. reservedBudget={reservedBudgetCategoryInfo[category]?.totalReservedBudget}
  241. prepaidBudget={reservedBudgetCategoryInfo[category]?.prepaidBudget}
  242. reservedSpend={reservedBudgetCategoryInfo[category]?.reservedSpend}
  243. freeBudget={reservedBudgetCategoryInfo[category]?.freeBudget}
  244. />
  245. );
  246. })}
  247. {nonPlanProductTrials?.map(pt => {
  248. const categoryTotals = usageData.totals[pt.category];
  249. const eventTotals = usageData.eventTotals?.[pt.category];
  250. return (
  251. <UsageTotals
  252. key={pt.category}
  253. category={pt.category}
  254. totals={categoryTotals}
  255. eventTotals={eventTotals}
  256. showEventBreakdown={showProductTrialEventBreakdown}
  257. subscription={subscription}
  258. organization={organization}
  259. displayMode={displayMode}
  260. />
  261. );
  262. })}
  263. </TotalsWrapper>
  264. );
  265. }
  266. if (isPending) {
  267. return (
  268. <Fragment>
  269. <SubscriptionHeader subscription={subscription} organization={organization} />
  270. <LoadingIndicator />
  271. </Fragment>
  272. );
  273. }
  274. if (isError) {
  275. return <LoadingError onRetry={refetchUsage} />;
  276. }
  277. /**
  278. * It's important to separate the views for folks with billing permissions (org:billing) and those without.
  279. * Only owners and billing admins have the billing scope, everyone else including managers, admins, and members lack that scope.
  280. *
  281. * Non-billing users should be able to see the following info:
  282. * - Current Plan information and the date when it ends
  283. * - Event totals, dropped events, usage charts
  284. * - Alerts for overages (usage alert, grace period, etc)
  285. * - CTAs asking the user to request a plan change
  286. *
  287. * Non-billing users should NOT see any of the following:
  288. * - Anything with a dollar amount
  289. * - Receipts
  290. * - Credit card on file
  291. * - Previous usage history
  292. * - On-demand information
  293. */
  294. function contentWithBillingPerms(usageData: CustomerUsage, planDetails: Plan) {
  295. return (
  296. <Fragment>
  297. <RecurringCredits displayType="discount" planDetails={planDetails} />
  298. <RecurringCredits displayType="data" planDetails={planDetails} />
  299. <OnDemandDisabled subscription={subscription} />
  300. <UsageAlert subscription={subscription} usage={usageData} />
  301. <DisplayModeToggle subscription={subscription} displayMode={displayMode} />
  302. {renderUsageChart(usageData)}
  303. {renderUsageCards(usageData)}
  304. <OnDemandSettings organization={organization} subscription={subscription} />
  305. <TrialEnded subscription={subscription} />
  306. </Fragment>
  307. );
  308. }
  309. function contentWithoutBillingPerms(usageData: CustomerUsage) {
  310. return (
  311. <Fragment>
  312. <OnDemandDisabled subscription={subscription} />
  313. <UsageAlert subscription={subscription} usage={usageData} />
  314. {renderUsageChart(usageData)}
  315. {renderUsageCards(usageData)}
  316. <TrialEnded subscription={subscription} />
  317. </Fragment>
  318. );
  319. }
  320. return (
  321. <Fragment>
  322. <SubscriptionHeader organization={organization} subscription={subscription} />
  323. <div>
  324. {hasBillingPerms
  325. ? contentWithBillingPerms(usage, subscription.planDetails)
  326. : contentWithoutBillingPerms(usage)}
  327. </div>
  328. </Fragment>
  329. );
  330. }
  331. export default withApi(withOrganization(withSubscription(withPromotions(Overview))));
  332. const TotalsWrapper = styled('div')`
  333. margin-bottom: ${space(3)};
  334. `;