usageAlert.tsx 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. import styled from '@emotion/styled';
  2. import moment from 'moment-timezone';
  3. import Panel from 'sentry/components/panels/panel';
  4. import {IconFire, IconStats, IconWarning} from 'sentry/icons';
  5. import {t, tct} from 'sentry/locale';
  6. import {space} from 'sentry/styles/space';
  7. import {DataCategory} from 'sentry/types/core';
  8. import type {Organization} from 'sentry/types/organization';
  9. import oxfordizeArray from 'sentry/utils/oxfordizeArray';
  10. import withOrganization from 'sentry/utils/withOrganization';
  11. import TextBlock from 'sentry/views/settings/components/text/textBlock';
  12. import AddEventsCTA from 'getsentry/components/addEventsCTA';
  13. import {GIGABYTE, RESERVED_BUDGET_QUOTA} from 'getsentry/constants';
  14. import OrgStatsBanner from 'getsentry/hooks/orgStatsBanner';
  15. import type {CustomerUsage, Subscription} from 'getsentry/types';
  16. import {
  17. formatReservedWithUnits,
  18. formatUsageWithUnits,
  19. getBestActionToIncreaseEventLimits,
  20. hasPerformance,
  21. isBizPlanFamily,
  22. isUnlimitedReserved,
  23. UsageAction,
  24. } from 'getsentry/utils/billing';
  25. import {getPlanCategoryName, sortCategoriesWithKeys} from 'getsentry/utils/dataCategory';
  26. import {ButtonWrapper, SubscriptionBody} from './styles';
  27. type ProjectedOverages = string[];
  28. type Props = {
  29. organization: Organization;
  30. subscription: Subscription;
  31. usage: CustomerUsage;
  32. };
  33. function UsageAlert({organization, subscription, usage}: Props) {
  34. function getActionSentence() {
  35. switch (getBestActionToIncreaseEventLimits(organization, subscription)) {
  36. case UsageAction.START_TRIAL:
  37. return t('Start a free trial to avoid data loss.');
  38. case UsageAction.ADD_EVENTS:
  39. return t('Increase your event limits to avoid data loss.');
  40. case UsageAction.REQUEST_ADD_EVENTS:
  41. return t(
  42. 'Bug your organization owner to increase your event limits to avoid data loss.'
  43. );
  44. case UsageAction.REQUEST_UPGRADE:
  45. return t('Bug your organization owner to upgrade your plan to avoid data loss.');
  46. case UsageAction.SEND_TO_CHECKOUT:
  47. default:
  48. return t('Upgrade your plan to avoid data loss.');
  49. }
  50. }
  51. function formatProjected(projected: number, category: string): string {
  52. const displayName = getPlanCategoryName({
  53. plan: subscription.planDetails,
  54. category,
  55. capitalize: false,
  56. hadCustomDynamicSampling: subscription.hadCustomDynamicSampling,
  57. });
  58. return category === DataCategory.ATTACHMENTS
  59. ? `${formatUsageWithUnits(projected, category)} of attachments`
  60. : `${formatReservedWithUnits(projected, category, {
  61. isAbbreviated: true,
  62. })} ${displayName}`;
  63. }
  64. function projectedCategoryOverages() {
  65. // hide projected overages for plans with on-demand for now since
  66. // shared on-demand budget can be applied to any data category
  67. if (subscription.onDemandMaxSpend) {
  68. return [];
  69. }
  70. return Object.entries(subscription.categories).reduce<ProjectedOverages>(
  71. (acc, [category, currentHistory]) => {
  72. if (
  73. currentHistory.reserved === RESERVED_BUDGET_QUOTA ||
  74. isUnlimitedReserved(currentHistory.reserved)
  75. ) {
  76. return acc;
  77. }
  78. const projected = usage.totals[category]?.projected || 0;
  79. const projectedWithReservedUnit =
  80. category === DataCategory.ATTACHMENTS ? projected / GIGABYTE : projected;
  81. const hasOverage =
  82. !!currentHistory.reserved &&
  83. projectedWithReservedUnit > (currentHistory.prepaid ?? 0);
  84. if (hasOverage) {
  85. acc.push(formatProjected(projected, category));
  86. }
  87. return acc;
  88. },
  89. []
  90. );
  91. }
  92. function getProjectedOverages(): ProjectedOverages {
  93. if (subscription.isEnterpriseTrial || subscription.hasOverageNotificationsDisabled) {
  94. return [];
  95. }
  96. return projectedCategoryOverages();
  97. }
  98. function renderProjectedInfo(projectedOverages: ProjectedOverages) {
  99. if (!projectedOverages) {
  100. return null;
  101. }
  102. return (
  103. <Panel data-test-id="projected-overage-alert">
  104. <SubscriptionBody withPadding>
  105. <UsageInfo>
  106. <IconStats size="md" color="blue300" />
  107. <div>
  108. <h3>{t('Projected Overage')}</h3>
  109. <Description>
  110. {tct(
  111. `Based on your previous usage, we predict your organization will need at least [totals].`,
  112. {totals: oxfordizeArray(projectedOverages)}
  113. )}{' '}
  114. {getActionSentence()}
  115. </Description>
  116. </div>
  117. </UsageInfo>
  118. {renderPrimaryCTA('projected-overage')}
  119. </SubscriptionBody>
  120. </Panel>
  121. );
  122. }
  123. function renderGracePeriodInfo() {
  124. return (
  125. <Panel data-test-id="grace-period-alert">
  126. <SubscriptionBody withPadding>
  127. <UsageInfo>
  128. <IconWarning size="md" color="yellow300" />
  129. <div>
  130. <h3>{t('Grace Period')}</h3>
  131. <Description>
  132. {tct(
  133. `Your organization has depleted its error capacity for the current usage period.
  134. We've put your account into a one time grace period, which will continue to accept errors at a limited rate.
  135. This grace period ends on [gracePeriodEnd].`,
  136. {gracePeriodEnd: moment(subscription.gracePeriodEnd).format('ll')}
  137. )}{' '}
  138. {getActionSentence()}
  139. </Description>
  140. </div>
  141. </UsageInfo>
  142. {renderPrimaryCTA('grace-period')}
  143. </SubscriptionBody>
  144. </Panel>
  145. );
  146. }
  147. function renderExceededInfo() {
  148. const exceededList = sortCategoriesWithKeys(subscription.categories)
  149. .filter(
  150. ([category]) =>
  151. category !== DataCategory.SPANS_INDEXED || subscription.hadCustomDynamicSampling
  152. )
  153. .reduce((acc, [category, currentHistory]) => {
  154. if (currentHistory.usageExceeded) {
  155. acc.push(
  156. getPlanCategoryName({
  157. plan: subscription.planDetails,
  158. category,
  159. capitalize: false,
  160. hadCustomDynamicSampling: subscription.hadCustomDynamicSampling,
  161. })
  162. );
  163. }
  164. return acc;
  165. }, [] as string[]);
  166. const quotasExceeded =
  167. exceededList.length > 0
  168. ? oxfordizeArray(exceededList)
  169. : getPlanCategoryName({
  170. plan: subscription.planDetails,
  171. category: DataCategory.ERRORS,
  172. capitalize: false,
  173. hadCustomDynamicSampling: subscription.hadCustomDynamicSampling,
  174. });
  175. return (
  176. <Panel data-test-id="usage-exceeded-alert">
  177. <SubscriptionBody withPadding>
  178. <UsageInfo>
  179. <IconFire size="md" color="red300" />
  180. <div>
  181. <h3>{t('Usage Exceeded')}</h3>
  182. <Description>
  183. {tct(
  184. `Your organization has depleted its [quotasExceeded] capacity for the current usage period.`,
  185. {quotasExceeded}
  186. )}{' '}
  187. {getActionSentence()}
  188. </Description>
  189. </div>
  190. </UsageInfo>
  191. {renderPrimaryCTA('exceded-quota')}
  192. </SubscriptionBody>
  193. </Panel>
  194. );
  195. }
  196. function renderDefaultEventCTA() {
  197. // allow business plan members to request events even if no overages
  198. // every other user will have another type of CTA
  199. if (
  200. getBestActionToIncreaseEventLimits(organization, subscription) ===
  201. 'request_add_events' &&
  202. isBizPlanFamily(subscription.planDetails) &&
  203. hasPerformance(subscription.planDetails)
  204. ) {
  205. return (
  206. <OrgStatsBanner
  207. organization={organization}
  208. referrer="subscription-default-event-cta"
  209. />
  210. );
  211. }
  212. return null;
  213. }
  214. function renderPrimaryCTA(alertType: string) {
  215. if (!subscription.canSelfServe) {
  216. return null;
  217. }
  218. return (
  219. <ButtonWrapper>
  220. <AddEventsCTA
  221. {...{
  222. organization,
  223. subscription,
  224. source: `subscription-usage-alert-${alertType}`,
  225. referrer: `subscription-usage-alert-${alertType}`,
  226. buttonProps: {
  227. size: 'sm',
  228. },
  229. }}
  230. />
  231. </ButtonWrapper>
  232. );
  233. }
  234. if (!subscription || !usage) {
  235. return null;
  236. }
  237. const hasExceeded =
  238. Object.values(subscription.categories).some(({usageExceeded}) => usageExceeded) ||
  239. // TODO: Remove when mmx plans have error BillingMetricHistory
  240. subscription.usageExceeded;
  241. const projectedOverages = getProjectedOverages();
  242. const hasOverage =
  243. subscription.isGracePeriod || hasExceeded || !!projectedOverages.length;
  244. // if no overage, we can still have a CTA
  245. if (!hasOverage) {
  246. return renderDefaultEventCTA();
  247. }
  248. const showProjected = !hasExceeded && !subscription.isGracePeriod;
  249. return (
  250. <div data-test-id="usage-alert">
  251. {hasExceeded && renderExceededInfo()}
  252. {subscription.isGracePeriod && renderGracePeriodInfo()}
  253. {showProjected && renderProjectedInfo(projectedOverages)}
  254. </div>
  255. );
  256. }
  257. export default withOrganization(UsageAlert);
  258. const UsageInfo = styled('div')`
  259. display: grid;
  260. grid-template-columns: max-content auto;
  261. gap: ${space(1)};
  262. `;
  263. const Description = styled(TextBlock)`
  264. font-size: ${p => p.theme.fontSizeMedium};
  265. color: ${p => p.theme.subText};
  266. margin-bottom: 0;
  267. `;