changePlanAction.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
  1. import React, {Fragment} from 'react';
  2. import classNames from 'classnames';
  3. import {addLoadingMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
  4. import {Client} from 'sentry/api';
  5. import DeprecatedAsyncComponent from 'sentry/components/deprecatedAsyncComponent';
  6. import NavTabs from 'sentry/components/navTabs';
  7. import type {AdminConfirmRenderProps} from 'admin/components/adminConfirmationModal';
  8. import PlanList from 'admin/components/planList';
  9. import {ANNUAL, MONTHLY} from 'getsentry/constants';
  10. import type {BillingConfig} from 'getsentry/types';
  11. import {CheckoutType, PlanTier} from 'getsentry/types';
  12. import {getAmPlanTier} from 'getsentry/utils/billing';
  13. type Props = DeprecatedAsyncComponent['props'] &
  14. AdminConfirmRenderProps & {
  15. orgId: string;
  16. partnerPlanId: string | null;
  17. };
  18. type State = DeprecatedAsyncComponent['state'] & {
  19. activeTier: Exclude<PlanTier, PlanTier.MM1>;
  20. am1BillingConfig: BillingConfig | null;
  21. am2BillingConfig: BillingConfig | null;
  22. am3BillingConfig: BillingConfig | null;
  23. billingInterval: 'monthly' | 'annual';
  24. contractInterval: 'monthly' | 'annual';
  25. mm2BillingConfig: BillingConfig | null;
  26. plan: null | string;
  27. reservedAttachments: null | number;
  28. reservedErrors: null | number;
  29. reservedMonitorSeats: null | number;
  30. reservedReplays: null | number;
  31. reservedSpans: null | number;
  32. reservedTransactions: null | number;
  33. reservedUptime: null | number;
  34. };
  35. /**
  36. * Rendered as part of a openAdminConfirmModal call
  37. */
  38. class ChangePlanAction extends DeprecatedAsyncComponent<Props, State> {
  39. componentDidMount() {
  40. super.componentDidMount();
  41. this.props.setConfirmCallback(this.handleConfirm);
  42. }
  43. getDefaultState() {
  44. return {
  45. ...super.getDefaultState(),
  46. plan: this.props.partnerPlanId,
  47. reservedErrors: null,
  48. reservedTransactions: null,
  49. reservedReplays: null,
  50. reservedAttachments: null,
  51. reservedMonitorSeats: null,
  52. reservedUptime: null,
  53. reservedSpans: null,
  54. activeTier: this.props.partnerPlanId
  55. ? getAmPlanTier(this.props.partnerPlanId)
  56. : PlanTier.AM3,
  57. billingInterval: MONTHLY,
  58. contractInterval: MONTHLY,
  59. am1BillingConfig: null,
  60. mm2BillingConfig: null,
  61. };
  62. }
  63. getEndpoints(): ReturnType<DeprecatedAsyncComponent['getEndpoints']> {
  64. return [
  65. ['mm2BillingConfig', `/customers/${this.props.orgId}/billing-config/?tier=mm2`],
  66. ['am1BillingConfig', `/customers/${this.props.orgId}/billing-config/?tier=am1`],
  67. ['am2BillingConfig', `/customers/${this.props.orgId}/billing-config/?tier=am2`],
  68. ['am3BillingConfig', `/customers/${this.props.orgId}/billing-config/?tier=am3`],
  69. ];
  70. }
  71. handleConfirm = async () => {
  72. const {onConfirm, orgId} = this.props;
  73. const {
  74. activeTier,
  75. plan,
  76. reservedErrors,
  77. reservedTransactions,
  78. reservedReplays,
  79. reservedAttachments,
  80. reservedMonitorSeats,
  81. reservedUptime,
  82. reservedSpans,
  83. reservedProfileDuration,
  84. } = this.state;
  85. const api = new Client();
  86. addLoadingMessage('Updating plan\u2026');
  87. if (activeTier === PlanTier.MM2) {
  88. const data = {plan};
  89. try {
  90. await api.requestPromise(`/customers/${orgId}/`, {
  91. method: 'PUT',
  92. data,
  93. });
  94. addSuccessMessage(
  95. `Customer account has been updated with ${JSON.stringify(data)}.`
  96. );
  97. onConfirm?.(data);
  98. } catch (error) {
  99. onConfirm?.({error});
  100. }
  101. return;
  102. }
  103. // AM plans use a different endpoint to update plans.
  104. const data: {
  105. plan: string | null;
  106. reservedAttachments: number | null;
  107. reservedErrors: number | null;
  108. reservedMonitorSeats: number | null;
  109. reservedReplays: number | null;
  110. reservedUptime: number | null;
  111. reservedProfileDuration?: number | null;
  112. reservedSpans?: number | null;
  113. reservedTransactions?: number | null;
  114. } = {
  115. plan,
  116. reservedErrors,
  117. reservedReplays,
  118. reservedAttachments,
  119. reservedMonitorSeats,
  120. reservedUptime,
  121. reservedProfileDuration,
  122. };
  123. if (reservedSpans) {
  124. data.reservedSpans = reservedSpans;
  125. }
  126. if (reservedTransactions) {
  127. data.reservedTransactions = reservedTransactions;
  128. }
  129. try {
  130. await api.requestPromise(`/customers/${orgId}/subscription/`, {
  131. method: 'PUT',
  132. data,
  133. });
  134. addSuccessMessage(
  135. `Customer account has been updated with ${JSON.stringify(data)}.`
  136. );
  137. onConfirm?.(data);
  138. } catch (error) {
  139. onConfirm?.({error});
  140. }
  141. };
  142. canSubmit() {
  143. const {
  144. activeTier,
  145. plan,
  146. reservedErrors,
  147. reservedTransactions,
  148. reservedAttachments,
  149. reservedReplays,
  150. reservedMonitorSeats,
  151. reservedUptime,
  152. reservedSpans,
  153. reservedProfileDuration,
  154. am2BillingConfig,
  155. am3BillingConfig,
  156. } = this.state;
  157. if (activeTier === PlanTier.MM2 && plan) {
  158. return true;
  159. }
  160. // TODO(brendan): remove checking profileDuration !== undefined once we launch profile duration
  161. const profileDurationTier =
  162. (activeTier === PlanTier.AM2 &&
  163. am2BillingConfig?.defaultReserved.profileDuration !== undefined) ||
  164. (activeTier === PlanTier.AM3 &&
  165. am3BillingConfig?.defaultReserved.profileDuration !== undefined);
  166. return (
  167. plan &&
  168. reservedErrors &&
  169. reservedReplays &&
  170. reservedAttachments &&
  171. reservedMonitorSeats &&
  172. reservedUptime &&
  173. (profileDurationTier ? reservedProfileDuration >= 0 : true) &&
  174. (reservedTransactions || reservedSpans)
  175. );
  176. }
  177. handlePlanChange = (planId: string) => {
  178. this.setState({plan: planId}, () => {
  179. this.props.disableConfirmButton(!this.canSubmit());
  180. });
  181. };
  182. handleLimitChange = (
  183. limit:
  184. | 'reservedErrors'
  185. | 'reservedTransactions'
  186. | 'reservedReplays'
  187. | 'reservedAttachments'
  188. | 'reservedMonitorSeats'
  189. | 'reservedUptime'
  190. | 'reservedSpans'
  191. | 'reservedProfileDuration',
  192. value: number
  193. ) => {
  194. this.setState({[limit]: value}, () => {
  195. this.props.disableConfirmButton(!this.canSubmit());
  196. });
  197. };
  198. renderBody() {
  199. const {
  200. plan,
  201. reservedErrors,
  202. reservedAttachments,
  203. reservedReplays,
  204. reservedTransactions,
  205. reservedMonitorSeats,
  206. reservedUptime,
  207. reservedSpans,
  208. reservedProfileDuration,
  209. activeTier,
  210. loading,
  211. billingInterval,
  212. am1BillingConfig,
  213. am2BillingConfig,
  214. am3BillingConfig,
  215. mm2BillingConfig,
  216. contractInterval,
  217. } = this.state;
  218. const {partnerPlanId} = this.props;
  219. if (loading) {
  220. return null;
  221. }
  222. let planList: BillingConfig['planList'] = [];
  223. if (activeTier === PlanTier.MM2 && mm2BillingConfig) {
  224. planList = mm2BillingConfig.planList;
  225. } else if (activeTier === PlanTier.AM1 && am1BillingConfig) {
  226. planList = am1BillingConfig.planList;
  227. } else if (activeTier === PlanTier.AM2 && am2BillingConfig) {
  228. planList = am2BillingConfig.planList;
  229. } else if (activeTier === PlanTier.AM3 && am3BillingConfig) {
  230. planList = am3BillingConfig.planList;
  231. }
  232. planList = planList
  233. .sort((a, b) => a.reservedMinimum - b.reservedMinimum)
  234. .filter(
  235. p =>
  236. p.price &&
  237. p.contractInterval === contractInterval &&
  238. p.billingInterval === billingInterval &&
  239. (p.userSelectable || p.checkoutType === CheckoutType.BUNDLE) &&
  240. // Plan id on partner sponsored subscriptions is not modifiable so only including
  241. // the existing plan in the list
  242. (partnerPlanId === null || partnerPlanId === p.id)
  243. );
  244. // Plan for partner sponsored subscriptions is not modifiable so skipping
  245. // the navigation that will allow modifying billing cycle and plan tier
  246. const header = partnerPlanId ? null : (
  247. <React.Fragment>
  248. <NavTabs>
  249. <li className={activeTier === PlanTier.AM3 ? 'active' : ''}>
  250. <a
  251. data-test-id="am3-tier"
  252. onClick={() =>
  253. this.setState({
  254. activeTier: PlanTier.AM3,
  255. billingInterval: MONTHLY,
  256. contractInterval: MONTHLY,
  257. plan: null,
  258. })
  259. }
  260. >
  261. AM3
  262. </a>
  263. </li>
  264. <li className={activeTier === PlanTier.AM2 ? 'active' : ''}>
  265. <a
  266. data-test-id="am2-tier"
  267. onClick={() =>
  268. this.setState({
  269. activeTier: PlanTier.AM2,
  270. billingInterval: MONTHLY,
  271. contractInterval: MONTHLY,
  272. plan: null,
  273. })
  274. }
  275. >
  276. AM2
  277. </a>
  278. </li>
  279. <li className={activeTier === PlanTier.AM1 ? 'active' : ''}>
  280. <a
  281. data-test-id="am1-tier"
  282. role="link"
  283. aria-disabled
  284. onClick={() =>
  285. this.setState({
  286. activeTier: PlanTier.AM1,
  287. billingInterval: MONTHLY,
  288. contractInterval: MONTHLY,
  289. plan: null,
  290. })
  291. }
  292. >
  293. AM1
  294. </a>
  295. </li>
  296. <li className={activeTier === PlanTier.MM2 ? 'active' : ''}>
  297. <a
  298. data-test-id="mm2-tier"
  299. onClick={() =>
  300. this.setState({
  301. activeTier: PlanTier.MM2,
  302. billingInterval: MONTHLY,
  303. contractInterval: MONTHLY,
  304. plan: null,
  305. })
  306. }
  307. >
  308. MM2
  309. </a>
  310. </li>
  311. </NavTabs>
  312. <ul className="nav nav-pills">
  313. <li
  314. className={classNames({
  315. active: contractInterval === MONTHLY && billingInterval === MONTHLY,
  316. })}
  317. >
  318. <a
  319. onClick={() =>
  320. this.setState({
  321. billingInterval: MONTHLY,
  322. contractInterval: MONTHLY,
  323. plan: null,
  324. })
  325. }
  326. >
  327. Monthly
  328. </a>
  329. </li>
  330. {activeTier === PlanTier.MM2 && (
  331. <li
  332. className={classNames({
  333. active: contractInterval === ANNUAL && billingInterval === MONTHLY,
  334. })}
  335. >
  336. <a
  337. onClick={() =>
  338. this.setState({
  339. billingInterval: MONTHLY,
  340. contractInterval: ANNUAL,
  341. plan: null,
  342. })
  343. }
  344. >
  345. Annual (Contract)
  346. </a>
  347. </li>
  348. )}
  349. <li
  350. className={classNames({
  351. active: contractInterval === ANNUAL && billingInterval === ANNUAL,
  352. })}
  353. >
  354. <a
  355. onClick={() =>
  356. this.setState({
  357. billingInterval: ANNUAL,
  358. contractInterval: ANNUAL,
  359. plan: null,
  360. })
  361. }
  362. >
  363. Annual (Upfront)
  364. </a>
  365. </li>
  366. </ul>
  367. </React.Fragment>
  368. );
  369. return (
  370. <Fragment>
  371. {header}
  372. <PlanList
  373. planId={plan}
  374. reservedErrors={reservedErrors}
  375. reservedTransactions={reservedTransactions}
  376. reservedReplays={reservedReplays}
  377. reservedSpans={reservedSpans}
  378. reservedAttachments={reservedAttachments}
  379. reservedMonitorSeats={reservedMonitorSeats}
  380. reservedUptime={reservedUptime}
  381. reservedProfileDuration={reservedProfileDuration}
  382. plans={planList}
  383. onPlanChange={this.handlePlanChange}
  384. onLimitChange={this.handleLimitChange}
  385. />
  386. </Fragment>
  387. );
  388. }
  389. }
  390. export default ChangePlanAction;