reviewAndConfirm.spec.tsx 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966
  1. import moment from 'moment-timezone';
  2. import {OrganizationFixture} from 'sentry-fixture/organization';
  3. import {RouteComponentPropsFixture} from 'sentry-fixture/routeComponentPropsFixture';
  4. import {BillingConfigFixture} from 'getsentry-test/fixtures/billingConfig';
  5. import {InvoicePreviewFixture} from 'getsentry-test/fixtures/invoicePreview';
  6. import {MetricHistoryFixture} from 'getsentry-test/fixtures/metricHistory';
  7. import {PlanDetailsLookupFixture} from 'getsentry-test/fixtures/planDetailsLookup';
  8. import {ProjectFixture} from 'getsentry-test/fixtures/project';
  9. import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription';
  10. import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
  11. import {addErrorMessage} from 'sentry/actionCreators/indicator';
  12. import {browserHistory} from 'sentry/utils/browserHistory';
  13. import SubscriptionStore from 'getsentry/stores/subscriptionStore';
  14. import {PlanTier} from 'getsentry/types';
  15. import trackGetsentryAnalytics from 'getsentry/utils/trackGetsentryAnalytics';
  16. import AMCheckout from 'getsentry/views/amCheckout/';
  17. import {getCheckoutAPIData} from '../utils';
  18. import ReviewAndConfirm from './reviewAndConfirm';
  19. jest.mock('sentry/actionCreators/indicator');
  20. jest.mock('getsentry/utils/trackGetsentryAnalytics');
  21. jest.mock('getsentry/utils/stripe', () => ({
  22. loadStripe: (cb: any) => {
  23. if (!cb) {
  24. return;
  25. }
  26. cb(() => ({
  27. handleCardAction(secretKey: string, _options: any) {
  28. if (secretKey === 'ERROR') {
  29. return new Promise(resolve => {
  30. resolve({error: {message: 'Invalid card', type: 'card_error'}});
  31. });
  32. }
  33. if (secretKey === 'GENERIC_ERROR') {
  34. return new Promise(resolve => {
  35. resolve({
  36. error: {
  37. message: 'Something bad that users should not see',
  38. type: 'internal_error',
  39. },
  40. });
  41. });
  42. }
  43. return new Promise(resolve => {
  44. resolve({setupIntent: {payment_method: 'pm_abc123'}});
  45. });
  46. },
  47. }));
  48. },
  49. }));
  50. describe('AmCheckout > ReviewAndConfirm', function () {
  51. const api = new MockApiClient();
  52. const organization = OrganizationFixture();
  53. const subscription = SubscriptionFixture({organization});
  54. const params = {};
  55. const bizPlan = PlanDetailsLookupFixture('am1_business')!;
  56. const billingConfig = BillingConfigFixture(PlanTier.AM2);
  57. const formData = {
  58. plan: billingConfig.defaultPlan,
  59. reserved: billingConfig.defaultReserved,
  60. };
  61. const stepProps = {
  62. stepNumber: 6,
  63. onUpdate: jest.fn(),
  64. onCompleteStep: jest.fn(),
  65. onEdit: jest.fn(),
  66. billingConfig,
  67. formData,
  68. activePlan: bizPlan,
  69. organization,
  70. subscription,
  71. isActive: false,
  72. isCompleted: false,
  73. prevStepCompleted: false,
  74. };
  75. function mockPreviewGet(slug = organization.slug, effectiveAt: Date | null = null) {
  76. const preview = InvoicePreviewFixture();
  77. if (effectiveAt) {
  78. preview.effectiveAt = effectiveAt.toISOString();
  79. }
  80. const mockPreview = MockApiClient.addMockResponse({
  81. url: `/customers/${slug}/subscription/preview/`,
  82. method: 'GET',
  83. body: preview,
  84. });
  85. return {mockPreview, preview};
  86. }
  87. function mockSubscriptionPut(mockParams = {}, slug = organization.slug) {
  88. return MockApiClient.addMockResponse({
  89. url: `/customers/${slug}/subscription/`,
  90. method: 'PUT',
  91. ...mockParams,
  92. });
  93. }
  94. beforeEach(function () {
  95. SubscriptionStore.set(organization.slug, subscription);
  96. MockApiClient.clearMockResponses();
  97. MockApiClient.addMockResponse({
  98. url: `/subscriptions/${organization.slug}/`,
  99. method: 'GET',
  100. body: {},
  101. });
  102. MockApiClient.addMockResponse({
  103. url: `/organizations/${organization.slug}/`,
  104. method: 'GET',
  105. body: organization,
  106. });
  107. MockApiClient.addMockResponse({
  108. url: `/organizations/${organization.slug}/projects/`,
  109. method: 'GET',
  110. body: [ProjectFixture({})],
  111. });
  112. MockApiClient.addMockResponse({
  113. url: `/organizations/${organization.slug}/teams/`,
  114. method: 'GET',
  115. body: [],
  116. });
  117. MockApiClient.addMockResponse({
  118. url: `/customers/${organization.slug}/billing-config/`,
  119. method: 'GET',
  120. body: BillingConfigFixture(PlanTier.AM2),
  121. });
  122. MockApiClient.addMockResponse({
  123. url: `/customers/${organization.slug}/plan-migrations/?applied=0`,
  124. method: 'GET',
  125. body: [],
  126. });
  127. });
  128. it('cannot skip to review step', async function () {
  129. mockPreviewGet();
  130. MockApiClient.addMockResponse({
  131. url: `/organizations/${organization.slug}/promotions/trigger-check/`,
  132. method: 'POST',
  133. });
  134. render(
  135. <AMCheckout
  136. {...RouteComponentPropsFixture()}
  137. params={params}
  138. api={api}
  139. onToggleLegacy={jest.fn()}
  140. checkoutTier={subscription.planTier as PlanTier}
  141. />
  142. );
  143. const heading = await screen.findByText('Review & Confirm');
  144. expect(heading).toBeInTheDocument();
  145. // Submit should not be visible
  146. expect(screen.queryByText('Confirm Changes')).not.toBeInTheDocument();
  147. // Clicking the heading should not reveal the submit button
  148. await userEvent.click(heading);
  149. expect(screen.queryByText('Confirm Changes')).not.toBeInTheDocument();
  150. });
  151. it('renders closed', function () {
  152. const {mockPreview} = mockPreviewGet();
  153. render(<ReviewAndConfirm {...stepProps} />);
  154. // Submit should not be visible
  155. expect(screen.queryByText('Confirm Changes')).not.toBeInTheDocument();
  156. expect(mockPreview).not.toHaveBeenCalled();
  157. });
  158. it('renders open when active', async function () {
  159. const {preview, mockPreview} = mockPreviewGet();
  160. render(<ReviewAndConfirm {...stepProps} isActive />);
  161. expect(
  162. await screen.findByText(preview.invoiceItems[0]!.description)
  163. ).toBeInTheDocument();
  164. expect(screen.getByText(preview.invoiceItems[1]!.description)).toBeInTheDocument();
  165. expect(screen.getByText(preview.invoiceItems[2]!.description)).toBeInTheDocument();
  166. expect(screen.getByText(preview.invoiceItems[3]!.description)).toBeInTheDocument();
  167. expect(screen.getByTestId('dates')).toBeInTheDocument();
  168. expect(screen.getAllByText('$89')).toHaveLength(2);
  169. expect(screen.getByRole('button', {name: 'Confirm Changes'})).toBeInTheDocument();
  170. expect(screen.queryByRole('button', {name: 'Migrate Now'})).not.toBeInTheDocument();
  171. expect(mockPreview).toHaveBeenCalledWith(
  172. `/customers/${organization.slug}/subscription/preview/`,
  173. expect.objectContaining({
  174. method: 'GET',
  175. data: getCheckoutAPIData({formData, isPreview: true}),
  176. })
  177. );
  178. });
  179. it('requests preview with ondemand spend', async function () {
  180. const {mockPreview, preview} = mockPreviewGet();
  181. const updatedData = {...formData, onDemandMaxSpend: 5000};
  182. render(<ReviewAndConfirm {...stepProps} formData={updatedData} isActive />);
  183. expect(
  184. await screen.findByText(preview.invoiceItems[0]!.description)
  185. ).toBeInTheDocument();
  186. expect(mockPreview).toHaveBeenCalledWith(
  187. `/customers/${organization.slug}/subscription/preview/`,
  188. expect.objectContaining({
  189. method: 'GET',
  190. data: getCheckoutAPIData({formData: updatedData, isPreview: true}),
  191. })
  192. );
  193. });
  194. it('updates preview with formData change when active', async function () {
  195. const {preview, mockPreview} = mockPreviewGet();
  196. const {rerender} = render(<ReviewAndConfirm {...stepProps} />);
  197. expect(await screen.findByText('Review & Confirm')).toBeInTheDocument();
  198. expect(screen.queryByText('Confirm Changes')).not.toBeInTheDocument();
  199. expect(mockPreview).not.toHaveBeenCalled();
  200. const updatedData = {...formData, plan: 'am1_business_auf'};
  201. rerender(<ReviewAndConfirm {...stepProps} isActive formData={updatedData} />);
  202. // Wait for invoice to render.
  203. expect(
  204. await screen.findByText(preview.invoiceItems[0]!.description)
  205. ).toBeInTheDocument();
  206. expect(screen.getByText('Confirm Changes')).toBeInTheDocument();
  207. expect(mockPreview).toHaveBeenCalledWith(
  208. `/customers/${organization.slug}/subscription/preview/`,
  209. expect.objectContaining({
  210. method: 'GET',
  211. data: getCheckoutAPIData({formData: updatedData, isPreview: true}),
  212. })
  213. );
  214. });
  215. it('can confirm changes', async function () {
  216. const {preview} = mockPreviewGet();
  217. const mockConfirm = mockSubscriptionPut();
  218. const reservedErrors = 100000;
  219. const updatedData = {
  220. ...formData,
  221. reserved: {...formData.reserved, errors: reservedErrors},
  222. };
  223. render(<ReviewAndConfirm {...stepProps} formData={updatedData} isActive />);
  224. await userEvent.click(await screen.findByText('Confirm Changes'));
  225. expect(mockConfirm).toHaveBeenCalledWith(
  226. `/customers/${organization.slug}/subscription/`,
  227. expect.objectContaining({
  228. method: 'PUT',
  229. data: getCheckoutAPIData({
  230. formData: updatedData,
  231. previewToken: preview.previewToken,
  232. }),
  233. })
  234. );
  235. // No DOM updates to wait on, but we can use this.
  236. await waitFor(() =>
  237. expect(browserHistory.push).toHaveBeenCalledWith(
  238. `/settings/${organization.slug}/billing/overview/?open_codecov_modal=1&referrer=checkout`
  239. )
  240. );
  241. expect(trackGetsentryAnalytics).toHaveBeenCalledWith('checkout.upgrade', {
  242. organization,
  243. subscription,
  244. previous_plan: 'am1_f',
  245. previous_errors: 5000,
  246. previous_transactions: 10_000,
  247. previous_attachments: 1,
  248. previous_replays: 50,
  249. previous_monitorSeats: 1,
  250. previous_profileDuration: undefined,
  251. previous_spans: undefined,
  252. previous_uptime: 1,
  253. plan: updatedData.plan,
  254. errors: updatedData.reserved.errors,
  255. transactions: updatedData.reserved.transactions,
  256. attachments: updatedData.reserved.attachments,
  257. replays: updatedData.reserved.replays,
  258. monitorSeats: updatedData.reserved.monitorSeats,
  259. spans: undefined,
  260. uptime: 1,
  261. });
  262. expect(trackGetsentryAnalytics).toHaveBeenCalledWith(
  263. 'checkout.transactions_upgrade',
  264. {
  265. organization,
  266. subscription,
  267. plan: updatedData.plan,
  268. transactions: updatedData.reserved.transactions,
  269. previous_transactions: 10_000,
  270. }
  271. );
  272. });
  273. it('can schedule changes for partner migration', async function () {
  274. const partnerOrg = OrganizationFixture({features: ['partner-billing-migration']});
  275. const partnerSub = SubscriptionFixture({
  276. organization: partnerOrg,
  277. partner: {
  278. externalId: 'whateva',
  279. isActive: true,
  280. partnership: {
  281. id: 'FOO',
  282. displayName: 'FOO',
  283. supportNote: '',
  284. },
  285. name: '',
  286. },
  287. contractPeriodEnd: moment().add(7, 'days').toString(),
  288. });
  289. const {preview} = mockPreviewGet(partnerOrg.slug);
  290. const mockConfirm = mockSubscriptionPut(partnerOrg.slug);
  291. const updatedData = {
  292. plan: 'am3_business',
  293. reserved: {
  294. errors: 100_000,
  295. replays: 5000,
  296. spans: 10_000_000,
  297. attachments: 1,
  298. monitorSeats: 1,
  299. profileDuration: 0,
  300. uptime: 1,
  301. },
  302. };
  303. const partnerStepProps = {
  304. ...stepProps,
  305. organization: partnerOrg,
  306. subscription: partnerSub,
  307. };
  308. render(<ReviewAndConfirm {...partnerStepProps} formData={updatedData} isActive />);
  309. expect(
  310. await screen.findByText(
  311. `These changes will take effect at the end of your current FOO sponsored plan on ${moment(partnerSub.contractPeriodEnd).add(1, 'days').format('ll')}. If you want these changes to apply immediately, select Migrate Now.`
  312. )
  313. ).toBeInTheDocument();
  314. await userEvent.click(await screen.findByText('Schedule Changes'));
  315. expect(mockConfirm).toHaveBeenCalledWith(
  316. `/customers/${partnerOrg.slug}/subscription/`,
  317. expect.objectContaining({
  318. method: 'PUT',
  319. data: getCheckoutAPIData({
  320. formData: updatedData,
  321. previewToken: preview.previewToken,
  322. }),
  323. })
  324. );
  325. // No DOM updates to wait on, but we can use this.
  326. await waitFor(() =>
  327. expect(browserHistory.push).toHaveBeenCalledWith(
  328. `/settings/${partnerOrg.slug}/billing/overview/?open_codecov_modal=1&referrer=checkout`
  329. )
  330. );
  331. expect(trackGetsentryAnalytics).toHaveBeenCalledWith('checkout.upgrade', {
  332. organization: partnerOrg,
  333. subscription: partnerSub,
  334. previous_plan: 'am1_f',
  335. previous_errors: 5000,
  336. previous_transactions: 10_000,
  337. previous_attachments: 1,
  338. previous_replays: 50,
  339. previous_monitorSeats: 1,
  340. previous_profileDuration: undefined,
  341. previous_spans: undefined,
  342. previous_uptime: 1,
  343. plan: updatedData.plan,
  344. errors: updatedData.reserved.errors,
  345. transactions: undefined,
  346. attachments: updatedData.reserved.attachments,
  347. replays: updatedData.reserved.replays,
  348. monitorSeats: updatedData.reserved.monitorSeats,
  349. spans: updatedData.reserved.spans,
  350. profileDuration: updatedData.reserved.profileDuration,
  351. uptime: updatedData.reserved.uptime,
  352. });
  353. expect(trackGetsentryAnalytics).toHaveBeenCalledWith(
  354. 'partner_billing_migration.checkout.completed',
  355. {
  356. organization: partnerOrg,
  357. subscription: partnerSub,
  358. applyNow: false,
  359. daysLeft: 7,
  360. partner: 'FOO',
  361. }
  362. );
  363. });
  364. it('can migrate immediately for partner migration', async function () {
  365. const partnerOrg = OrganizationFixture({features: ['partner-billing-migration']});
  366. const partnerSub = SubscriptionFixture({
  367. organization: partnerOrg,
  368. partner: {
  369. externalId: 'whateva',
  370. isActive: true,
  371. partnership: {
  372. id: 'FOO',
  373. displayName: 'FOO',
  374. supportNote: '',
  375. },
  376. name: '',
  377. },
  378. contractPeriodEnd: moment().add(20, 'days').toString(),
  379. });
  380. const {preview} = mockPreviewGet(partnerOrg.slug);
  381. const mockConfirm = mockSubscriptionPut(partnerOrg.slug);
  382. const updatedData = {
  383. plan: 'am3_business',
  384. reserved: {
  385. errors: 100_000,
  386. replays: 5000,
  387. spans: 10_000_000,
  388. attachments: 1,
  389. monitorSeats: 1,
  390. },
  391. };
  392. const partnerStepProps = {
  393. ...stepProps,
  394. organization: partnerOrg,
  395. subscription: partnerSub,
  396. };
  397. render(<ReviewAndConfirm {...partnerStepProps} formData={updatedData} isActive />);
  398. expect(
  399. await screen.findByText(
  400. `These changes will take effect at the end of your current FOO sponsored plan on ${moment(partnerSub.contractPeriodEnd).add(1, 'days').format('ll')}. If you want these changes to apply immediately, select Migrate Now.`
  401. )
  402. ).toBeInTheDocument();
  403. await userEvent.click(await screen.findByText('Migrate Now'));
  404. expect(mockConfirm).toHaveBeenCalledWith(
  405. `/customers/${partnerOrg.slug}/subscription/`,
  406. expect.objectContaining({
  407. method: 'PUT',
  408. data: getCheckoutAPIData({
  409. formData: {...updatedData, applyNow: true},
  410. previewToken: preview.previewToken,
  411. }),
  412. })
  413. );
  414. // No DOM updates to wait on, but we can use this.
  415. await waitFor(() =>
  416. expect(browserHistory.push).toHaveBeenCalledWith(
  417. `/settings/${partnerOrg.slug}/billing/overview/?open_codecov_modal=1&referrer=checkout`
  418. )
  419. );
  420. expect(trackGetsentryAnalytics).toHaveBeenCalledWith('checkout.upgrade', {
  421. organization: partnerOrg,
  422. subscription: partnerSub,
  423. previous_plan: 'am1_f',
  424. previous_errors: 5000,
  425. previous_transactions: 10_000,
  426. previous_attachments: 1,
  427. previous_replays: 50,
  428. previous_monitorSeats: 1,
  429. previous_profileDuration: undefined,
  430. previous_spans: undefined,
  431. plan: updatedData.plan,
  432. errors: updatedData.reserved.errors,
  433. transactions: undefined,
  434. attachments: updatedData.reserved.attachments,
  435. replays: updatedData.reserved.replays,
  436. monitorSeats: updatedData.reserved.monitorSeats,
  437. spans: updatedData.reserved.spans,
  438. previous_uptime: 1,
  439. });
  440. expect(trackGetsentryAnalytics).toHaveBeenCalledWith(
  441. 'partner_billing_migration.checkout.completed',
  442. {
  443. organization: partnerOrg,
  444. subscription: partnerSub,
  445. applyNow: true,
  446. daysLeft: 20,
  447. partner: 'FOO',
  448. }
  449. );
  450. });
  451. it('should render immediate copy for effectiveNow', async function () {
  452. mockPreviewGet(organization.slug, new Date());
  453. mockSubscriptionPut(organization.slug);
  454. const updatedData = {
  455. plan: 'am3_business',
  456. reserved: {
  457. errors: 100_000,
  458. replays: 5000,
  459. spans: 10_000_000,
  460. attachments: 1,
  461. monitorSeats: 1,
  462. },
  463. };
  464. render(<ReviewAndConfirm {...stepProps} formData={updatedData} isActive />);
  465. expect(
  466. await screen.findByText(
  467. `These changes will apply immediately, and you will be billed today.`
  468. )
  469. ).toBeInTheDocument();
  470. });
  471. it('should render contract end copy for effective later', async function () {
  472. mockPreviewGet(organization.slug);
  473. mockSubscriptionPut(organization.slug);
  474. const updatedData = {
  475. plan: 'am3_business',
  476. reserved: {
  477. errors: 100_000,
  478. replays: 5000,
  479. spans: 10_000_000,
  480. attachments: 1,
  481. monitorSeats: 1,
  482. },
  483. };
  484. render(<ReviewAndConfirm {...stepProps} formData={updatedData} isActive />);
  485. expect(
  486. await screen.findByText(
  487. `This change will take effect at the end of your current contract period.`
  488. )
  489. ).toBeInTheDocument();
  490. });
  491. it('should render billed through self serve partner copy for effectiveNow', async function () {
  492. const partnerSub = SubscriptionFixture({
  493. organization,
  494. contractPeriodEnd: moment().add(20, 'days').toString(),
  495. plan: 'am3_f',
  496. planTier: PlanTier.AM3,
  497. isSelfServePartner: true,
  498. partner: {
  499. externalId: 'whateva',
  500. isActive: true,
  501. partnership: {
  502. id: 'FOO',
  503. displayName: 'FOO',
  504. supportNote: '',
  505. },
  506. name: '',
  507. },
  508. });
  509. mockPreviewGet(organization.slug, new Date());
  510. mockSubscriptionPut(organization.slug);
  511. const updatedData = {
  512. plan: 'am3_business',
  513. reserved: {
  514. errors: 100_000,
  515. replays: 5000,
  516. spans: 10_000_000,
  517. attachments: 1,
  518. monitorSeats: 1,
  519. },
  520. };
  521. const partnerStepProps = {
  522. ...stepProps,
  523. subscription: partnerSub,
  524. };
  525. render(<ReviewAndConfirm {...partnerStepProps} formData={updatedData} isActive />);
  526. expect(
  527. await screen.findByText(
  528. `These changes will apply immediately, and you will be billed by FOO monthly for any recurring subscription fees and incurred pay-as-you-go fees.`
  529. )
  530. ).toBeInTheDocument();
  531. });
  532. it('should render billed through self serve partner copy for effective later', async function () {
  533. mockPreviewGet(organization.slug);
  534. mockSubscriptionPut(organization.slug);
  535. const updatedData = {
  536. plan: 'am3_business',
  537. reserved: {
  538. errors: 100_000,
  539. replays: 5000,
  540. spans: 10_000_000,
  541. attachments: 1,
  542. monitorSeats: 1,
  543. },
  544. };
  545. const partnerSub = SubscriptionFixture({
  546. organization,
  547. contractPeriodEnd: moment().add(20, 'days').toString(),
  548. plan: 'am3_f',
  549. planTier: PlanTier.AM3,
  550. isSelfServePartner: true,
  551. partner: {
  552. externalId: 'whateva',
  553. isActive: true,
  554. partnership: {
  555. id: 'FOO',
  556. displayName: 'FOO',
  557. supportNote: '',
  558. },
  559. name: '',
  560. },
  561. });
  562. const partnerStepProps = {
  563. ...stepProps,
  564. subscription: partnerSub,
  565. };
  566. render(<ReviewAndConfirm {...partnerStepProps} formData={updatedData} isActive />);
  567. expect(
  568. await screen.findByText(
  569. `These changes will apply on the date above, and you will be billed by FOO monthly for any recurring subscription fees and incurred pay-as-you-go fees.`
  570. )
  571. ).toBeInTheDocument();
  572. });
  573. it('does not send transactions upgrade event for plan upgrade', async function () {
  574. const {preview} = mockPreviewGet();
  575. const mockConfirm = mockSubscriptionPut();
  576. const sub = SubscriptionFixture({
  577. organization,
  578. plan: 'am1_team',
  579. categories: {
  580. errors: MetricHistoryFixture({reserved: 100_000}),
  581. transactions: MetricHistoryFixture({reserved: 250_000}),
  582. attachments: MetricHistoryFixture({reserved: 1}),
  583. replays: MetricHistoryFixture({reserved: 500}),
  584. monitorSeats: MetricHistoryFixture({reserved: 1}),
  585. },
  586. });
  587. SubscriptionStore.set(organization.slug, sub);
  588. const updatedData = {...formData, plan: 'am1_business'};
  589. const props = {...stepProps, subscription: sub, formData: updatedData};
  590. render(<ReviewAndConfirm {...props} isActive />);
  591. await userEvent.click(await screen.findByText('Confirm Changes'));
  592. expect(mockConfirm).toHaveBeenCalledWith(
  593. `/customers/${organization.slug}/subscription/`,
  594. expect.objectContaining({
  595. method: 'PUT',
  596. data: getCheckoutAPIData({
  597. formData: updatedData,
  598. previewToken: preview.previewToken,
  599. }),
  600. })
  601. );
  602. // No DOM updates to wait on, but we can use this.
  603. await waitFor(() =>
  604. expect(browserHistory.push).toHaveBeenCalledWith(
  605. `/settings/${organization.slug}/billing/overview/?open_codecov_modal=1&referrer=checkout`
  606. )
  607. );
  608. expect(trackGetsentryAnalytics).toHaveBeenCalledWith('checkout.upgrade', {
  609. organization,
  610. subscription: sub,
  611. previous_plan: 'am1_team',
  612. previous_errors: 100000,
  613. previous_transactions: 250000,
  614. previous_attachments: 1,
  615. previous_replays: 500,
  616. previous_monitorSeats: 1,
  617. previous_profileDuration: undefined,
  618. previous_spans: undefined,
  619. plan: 'am1_business',
  620. errors: updatedData.reserved.errors,
  621. transactions: updatedData.reserved.transactions,
  622. attachments: updatedData.reserved.attachments,
  623. replays: updatedData.reserved.replays,
  624. monitorSeats: updatedData.reserved.monitorSeats,
  625. uptime: updatedData.reserved.uptime,
  626. spans: undefined,
  627. });
  628. expect(trackGetsentryAnalytics).not.toHaveBeenCalledWith(
  629. 'checkout.transactions_upgrade'
  630. );
  631. });
  632. it('does not send transactions upgrade event for transactions downgrade', async function () {
  633. const {preview} = mockPreviewGet();
  634. const mockConfirm = mockSubscriptionPut();
  635. const sub = SubscriptionFixture({
  636. organization,
  637. plan: 'am1_team',
  638. categories: {
  639. errors: MetricHistoryFixture({reserved: 100000}),
  640. transactions: MetricHistoryFixture({reserved: 500000}),
  641. attachments: MetricHistoryFixture({reserved: 1}),
  642. replays: MetricHistoryFixture({reserved: 500}),
  643. monitorSeats: MetricHistoryFixture({reserved: 1}),
  644. },
  645. });
  646. SubscriptionStore.set(organization.slug, sub);
  647. const updatedData = {...formData};
  648. const props = {...stepProps, subscription: sub, formData: updatedData};
  649. render(<ReviewAndConfirm {...props} isActive />);
  650. await userEvent.click(await screen.findByText('Confirm Changes'));
  651. expect(mockConfirm).toHaveBeenCalledWith(
  652. `/customers/${organization.slug}/subscription/`,
  653. expect.objectContaining({
  654. method: 'PUT',
  655. data: getCheckoutAPIData({
  656. formData: updatedData,
  657. previewToken: preview.previewToken,
  658. }),
  659. })
  660. );
  661. // No DOM updates to wait on, but we can use this.
  662. await waitFor(() =>
  663. expect(browserHistory.push).toHaveBeenCalledWith(
  664. `/settings/${organization.slug}/billing/overview/?open_codecov_modal=1&referrer=checkout`
  665. )
  666. );
  667. expect(trackGetsentryAnalytics).toHaveBeenCalledWith('checkout.upgrade', {
  668. organization,
  669. subscription: sub,
  670. previous_plan: 'am1_team',
  671. previous_errors: 100000,
  672. previous_transactions: 500000,
  673. previous_attachments: 1,
  674. previous_replays: 500,
  675. previous_monitorSeats: 1,
  676. previous_profileDuration: undefined,
  677. previous_spans: undefined,
  678. plan: updatedData.plan,
  679. errors: updatedData.reserved.errors,
  680. transactions: updatedData.reserved.transactions,
  681. attachments: updatedData.reserved.attachments,
  682. replays: updatedData.reserved.replays,
  683. monitorSeats: updatedData.reserved.monitorSeats,
  684. uptime: updatedData.reserved.uptime,
  685. spans: undefined,
  686. });
  687. expect(trackGetsentryAnalytics).not.toHaveBeenCalledWith(
  688. 'checkout.transactions_upgrade'
  689. );
  690. });
  691. it('can confirm with ondemand spend', async function () {
  692. const {preview} = mockPreviewGet();
  693. const mockConfirm = mockSubscriptionPut();
  694. const updatedData = {...formData, reserved: {errors: 100000}, onDemandMaxSpend: 5000};
  695. render(<ReviewAndConfirm {...stepProps} isActive formData={updatedData} />);
  696. await userEvent.click(await screen.findByText('Confirm Changes'));
  697. expect(mockConfirm).toHaveBeenCalledWith(
  698. `/customers/${organization.slug}/subscription/`,
  699. expect.objectContaining({
  700. method: 'PUT',
  701. data: getCheckoutAPIData({
  702. formData: updatedData,
  703. previewToken: preview.previewToken,
  704. }),
  705. })
  706. );
  707. // No DOM updates to wait on, but we can use this.
  708. await waitFor(() =>
  709. expect(browserHistory.push).toHaveBeenCalledWith(
  710. `/settings/${organization.slug}/billing/overview/?open_codecov_modal=1&referrer=checkout`
  711. )
  712. );
  713. });
  714. it('handles expired token on confirm', async function () {
  715. const {preview, mockPreview} = mockPreviewGet();
  716. const mockConfirm = MockApiClient.addMockResponse({
  717. url: `/customers/${organization.slug}/subscription/`,
  718. method: 'PUT',
  719. statusCode: 400,
  720. body: {
  721. previewToken: ['The preview token is invalid or has expired.'],
  722. },
  723. });
  724. const updatedData = {...formData, reservedErrors: 100000};
  725. render(<ReviewAndConfirm {...stepProps} formData={updatedData} isActive />);
  726. expect(mockPreview).toHaveBeenCalledTimes(1);
  727. await userEvent.click(await screen.findByText('Confirm Changes'));
  728. await waitFor(() => {
  729. expect(mockConfirm).toHaveBeenCalledWith(
  730. `/customers/${organization.slug}/subscription/`,
  731. expect.objectContaining({
  732. method: 'PUT',
  733. data: getCheckoutAPIData({
  734. formData: updatedData,
  735. previewToken: preview.previewToken,
  736. }),
  737. })
  738. );
  739. });
  740. expect(mockPreview).toHaveBeenCalledTimes(2);
  741. expect(addErrorMessage).toHaveBeenCalledWith(
  742. 'Your preview expired, please review changes and submit again'
  743. );
  744. expect(browserHistory.push).not.toHaveBeenCalled();
  745. });
  746. it('handles unknown error when updating subscription', async function () {
  747. const {preview, mockPreview} = mockPreviewGet();
  748. const mockConfirm = MockApiClient.addMockResponse({
  749. url: `/customers/${organization.slug}/subscription/`,
  750. method: 'PUT',
  751. statusCode: 500,
  752. });
  753. const updatedData = {...formData, reservedTransactions: 1500000};
  754. render(<ReviewAndConfirm {...stepProps} formData={updatedData} isActive />);
  755. expect(mockPreview).toHaveBeenCalledTimes(1);
  756. await userEvent.click(await screen.findByText('Confirm Changes'));
  757. await waitFor(() => {
  758. expect(mockConfirm).toHaveBeenCalledWith(
  759. `/customers/${organization.slug}/subscription/`,
  760. expect.objectContaining({
  761. method: 'PUT',
  762. data: getCheckoutAPIData({
  763. formData: updatedData,
  764. previewToken: preview.previewToken,
  765. }),
  766. })
  767. );
  768. });
  769. expect(mockPreview).toHaveBeenCalledTimes(1);
  770. expect(addErrorMessage).toHaveBeenCalledWith(
  771. 'An unknown error occurred while saving your subscription'
  772. );
  773. expect(browserHistory.push).not.toHaveBeenCalled();
  774. });
  775. it('handles completing a card action when required', async function () {
  776. const {preview} = mockPreviewGet();
  777. // We make two API calls. The first fails with a card action required
  778. // which we have mocked to succeed. The second request will have
  779. // the intent to complete payment with.
  780. const mockConfirm = mockSubscriptionPut({
  781. statusCode: 402,
  782. body: {
  783. detail: 'Card action required',
  784. paymentIntent: 'pi_abc123',
  785. paymentSecret: 'pi_abc123-secret',
  786. },
  787. });
  788. const mockComplete = mockSubscriptionPut({
  789. statusCode: 200,
  790. body: subscription,
  791. match: [MockApiClient.matchData({paymentIntent: 'pi_abc123'})],
  792. });
  793. const updatedData = {...formData, reserved: {errors: 100000}, onDemandMaxSpend: 5000};
  794. render(<ReviewAndConfirm {...stepProps} isActive formData={updatedData} />);
  795. await userEvent.click(await screen.findByText('Confirm Changes'));
  796. // Wait for URL to change as that signals completion.
  797. await waitFor(() =>
  798. expect(browserHistory.push).toHaveBeenCalledWith(
  799. `/settings/${organization.slug}/billing/overview/?open_codecov_modal=1&referrer=checkout`
  800. )
  801. );
  802. expect(mockConfirm).toHaveBeenCalledWith(
  803. `/customers/${organization.slug}/subscription/`,
  804. expect.objectContaining({
  805. method: 'PUT',
  806. data: getCheckoutAPIData({
  807. formData: updatedData,
  808. previewToken: preview.previewToken,
  809. }),
  810. })
  811. );
  812. expect(mockComplete).toHaveBeenCalledWith(
  813. `/customers/${organization.slug}/subscription/`,
  814. expect.objectContaining({
  815. method: 'PUT',
  816. data: getCheckoutAPIData({
  817. formData: updatedData,
  818. previewToken: preview.previewToken,
  819. paymentIntent: 'pi_abc123',
  820. }),
  821. })
  822. );
  823. });
  824. it('handles payment intent errors', async function () {
  825. mockPreviewGet();
  826. const mockConfirm = mockSubscriptionPut({
  827. statusCode: 402,
  828. body: {
  829. detail: 'Card action required',
  830. paymentIntent: 'pi_abc123',
  831. paymentSecret: 'ERROR',
  832. },
  833. });
  834. const updatedData = {...formData, reserved: {errors: 100000}, onDemandMaxSpend: 5000};
  835. render(<ReviewAndConfirm {...stepProps} isActive formData={updatedData} />);
  836. const button = await screen.findByRole('button', {name: 'Confirm Changes'});
  837. await userEvent.click(button);
  838. expect(await screen.findByText('Invalid card')).toBeInTheDocument();
  839. // Because our payment confirmation failed we can't continue
  840. expect(button).toBeDisabled();
  841. expect(mockConfirm).toHaveBeenCalled();
  842. });
  843. it('shows generic intent errors for odd types', async function () {
  844. mockPreviewGet();
  845. const mockConfirm = mockSubscriptionPut({
  846. statusCode: 402,
  847. body: {
  848. detail: 'Card action required',
  849. paymentIntent: 'pi_abc123',
  850. paymentSecret: 'GENERIC_ERROR',
  851. },
  852. });
  853. const updatedData = {...formData, reserved: {errors: 100000}, onDemandMaxSpend: 5000};
  854. render(<ReviewAndConfirm {...stepProps} isActive formData={updatedData} />);
  855. const button = await screen.findByRole('button', {name: 'Confirm Changes'});
  856. await userEvent.click(button);
  857. expect(
  858. await screen.findByText(/Your payment could not be authorized/)
  859. ).toBeInTheDocument();
  860. // Because our payment confirmation failed we can't continue
  861. await waitFor(() => {
  862. expect(button).toBeDisabled();
  863. });
  864. expect(mockConfirm).toHaveBeenCalled();
  865. });
  866. });