index.tsx 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782
  1. import {Component, Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import * as Sentry from '@sentry/react';
  4. import type {QueryObserverResult} from '@tanstack/react-query';
  5. import isEqual from 'lodash/isEqual';
  6. import moment from 'moment-timezone';
  7. import type {Client} from 'sentry/api';
  8. import {Button} from 'sentry/components/button';
  9. import {Alert} from 'sentry/components/core/alert';
  10. import ExternalLink from 'sentry/components/links/externalLink';
  11. import LoadingError from 'sentry/components/loadingError';
  12. import LoadingIndicator from 'sentry/components/loadingIndicator';
  13. import Panel from 'sentry/components/panels/panel';
  14. import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
  15. import TextOverflow from 'sentry/components/textOverflow';
  16. import {t, tct} from 'sentry/locale';
  17. import {space} from 'sentry/styles/space';
  18. import type {RouteComponentProps} from 'sentry/types/legacyReactRouter';
  19. import type {Organization} from 'sentry/types/organization';
  20. import type {QueryClient} from 'sentry/utils/queryClient';
  21. import normalizeUrl from 'sentry/utils/url/normalizeUrl';
  22. import withApi from 'sentry/utils/withApi';
  23. import withOrganization from 'sentry/utils/withOrganization';
  24. import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
  25. import TextBlock from 'sentry/views/settings/components/text/textBlock';
  26. import withSubscription from 'getsentry/components/withSubscription';
  27. import ZendeskLink from 'getsentry/components/zendeskLink';
  28. import {ANNUAL, MONTHLY} from 'getsentry/constants';
  29. import {
  30. type BillingConfig,
  31. CheckoutType,
  32. type DataCategories,
  33. OnDemandBudgetMode,
  34. type OnDemandBudgets,
  35. type Plan,
  36. PlanName,
  37. PlanTier,
  38. type PromotionData,
  39. type Subscription,
  40. } from 'getsentry/types';
  41. import {
  42. hasActiveVCFeature,
  43. hasPartnerMigrationFeature,
  44. hasPerformance,
  45. isAmPlan,
  46. isBizPlanFamily,
  47. } from 'getsentry/utils/billing';
  48. import {getCompletedOrActivePromotion} from 'getsentry/utils/promotions';
  49. import {showSubscriptionDiscount} from 'getsentry/utils/promotionUtils';
  50. import {loadStripe} from 'getsentry/utils/stripe';
  51. import trackGetsentryAnalytics from 'getsentry/utils/trackGetsentryAnalytics';
  52. import withPromotions from 'getsentry/utils/withPromotions';
  53. import CheckoutOverview from 'getsentry/views/amCheckout/checkoutOverview';
  54. import CheckoutOverviewV2 from 'getsentry/views/amCheckout/checkoutOverviewV2';
  55. import AddBillingDetails from 'getsentry/views/amCheckout/steps/addBillingDetails';
  56. import AddDataVolume from 'getsentry/views/amCheckout/steps/addDataVolume';
  57. import AddPaymentMethod from 'getsentry/views/amCheckout/steps/addPaymentMethod';
  58. import ContractSelect from 'getsentry/views/amCheckout/steps/contractSelect';
  59. import OnDemandBudgetsStep from 'getsentry/views/amCheckout/steps/onDemandBudgets';
  60. import OnDemandSpend from 'getsentry/views/amCheckout/steps/onDemandSpend';
  61. import PlanSelect from 'getsentry/views/amCheckout/steps/planSelect';
  62. import ReviewAndConfirm from 'getsentry/views/amCheckout/steps/reviewAndConfirm';
  63. import SetBudgetAndReserves from 'getsentry/views/amCheckout/steps/setBudgetAndReserves';
  64. import type {CheckoutFormData} from 'getsentry/views/amCheckout/types';
  65. import {getBucket} from 'getsentry/views/amCheckout/utils';
  66. import {
  67. getTotalBudget,
  68. hasOnDemandBudgetsFeature,
  69. parseOnDemandBudgetsFromSubscription,
  70. } from 'getsentry/views/onDemandBudgets/utils';
  71. // TODO: push promotion logic to subcomponents
  72. type Props = {
  73. api: Client;
  74. checkoutTier: PlanTier;
  75. isError: boolean;
  76. isLoading: boolean;
  77. onToggleLegacy: (tier: string) => void;
  78. organization: Organization;
  79. queryClient: QueryClient;
  80. subscription: Subscription;
  81. promotionData?: PromotionData;
  82. refetch?: () => Promise<QueryObserverResult<PromotionData, unknown>>;
  83. } & RouteComponentProps<Record<PropertyKey, unknown>, unknown>;
  84. type State = {
  85. billingConfig: BillingConfig | null;
  86. completedSteps: Set<number>;
  87. currentStep: number;
  88. error: Error | boolean;
  89. formData: CheckoutFormData | null;
  90. loading: boolean;
  91. };
  92. class AMCheckout extends Component<Props, State> {
  93. constructor(props: Props) {
  94. super(props);
  95. // TODO(am3): for now, only new customers and migrating partner customers can use the AM3 checkout flow
  96. if (
  97. props.checkoutTier === PlanTier.AM3 &&
  98. !props.subscription.plan.startsWith('am3') &&
  99. !props.organization.features.includes('partner-billing-migration')
  100. ) {
  101. props.onToggleLegacy(props.subscription.planTier);
  102. }
  103. let step = 1;
  104. if (props.location?.hash) {
  105. const stepMatch = /^#step(\d)$/.exec(props.location.hash);
  106. if (stepMatch) {
  107. step = parseInt(stepMatch[1]!, 10);
  108. if (step < 1 || step > 6) {
  109. step = 1;
  110. }
  111. }
  112. } else if (
  113. // skip 'Choose Your Plan' if customer is already on Business plan
  114. isBizPlanFamily(props.subscription.planDetails) &&
  115. props.checkoutTier === props.subscription.planTier
  116. ) {
  117. step = 2;
  118. }
  119. this.initialStep = step;
  120. this.state = {
  121. loading: true,
  122. error: false,
  123. currentStep: step,
  124. completedSteps: new Set(),
  125. formData: null,
  126. billingConfig: null,
  127. };
  128. }
  129. state: State;
  130. componentDidMount() {
  131. const {subscription, organization} = this.props;
  132. /**
  133. * Preload Stripe so it's ready when the subscription + cc form becomes
  134. * available. `loadStripe` ensures Stripe is not loaded multiple times
  135. */
  136. loadStripe();
  137. if (!subscription.canSelfServe) {
  138. this.handleRedirect();
  139. } else {
  140. this.fetchBillingConfig();
  141. }
  142. if (organization) {
  143. trackGetsentryAnalytics('am_checkout.viewed', {organization, subscription});
  144. }
  145. }
  146. componentDidUpdate(prevProps: Props) {
  147. const {checkoutTier, subscription} = this.props;
  148. if (checkoutTier === prevProps.checkoutTier) {
  149. return;
  150. }
  151. if (!subscription.canSelfServe) {
  152. this.handleRedirect();
  153. } else {
  154. this.fetchBillingConfig();
  155. }
  156. }
  157. readonly initialStep: number;
  158. get referrer(): string | undefined {
  159. const {location} = this.props;
  160. return location?.query?.referrer;
  161. }
  162. /**
  163. * Managed subscriptions need to go through Sales or Support to make
  164. * changes to their plan and cannot use the self-serve checkout flow
  165. */
  166. handleRedirect() {
  167. const {organization, router} = this.props;
  168. return router.push(normalizeUrl(`/settings/${organization.slug}/billing/overview/`));
  169. }
  170. async fetchBillingConfig() {
  171. const {api, organization, checkoutTier} = this.props;
  172. this.setState({loading: true});
  173. const endpoint = `/customers/${organization.slug}/billing-config/`;
  174. try {
  175. const config = await api.requestPromise(endpoint, {
  176. method: 'GET',
  177. data: {tier: checkoutTier},
  178. });
  179. const planList = this.getPaidPlans(config);
  180. const billingConfig = {...config, planList};
  181. const formData = this.getInitialData(billingConfig);
  182. this.setState({billingConfig, formData});
  183. } catch (error) {
  184. this.setState({error, loading: false});
  185. if (error.status !== 401 && error.status !== 403) {
  186. Sentry.captureException(error);
  187. }
  188. }
  189. this.setState({loading: false});
  190. }
  191. getPaidPlans(billingConfig: BillingConfig) {
  192. const paidPlans = billingConfig.planList.filter(
  193. plan =>
  194. plan.basePrice &&
  195. plan.userSelectable &&
  196. ((plan.billingInterval === MONTHLY && plan.contractInterval === MONTHLY) ||
  197. (plan.billingInterval === ANNUAL && plan.contractInterval === ANNUAL))
  198. );
  199. if (!paidPlans) {
  200. throw new Error('Cannot get plan options');
  201. }
  202. return paidPlans;
  203. }
  204. get checkoutSteps() {
  205. const {organization, subscription, checkoutTier} = this.props;
  206. const OnDemandStep = hasOnDemandBudgetsFeature(organization, subscription)
  207. ? OnDemandBudgetsStep
  208. : OnDemandSpend;
  209. const preAM3Tiers = [PlanTier.AM1, PlanTier.AM2];
  210. const notAMTier = !isAmPlan(checkoutTier);
  211. if (preAM3Tiers.includes(checkoutTier) || notAMTier) {
  212. // Display for AM1 and AM2 tiers, and non-AM tiers (e.g. L1)
  213. return [
  214. PlanSelect,
  215. AddDataVolume,
  216. OnDemandStep,
  217. ContractSelect,
  218. AddPaymentMethod,
  219. AddBillingDetails,
  220. ReviewAndConfirm,
  221. ];
  222. }
  223. // Do not include Payment Method and Billing Details sections for subscriptions billed through partners
  224. if (subscription.isSelfServePartner) {
  225. if (hasActiveVCFeature(organization)) {
  226. // Don't allow VC customers to choose Annual plans
  227. return [PlanSelect, SetBudgetAndReserves, ReviewAndConfirm];
  228. }
  229. return [PlanSelect, SetBudgetAndReserves, ContractSelect, ReviewAndConfirm];
  230. }
  231. // Display for AM3 tiers and above
  232. return [
  233. PlanSelect,
  234. SetBudgetAndReserves,
  235. ContractSelect,
  236. AddPaymentMethod,
  237. AddBillingDetails,
  238. ReviewAndConfirm,
  239. ];
  240. }
  241. get activePlan() {
  242. const {formData} = this.state;
  243. const activePlan = formData && this.getPlan(formData.plan);
  244. if (!activePlan) {
  245. throw new Error('Cannot get active plan');
  246. }
  247. return activePlan;
  248. }
  249. getPlan(plan: string) {
  250. const {billingConfig} = this.state;
  251. return billingConfig?.planList.find(({id}) => id === plan);
  252. }
  253. /**
  254. * Default to the business plan if:
  255. * 1. The account has an upsell/upgrade referrer
  256. * 2. The subscription is free
  257. * 3. Or, the subscription is on a free trial
  258. */
  259. shouldDefaultToBusiness() {
  260. const {subscription} = this.props;
  261. const hasUpsell =
  262. (this.referrer?.startsWith('upgrade') || this.referrer?.startsWith('upsell')) &&
  263. this.initialStep === 1;
  264. return hasUpsell || subscription.isFree || subscription.isTrial;
  265. }
  266. getBusinessPlan(billingConfig: BillingConfig) {
  267. const {subscription} = this.props;
  268. const {planList} = billingConfig;
  269. return planList.find(({name, contractInterval}) => {
  270. return (
  271. name === 'Business' &&
  272. contractInterval === subscription?.planDetails?.contractInterval
  273. );
  274. });
  275. }
  276. /**
  277. * Logic for initial plan:
  278. * 1. Default to the business plan
  279. * 2. Then default to the current paid plan
  280. * 3. Then default to an equivalent paid plan (mm2 Business -> am1 Business)
  281. * 4. Then default to the server default plan (Team)
  282. */
  283. getInitialPlan(billingConfig: BillingConfig) {
  284. const {subscription, checkoutTier} = this.props;
  285. const {planList, defaultPlan} = billingConfig;
  286. const initialPlan = planList.find(({id}) => id === subscription.plan);
  287. if (this.shouldDefaultToBusiness()) {
  288. const plan = this.getBusinessPlan(billingConfig);
  289. if (plan) {
  290. return plan;
  291. }
  292. }
  293. // Current tier paid plan
  294. if (initialPlan) {
  295. return initialPlan;
  296. }
  297. // map bundle plans
  298. if (subscription.planDetails.name === PlanName.BUSINESS_BUNDLE) {
  299. return planList.find(
  300. p => p.name === PlanName.BUSINESS && p.contractInterval === 'monthly'
  301. );
  302. }
  303. if (subscription.planDetails.name === PlanName.TEAM_BUNDLE) {
  304. return planList.find(
  305. p => p.name === PlanName.TEAM && p.contractInterval === 'monthly'
  306. );
  307. }
  308. // find equivalent current plan for legacy
  309. const legacyInitialPlan =
  310. subscription.planTier !== checkoutTier &&
  311. planList.find(
  312. ({name, contractInterval}) =>
  313. name === subscription?.planDetails?.name &&
  314. contractInterval === subscription?.planDetails?.contractInterval
  315. );
  316. return legacyInitialPlan || planList.find(({id}) => id === defaultPlan);
  317. }
  318. canComparePrices(initialPlan: Plan) {
  319. const {subscription} = this.props;
  320. return (
  321. // MMx event buckets are priced differently
  322. hasPerformance(subscription?.planDetails) &&
  323. subscription.planDetails.name === initialPlan.name &&
  324. subscription.planDetails.billingInterval === initialPlan.billingInterval
  325. );
  326. }
  327. /**
  328. * Get the current subscription plan and event volumes.
  329. * If not available on current tier, use the default plan.
  330. */
  331. getInitialData(billingConfig: BillingConfig): CheckoutFormData {
  332. const {subscription} = this.props;
  333. const {onDemandMaxSpend, planDetails} = subscription;
  334. const initialPlan = this.getInitialPlan(billingConfig);
  335. if (!initialPlan) {
  336. throw new Error('Cannot get initial plan');
  337. }
  338. const canComparePrices = this.canComparePrices(initialPlan);
  339. // Default to the max event volume per category based on either
  340. // the current reserved volume or the current reserved price.
  341. const reserved = Object.fromEntries(
  342. Object.entries(planDetails.planCategories)
  343. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  344. .filter(([category, _]) => initialPlan.planCategories[category])
  345. .map(([category, eventBuckets]) => {
  346. const currentHistory = subscription.categories[category as DataCategories];
  347. // When introducing a new category before backfilling, the reserved value from the billing metric
  348. // history is not available, so we default to 0.
  349. let events = currentHistory?.reserved || 0;
  350. if (canComparePrices) {
  351. const price = getBucket({events, buckets: eventBuckets}).price;
  352. const eventsByPrice = getBucket({
  353. price,
  354. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  355. buckets: initialPlan.planCategories[category],
  356. }).events;
  357. events = Math.max(events, eventsByPrice);
  358. }
  359. return [category, events];
  360. })
  361. );
  362. const defaultReservedCategories = Object.entries(billingConfig.defaultReserved).map(
  363. ([k, _]) => k
  364. );
  365. // this is the customer's reserved values that overlap with
  366. // the categories in the new checkout plan
  367. // e.g. AM2 customers checking out an AM3 plan will have
  368. // reserved transactions in AM2 but do not need reserved transactions in AM3
  369. const reservedOverlapping = Object.fromEntries(
  370. Object.entries(reserved).filter(([k, _]) => defaultReservedCategories.includes(k))
  371. );
  372. const data = {
  373. reserved: {
  374. ...billingConfig.defaultReserved,
  375. ...reservedOverlapping,
  376. },
  377. ...(onDemandMaxSpend > 0 && {onDemandMaxSpend}),
  378. onDemandBudget: parseOnDemandBudgetsFromSubscription(subscription),
  379. };
  380. return this.getValidData(initialPlan, data);
  381. }
  382. getValidData(plan: Plan, data: Omit<CheckoutFormData, 'plan'>): CheckoutFormData {
  383. const {subscription, organization, checkoutTier} = this.props;
  384. const {onDemandMaxSpend, onDemandBudget} = data;
  385. // Verify next plan data volumes before updating form data
  386. // finds the approximate bucket if event level does not exist
  387. const nextReserved = Object.fromEntries(
  388. Object.entries(data.reserved).map(([category, value]) => [
  389. category,
  390. getBucket({
  391. events: value,
  392. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  393. buckets: plan.planCategories[category],
  394. shouldMinimize: hasPartnerMigrationFeature(organization),
  395. }).events,
  396. ])
  397. );
  398. const onDemandSupported = plan.allowOnDemand && subscription.supportsOnDemand;
  399. // reset ondemand if not allowed or supported
  400. let newOnDemandMaxSpend = onDemandSupported ? onDemandMaxSpend : 0;
  401. if (typeof newOnDemandMaxSpend === 'number') {
  402. newOnDemandMaxSpend = Math.max(newOnDemandMaxSpend, 0);
  403. }
  404. let newOnDemandBudget: OnDemandBudgets | undefined = undefined;
  405. if (
  406. hasOnDemandBudgetsFeature(organization, subscription) ||
  407. checkoutTier === PlanTier.AM3
  408. ) {
  409. newOnDemandBudget =
  410. onDemandBudget && onDemandSupported
  411. ? onDemandBudget
  412. : {
  413. budgetMode: OnDemandBudgetMode.SHARED,
  414. sharedMaxBudget: 0,
  415. };
  416. newOnDemandMaxSpend = getTotalBudget(newOnDemandBudget);
  417. }
  418. return {
  419. plan: plan.id,
  420. onDemandMaxSpend: newOnDemandMaxSpend,
  421. onDemandBudget: newOnDemandBudget,
  422. reserved: nextReserved,
  423. };
  424. }
  425. handleUpdate = (updatedData: any) => {
  426. const {organization, subscription, checkoutTier} = this.props;
  427. const {formData} = this.state;
  428. const data = {...formData, ...updatedData};
  429. const plan = this.getPlan(data.plan) || this.activePlan;
  430. const validData = this.getValidData(plan, data);
  431. this.setState({
  432. formData: validData,
  433. });
  434. const analyticsParams = {
  435. organization,
  436. subscription,
  437. plan: plan.id,
  438. };
  439. if (this.state.currentStep === 1) {
  440. trackGetsentryAnalytics('checkout.change_plan', analyticsParams);
  441. } else if (checkoutTier !== PlanTier.AM3 && this.state.currentStep === 3) {
  442. trackGetsentryAnalytics('checkout.ondemand_changed', {
  443. ...analyticsParams,
  444. cents: validData.onDemandMaxSpend || 0,
  445. });
  446. } else if (
  447. (checkoutTier === PlanTier.AM3 && this.state.currentStep === 3) ||
  448. (checkoutTier !== PlanTier.AM3 && this.state.currentStep === 4)
  449. ) {
  450. trackGetsentryAnalytics('checkout.change_contract', analyticsParams);
  451. }
  452. if (!isEqual(validData.reserved, data.reserved)) {
  453. Sentry.withScope(scope => {
  454. scope.setExtras({validData, updatedData, previous: formData});
  455. scope.setLevel('warning' as any);
  456. Sentry.captureException(new Error('Plan event levels do not match'));
  457. });
  458. }
  459. };
  460. /**
  461. * Complete step and all previous steps
  462. */
  463. handleCompleteStep = (stepNumber: number) => {
  464. const {organization, subscription} = this.props;
  465. const previousSteps = Array.from({length: stepNumber}, (_, idx) => idx + 1);
  466. trackGetsentryAnalytics('checkout.click_continue', {
  467. organization,
  468. subscription,
  469. step_number: stepNumber,
  470. plan: this.activePlan.id,
  471. checkoutType: CheckoutType.STANDARD,
  472. });
  473. this.setState(state => ({
  474. currentStep: state.currentStep + 1,
  475. completedSteps: new Set([...state.completedSteps, ...previousSteps]),
  476. }));
  477. };
  478. handleEdit = (stepNumber: number) => {
  479. this.setState({
  480. currentStep: stepNumber,
  481. });
  482. };
  483. renderSteps() {
  484. const {organization, onToggleLegacy, subscription, checkoutTier, promotionData} =
  485. this.props;
  486. const {currentStep, completedSteps, formData, billingConfig} = this.state;
  487. const promoClaimed = getCompletedOrActivePromotion(promotionData);
  488. if (!formData || !billingConfig) {
  489. return null;
  490. }
  491. const promotion = promoClaimed?.promotion;
  492. const stepProps = {
  493. formData,
  494. billingConfig,
  495. activePlan: this.activePlan,
  496. onUpdate: this.handleUpdate,
  497. onCompleteStep: this.handleCompleteStep,
  498. onEdit: this.handleEdit,
  499. onToggleLegacy,
  500. organization,
  501. subscription,
  502. checkoutTier,
  503. promotion,
  504. };
  505. return this.checkoutSteps.map((CheckoutStep, idx) => {
  506. const stepNumber = idx + 1;
  507. const isActive = currentStep === stepNumber;
  508. const isCompleted = !isActive && completedSteps.has(stepNumber);
  509. const prevStepCompleted = completedSteps.has(stepNumber - 1);
  510. return (
  511. <CheckoutStep
  512. {...stepProps}
  513. key={idx}
  514. stepNumber={stepNumber}
  515. isActive={isActive}
  516. isCompleted={isCompleted}
  517. prevStepCompleted={prevStepCompleted}
  518. referrer={this.referrer}
  519. />
  520. );
  521. });
  522. }
  523. render() {
  524. const {subscription, organization, isLoading, promotionData, checkoutTier} =
  525. this.props;
  526. const {loading, error, formData, billingConfig} = this.state;
  527. if (loading || isLoading) {
  528. return <LoadingIndicator />;
  529. }
  530. if (error) {
  531. return <LoadingError />;
  532. }
  533. if (!formData || !billingConfig) {
  534. return null;
  535. }
  536. const promotionClaimed = getCompletedOrActivePromotion(promotionData);
  537. const promo = promotionClaimed?.promotion;
  538. const discountInfo = promo?.discountInfo;
  539. const subscriptionDiscountInfo = showSubscriptionDiscount({
  540. activePlan: this.activePlan,
  541. discountInfo,
  542. });
  543. const overviewProps = {
  544. formData,
  545. billingConfig,
  546. activePlan: this.activePlan,
  547. onUpdate: this.handleUpdate,
  548. organization,
  549. subscription,
  550. discountInfo: discountInfo ?? undefined,
  551. };
  552. const showAnnualTerms =
  553. subscription.contractInterval === ANNUAL ||
  554. this.activePlan.contractInterval === ANNUAL;
  555. const promotionDisclaimerText =
  556. promotionData?.activePromotions?.[0]?.promotion.discountInfo.disclaimerText;
  557. const isOnSponsoredPartnerPlan =
  558. (subscription.partner?.isActive && subscription.isSponsored) || false;
  559. return (
  560. <Fragment>
  561. <SentryDocumentTitle
  562. title={t('Change Subscription')}
  563. orgSlug={organization.slug}
  564. />
  565. {isOnSponsoredPartnerPlan && (
  566. <Alert.Container>
  567. <Alert type="info" showIcon>
  568. {t(
  569. 'Your promotional plan with %s ends on %s.',
  570. subscription.partner?.partnership.displayName,
  571. moment(subscription.contractPeriodEnd).format('ll')
  572. )}
  573. </Alert>
  574. </Alert.Container>
  575. )}
  576. {promotionDisclaimerText && (
  577. <Alert.Container>
  578. <Alert type="info" showIcon>
  579. {promotionDisclaimerText}
  580. </Alert>
  581. </Alert.Container>
  582. )}
  583. <SettingsPageHeader
  584. title="Change Subscription"
  585. colorSubtitle={subscriptionDiscountInfo}
  586. data-test-id="change-subscription"
  587. />
  588. <CheckoutContainer>
  589. <div data-test-id="checkout-steps">{this.renderSteps()}</div>
  590. <SidePanel>
  591. <OverviewContainer>
  592. {checkoutTier === PlanTier.AM3 ? (
  593. <CheckoutOverviewV2 {...overviewProps} />
  594. ) : (
  595. <CheckoutOverview {...overviewProps} />
  596. )}
  597. <SupportPrompt>
  598. {t('Have a question?')}
  599. <TextOverflow>
  600. {tct('[help:Find an Answer] or [contact:Ask Support]', {
  601. help: (
  602. <ExternalLink href="https://sentry.zendesk.com/hc/en-us/categories/17135853065755-Account-Billing" />
  603. ),
  604. contact: <ZendeskLink subject="Billing Question" source="checkout" />,
  605. })}
  606. </TextOverflow>
  607. </SupportPrompt>
  608. </OverviewContainer>
  609. <DisclaimerText>{discountInfo?.disclaimerText}</DisclaimerText>
  610. {subscription.canCancel && (
  611. <CancelSubscription>
  612. <Button
  613. to={`/settings/${organization.slug}/billing/cancel/`}
  614. disabled={subscription.cancelAtPeriodEnd}
  615. >
  616. {subscription.cancelAtPeriodEnd
  617. ? t('Pending Cancellation')
  618. : t('Cancel Subscription')}
  619. </Button>
  620. </CancelSubscription>
  621. )}
  622. {showAnnualTerms && (
  623. <AnnualTerms>
  624. {tct(
  625. `Annual subscriptions require a one-year non-cancellable commitment.
  626. By using Sentry you agree to our [terms: Terms of Service].`,
  627. {terms: <a href="https://sentry.io/terms/" />}
  628. )}
  629. </AnnualTerms>
  630. )}
  631. </SidePanel>
  632. </CheckoutContainer>
  633. </Fragment>
  634. );
  635. }
  636. }
  637. const CheckoutContainer = styled('div')`
  638. display: grid;
  639. gap: ${space(3)};
  640. grid-template-columns: 58% auto;
  641. @media (max-width: ${p => p.theme.breakpoints.large}) {
  642. grid-template-columns: auto;
  643. }
  644. `;
  645. const SidePanel = styled('div')`
  646. height: max-content;
  647. position: sticky;
  648. top: 70px;
  649. align-self: start;
  650. `;
  651. /**
  652. * Hide overview at smaller screen sizes
  653. * but keep cancel subscription button
  654. */
  655. const OverviewContainer = styled('div')`
  656. @media (max-width: ${p => p.theme.breakpoints.large}) {
  657. display: none;
  658. }
  659. `;
  660. const SupportPrompt = styled(Panel)`
  661. display: grid;
  662. grid-template-columns: repeat(2, auto);
  663. justify-content: space-between;
  664. gap: ${space(1)};
  665. padding: ${space(2)};
  666. font-size: ${p => p.theme.fontSizeMedium};
  667. color: ${p => p.theme.subText};
  668. align-items: center;
  669. `;
  670. const CancelSubscription = styled('div')`
  671. display: grid;
  672. justify-items: center;
  673. margin-bottom: ${space(3)};
  674. `;
  675. const DisclaimerText = styled('div')`
  676. font-size: ${p => p.theme.fontSizeMedium};
  677. color: ${p => p.theme.subText};
  678. text-align: center;
  679. margin-bottom: ${space(1)};
  680. `;
  681. const AnnualTerms = styled(TextBlock)`
  682. color: ${p => p.theme.subText};
  683. font-size: ${p => p.theme.fontSizeMedium};
  684. `;
  685. export default withPromotions(withApi(withOrganization(withSubscription(AMCheckout))));