pendingChanges.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. import {Fragment} from 'react';
  2. import moment from 'moment-timezone';
  3. import {Alert} from 'sentry/components/core/alert';
  4. import List from 'sentry/components/list';
  5. import ListItem from 'sentry/components/list/listItem';
  6. import {IconArrow} from 'sentry/icons';
  7. import {DataCategory} from 'sentry/types/core';
  8. import {RESERVED_BUDGET_QUOTA} from 'getsentry/constants';
  9. import {usePlanMigrations} from 'getsentry/hooks/usePlanMigrations';
  10. import type {DataCategories, Plan, PlanMigration, Subscription} from 'getsentry/types';
  11. import {formatReservedWithUnits} from 'getsentry/utils/billing';
  12. import {
  13. getPlanCategoryName,
  14. getReservedBudgetDisplayName,
  15. } from 'getsentry/utils/dataCategory';
  16. import {displayPriceWithCents} from 'getsentry/views/amCheckout/utils';
  17. import {
  18. formatOnDemandBudget,
  19. isOnDemandBudgetsEqual,
  20. parseOnDemandBudgets,
  21. parseOnDemandBudgetsFromSubscription,
  22. } from 'getsentry/views/onDemandBudgets/utils';
  23. function getStringForPrice(
  24. price: number | null | undefined,
  25. minimumFractionDigits?: number,
  26. maximumFractionDigits?: number
  27. ) {
  28. return price === null
  29. ? 'None'
  30. : displayPriceWithCents({
  31. cents: price ?? 0,
  32. minimumFractionDigits,
  33. maximumFractionDigits,
  34. });
  35. }
  36. function formatChangeForCategory({
  37. category,
  38. changeTitle,
  39. oldValue,
  40. pendingValue,
  41. oldPlan,
  42. pendingPlan,
  43. shouldDistinguishDisplayNames = false,
  44. }: {
  45. category: DataCategory;
  46. changeTitle: string;
  47. oldPlan: Plan;
  48. oldValue: string;
  49. pendingPlan: Plan;
  50. pendingValue: string;
  51. shouldDistinguishDisplayNames?: boolean;
  52. }) {
  53. const oldUsesDsNames = oldPlan.categories.includes(DataCategory.SPANS_INDEXED);
  54. const pendingUsesDsNames = pendingPlan.categories.includes(DataCategory.SPANS_INDEXED);
  55. const oldDisplayName = getPlanCategoryName({
  56. plan: oldPlan,
  57. category,
  58. capitalize: false,
  59. hadCustomDynamicSampling: oldUsesDsNames,
  60. });
  61. const pendingDisplayName = getPlanCategoryName({
  62. plan: pendingPlan,
  63. category,
  64. capitalize: false,
  65. hadCustomDynamicSampling: pendingUsesDsNames,
  66. });
  67. return `${changeTitle} ${shouldDistinguishDisplayNames ? oldDisplayName : pendingDisplayName} — ${oldValue} → ${pendingValue} ${
  68. shouldDistinguishDisplayNames ? pendingDisplayName : ''
  69. }`;
  70. }
  71. function getRegularChanges(subscription: Subscription) {
  72. const {pendingChanges} = subscription;
  73. const changes: string[] = [];
  74. if (pendingChanges === null) {
  75. return changes;
  76. }
  77. if (Object.keys(pendingChanges).length === 0) {
  78. return changes;
  79. }
  80. if (pendingChanges.plan !== subscription.plan) {
  81. const old = subscription.planDetails.name;
  82. const change = pendingChanges.planDetails.name;
  83. changes.push(`Plan changes — ${old} → ${change}`);
  84. }
  85. const oldPlanUsesDsNames = subscription.planDetails.categories.includes(
  86. DataCategory.SPANS_INDEXED
  87. );
  88. const newPlanUsesDsNames = pendingChanges.planDetails.categories.includes(
  89. DataCategory.SPANS_INDEXED
  90. );
  91. if (
  92. pendingChanges.planDetails.contractInterval !==
  93. subscription.planDetails.contractInterval
  94. ) {
  95. const old = subscription.planDetails.contractInterval;
  96. const change = pendingChanges.planDetails.contractInterval;
  97. changes.push(`Contract period — ${old} → ${change}`);
  98. }
  99. if (
  100. pendingChanges.planDetails.billingInterval !==
  101. subscription.planDetails.billingInterval
  102. ) {
  103. const old = subscription.planDetails.billingInterval;
  104. const change = pendingChanges.planDetails.billingInterval;
  105. changes.push(`Billing period — ${old} → ${change}`);
  106. }
  107. if (
  108. pendingChanges.reservedEvents !== subscription.reservedEvents ||
  109. pendingChanges.reserved.errors !== subscription.categories.errors?.reserved
  110. ) {
  111. const old = formatReservedWithUnits(
  112. subscription.reservedEvents || (subscription.categories.errors?.reserved ?? null),
  113. DataCategory.ERRORS
  114. );
  115. const change = formatReservedWithUnits(
  116. pendingChanges.reservedEvents || (pendingChanges.reserved?.errors ?? null),
  117. DataCategory.ERRORS
  118. );
  119. changes.push(
  120. formatChangeForCategory({
  121. category: DataCategory.ERRORS,
  122. changeTitle: 'Reserved',
  123. oldValue: old,
  124. pendingValue: change,
  125. oldPlan: subscription.planDetails,
  126. pendingPlan: pendingChanges.planDetails,
  127. shouldDistinguishDisplayNames: true,
  128. })
  129. );
  130. }
  131. const categories = [
  132. ...new Set([
  133. ...subscription.planDetails.categories,
  134. ...Object.keys(pendingChanges.reserved ?? {}),
  135. ]),
  136. ] as DataCategories[];
  137. categories.forEach(category => {
  138. if (category !== 'errors') {
  139. // Errors and Events handled above
  140. if (
  141. (pendingChanges.reserved?.[category] ?? 0) !==
  142. (subscription.categories?.[category]?.reserved ?? 0)
  143. ) {
  144. const categoryEnum = category as DataCategory;
  145. const oldReserved = subscription.categories?.[category]?.reserved ?? null;
  146. const pendingReserved = pendingChanges.reserved?.[category] ?? null;
  147. const old =
  148. oldReserved === RESERVED_BUDGET_QUOTA
  149. ? 'reserved budget'
  150. : formatReservedWithUnits(oldReserved, categoryEnum);
  151. const change =
  152. pendingReserved === RESERVED_BUDGET_QUOTA
  153. ? 'reserved budget'
  154. : formatReservedWithUnits(pendingReserved, categoryEnum);
  155. changes.push(
  156. formatChangeForCategory({
  157. category: categoryEnum,
  158. changeTitle: 'Reserved',
  159. oldValue: old,
  160. pendingValue: change,
  161. oldPlan: subscription.planDetails,
  162. pendingPlan: pendingChanges.planDetails,
  163. shouldDistinguishDisplayNames: pendingReserved !== RESERVED_BUDGET_QUOTA,
  164. })
  165. );
  166. }
  167. }
  168. });
  169. if (pendingChanges.customPrice !== subscription.customPrice) {
  170. const old = getStringForPrice(subscription.customPrice);
  171. const change = getStringForPrice(pendingChanges.customPrice);
  172. changes.push(`Custom price (ACV) — ${old} → ${change}`);
  173. }
  174. categories.forEach(category => {
  175. if (
  176. (pendingChanges.customPrices?.[category as DataCategories] ?? 0) !==
  177. (subscription.categories?.[category as DataCategories]?.customPrice ?? 0)
  178. ) {
  179. const old = getStringForPrice(
  180. subscription.categories?.[category as DataCategories]?.customPrice
  181. );
  182. const change = getStringForPrice(
  183. pendingChanges.customPrices?.[category as DataCategories]
  184. );
  185. changes.push(
  186. formatChangeForCategory({
  187. category: category as DataCategory,
  188. changeTitle: 'Custom price for',
  189. oldValue: old,
  190. pendingValue: change,
  191. oldPlan: subscription.planDetails,
  192. pendingPlan: pendingChanges.planDetails,
  193. })
  194. );
  195. }
  196. });
  197. if (pendingChanges.customPricePcss !== subscription.customPricePcss) {
  198. const old = getStringForPrice(subscription.customPricePcss);
  199. const change = getStringForPrice(pendingChanges.customPricePcss);
  200. changes.push(`Custom price for PCSS — ${old} → ${change}`);
  201. }
  202. const oldBudgets = subscription.reservedBudgets;
  203. const oldCpeByCategory: Record<string, number> = {};
  204. oldBudgets?.forEach(budget => {
  205. Object.entries(budget.categories).forEach(([category, info]) => {
  206. if (info?.reservedCpe) {
  207. oldCpeByCategory[category] = info.reservedCpe;
  208. }
  209. });
  210. });
  211. categories.forEach(category => {
  212. if (
  213. (pendingChanges.reservedCpe?.[category] ?? null) !==
  214. (oldCpeByCategory[category] ?? null)
  215. ) {
  216. const old = getStringForPrice(oldCpeByCategory[category] ?? null, 8, 8);
  217. const change = getStringForPrice(
  218. pendingChanges.reservedCpe?.[category] ?? null,
  219. 8,
  220. 8
  221. );
  222. changes.push(
  223. formatChangeForCategory({
  224. category: category as DataCategory,
  225. changeTitle: 'Reserved cost-per-event for',
  226. oldValue: old,
  227. pendingValue: change,
  228. oldPlan: subscription.planDetails,
  229. pendingPlan: pendingChanges.planDetails,
  230. })
  231. );
  232. }
  233. });
  234. const oldBudgetsChanges: string[] = [];
  235. const newBudgetsChanges: string[] = [];
  236. oldBudgets?.forEach(budget => {
  237. const budgetName = getReservedBudgetDisplayName({
  238. plan: subscription.planDetails,
  239. categories: Object.keys(budget.categories),
  240. hadCustomDynamicSampling: oldPlanUsesDsNames,
  241. });
  242. oldBudgetsChanges.push(
  243. `${getStringForPrice(budget.reservedBudget)} for ${budgetName}`
  244. );
  245. });
  246. pendingChanges.reservedBudgets.forEach(budget => {
  247. const budgetName = getReservedBudgetDisplayName({
  248. plan: pendingChanges.planDetails,
  249. categories: Object.keys(budget.categories),
  250. hadCustomDynamicSampling: newPlanUsesDsNames,
  251. });
  252. newBudgetsChanges.push(
  253. `${getStringForPrice(budget.reservedBudget)} for ${budgetName}`
  254. );
  255. });
  256. if (oldBudgetsChanges.length > 0 || newBudgetsChanges.length > 0) {
  257. changes.push(
  258. `Reserved budgets — ${
  259. oldBudgetsChanges.length > 0 ? oldBudgetsChanges.join(', ') : 'None'
  260. } → ${newBudgetsChanges.length > 0 ? newBudgetsChanges.join(', ') : 'None'}`
  261. );
  262. }
  263. return changes;
  264. }
  265. function getOnDemandChanges(subscription: Subscription) {
  266. const {pendingChanges} = subscription;
  267. const changes: React.ReactNode[] = [];
  268. if (pendingChanges === null) {
  269. return changes;
  270. }
  271. if (Object.keys(pendingChanges).length === 0) {
  272. return changes;
  273. }
  274. if (subscription.onDemandBudgets && pendingChanges.onDemandBudgets) {
  275. const pendingOnDemandBudgets = parseOnDemandBudgets(pendingChanges.onDemandBudgets);
  276. const currentOnDemandBudgets = parseOnDemandBudgetsFromSubscription(subscription);
  277. if (!isOnDemandBudgetsEqual(pendingOnDemandBudgets, currentOnDemandBudgets)) {
  278. const current = formatOnDemandBudget(
  279. subscription.planDetails,
  280. subscription.planTier,
  281. currentOnDemandBudgets,
  282. subscription.planDetails.onDemandCategories
  283. );
  284. const change = formatOnDemandBudget(
  285. pendingChanges.planDetails,
  286. subscription.planTier,
  287. pendingOnDemandBudgets,
  288. pendingChanges.planDetails.onDemandCategories
  289. );
  290. changes.push(
  291. <span>
  292. On-demand budget — {current} → {change}
  293. </span>
  294. );
  295. }
  296. } else if (pendingChanges.onDemandMaxSpend !== subscription.onDemandMaxSpend) {
  297. const old = getStringForPrice(subscription.onDemandMaxSpend);
  298. const change = getStringForPrice(pendingChanges.onDemandMaxSpend);
  299. changes.push(
  300. <span>
  301. On-demand maximum — {old} → {change}
  302. </span>
  303. );
  304. }
  305. return changes;
  306. }
  307. type Change = {
  308. effectiveDate: string;
  309. items: React.ReactNode[];
  310. };
  311. function getChanges(subscription: Subscription, planMigrations: PlanMigration[]) {
  312. const {pendingChanges} = subscription;
  313. const changeSet: Change[] = [];
  314. if (pendingChanges === null) {
  315. return changeSet;
  316. }
  317. const activeMigration = planMigrations.find(
  318. ({dateApplied, cohort}) => dateApplied === null && cohort?.nextPlan
  319. );
  320. const {onDemandEffectiveDate} = pendingChanges;
  321. const effectiveDate = activeMigration?.effectiveAt ?? pendingChanges.effectiveDate;
  322. const regularChanges = getRegularChanges(subscription);
  323. if (regularChanges.length > 0) {
  324. changeSet.push({effectiveDate, items: regularChanges});
  325. }
  326. const onDemandChanges = getOnDemandChanges(subscription);
  327. if (onDemandChanges.length) {
  328. changeSet.push({effectiveDate: onDemandEffectiveDate, items: onDemandChanges});
  329. }
  330. return changeSet;
  331. }
  332. function PendingChanges({subscription}: any) {
  333. const {pendingChanges} = subscription;
  334. const {planMigrations, isLoading} = usePlanMigrations();
  335. if (isLoading) {
  336. return null;
  337. }
  338. if (typeof pendingChanges !== 'object' || pendingChanges === null) {
  339. return null;
  340. }
  341. const changes = getChanges(subscription, planMigrations);
  342. if (!changes.length) {
  343. return null;
  344. }
  345. return (
  346. <Fragment>
  347. <Alert.Container>
  348. <Alert type="info" showIcon>
  349. This account has pending changes to the subscription
  350. </Alert>
  351. </Alert.Container>
  352. <List>
  353. {changes.map((change, changeIdx) => (
  354. <ListItem key={changeIdx}>
  355. <p>
  356. The following changes will take effect on{' '}
  357. <strong>{moment(change.effectiveDate).format('ll')}</strong>:
  358. </p>
  359. <List symbol={<IconArrow direction="right" size="xs" />}>
  360. {change.items.map((item, itemIdx) => (
  361. <ListItem key={itemIdx}>{item}</ListItem>
  362. ))}
  363. </List>
  364. </ListItem>
  365. ))}
  366. </List>
  367. </Fragment>
  368. );
  369. }
  370. export default PendingChanges;