utils.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619
  1. import * as Sentry from '@sentry/react';
  2. import moment from 'moment-timezone';
  3. import {
  4. addErrorMessage,
  5. addLoadingMessage,
  6. addSuccessMessage,
  7. } from 'sentry/actionCreators/indicator';
  8. import {fetchOrganizationDetails} from 'sentry/actionCreators/organization';
  9. import {Client} from 'sentry/api';
  10. import {t} from 'sentry/locale';
  11. import {DataCategory} from 'sentry/types/core';
  12. import type {Organization} from 'sentry/types/organization';
  13. import {browserHistory} from 'sentry/utils/browserHistory';
  14. import normalizeUrl from 'sentry/utils/url/normalizeUrl';
  15. import {DEFAULT_TIER, MONTHLY, SUPPORTED_TIERS} from 'getsentry/constants';
  16. import SubscriptionStore from 'getsentry/stores/subscriptionStore';
  17. import type {
  18. DataCategories,
  19. EventBucket,
  20. OnDemandBudgets,
  21. Plan,
  22. PlanTier,
  23. PreviewData,
  24. Subscription,
  25. } from 'getsentry/types';
  26. import {getSlot} from 'getsentry/utils/billing';
  27. import trackGetsentryAnalytics from 'getsentry/utils/trackGetsentryAnalytics';
  28. import trackMarketingEvent from 'getsentry/utils/trackMarketingEvent';
  29. import type {CheckoutAPIData, CheckoutFormData} from 'getsentry/views/amCheckout/types';
  30. import {
  31. normalizeOnDemandBudget,
  32. parseOnDemandBudgetsFromSubscription,
  33. trackOnDemandBudgetAnalytics,
  34. } from 'getsentry/views/onDemandBudgets/utils';
  35. import {bigNumFormatter} from 'getsentry/views/spendAllocations/utils';
  36. const CURRENCY_LOCALE = 'en-US';
  37. /**
  38. * Includes $, and cents in the price when needed.
  39. *
  40. * 100.01 => $100.01
  41. * 100.00 => $100
  42. * 100.30 => $100.30
  43. * -100 => -$100
  44. */
  45. type DisplayPriceTypes = {
  46. cents: number;
  47. formatBigNum?: boolean;
  48. };
  49. // Intent details returned by CustomerSubscriptionEndpoint
  50. // when there is an error and customer card actions are
  51. // required.
  52. export type IntentDetails = {
  53. paymentIntent: string;
  54. paymentSecret: string;
  55. };
  56. type APIDataProps = {
  57. formData: CheckoutFormData;
  58. isPreview?: boolean;
  59. onDemandBudget?: OnDemandBudgets;
  60. paymentIntent?: string;
  61. previewToken?: PreviewData['previewToken'];
  62. referrer?: string;
  63. shouldUpdateOnDemand?: boolean;
  64. };
  65. export function displayPrice({cents, formatBigNum = false}: DisplayPriceTypes): string {
  66. const dollars = cents / 100;
  67. const prefix = dollars >= 0 ? '$' : '-$';
  68. if (formatBigNum) {
  69. return prefix + bigNumFormatter(Math.abs(dollars));
  70. }
  71. const hasCents = dollars % 1 !== 0;
  72. if (hasCents) {
  73. return displayPriceWithCents({cents});
  74. }
  75. return prefix + Math.abs(dollars).toLocaleString(CURRENCY_LOCALE);
  76. }
  77. /**
  78. * Always include $ and cents in the price.
  79. *
  80. * $100.01 => $100.01
  81. * $100.00 => $100.00
  82. * $100.30 => $100.30
  83. * -100 => -$100.00
  84. */
  85. export function displayPriceWithCents({
  86. cents,
  87. minimumFractionDigits,
  88. maximumFractionDigits,
  89. }: {
  90. cents: number;
  91. maximumFractionDigits?: number;
  92. minimumFractionDigits?: number;
  93. }): string {
  94. const dollars = cents / 100;
  95. const prefix = dollars >= 0 ? '$' : '-$';
  96. return (
  97. prefix +
  98. Math.abs(dollars).toLocaleString(CURRENCY_LOCALE, {
  99. minimumFractionDigits: minimumFractionDigits ?? 2,
  100. maximumFractionDigits: maximumFractionDigits ?? 2,
  101. })
  102. );
  103. }
  104. type UnitPriceProps = {
  105. cents: number;
  106. maxDigits?: number;
  107. minDigits?: number;
  108. };
  109. /**
  110. * Includes cents in the price when needed and excludes $ for separate formatting.
  111. * Note: Use `displayPrice` if prices can be negative (ex: credits)
  112. *
  113. * 100.01 => 100.01
  114. * 100.00 => 100
  115. * 100.30 => 100.30
  116. * -100 => -100
  117. */
  118. export function formatPrice({cents}: {cents: number}): string {
  119. return displayPrice({cents}).replace('$', '');
  120. }
  121. /**
  122. * Format per unit price for events,
  123. * where errors and transactions default to 5 digits
  124. * and attachments should use 2 digits.
  125. */
  126. export function displayUnitPrice({
  127. cents,
  128. minDigits = 5,
  129. maxDigits = 7,
  130. }: UnitPriceProps): string {
  131. const dollars = cents / 100;
  132. return (
  133. '$' +
  134. dollars.toLocaleString(CURRENCY_LOCALE, {
  135. minimumFractionDigits: minDigits,
  136. maximumFractionDigits: maxDigits,
  137. })
  138. );
  139. }
  140. export function getBucket({
  141. buckets,
  142. events,
  143. price,
  144. shouldMinimize = false,
  145. }: {
  146. buckets?: EventBucket[];
  147. events?: number;
  148. price?: number;
  149. shouldMinimize?: boolean; // the slot strategy when `events` does not exist in `buckets`
  150. }): EventBucket {
  151. if (buckets) {
  152. const slot = getSlot(events, price, buckets, shouldMinimize);
  153. if (slot in buckets) {
  154. return buckets[slot]!;
  155. }
  156. }
  157. throw new Error('Invalid data category for plan');
  158. }
  159. type ReservedTotalProps = {
  160. plan: Plan;
  161. reserved: {
  162. [categoryKey in DataCategory]?: number;
  163. };
  164. amount?: number;
  165. creditCategory?: string;
  166. discountType?: string;
  167. maxDiscount?: number;
  168. };
  169. /**
  170. * Returns the total plan price (including prices for reserved categories) in cents.
  171. */
  172. export function getReservedPriceCents({
  173. plan,
  174. reserved,
  175. amount,
  176. discountType,
  177. maxDiscount,
  178. creditCategory,
  179. }: ReservedTotalProps): number {
  180. let reservedCents = plan.basePrice;
  181. if (amount && discountType && creditCategory) {
  182. reservedCents = getDiscountedPrice({
  183. basePrice: reservedCents,
  184. amount,
  185. discountType,
  186. creditCategory,
  187. });
  188. }
  189. Object.entries(reserved).forEach(
  190. ([category, quantity]) =>
  191. (reservedCents += getBucket({
  192. events: quantity,
  193. buckets: plan.planCategories[category as DataCategories],
  194. }).price)
  195. );
  196. if (amount && maxDiscount) {
  197. const discount = Math.min(maxDiscount, (reservedCents * amount) / 10000);
  198. reservedCents -= discount;
  199. }
  200. return reservedCents;
  201. }
  202. /**
  203. * Gets the price in cents per reserved category, and returns the
  204. * reserved total in dollars.
  205. */
  206. export function getReservedTotal({
  207. plan,
  208. reserved,
  209. amount,
  210. discountType,
  211. maxDiscount,
  212. creditCategory,
  213. }: ReservedTotalProps): string {
  214. return formatPrice({
  215. cents: getReservedPriceCents({
  216. plan,
  217. reserved,
  218. amount,
  219. discountType,
  220. maxDiscount,
  221. creditCategory,
  222. }),
  223. });
  224. }
  225. type DiscountedPriceProps = {
  226. amount: number;
  227. basePrice: number;
  228. creditCategory: string;
  229. discountType: string;
  230. };
  231. /**
  232. * Gets the price in cents after the discount is applied.
  233. */
  234. export function getDiscountedPrice({
  235. basePrice,
  236. discountType,
  237. amount,
  238. creditCategory,
  239. }: DiscountedPriceProps): number {
  240. let price = basePrice;
  241. if (discountType === 'percentPoints' && creditCategory === 'subscription') {
  242. const discount = (basePrice * amount) / 10000;
  243. price = basePrice - discount;
  244. } else if (discountType === 'amountCents') {
  245. price = basePrice - amount;
  246. }
  247. return price;
  248. }
  249. /**
  250. * Returns the short billing interval name.
  251. */
  252. export function getShortInterval(billingInterval: string): string {
  253. return billingInterval === MONTHLY ? 'mo' : 'yr';
  254. }
  255. function getAttachmentsWithUnit(gigabytes: number): string {
  256. return `${gigabytes.toLocaleString()} GB`;
  257. }
  258. /**
  259. * Used by RangeSlider. As such, a value of zero is not equivalent to unlimited.
  260. */
  261. export function getEventsWithUnit(
  262. events: number,
  263. dataType: string
  264. ): string | number | null {
  265. if (!events) {
  266. return null;
  267. }
  268. if (dataType === DataCategory.ATTACHMENTS) {
  269. return getAttachmentsWithUnit(events).replace(' ', '');
  270. }
  271. if (events >= 1_000_000_000) {
  272. return `${events / 1_000_000_000}B`;
  273. }
  274. if (events >= 1_000_000) {
  275. return `${events / 1_000_000}M`;
  276. }
  277. if (events >= 1_000) {
  278. return `${events / 1_000}K`;
  279. }
  280. return events;
  281. }
  282. function recordAnalytics(
  283. organization: Organization,
  284. subscription: Subscription,
  285. data: CheckoutAPIData,
  286. isMigratingPartnerAccount: boolean
  287. ) {
  288. trackMarketingEvent('Upgrade', {plan: data.plan});
  289. const currentData = {
  290. plan: data.plan,
  291. errors: data.reservedErrors,
  292. transactions: data.reservedTransactions,
  293. attachments: data.reservedAttachments,
  294. replays: data.reservedReplays,
  295. monitorSeats: data.reservedMonitorSeats,
  296. spans: data.reservedSpans,
  297. profileDuration: data.reservedProfileDuration,
  298. uptime: data.reservedUptime,
  299. };
  300. const previousData = {
  301. plan: subscription.plan,
  302. errors: subscription.categories.errors?.reserved || undefined,
  303. transactions: subscription.categories.transactions?.reserved || undefined,
  304. attachments: subscription.categories.attachments?.reserved || undefined,
  305. replays: subscription.categories.replays?.reserved || undefined,
  306. monitorSeats: subscription.categories.monitorSeats?.reserved || undefined,
  307. profileDuration: subscription.categories.profileDuration?.reserved || undefined,
  308. spans: subscription.categories.spans?.reserved || undefined,
  309. uptime: subscription.categories.uptime?.reserved || undefined,
  310. };
  311. trackGetsentryAnalytics('checkout.upgrade', {
  312. organization,
  313. subscription,
  314. previous_plan: previousData.plan,
  315. previous_errors: previousData.errors,
  316. previous_transactions: previousData.transactions,
  317. previous_attachments: previousData.attachments,
  318. previous_replays: previousData.replays,
  319. previous_monitorSeats: previousData.monitorSeats,
  320. previous_profileDuration: previousData.profileDuration,
  321. previous_spans: previousData.spans,
  322. previous_uptime: previousData.uptime,
  323. ...currentData,
  324. });
  325. let {onDemandBudget} = data;
  326. if (onDemandBudget) {
  327. onDemandBudget = normalizeOnDemandBudget(onDemandBudget);
  328. const previousOnDemandBudget = parseOnDemandBudgetsFromSubscription(subscription);
  329. trackOnDemandBudgetAnalytics(
  330. organization,
  331. previousOnDemandBudget,
  332. onDemandBudget,
  333. 'checkout'
  334. );
  335. }
  336. if (
  337. currentData.transactions &&
  338. previousData.transactions &&
  339. currentData.transactions > previousData.transactions
  340. ) {
  341. trackGetsentryAnalytics('checkout.transactions_upgrade', {
  342. organization,
  343. subscription,
  344. plan: data.plan,
  345. previous_transactions: previousData.transactions,
  346. transactions: currentData.transactions,
  347. });
  348. }
  349. if (isMigratingPartnerAccount) {
  350. trackGetsentryAnalytics('partner_billing_migration.checkout.completed', {
  351. subscription,
  352. organization,
  353. applyNow: data.applyNow ?? false,
  354. daysLeft: moment(subscription.contractPeriodEnd).diff(moment(), 'days'),
  355. partner: subscription.partner?.partnership.id,
  356. });
  357. }
  358. }
  359. export function stripeHandleCardAction(
  360. intentDetails: IntentDetails,
  361. stripeInstance?: stripe.Stripe,
  362. onSuccess?: () => void,
  363. onError?: (errorMessage?: string) => void
  364. ) {
  365. if (!stripeInstance) {
  366. return;
  367. }
  368. // Use stripe client library to handle additional actions.
  369. // This allows us to complete 3DS and MFA during checkout.
  370. stripeInstance
  371. .handleCardAction(intentDetails.paymentSecret)
  372. .then((result: stripe.PaymentIntentResponse) => {
  373. if (result.error) {
  374. let message =
  375. 'Your payment could not be authorized. Please try a different card, or try again later.';
  376. if (
  377. ['card_error', 'validation_error'].includes(result.error.type) &&
  378. result.error.message
  379. ) {
  380. message = result.error.message;
  381. }
  382. onError?.(message);
  383. return;
  384. }
  385. // With our intent confirmed we can complete checkout.
  386. onSuccess?.();
  387. });
  388. }
  389. /** @internal exported for tests only */
  390. export function getCheckoutAPIData({
  391. formData,
  392. onDemandBudget,
  393. previewToken,
  394. paymentIntent,
  395. referrer,
  396. shouldUpdateOnDemand = true,
  397. }: APIDataProps) {
  398. const formatReservedData = (value: number | null | undefined) => value ?? undefined;
  399. const reservedData = {
  400. reservedErrors: formatReservedData(formData.reserved.errors),
  401. reservedTransactions: formatReservedData(formData.reserved.transactions),
  402. reservedAttachments: formatReservedData(formData.reserved.attachments),
  403. reservedReplays: formatReservedData(formData.reserved.replays),
  404. reservedMonitorSeats: formatReservedData(formData.reserved.monitorSeats),
  405. reservedProfileDuration: formatReservedData(formData.reserved.profileDuration),
  406. reservedSpans: formatReservedData(formData.reserved.spans),
  407. reservedUptime: formatReservedData(formData.reserved.uptime),
  408. } satisfies Partial<
  409. // Enforce plural spelling against the enums in DataCategory
  410. Record<`reserved${Capitalize<DataCategory>}`, number | undefined>
  411. >;
  412. const onDemandMaxSpend = shouldUpdateOnDemand
  413. ? (formData.onDemandMaxSpend ?? 0)
  414. : undefined;
  415. let data: CheckoutAPIData = {
  416. ...reservedData,
  417. onDemandBudget,
  418. plan: formData.plan,
  419. onDemandMaxSpend,
  420. referrer: referrer || 'billing',
  421. ...(previewToken && {previewToken}),
  422. ...(paymentIntent && {paymentIntent}),
  423. };
  424. if (formData.applyNow) {
  425. data = {
  426. ...data,
  427. applyNow: true,
  428. };
  429. }
  430. return data;
  431. }
  432. export async function fetchPreviewData(
  433. organization: Organization,
  434. api: Client,
  435. formData: CheckoutFormData,
  436. onLoading: () => void,
  437. onSuccess: (previewData: PreviewData) => void,
  438. onError: (error: any) => void
  439. ) {
  440. onLoading?.();
  441. const data = getCheckoutAPIData({formData, isPreview: true});
  442. try {
  443. const previewData: PreviewData = await api.requestPromise(
  444. `/customers/${organization.slug}/subscription/preview/`,
  445. {
  446. method: 'GET',
  447. data,
  448. }
  449. );
  450. onSuccess?.(previewData);
  451. } catch (error) {
  452. onError?.(error);
  453. Sentry.withScope(scope => {
  454. scope.setExtras({data});
  455. Sentry.captureException(error);
  456. });
  457. }
  458. }
  459. export async function submitCheckout(
  460. organization: Organization,
  461. subscription: Subscription,
  462. previewData: PreviewData,
  463. formData: CheckoutFormData,
  464. api: Client,
  465. onFetchPreviewData: () => void,
  466. onHandleCardAction: (intentDetails: IntentDetails) => void,
  467. onSubmitting?: (b: boolean) => void,
  468. intentId?: string,
  469. referrer = 'billing',
  470. codecovReferrer = 'checkout',
  471. shouldUpdateOnDemand = true
  472. ) {
  473. const endpoint = `/customers/${organization.slug}/subscription/`;
  474. let {onDemandBudget} = formData;
  475. if (onDemandBudget) {
  476. onDemandBudget = normalizeOnDemandBudget(onDemandBudget);
  477. }
  478. // this is necessary for recording partner billing migration-specific analytics after
  479. // the migration is successful (during which the flag is flipped off)
  480. const isMigratingPartnerAccount = organization.features.includes(
  481. 'partner-billing-migration'
  482. );
  483. const data = getCheckoutAPIData({
  484. formData,
  485. onDemandBudget,
  486. previewToken: previewData?.previewToken,
  487. paymentIntent: intentId,
  488. referrer,
  489. shouldUpdateOnDemand,
  490. });
  491. addLoadingMessage(t('Saving changes\u{2026}'));
  492. try {
  493. onSubmitting?.(true);
  494. await api.requestPromise(endpoint, {
  495. method: 'PUT',
  496. data,
  497. });
  498. addSuccessMessage(t('Success'));
  499. recordAnalytics(organization, subscription, data, isMigratingPartnerAccount);
  500. // refresh org and subscription state
  501. // useApi cancels open requests on unmount by default, so we create a new Client to ensure this
  502. // request doesn't get cancelled
  503. fetchOrganizationDetails(new Client(), organization.slug);
  504. SubscriptionStore.loadData(organization.slug);
  505. browserHistory.push(
  506. normalizeUrl(
  507. `/settings/${organization.slug}/billing/overview/?open_codecov_modal=1&referrer=${codecovReferrer}`
  508. )
  509. );
  510. } catch (error) {
  511. const body = error.responseJSON;
  512. if (body?.previewToken) {
  513. onSubmitting?.(false);
  514. addErrorMessage(t('Your preview expired, please review changes and submit again'));
  515. onFetchPreviewData?.();
  516. } else if (body?.paymentIntent && body?.paymentSecret && body?.detail) {
  517. // When an error response contains payment intent information
  518. // we can retry the payment using the client-side confirmation flow
  519. // in stripe.
  520. // We don't re-enable the button here as we don't want users clicking it
  521. // while there are UI transitions happening.
  522. addErrorMessage(body.detail);
  523. const intent: IntentDetails = {
  524. paymentIntent: body.paymentIntent,
  525. paymentSecret: body.paymentSecret,
  526. };
  527. onHandleCardAction?.(intent);
  528. } else {
  529. const msg =
  530. body?.detail || t('An unknown error occurred while saving your subscription');
  531. addErrorMessage(msg);
  532. onSubmitting?.(false);
  533. // Don't capture 402 errors as that status code is used for
  534. // customer credit card failures.
  535. if (error.status !== 402) {
  536. Sentry.withScope(scope => {
  537. scope.setExtras({data});
  538. Sentry.captureException(error);
  539. });
  540. }
  541. }
  542. }
  543. }
  544. export function getToggleTier(checkoutTier: PlanTier | undefined) {
  545. // cannot toggle from or to AM3
  546. if (checkoutTier === DEFAULT_TIER || !checkoutTier || SUPPORTED_TIERS.length === 0) {
  547. return null;
  548. }
  549. if (SUPPORTED_TIERS.length === 1) {
  550. return SUPPORTED_TIERS[0];
  551. }
  552. const tierIndex = SUPPORTED_TIERS.indexOf(checkoutTier);
  553. // can toggle between AM1 and AM2 for AM1 customers
  554. if (tierIndex === SUPPORTED_TIERS.length - 1) {
  555. return SUPPORTED_TIERS[tierIndex - 1];
  556. }
  557. return SUPPORTED_TIERS[tierIndex + 1];
  558. }