onDemandBudgets.spec.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597
  1. import {OrganizationFixture} from 'sentry-fixture/organization';
  2. import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription';
  3. import {
  4. fireEvent,
  5. render,
  6. renderGlobalModal,
  7. screen,
  8. userEvent,
  9. } from 'sentry-test/reactTestingLibrary';
  10. import ModalStore from 'sentry/stores/modalStore';
  11. import SubscriptionStore from 'getsentry/stores/subscriptionStore';
  12. import type {Subscription as TSubscription} from 'getsentry/types';
  13. import {OnDemandBudgetMode, PlanTier} from 'getsentry/types';
  14. import OnDemandBudgets from 'getsentry/views/onDemandBudgets';
  15. describe('OnDemandBudgets', () => {
  16. const organization = OrganizationFixture();
  17. const getComponent = (
  18. props: Omit<React.ComponentProps<typeof OnDemandBudgets>, 'organization'>
  19. ) => <OnDemandBudgets organization={organization} {...props} />;
  20. const createWrapper = (
  21. props: Omit<React.ComponentProps<typeof OnDemandBudgets>, 'organization'>
  22. ) => render(getComponent(props));
  23. beforeEach(function () {
  24. MockApiClient.clearMockResponses();
  25. MockApiClient.addMockResponse({
  26. url: `/organizations/${organization.slug}/payments/setup/`,
  27. method: 'POST',
  28. statusCode: 200,
  29. });
  30. MockApiClient.addMockResponse({
  31. url: `/organizations/${organization.slug}/monitor-count/`,
  32. method: 'GET',
  33. body: {enabledMonitorCount: 0, disabledMonitorCount: 0},
  34. });
  35. SubscriptionStore.set(organization.slug, {});
  36. });
  37. it('renders on-demand not supported state', function () {
  38. const subscription = SubscriptionFixture({
  39. plan: 'am1_business',
  40. planTier: PlanTier.AM1,
  41. isFree: false,
  42. isTrial: false,
  43. supportsOnDemand: true,
  44. organization,
  45. onDemandBudgets: {
  46. enabled: false,
  47. budgetMode: OnDemandBudgetMode.SHARED,
  48. sharedMaxBudget: 0,
  49. onDemandSpendUsed: 0,
  50. },
  51. });
  52. SubscriptionStore.set(organization.slug, subscription);
  53. createWrapper({
  54. subscription,
  55. onDemandEnabled: false,
  56. hasPaymentSource: true,
  57. });
  58. expect(
  59. screen.getByText('On-Demand is not supported for your account.')
  60. ).toBeInTheDocument();
  61. expect(screen.getByText('Contact Support')).toBeInTheDocument();
  62. });
  63. it('renders credit card modal on the on-demand setting for account without a credit card', async function () {
  64. const subscription = SubscriptionFixture({
  65. plan: 'am1_business',
  66. planTier: PlanTier.AM1,
  67. isFree: false,
  68. isTrial: false,
  69. supportsOnDemand: true,
  70. organization,
  71. onDemandBudgets: {
  72. enabled: false,
  73. budgetMode: OnDemandBudgetMode.SHARED,
  74. sharedMaxBudget: 0,
  75. onDemandSpendUsed: 0,
  76. },
  77. });
  78. SubscriptionStore.set(organization.slug, subscription);
  79. createWrapper({
  80. subscription,
  81. onDemandEnabled: true,
  82. hasPaymentSource: false,
  83. });
  84. renderGlobalModal();
  85. expect(
  86. screen.getByText(
  87. `To use on-demand budgets, you'll need a valid credit card on file.`
  88. )
  89. ).toBeInTheDocument();
  90. expect(screen.getByText('Add Credit Card')).toBeInTheDocument();
  91. await userEvent.click(screen.getByTestId('add-cc-card'));
  92. expect(await screen.findByText('Stripe')).toBeInTheDocument();
  93. ModalStore.reset();
  94. });
  95. it('allows VC partner accounts to set up on-demand budget without credit card', function () {
  96. const subscription = SubscriptionFixture({
  97. plan: 'am3_business',
  98. planTier: PlanTier.AM3,
  99. isFree: false,
  100. isTrial: false,
  101. supportsOnDemand: true,
  102. organization,
  103. partner: {
  104. externalId: 'x123x',
  105. name: 'VC Org',
  106. partnership: {
  107. id: 'VC',
  108. displayName: 'VC',
  109. supportNote: '',
  110. },
  111. isActive: true,
  112. },
  113. onDemandBudgets: {
  114. enabled: false,
  115. budgetMode: OnDemandBudgetMode.SHARED,
  116. sharedMaxBudget: 0,
  117. onDemandSpendUsed: 0,
  118. },
  119. });
  120. SubscriptionStore.set(organization.slug, subscription);
  121. const isVCPartner = subscription.partner?.partnership?.id === 'VC';
  122. createWrapper({
  123. subscription,
  124. onDemandEnabled: true,
  125. hasPaymentSource: isVCPartner,
  126. });
  127. // Should show Set Up Pay-as-you-go button instead of Add Credit Card
  128. expect(screen.getByText('Set Up Pay-as-you-go')).toBeInTheDocument();
  129. expect(screen.queryByText('Add Credit Card')).not.toBeInTheDocument();
  130. });
  131. it('renders initial on-demand budget setup state', function () {
  132. const subscription = SubscriptionFixture({
  133. plan: 'am1_business',
  134. planTier: PlanTier.AM1,
  135. isFree: false,
  136. isTrial: false,
  137. supportsOnDemand: true,
  138. organization,
  139. onDemandBudgets: {
  140. enabled: false,
  141. budgetMode: OnDemandBudgetMode.SHARED,
  142. sharedMaxBudget: 0,
  143. onDemandSpendUsed: 0,
  144. },
  145. });
  146. SubscriptionStore.set(organization.slug, subscription);
  147. createWrapper({
  148. subscription,
  149. onDemandEnabled: true,
  150. hasPaymentSource: true,
  151. });
  152. expect(screen.getByText('Set Up On-Demand')).toBeInTheDocument();
  153. });
  154. it('renders shared on-demand budget', function () {
  155. const subscription = SubscriptionFixture({
  156. plan: 'am1_business',
  157. planTier: PlanTier.AM1,
  158. isFree: false,
  159. isTrial: false,
  160. supportsOnDemand: true,
  161. organization,
  162. onDemandBudgets: {
  163. enabled: true,
  164. budgetMode: OnDemandBudgetMode.SHARED,
  165. sharedMaxBudget: 4200,
  166. onDemandSpendUsed: 0,
  167. },
  168. });
  169. SubscriptionStore.set(organization.slug, subscription);
  170. createWrapper({
  171. subscription,
  172. onDemandEnabled: true,
  173. hasPaymentSource: true,
  174. });
  175. expect(screen.getByText('Shared Budget')).toBeInTheDocument();
  176. expect(
  177. screen.getByText(
  178. 'Your on-demand budget is shared among all categories on a first come, first serve basis. There are no restrictions for any single category consuming the entire budget.'
  179. )
  180. ).toBeInTheDocument();
  181. expect(screen.getByText('$42')).toBeInTheDocument();
  182. });
  183. it('renders per-category budget', function () {
  184. const subscription = SubscriptionFixture({
  185. plan: 'am1_business',
  186. planTier: PlanTier.AM1,
  187. isFree: false,
  188. isTrial: false,
  189. supportsOnDemand: true,
  190. organization,
  191. onDemandBudgets: {
  192. enabled: true,
  193. budgetMode: OnDemandBudgetMode.PER_CATEGORY,
  194. errorsBudget: 1000,
  195. transactionsBudget: 2000,
  196. attachmentsBudget: 3000,
  197. replaysBudget: 0,
  198. monitorSeatsBudget: 0,
  199. budgets: {
  200. errors: 1000,
  201. transactions: 2000,
  202. attachments: 3000,
  203. replays: 0,
  204. monitorSeats: 0,
  205. },
  206. attachmentSpendUsed: 0,
  207. errorSpendUsed: 0,
  208. transactionSpendUsed: 0,
  209. usedSpends: {},
  210. },
  211. });
  212. SubscriptionStore.set(organization.slug, subscription);
  213. createWrapper({
  214. subscription,
  215. onDemandEnabled: true,
  216. hasPaymentSource: true,
  217. });
  218. expect(
  219. screen.getByText(
  220. 'You have dedicated on-demand budget for errors, transactions, replays, attachments, cron monitors, and uptime monitors.'
  221. )
  222. ).toBeInTheDocument();
  223. expect(screen.getByText('$10')).toBeInTheDocument();
  224. expect(screen.getByText('$20')).toBeInTheDocument();
  225. expect(screen.getByText('$30')).toBeInTheDocument();
  226. });
  227. it('initialize shared budget', async function () {
  228. MockApiClient.addMockResponse({
  229. url: `/customers/${organization.slug}/ondemand-budgets/`,
  230. method: 'POST',
  231. statusCode: 200,
  232. body: {
  233. enabled: true,
  234. budgetMode: OnDemandBudgetMode.SHARED,
  235. sharedMaxBudget: 4200,
  236. },
  237. });
  238. const subscription = SubscriptionFixture({
  239. plan: 'am1_business',
  240. planTier: PlanTier.AM1,
  241. isFree: false,
  242. isTrial: false,
  243. supportsOnDemand: true,
  244. organization,
  245. onDemandBudgets: {
  246. enabled: false,
  247. budgetMode: OnDemandBudgetMode.SHARED,
  248. sharedMaxBudget: 0,
  249. onDemandSpendUsed: 0,
  250. },
  251. });
  252. SubscriptionStore.set(organization.slug, subscription);
  253. MockApiClient.addMockResponse({
  254. url: `/subscriptions/${organization.slug}/`,
  255. method: 'GET',
  256. statusCode: 200,
  257. body: {
  258. ...subscription,
  259. onDemandMaxSpend: 4200,
  260. onDemandSpendUsed: 100,
  261. onDemandBudgets: {
  262. enabled: true,
  263. budgetMode: OnDemandBudgetMode.SHARED,
  264. sharedMaxBudget: 4200,
  265. onDemandSpendUsed: 100,
  266. },
  267. },
  268. });
  269. const props = {
  270. subscription,
  271. onDemandEnabled: true,
  272. hasPaymentSource: true,
  273. };
  274. const {rerender} = createWrapper(props);
  275. const {waitForModalToHide} = renderGlobalModal();
  276. await userEvent.click(screen.getByText('Set Up On-Demand'));
  277. expect(await screen.findByText('Save')).toBeInTheDocument();
  278. expect(screen.getByTestId('shared-budget-radio')).toBeChecked();
  279. expect(screen.getByRole('textbox', {name: 'Shared max budget'})).toHaveValue('0');
  280. fireEvent.change(screen.getByRole('textbox', {name: 'Shared max budget'}), {
  281. target: {value: '42'},
  282. });
  283. await userEvent.click(screen.getByLabelText('Save'));
  284. await waitForModalToHide();
  285. const updatedSubscription = await new Promise<TSubscription>(resolve => {
  286. SubscriptionStore.get(organization.slug, resolve);
  287. });
  288. expect(updatedSubscription.onDemandMaxSpend).toBe(4200);
  289. expect(updatedSubscription.onDemandSpendUsed).toBe(100);
  290. rerender(getComponent({...props, subscription: updatedSubscription}));
  291. expect(await screen.findByText('$42')).toBeInTheDocument();
  292. expect(screen.getByTestId('shared-budget-info')).toBeInTheDocument();
  293. });
  294. it('initialize per-category budget', async function () {
  295. MockApiClient.addMockResponse({
  296. url: `/customers/${organization.slug}/ondemand-budgets/`,
  297. method: 'POST',
  298. statusCode: 200,
  299. body: {
  300. enabled: true,
  301. budgetMode: OnDemandBudgetMode.PER_CATEGORY,
  302. errorsBudget: 1000,
  303. transactionsBudget: 2000,
  304. attachmentsBudget: 3000,
  305. budgets: {errors: 1000, transactions: 2000, attachments: 3000},
  306. },
  307. });
  308. const subscription = SubscriptionFixture({
  309. plan: 'am1_business',
  310. planTier: PlanTier.AM1,
  311. isFree: false,
  312. isTrial: false,
  313. supportsOnDemand: true,
  314. organization,
  315. onDemandBudgets: {
  316. enabled: false,
  317. budgetMode: OnDemandBudgetMode.SHARED,
  318. sharedMaxBudget: 0,
  319. onDemandSpendUsed: 0,
  320. },
  321. });
  322. SubscriptionStore.set(organization.slug, subscription);
  323. MockApiClient.addMockResponse({
  324. url: `/subscriptions/${organization.slug}/`,
  325. method: 'GET',
  326. statusCode: 200,
  327. body: {
  328. ...subscription,
  329. onDemandMaxSpend: 1000 + 2000 + 3000,
  330. onDemandSpendUsed: 100 + 200 + 300,
  331. onDemandBudgets: {
  332. enabled: true,
  333. budgetMode: OnDemandBudgetMode.PER_CATEGORY,
  334. errorsBudget: 1000,
  335. transactionsBudget: 2000,
  336. attachmentsBudget: 3000,
  337. budgets: {
  338. errors: 1000,
  339. transactions: 2000,
  340. attachments: 3000,
  341. monitorSeats: 4000,
  342. },
  343. errorSpendUsed: 100,
  344. transactionSpendUsed: 200,
  345. attachmentSpendUsed: 300,
  346. usedSpends: {
  347. errors: 100,
  348. transactions: 200,
  349. attachments: 300,
  350. monitorSeats: 400,
  351. },
  352. },
  353. },
  354. });
  355. const props = {
  356. subscription,
  357. onDemandEnabled: true,
  358. hasPaymentSource: true,
  359. };
  360. const {rerender} = createWrapper(props);
  361. const {waitForModalToHide} = renderGlobalModal();
  362. await userEvent.click(screen.getByText('Set Up On-Demand'));
  363. expect(await screen.findByText('Save')).toBeInTheDocument();
  364. expect(screen.getByTestId('shared-budget-radio')).toBeChecked();
  365. expect(screen.getByRole('textbox', {name: 'Shared max budget'})).toHaveValue('0');
  366. // Select per-category budget strategy
  367. await userEvent.click(screen.getByTestId('per-category-budget-radio'));
  368. expect(screen.getByTestId('per-category-budget-radio')).toBeChecked();
  369. expect(screen.getByTestId('shared-budget-radio')).not.toBeChecked();
  370. expect(
  371. screen.queryByRole('textbox', {name: 'Shared max budget'})
  372. ).not.toBeInTheDocument();
  373. fireEvent.change(screen.getByRole('textbox', {name: 'Errors budget'}), {
  374. target: {value: '10'},
  375. });
  376. fireEvent.change(screen.getByRole('textbox', {name: 'Transactions budget'}), {
  377. target: {value: '20'},
  378. });
  379. fireEvent.change(screen.getByRole('textbox', {name: 'Attachments budget'}), {
  380. target: {value: '30'},
  381. });
  382. fireEvent.change(screen.getByRole('textbox', {name: 'Cron monitors budget'}), {
  383. target: {value: '40'},
  384. });
  385. await userEvent.click(screen.getByLabelText('Save'));
  386. await waitForModalToHide();
  387. const updatedSubscription = await new Promise<TSubscription>(resolve => {
  388. SubscriptionStore.get(organization.slug, resolve);
  389. });
  390. expect(updatedSubscription.onDemandMaxSpend).toBe(1000 + 2000 + 3000);
  391. rerender(getComponent({...props, subscription: updatedSubscription}));
  392. expect(await screen.findByText('$10')).toBeInTheDocument();
  393. expect(screen.getByText('$20')).toBeInTheDocument();
  394. expect(screen.getByText('$30')).toBeInTheDocument();
  395. expect(screen.getByText('$40')).toBeInTheDocument();
  396. expect(screen.getByTestId('per-category-budget-info')).toBeInTheDocument();
  397. });
  398. it('renders pay-as-you-go instead of on-demand for am3', async function () {
  399. MockApiClient.addMockResponse({
  400. url: `/customers/${organization.slug}/ondemand-budgets/`,
  401. method: 'POST',
  402. statusCode: 200,
  403. body: {
  404. enabled: true,
  405. budgetMode: OnDemandBudgetMode.SHARED,
  406. sharedMaxBudget: 4200,
  407. },
  408. });
  409. const subscription = SubscriptionFixture({
  410. plan: 'am3_business',
  411. planTier: PlanTier.AM3,
  412. isFree: false,
  413. isTrial: false,
  414. supportsOnDemand: true,
  415. organization,
  416. onDemandBudgets: {
  417. enabled: false,
  418. budgetMode: OnDemandBudgetMode.SHARED,
  419. sharedMaxBudget: 0,
  420. onDemandSpendUsed: 0,
  421. },
  422. });
  423. SubscriptionStore.set(organization.slug, subscription);
  424. MockApiClient.addMockResponse({
  425. url: `/subscriptions/${organization.slug}/`,
  426. method: 'GET',
  427. statusCode: 200,
  428. body: {
  429. ...subscription,
  430. onDemandMaxSpend: 4200,
  431. onDemandSpendUsed: 100,
  432. onDemandBudgets: {
  433. enabled: true,
  434. budgetMode: OnDemandBudgetMode.SHARED,
  435. sharedMaxBudget: 4200,
  436. onDemandSpendUsed: 100,
  437. },
  438. },
  439. });
  440. const props = {
  441. subscription,
  442. onDemandEnabled: true,
  443. hasPaymentSource: true,
  444. };
  445. const {rerender} = createWrapper(props);
  446. const {waitForModalToHide} = renderGlobalModal();
  447. await userEvent.click(screen.getByRole('button', {name: 'Set Up Pay-as-you-go'}));
  448. expect(await screen.findByText('Save')).toBeInTheDocument();
  449. const budgetTextbox = screen.getByRole('textbox', {name: 'Pay-as-you-go max budget'});
  450. expect(budgetTextbox).toHaveValue('0');
  451. expect(
  452. screen.getByText(
  453. `This budget ensures continued monitoring after you've used up your reserved event volume. We'll only charge you for actual usage, so this is your maximum charge for overage.`
  454. )
  455. ).toBeInTheDocument();
  456. await userEvent.type(budgetTextbox, '42');
  457. await userEvent.click(screen.getByLabelText('Save'));
  458. await waitForModalToHide();
  459. const updatedSubscription = await new Promise<TSubscription>(resolve => {
  460. SubscriptionStore.get(organization.slug, resolve);
  461. });
  462. expect(updatedSubscription.onDemandMaxSpend).toBe(4200);
  463. expect(updatedSubscription.onDemandSpendUsed).toBe(100);
  464. rerender(getComponent({...props, subscription: updatedSubscription}));
  465. expect(await screen.findByText('$42')).toBeInTheDocument();
  466. expect(screen.getByTestId('shared-budget-info')).toBeInTheDocument();
  467. });
  468. it('renders billed through partner for self serve partner', async function () {
  469. MockApiClient.addMockResponse({
  470. url: `/customers/${organization.slug}/ondemand-budgets/`,
  471. method: 'POST',
  472. statusCode: 200,
  473. body: {
  474. enabled: true,
  475. budgetMode: OnDemandBudgetMode.SHARED,
  476. sharedMaxBudget: 4200,
  477. },
  478. });
  479. const subscription = SubscriptionFixture({
  480. plan: 'am3_business',
  481. planTier: PlanTier.AM3,
  482. isFree: false,
  483. isTrial: false,
  484. supportsOnDemand: true,
  485. isSelfServePartner: true,
  486. partner: {
  487. externalId: 'x123x',
  488. name: 'FOO',
  489. partnership: {
  490. id: 'foo',
  491. displayName: 'FOO',
  492. supportNote: '',
  493. },
  494. isActive: true,
  495. },
  496. organization,
  497. onDemandBudgets: {
  498. enabled: false,
  499. budgetMode: OnDemandBudgetMode.SHARED,
  500. sharedMaxBudget: 0,
  501. onDemandSpendUsed: 0,
  502. },
  503. });
  504. SubscriptionStore.set(organization.slug, subscription);
  505. MockApiClient.addMockResponse({
  506. url: `/subscriptions/${organization.slug}/`,
  507. method: 'GET',
  508. statusCode: 200,
  509. body: {
  510. ...subscription,
  511. onDemandMaxSpend: 4200,
  512. onDemandSpendUsed: 100,
  513. onDemandBudgets: {
  514. enabled: true,
  515. budgetMode: OnDemandBudgetMode.SHARED,
  516. sharedMaxBudget: 4200,
  517. onDemandSpendUsed: 100,
  518. },
  519. },
  520. });
  521. const props = {
  522. subscription,
  523. onDemandEnabled: true,
  524. hasPaymentSource: true,
  525. };
  526. createWrapper(props);
  527. renderGlobalModal();
  528. await userEvent.click(screen.getByRole('button', {name: 'Set Up Pay-as-you-go'}));
  529. expect(
  530. screen.getByText(
  531. `This budget ensures continued monitoring after you've used up your reserved event volume. We'll only charge you for actual usage, so this is your maximum charge for overage. This will be part of your FOO bill.`
  532. )
  533. ).toBeInTheDocument();
  534. });
  535. });