usageTotals.tsx 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830
  1. import {Fragment, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import colorFn from 'color';
  4. import {Button} from 'sentry/components/button';
  5. import Card from 'sentry/components/card';
  6. import {CHART_PALETTE} from 'sentry/constants/chartPalette';
  7. import {IconChevron} from 'sentry/icons';
  8. import {t, tct} from 'sentry/locale';
  9. import {space} from 'sentry/styles/space';
  10. import {DataCategory} from 'sentry/types/core';
  11. import type {Organization} from 'sentry/types/organization';
  12. import getDaysSinceDate from 'sentry/utils/getDaysSinceDate';
  13. import {formatPercentage} from 'sentry/utils/number/formatPercentage';
  14. import ProductTrialTag from 'getsentry/components/productTrial/productTrialTag';
  15. import StartTrialButton from 'getsentry/components/startTrialButton';
  16. import {GIGABYTE, RESERVED_BUDGET_QUOTA, UNLIMITED} from 'getsentry/constants';
  17. import {
  18. type BillingMetricHistory,
  19. type BillingStatTotal,
  20. type EventBucket,
  21. PlanTier,
  22. type ProductTrial,
  23. type Subscription,
  24. } from 'getsentry/types';
  25. import {
  26. formatReservedWithUnits,
  27. formatUsageWithUnits,
  28. getActiveProductTrial,
  29. getPotentialProductTrial,
  30. isUnlimitedReserved,
  31. MILLISECONDS_IN_HOUR,
  32. } from 'getsentry/utils/billing';
  33. import {getPlanCategoryName} from 'getsentry/utils/dataCategory';
  34. import formatCurrency from 'getsentry/utils/formatCurrency';
  35. import {roundUpToNearestDollar} from 'getsentry/utils/roundUpToNearestDollar';
  36. import titleCase from 'getsentry/utils/titleCase';
  37. import {getBucket} from 'getsentry/views/amCheckout/utils';
  38. import {
  39. getOnDemandBudget,
  40. hasOnDemandBudgetsFeature,
  41. parseOnDemandBudgetsFromSubscription,
  42. } from 'getsentry/views/onDemandBudgets/utils';
  43. import UsageTotalsTable from 'getsentry/views/subscriptionPage/usageTotalsTable';
  44. import {
  45. calculateCategorySpend,
  46. calculateTotalSpend,
  47. } from 'getsentry/views/subscriptionPage/utils';
  48. const EMPTY_STAT_TOTAL = {
  49. accepted: 0,
  50. dropped: 0,
  51. droppedOther: 0,
  52. droppedOverQuota: 0,
  53. droppedSpikeProtection: 0,
  54. filtered: 0,
  55. projected: 0,
  56. };
  57. const COLORS = {
  58. reserved: CHART_PALETTE[5]![0]!,
  59. ondemand: CHART_PALETTE[5]![1]!,
  60. } as const;
  61. function getPercentage(quantity: number, total: number | null) {
  62. if (typeof total === 'number' && total > 0) {
  63. return (Math.min(quantity, total) / total) * 100;
  64. }
  65. return 0;
  66. }
  67. export function displayPercentage(quantity: number, total: number | null) {
  68. const percentage = getPercentage(quantity, total);
  69. return percentage.toFixed(0) + '%';
  70. }
  71. type UsageProps = {
  72. /**
  73. * The data category to display
  74. */
  75. category: string;
  76. displayMode: 'usage' | 'cost';
  77. organization: Organization;
  78. subscription: Subscription;
  79. /**
  80. * Do not allow the table to be expansded
  81. */
  82. disableTable?: boolean;
  83. /**
  84. * Event breakdown totals
  85. */
  86. eventTotals?: {[key: string]: BillingStatTotal};
  87. /**
  88. * Gifted budget for the current billing period.
  89. */
  90. freeBudget?: number | null;
  91. /**
  92. * Gifted events for the current billing period.
  93. */
  94. freeUnits?: number;
  95. /**
  96. * The prepaid budget (reserved + gifted) if any
  97. */
  98. prepaidBudget?: number | null;
  99. /**
  100. * Total events allowed for the current usage period including gifted
  101. */
  102. prepaidUnits?: number;
  103. /**
  104. * The reserved budget if any
  105. */
  106. reservedBudget?: number | null;
  107. /**
  108. * The reserved spend if any
  109. */
  110. reservedSpend?: number | null;
  111. /**
  112. * The reserved amount or null if the account doesn't have this category.
  113. */
  114. reservedUnits?: number | null;
  115. /**
  116. * Show event breakdown
  117. */
  118. showEventBreakdown?: boolean;
  119. /**
  120. * If soft cap is enabled, the type of soft cap in use: true forward or on-demand
  121. */
  122. softCapType?: 'ON_DEMAND' | 'TRUE_FORWARD' | null;
  123. /**
  124. * Usage totals.
  125. */
  126. totals?: BillingStatTotal;
  127. /**
  128. * Whether this category has True Forward
  129. */
  130. trueForward?: boolean;
  131. };
  132. type State = {expanded: boolean; trialButtonBusy: boolean};
  133. /**
  134. * Calculates usage metrics for a subscription category's prepaid (reserved) events.
  135. *
  136. * @param category - The data category to calculate usage for (e.g. 'errors', 'transactions')
  137. * @param subscription - The subscription object containing plan and usage details
  138. * @param totals - Object containing the accepted event count for this category
  139. * @param prepaid - The prepaid/reserved event limit (volume-based reserved) or commited spend (budget-based reserved) for this category
  140. * @param reservedCpe - The reserved cost-per-event for this category (for reserved budget categories), in cents
  141. * @param reservedSpend - The reserved spend for this category (for reserved budget categories). If provided, calculations with `totals` and `reservedCpe` are overriden to use the number provided for `prepaidSpend`
  142. *
  143. * @returns Object containing:
  144. * - onDemandUsage: Number of events that exceeded the prepaid limit and went to on-demand
  145. * - prepaidPercentUsed: Percentage of prepaid limit used (0-100)
  146. * - prepaidPrice: Monthly cost of the prepaid events (reserved budget if it is a reserved budget category)
  147. * - prepaidSpend: Cost of prepaid events used so far this period
  148. * - prepaidUsage: Number of events used within prepaid limit
  149. */
  150. export function calculateCategoryPrepaidUsage(
  151. category: string,
  152. subscription: Subscription,
  153. totals: Pick<BillingStatTotal, 'accepted'>,
  154. prepaid: number,
  155. reservedCpe?: number | null,
  156. reservedSpend?: number | null
  157. ): {
  158. onDemandUsage: number;
  159. prepaidPercentUsed: number;
  160. prepaidPrice: number;
  161. /**
  162. * Total category spend this period
  163. */
  164. prepaidSpend: number;
  165. prepaidUsage: number;
  166. } {
  167. // Calculate the prepaid total
  168. let prepaidTotal: any;
  169. if (isUnlimitedReserved(prepaid)) {
  170. prepaidTotal = prepaid;
  171. } else {
  172. // Convert prepaid limits to the appropriate unit based on category
  173. switch (category) {
  174. case DataCategory.ATTACHMENTS:
  175. prepaidTotal = prepaid * GIGABYTE;
  176. break;
  177. case DataCategory.PROFILE_DURATION:
  178. prepaidTotal = prepaid * MILLISECONDS_IN_HOUR;
  179. break;
  180. default:
  181. prepaidTotal = prepaid;
  182. }
  183. }
  184. const hasReservedBudget = reservedCpe || typeof reservedSpend === 'number'; // reservedSpend can be 0
  185. const prepaidUsed = hasReservedBudget
  186. ? (reservedSpend ?? totals.accepted * (reservedCpe ?? 0))
  187. : totals.accepted;
  188. const prepaidPercentUsed = getPercentage(prepaidUsed, prepaidTotal);
  189. // Calculate the prepaid price
  190. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  191. const categoryInfo: BillingMetricHistory = subscription.categories[category];
  192. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  193. const slots: EventBucket[] = subscription.planDetails.planCategories[category];
  194. // If the category billing info is not in the subscription, return 0 for all values
  195. // This seems to happen sometimes on partner accounts
  196. if (!categoryInfo || !slots) {
  197. return {
  198. prepaidPrice: 0,
  199. prepaidSpend: 0,
  200. prepaidPercentUsed: 0,
  201. onDemandUsage: 0,
  202. prepaidUsage: 0,
  203. };
  204. }
  205. // Get the price bucket for the reserved event amount
  206. const prepaidPriceBucket = getBucket({events: categoryInfo.reserved!, buckets: slots});
  207. // Convert annual prices to monthly if needed
  208. const isMonthly = subscription.planDetails.billingInterval === 'monthly';
  209. // This will be 0 when they are using the included amount
  210. const prepaidPrice = hasReservedBudget
  211. ? prepaid
  212. : (prepaidPriceBucket.price ?? 0) / (isMonthly ? 1 : 12);
  213. // Calculate spend based on percentage used
  214. const prepaidSpend = (prepaidPercentUsed / 100) * prepaidPrice;
  215. // Round the usage width to avoid half pixel artifacts
  216. const prepaidPercentUsedRounded = Math.round(prepaidPercentUsed);
  217. // Calculate on-demand usage if we've exceeded prepaid limit
  218. // No on-demand usage for unlimited reserved
  219. const onDemandUsage =
  220. (prepaidUsed > prepaidTotal && !isUnlimitedReserved(prepaidTotal)) ||
  221. (hasReservedBudget && prepaidUsed >= prepaidTotal)
  222. ? categoryInfo.onDemandQuantity
  223. : 0;
  224. const prepaidUsage = totals.accepted - onDemandUsage;
  225. return {
  226. prepaidPrice,
  227. prepaidSpend,
  228. prepaidPercentUsed: prepaidPercentUsedRounded,
  229. onDemandUsage,
  230. prepaidUsage,
  231. };
  232. }
  233. export function calculateCategoryOnDemandUsage(
  234. category: string,
  235. subscription: Subscription
  236. ): {
  237. /**
  238. * The maximum amount of on demand spend allowed for this category
  239. * This can be shared across all categories or specific to this category.
  240. * Other categories may have spent some of this budget making less avilable for this category.
  241. */
  242. onDemandCategoryMax: number;
  243. onDemandCategorySpend: number;
  244. /**
  245. * Will be the total on demand spend available for all categories if shared
  246. * or the total available for this category if not shared.
  247. */
  248. onDemandTotalAvailable: number;
  249. ondemandPercentUsed: number;
  250. } {
  251. const onDemandBudgets = parseOnDemandBudgetsFromSubscription(subscription);
  252. const isSharedOnDemand = 'sharedMaxBudget' in onDemandBudgets;
  253. const onDemandTotalAvailable = isSharedOnDemand
  254. ? onDemandBudgets.sharedMaxBudget
  255. : getOnDemandBudget(onDemandBudgets, category as DataCategory);
  256. const {onDemandTotalSpent} = calculateTotalSpend(subscription);
  257. const {onDemandSpent: onDemandCategorySpend} = calculateCategorySpend(
  258. subscription,
  259. category
  260. );
  261. const onDemandCategoryMax = isSharedOnDemand
  262. ? // Subtract other category spend from shared on demand budget
  263. onDemandTotalAvailable - onDemandTotalSpent + onDemandCategorySpend
  264. : onDemandTotalAvailable;
  265. // Round the usage width to avoid half pixel artifacts
  266. const ondemandPercentUsed = Math.round(
  267. getPercentage(onDemandCategorySpend, onDemandCategoryMax)
  268. );
  269. return {
  270. onDemandTotalAvailable,
  271. onDemandCategorySpend,
  272. onDemandCategoryMax,
  273. ondemandPercentUsed,
  274. };
  275. }
  276. function ReservedUsage({
  277. prepaidUsage,
  278. reserved,
  279. category,
  280. productTrial,
  281. }: {
  282. category: string;
  283. prepaidUsage: number;
  284. productTrial: ProductTrial | null;
  285. reserved: number | null;
  286. }) {
  287. const reservedOptions = {
  288. isAbbreviated: category !== DataCategory.ATTACHMENTS,
  289. };
  290. return (
  291. <Fragment>
  292. {formatUsageWithUnits(prepaidUsage, category, {
  293. isAbbreviated: true,
  294. })}{' '}
  295. of{' '}
  296. {productTrial?.isStarted && getDaysSinceDate(productTrial.endDate ?? '') <= 0
  297. ? UNLIMITED
  298. : formatReservedWithUnits(reserved, category, reservedOptions)}
  299. </Fragment>
  300. );
  301. }
  302. function UsageTotals({
  303. category,
  304. subscription,
  305. organization,
  306. freeUnits = 0,
  307. prepaidUnits = 0,
  308. reservedUnits = null,
  309. freeBudget = null,
  310. prepaidBudget = null,
  311. reservedBudget = null,
  312. reservedSpend = null,
  313. softCapType = null,
  314. totals = EMPTY_STAT_TOTAL,
  315. eventTotals = {},
  316. trueForward = false,
  317. showEventBreakdown = false,
  318. disableTable,
  319. displayMode,
  320. }: UsageProps) {
  321. const [state, setState] = useState<State>({expanded: false, trialButtonBusy: false});
  322. const usageOptions = {useUnitScaling: true};
  323. const reservedOptions = {
  324. isAbbreviated: category !== DataCategory.ATTACHMENTS,
  325. };
  326. const hasReservedBudget = reservedUnits === RESERVED_BUDGET_QUOTA;
  327. const free = hasReservedBudget ? freeBudget : freeUnits;
  328. const reserved = hasReservedBudget ? reservedBudget : reservedUnits;
  329. const prepaid = hasReservedBudget ? (prepaidBudget ?? 0) : prepaidUnits;
  330. const displayGifts = (free || freeBudget) && !isUnlimitedReserved(reservedUnits);
  331. const reservedTestId = displayGifts ? `gifted-${category}` : `reserved-${category}`;
  332. const hasOnDemand =
  333. hasOnDemandBudgetsFeature(organization, subscription) ||
  334. subscription.planTier === PlanTier.AM3;
  335. const onDemandBudgets = parseOnDemandBudgetsFromSubscription(subscription);
  336. const totalMaxOndemandBudget =
  337. 'sharedMaxBudget' in onDemandBudgets
  338. ? onDemandBudgets.sharedMaxBudget
  339. : getOnDemandBudget(onDemandBudgets, category as DataCategory);
  340. const {onDemandSpent: categoryOnDemandSpent, onDemandUnitPrice} =
  341. calculateCategorySpend(subscription, category);
  342. function getReservedInfo() {
  343. let reservedInfo = tct('[reserved] Reserved', {
  344. reserved: formatReservedWithUnits(
  345. reserved,
  346. category,
  347. reservedOptions,
  348. hasReservedBudget
  349. ),
  350. });
  351. if (softCapType) {
  352. const softCapName = titleCase(softCapType.replace(/_/g, ' '));
  353. reservedInfo = tct('[reservedInfo] ([softCapName])', {reservedInfo, softCapName});
  354. }
  355. // Fallback if softCapType was not set but True Forward is
  356. else if (trueForward) {
  357. reservedInfo = tct('[reservedInfo] (True Forward)', {reservedInfo});
  358. }
  359. if (displayGifts) {
  360. reservedInfo = tct('[reservedInfo] + [giftedAmount] Gifted', {
  361. reservedInfo,
  362. giftedAmount: formatReservedWithUnits(
  363. free,
  364. category,
  365. reservedOptions,
  366. hasReservedBudget
  367. ),
  368. });
  369. }
  370. return reservedInfo;
  371. }
  372. const productTrial =
  373. getActiveProductTrial(subscription.productTrials ?? null, category as DataCategory) ??
  374. getPotentialProductTrial(
  375. subscription.productTrials ?? null,
  376. category as DataCategory
  377. );
  378. const {
  379. ondemandPercentUsed,
  380. onDemandTotalAvailable,
  381. onDemandCategorySpend,
  382. onDemandCategoryMax,
  383. } = calculateCategoryOnDemandUsage(category, subscription);
  384. const unusedOnDemandWidth = 100 - ondemandPercentUsed;
  385. const {prepaidPrice, prepaidPercentUsed, prepaidUsage, onDemandUsage} =
  386. calculateCategoryPrepaidUsage(
  387. category,
  388. subscription,
  389. totals,
  390. prepaid,
  391. undefined,
  392. reservedSpend
  393. );
  394. const unusedPrepaidWidth =
  395. reserved !== 0 || subscription.isTrial ? 100 - prepaidPercentUsed : 0;
  396. const totalCategorySpend =
  397. (hasReservedBudget ? (reservedSpend ?? 0) : prepaidPrice) + categoryOnDemandSpent;
  398. // Shared on demand spend is gone, another category has spent all of it
  399. // It is confusing to show on demand spend when the category did not spend any and the budget is gone
  400. const onDemandIsGoneAndCategorySpentNone =
  401. 'sharedMaxBudget' in onDemandBudgets &&
  402. categoryOnDemandSpent === 0 &&
  403. onDemandCategoryMax === 0;
  404. // Don't show on demand when:
  405. // - There is none left to spend and this category spent 0
  406. // - There is no on demand budget for this category
  407. // - There is no on demand budget at all
  408. const showOnDemand =
  409. !onDemandIsGoneAndCategorySpentNone && hasOnDemand && totalMaxOndemandBudget !== 0;
  410. const isDisplayingSpend = displayMode === 'cost' || hasReservedBudget; // always display as spend for reserved budgets
  411. // Calculate the width of the reserved bar relative to on demand
  412. let reservedMaxWidth = showOnDemand ? (reserved !== 0 ? 50 : 0) : 100;
  413. if (showOnDemand && reserved && onDemandUnitPrice) {
  414. const onDemandTotalUnitsAvailable = onDemandCategoryMax / onDemandUnitPrice;
  415. reservedMaxWidth =
  416. showOnDemand && reserved
  417. ? (reserved / (reserved + onDemandTotalUnitsAvailable)) * 100
  418. : 100;
  419. }
  420. function getTitle(): React.ReactNode {
  421. if (productTrial?.isStarted) {
  422. return t('trial usage this period');
  423. }
  424. if (isDisplayingSpend) {
  425. return t('spend this period');
  426. }
  427. return t('usage this period');
  428. }
  429. const formattedUnitsUsed = formatUsageWithUnits(
  430. totals.accepted,
  431. category,
  432. usageOptions
  433. );
  434. return (
  435. <SubscriptionCard>
  436. <CardBody>
  437. <UsageProgress>
  438. <BaseRow>
  439. <div>
  440. <UsageSummaryTitle>
  441. {getPlanCategoryName({
  442. plan: subscription.planDetails,
  443. category,
  444. hadCustomDynamicSampling: subscription.hadCustomDynamicSampling,
  445. })}{' '}
  446. {getTitle()}
  447. {productTrial && (
  448. <MarginSpan>
  449. <ProductTrialTag trial={productTrial} />
  450. </MarginSpan>
  451. )}
  452. </UsageSummaryTitle>
  453. <SubText data-test-id={reservedTestId}>
  454. {productTrial?.isStarted &&
  455. getDaysSinceDate(productTrial.endDate ?? '') <= 0
  456. ? UNLIMITED
  457. : getReservedInfo()}
  458. </SubText>
  459. </div>
  460. <AcceptedSummary>
  461. {productTrial && !productTrial.isStarted && (
  462. <MarginSpan>
  463. <StartTrialButton
  464. organization={organization}
  465. source="usage-product-trials"
  466. requestData={{
  467. productTrial: {
  468. category,
  469. reasonCode: productTrial.reasonCode,
  470. },
  471. }}
  472. aria-label={t('Start trial')}
  473. priority="primary"
  474. handleClick={() => {
  475. setState({...state, trialButtonBusy: true});
  476. }}
  477. onTrialStarted={() => {
  478. setState({...state, trialButtonBusy: true});
  479. }}
  480. onTrialFailed={() => {
  481. setState({...state, trialButtonBusy: false});
  482. }}
  483. busy={state.trialButtonBusy}
  484. disabled={state.trialButtonBusy}
  485. />
  486. </MarginSpan>
  487. )}
  488. {!disableTable && (
  489. <Button
  490. data-test-id="expand-usage-totals"
  491. size="sm"
  492. onClick={() => setState({...state, expanded: !state.expanded})}
  493. icon={<IconChevron direction={state.expanded ? 'up' : 'down'} />}
  494. aria-label={t('Expand usage totals')}
  495. />
  496. )}
  497. </AcceptedSummary>
  498. </BaseRow>
  499. <PlanUseBarContainer>
  500. <PlanUseBarGroup style={{width: `${reservedMaxWidth}%`}}>
  501. {prepaidPercentUsed >= 1 && (
  502. <PlanUseBar
  503. style={{
  504. width: `${prepaidPercentUsed}%`,
  505. backgroundColor: COLORS.reserved,
  506. }}
  507. />
  508. )}
  509. {unusedPrepaidWidth >= 1 && (
  510. <PlanUseBar
  511. style={{
  512. width: `${unusedPrepaidWidth}%`,
  513. backgroundColor: colorFn(COLORS.reserved).fade(0.5).string(),
  514. }}
  515. />
  516. )}
  517. </PlanUseBarGroup>
  518. {showOnDemand && (
  519. <PlanUseBarGroup style={{width: `${100 - reservedMaxWidth}%`}}>
  520. {ondemandPercentUsed >= 1 && (
  521. <PlanUseBar
  522. style={{
  523. width: `${ondemandPercentUsed}%`,
  524. backgroundColor: COLORS.ondemand,
  525. }}
  526. />
  527. )}
  528. {unusedOnDemandWidth >= 1 && (
  529. <PlanUseBar
  530. style={{
  531. width: `${unusedOnDemandWidth}%`,
  532. backgroundColor: colorFn(COLORS.ondemand).fade(0.5).string(),
  533. }}
  534. />
  535. )}
  536. </PlanUseBarGroup>
  537. )}
  538. </PlanUseBarContainer>
  539. <LegendFooterWrapper>
  540. <LegendPriceWrapper>
  541. <LegendContainer>
  542. <LegendDot style={{backgroundColor: COLORS.reserved}} />
  543. {isDisplayingSpend ? (
  544. prepaidPrice === 0 ? (
  545. // No reserved price, included in plan
  546. <div>
  547. <LegendTitle>{t('Included in Subscription')}</LegendTitle>
  548. <LegendPriceSubText>
  549. <ReservedUsage
  550. prepaidUsage={prepaidUsage}
  551. reserved={reserved}
  552. category={category}
  553. productTrial={productTrial}
  554. />
  555. </LegendPriceSubText>
  556. </div>
  557. ) : (
  558. <div>
  559. <LegendTitle>{t('Included in Subscription')}</LegendTitle>
  560. <LegendPrice>
  561. {formatPercentage(prepaidPercentUsed / 100)} of{' '}
  562. {prepaidPrice === 0
  563. ? reserved
  564. : formatCurrency(roundUpToNearestDollar(prepaidPrice))}
  565. </LegendPrice>
  566. </div>
  567. )
  568. ) : (
  569. <div>
  570. <LegendTitle>{t('Included in Subscription')}</LegendTitle>
  571. <LegendPrice>
  572. <ReservedUsage
  573. prepaidUsage={prepaidUsage}
  574. reserved={reserved}
  575. category={category}
  576. productTrial={productTrial}
  577. />
  578. </LegendPrice>
  579. </div>
  580. )}
  581. </LegendContainer>
  582. {showOnDemand && (
  583. <LegendContainer>
  584. <LegendDot style={{backgroundColor: COLORS.ondemand}} />
  585. {isDisplayingSpend ? (
  586. <div>
  587. <LegendTitle>
  588. {subscription.planTier === PlanTier.AM3
  589. ? t('Pay-as-you-go')
  590. : t('On-Demand')}
  591. </LegendTitle>
  592. <LegendPrice>
  593. {formatCurrency(onDemandCategorySpend)} of{' '}
  594. {formatCurrency(onDemandCategoryMax)}{' '}
  595. {/* Shared on demand was used in another category, display the max */}
  596. {onDemandTotalAvailable !== onDemandCategoryMax && (
  597. <Fragment>
  598. ({formatCurrency(onDemandTotalAvailable)} max)
  599. </Fragment>
  600. )}
  601. </LegendPrice>
  602. </div>
  603. ) : (
  604. <div>
  605. <LegendTitle>
  606. {subscription.planTier === PlanTier.AM3
  607. ? t('Pay-as-you-go')
  608. : t('On-Demand')}
  609. </LegendTitle>
  610. <LegendPrice>
  611. {formatUsageWithUnits(onDemandUsage, category, usageOptions)}
  612. </LegendPrice>
  613. </div>
  614. )}
  615. </LegendContainer>
  616. )}
  617. </LegendPriceWrapper>
  618. {isDisplayingSpend ? (
  619. <TotalSpendWrapper>
  620. <UsageSummaryTitle>
  621. {formatCurrency(totalCategorySpend)}
  622. </UsageSummaryTitle>
  623. <TotalSpendLabel>
  624. {prepaidPrice !== 0 && (
  625. <Fragment>
  626. {formatCurrency(prepaidPrice)} {t('Included in Subscription')}
  627. </Fragment>
  628. )}
  629. {prepaidPrice !== 0 && showOnDemand && <Fragment> + </Fragment>}
  630. {showOnDemand && (
  631. <Fragment>
  632. {formatCurrency(onDemandCategorySpend)}{' '}
  633. {subscription.planTier === PlanTier.AM3
  634. ? t('Pay-as-you-go')
  635. : t('On-Demand')}
  636. </Fragment>
  637. )}
  638. </TotalSpendLabel>
  639. </TotalSpendWrapper>
  640. ) : (
  641. <TotalSpendWrapper>
  642. <UsageSummaryTitle>{formattedUnitsUsed}</UsageSummaryTitle>
  643. <TotalSpendLabel>{t('Total Usage')}</TotalSpendLabel>
  644. </TotalSpendWrapper>
  645. )}
  646. </LegendFooterWrapper>
  647. </UsageProgress>
  648. </CardBody>
  649. {state.expanded && !disableTable && (
  650. <Fragment>
  651. <UsageTotalsTable
  652. category={category}
  653. totals={totals}
  654. subscription={subscription}
  655. />
  656. {showEventBreakdown &&
  657. Object.entries(eventTotals).map(([key, eventTotal]) => {
  658. return (
  659. <UsageTotalsTable
  660. isEventBreakdown
  661. key={key}
  662. category={key}
  663. totals={eventTotal}
  664. subscription={subscription}
  665. />
  666. );
  667. })}
  668. </Fragment>
  669. )}
  670. </SubscriptionCard>
  671. );
  672. }
  673. export default UsageTotals;
  674. const SubscriptionCard = styled(Card)`
  675. padding: ${space(2)};
  676. `;
  677. const CardBody = styled('div')`
  678. display: grid;
  679. align-items: center;
  680. gap: ${space(2)};
  681. `;
  682. const UsageSummaryTitle = styled('h4')`
  683. font-size: ${p => p.theme.fontSizeLarge};
  684. margin-bottom: 0px;
  685. font-weight: 400;
  686. @media (min-width: ${p => p.theme.breakpoints.small}) {
  687. font-size: ${p => p.theme.fontSizeExtraLarge};
  688. }
  689. `;
  690. const UsageProgress = styled('div')`
  691. display: grid;
  692. grid-auto-rows: auto;
  693. gap: ${space(1)};
  694. `;
  695. const BaseRow = styled('div')`
  696. display: grid;
  697. grid-template-columns: repeat(2, auto);
  698. justify-content: space-between;
  699. align-items: center;
  700. gap: ${space(1)};
  701. `;
  702. const SubText = styled('span')`
  703. color: ${p => p.theme.chartLabel};
  704. font-size: ${p => p.theme.fontSizeMedium};
  705. `;
  706. const AcceptedSummary = styled('div')`
  707. display: flex;
  708. align-items: center;
  709. gap: ${space(1)};
  710. `;
  711. const MarginSpan = styled('span')`
  712. margin-left: ${space(0.5)};
  713. margin-right: ${space(1)};
  714. `;
  715. const TotalSpendWrapper = styled('div')`
  716. text-align: right;
  717. `;
  718. const TotalSpendLabel = styled('div')`
  719. color: ${p => p.theme.subText};
  720. `;
  721. const LegendFooterWrapper = styled('div')`
  722. display: flex;
  723. justify-content: space-between;
  724. align-items: center;
  725. gap: ${space(1)};
  726. `;
  727. const LegendPriceWrapper = styled('div')`
  728. display: flex;
  729. gap: ${space(1)};
  730. `;
  731. const LegendDot = styled('div')`
  732. border-radius: 100%;
  733. width: 10px;
  734. height: 10px;
  735. `;
  736. const LegendContainer = styled('div')`
  737. display: grid;
  738. grid-template-columns: min-content 1fr;
  739. gap: ${space(1)};
  740. align-items: baseline;
  741. `;
  742. const LegendTitle = styled('div')`
  743. font-weight: 700;
  744. font-size: ${p => p.theme.fontSizeSmall};
  745. `;
  746. const LegendPrice = styled('div')`
  747. font-size: ${p => p.theme.fontSizeSmall};
  748. `;
  749. const LegendPriceSubText = styled(LegendPrice)`
  750. color: ${p => p.theme.subText};
  751. `;
  752. const PlanUseBarContainer = styled('div')`
  753. display: flex;
  754. height: 16px;
  755. width: 100%;
  756. overflow: hidden;
  757. gap: 2px;
  758. `;
  759. const PlanUseBarGroup = styled('div')`
  760. display: flex;
  761. gap: 2px;
  762. `;
  763. const PlanUseBar = styled('div')`
  764. height: 100%;
  765. `;