utils.tsx 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  1. import isEqual from 'lodash/isEqual';
  2. import {DataCategory} from 'sentry/types/core';
  3. import type {Organization} from 'sentry/types/organization';
  4. import oxfordizeArray from 'sentry/utils/oxfordizeArray';
  5. import type {
  6. OnDemandBudgets,
  7. PendingOnDemandBudgets,
  8. PerCategoryOnDemandBudget,
  9. Plan,
  10. Subscription,
  11. SubscriptionOnDemandBudgets,
  12. } from 'getsentry/types';
  13. import {BillingType, OnDemandBudgetMode, PlanTier} from 'getsentry/types';
  14. import {getPlanCategoryName} from 'getsentry/utils/dataCategory';
  15. import formatCurrency from 'getsentry/utils/formatCurrency';
  16. import trackGetsentryAnalytics from 'getsentry/utils/trackGetsentryAnalytics';
  17. export function parseOnDemandBudgetsFromSubscription(
  18. subscription: Subscription
  19. ): OnDemandBudgets {
  20. const {onDemandBudgets, onDemandMaxSpend} = subscription;
  21. const validatedOnDemandMaxSpend = Math.max(onDemandMaxSpend ?? 0, 0);
  22. if (!onDemandBudgets) {
  23. return {
  24. budgetMode: OnDemandBudgetMode.SHARED,
  25. sharedMaxBudget: validatedOnDemandMaxSpend,
  26. };
  27. }
  28. return parseOnDemandBudgets(onDemandBudgets);
  29. }
  30. export function parseOnDemandBudgets(
  31. onDemandBudgets: SubscriptionOnDemandBudgets | PendingOnDemandBudgets
  32. ): OnDemandBudgets {
  33. if (onDemandBudgets.budgetMode === OnDemandBudgetMode.PER_CATEGORY) {
  34. return {
  35. budgetMode: OnDemandBudgetMode.PER_CATEGORY,
  36. errorsBudget: onDemandBudgets.budgets.errors ?? 0,
  37. transactionsBudget: onDemandBudgets.budgets.transactions ?? 0,
  38. attachmentsBudget: onDemandBudgets.budgets.attachments ?? 0,
  39. replaysBudget: onDemandBudgets.budgets.replays ?? 0,
  40. monitorSeatsBudget: onDemandBudgets.budgets.monitorSeats ?? 0,
  41. uptimeBudget: onDemandBudgets.budgets.uptime ?? 0,
  42. budgets: {
  43. errors: onDemandBudgets.budgets.errors,
  44. transactions: onDemandBudgets.budgets.transactions,
  45. attachments: onDemandBudgets.budgets.attachments,
  46. replays: onDemandBudgets.budgets.replays,
  47. monitorSeats: onDemandBudgets.budgets.monitorSeats,
  48. uptime: onDemandBudgets.budgets.uptime,
  49. },
  50. };
  51. }
  52. return {
  53. budgetMode: OnDemandBudgetMode.SHARED,
  54. sharedMaxBudget: onDemandBudgets.sharedMaxBudget ?? 0,
  55. };
  56. }
  57. export function getTotalBudget(onDemandBudgets: OnDemandBudgets): number {
  58. if (onDemandBudgets.budgetMode === OnDemandBudgetMode.PER_CATEGORY) {
  59. const errorsBudget = onDemandBudgets.budgets.errors ?? 0;
  60. const transactionsBudget = onDemandBudgets.budgets.transactions ?? 0;
  61. const attachmentsBudget = onDemandBudgets.budgets.attachments ?? 0;
  62. const replaysBudget = onDemandBudgets.budgets.replays ?? 0;
  63. const monitorSeatsBudget = onDemandBudgets.budgets.monitorSeats ?? 0;
  64. const uptimeBudget = onDemandBudgets.budgets.uptime ?? 0;
  65. return (
  66. errorsBudget +
  67. transactionsBudget +
  68. attachmentsBudget +
  69. replaysBudget +
  70. monitorSeatsBudget +
  71. uptimeBudget
  72. );
  73. }
  74. return onDemandBudgets.sharedMaxBudget ?? 0;
  75. }
  76. export function isOnDemandBudgetsEqual(
  77. value: OnDemandBudgets,
  78. other: OnDemandBudgets
  79. ): boolean {
  80. return isEqual(value, other);
  81. }
  82. type DisplayNameProps = {
  83. budget: PerCategoryOnDemandBudget;
  84. categories: string[];
  85. plan: Plan;
  86. };
  87. function listBudgets({plan, categories, budget}: DisplayNameProps) {
  88. const categoryNames = categories.map(category => {
  89. const displayName = getPlanCategoryName({plan, category, capitalize: false});
  90. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  91. const formattedBudget = formatCurrency(budget.budgets[category] ?? 0);
  92. return `${displayName} at ${formattedBudget}`;
  93. });
  94. return oxfordizeArray(categoryNames);
  95. }
  96. export function formatOnDemandBudget(
  97. plan: Plan,
  98. planTier: string,
  99. budget: OnDemandBudgets,
  100. categories: string[] = [
  101. 'errors',
  102. 'transactions',
  103. 'attachments',
  104. 'replays',
  105. 'monitorSeats',
  106. 'uptime',
  107. ]
  108. ): React.ReactNode {
  109. const budgetType = planTier === PlanTier.AM3 ? 'pay-as-you-go' : 'on-demand';
  110. if (budget.budgetMode === OnDemandBudgetMode.PER_CATEGORY) {
  111. return `per-category ${budgetType} (${listBudgets({plan, categories, budget})})`;
  112. }
  113. return `shared ${budgetType} of ${formatCurrency(budget.sharedMaxBudget ?? 0)}`;
  114. }
  115. export function hasOnDemandBudgetsFeature(
  116. organization: undefined | Organization,
  117. subscription: undefined | Subscription
  118. ) {
  119. // This function determines if the org can access the on-demand budgets UI.
  120. // Only orgs on the AM plan can access the on-demand budgets UI.
  121. return (
  122. subscription?.planDetails?.hasOnDemandModes &&
  123. organization?.features.includes('ondemand-budgets')
  124. );
  125. }
  126. function getBudgetMode(budget: OnDemandBudgets) {
  127. return budget.budgetMode === OnDemandBudgetMode.PER_CATEGORY
  128. ? 'per_category'
  129. : 'shared';
  130. }
  131. export function getOnDemandBudget(budget: OnDemandBudgets, dataCategory: DataCategory) {
  132. if (budget.budgetMode === OnDemandBudgetMode.PER_CATEGORY) {
  133. switch (dataCategory) {
  134. case DataCategory.ERRORS: {
  135. return budget.budgets.errors ?? 0;
  136. }
  137. case DataCategory.TRANSACTIONS: {
  138. return budget.budgets.transactions ?? 0;
  139. }
  140. case DataCategory.ATTACHMENTS: {
  141. return budget.budgets.attachments ?? 0;
  142. }
  143. case DataCategory.REPLAYS: {
  144. return budget.budgets.replays ?? 0;
  145. }
  146. case DataCategory.MONITOR_SEATS: {
  147. return budget.budgets.monitorSeats ?? 0;
  148. }
  149. case DataCategory.UPTIME: {
  150. return budget.budgets.uptime ?? 0;
  151. }
  152. default:
  153. return getTotalBudget(budget);
  154. }
  155. }
  156. return getTotalBudget(budget);
  157. }
  158. export function exceedsInvoicedBudgetLimit(
  159. subscription: Subscription,
  160. budget: OnDemandBudgets
  161. ): boolean {
  162. if (subscription.type !== BillingType.INVOICED) {
  163. return false;
  164. }
  165. // no limit for invoiced customers with CC-charged on-demand
  166. if (subscription.onDemandInvoiced && !subscription.onDemandInvoicedManual) {
  167. return false;
  168. }
  169. const totalBudget = getTotalBudget(budget);
  170. if (!subscription.onDemandInvoicedManual && totalBudget > 0) {
  171. return true;
  172. }
  173. let customPrice = subscription.customPrice;
  174. if (subscription.billingInterval === 'annual' && customPrice) {
  175. customPrice /= 12;
  176. }
  177. if (
  178. (customPrice && totalBudget > customPrice * 5) ||
  179. (subscription.acv && totalBudget > (subscription.acv / 12) * 5)
  180. ) {
  181. return true;
  182. }
  183. return false;
  184. }
  185. export function trackOnDemandBudgetAnalytics(
  186. organization: Organization,
  187. previousBudget: OnDemandBudgets,
  188. newBudget: OnDemandBudgets,
  189. prefix: 'ondemand_budget_modal' | 'checkout' = 'ondemand_budget_modal'
  190. ) {
  191. const previousTotalBudget = getTotalBudget(previousBudget);
  192. const totalBudget = getTotalBudget(newBudget);
  193. if (totalBudget > 0 && previousTotalBudget !== totalBudget) {
  194. trackGetsentryAnalytics(`${prefix}.ondemand_budget.update`, {
  195. organization,
  196. // new budget
  197. strategy: getBudgetMode(newBudget),
  198. total_budget: totalBudget,
  199. error_budget: getOnDemandBudget(newBudget, DataCategory.ERRORS),
  200. transaction_budget: getOnDemandBudget(newBudget, DataCategory.TRANSACTIONS),
  201. attachment_budget: getOnDemandBudget(newBudget, DataCategory.ATTACHMENTS),
  202. // previous budget
  203. previous_strategy: getBudgetMode(previousBudget),
  204. previous_total_budget: getTotalBudget(previousBudget),
  205. previous_error_budget: getOnDemandBudget(previousBudget, DataCategory.ERRORS),
  206. previous_transaction_budget: getOnDemandBudget(
  207. previousBudget,
  208. DataCategory.TRANSACTIONS
  209. ),
  210. previous_attachment_budget: getOnDemandBudget(
  211. previousBudget,
  212. DataCategory.ATTACHMENTS
  213. ),
  214. });
  215. return;
  216. }
  217. trackGetsentryAnalytics(`${prefix}.ondemand_budget.turned_off`, {
  218. organization,
  219. });
  220. }
  221. export function normalizeOnDemandBudget(budget: OnDemandBudgets): OnDemandBudgets {
  222. if (getTotalBudget(budget) <= 0) {
  223. return {
  224. budgetMode: OnDemandBudgetMode.SHARED,
  225. sharedMaxBudget: 0,
  226. };
  227. }
  228. return budget;
  229. }
  230. export function convertOnDemandBudget(
  231. currentOnDemandBudget: OnDemandBudgets,
  232. nextMode: OnDemandBudgetMode
  233. ): OnDemandBudgets {
  234. if (nextMode === OnDemandBudgetMode.PER_CATEGORY) {
  235. let errorsBudget = 0;
  236. let transactionsBudget = 0;
  237. let attachmentsBudget = 0;
  238. let replaysBudget = 0;
  239. let monitorSeatsBudget = 0;
  240. let uptimeBudget = 0;
  241. if (currentOnDemandBudget.budgetMode === OnDemandBudgetMode.PER_CATEGORY) {
  242. errorsBudget = currentOnDemandBudget.budgets.errors ?? 0;
  243. transactionsBudget = currentOnDemandBudget.budgets.transactions ?? 0;
  244. attachmentsBudget = currentOnDemandBudget.budgets.attachments ?? 0;
  245. replaysBudget = currentOnDemandBudget.budgets.replays ?? 0;
  246. monitorSeatsBudget = currentOnDemandBudget.budgets.monitorSeats ?? 0;
  247. uptimeBudget = currentOnDemandBudget.budgets.uptime ?? 0;
  248. } else {
  249. // should split 50:50 between transactions and errors (whole dollars, remainder added to errors)
  250. const total = getTotalBudget(currentOnDemandBudget);
  251. errorsBudget = Math.ceil(total / 100 / 2) * 100;
  252. transactionsBudget = Math.max(total - errorsBudget, 0);
  253. }
  254. return {
  255. budgetMode: OnDemandBudgetMode.PER_CATEGORY,
  256. errorsBudget,
  257. transactionsBudget,
  258. attachmentsBudget,
  259. replaysBudget,
  260. monitorSeatsBudget,
  261. uptimeBudget,
  262. budgets: {
  263. errors: errorsBudget,
  264. transactions: transactionsBudget,
  265. attachments: attachmentsBudget,
  266. replays: replaysBudget,
  267. monitorSeats: monitorSeatsBudget,
  268. uptime: uptimeBudget,
  269. },
  270. };
  271. }
  272. let sharedMaxBudget = 0;
  273. if (currentOnDemandBudget.budgetMode === OnDemandBudgetMode.SHARED) {
  274. sharedMaxBudget = currentOnDemandBudget.sharedMaxBudget ?? 0;
  275. } else {
  276. // The shared budget would be the total of the current per-category budgets.
  277. sharedMaxBudget = getTotalBudget(currentOnDemandBudget);
  278. }
  279. return {
  280. budgetMode: OnDemandBudgetMode.SHARED,
  281. sharedMaxBudget,
  282. };
  283. }