billingDetails.spec.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477
  1. import {OrganizationFixture} from 'sentry-fixture/organization';
  2. import {BillingConfigFixture} from 'getsentry-test/fixtures/billingConfig';
  3. import {BillingDetailsFixture} from 'getsentry-test/fixtures/billingDetails';
  4. import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription';
  5. import {initializeOrg} from 'sentry-test/initializeOrg';
  6. import {
  7. render,
  8. renderGlobalModal,
  9. screen,
  10. userEvent,
  11. within,
  12. } from 'sentry-test/reactTestingLibrary';
  13. import SubscriptionStore from 'getsentry/stores/subscriptionStore';
  14. import type {Subscription as TSubscription} from 'getsentry/types';
  15. import {PlanTier} from 'getsentry/types';
  16. import {BillingDetails as BillingDetailsView} from 'getsentry/views/subscriptionPage/billingDetails';
  17. jest.mock('getsentry/utils/stripe', () => ({
  18. loadStripe: (cb: any) => {
  19. cb(() => ({
  20. createToken: jest.fn(
  21. () =>
  22. new Promise(resolve => {
  23. resolve({token: {id: 'STRIPE_TOKEN'}});
  24. })
  25. ),
  26. confirmCardSetup(secretKey: string, _options: any) {
  27. if (secretKey !== 'ERROR') {
  28. return new Promise(resolve => {
  29. resolve({setupIntent: {payment_method: 'pm_abc123'}});
  30. });
  31. }
  32. return new Promise(resolve => {
  33. resolve({error: {message: 'card invalid'}});
  34. });
  35. },
  36. elements: jest.fn(() => ({
  37. create: jest.fn(() => ({
  38. mount: jest.fn(),
  39. on: jest.fn(),
  40. update: jest.fn(),
  41. })),
  42. })),
  43. }));
  44. },
  45. }));
  46. describe('Subscription > BillingDetails', function () {
  47. const {organization, router} = initializeOrg({
  48. organization: {access: ['org:billing']},
  49. });
  50. const subscription = SubscriptionFixture({organization});
  51. beforeEach(() => {
  52. MockApiClient.clearMockResponses();
  53. MockApiClient.addMockResponse({
  54. url: `/customers/${organization.slug}/billing-config/`,
  55. method: 'GET',
  56. body: BillingConfigFixture(PlanTier.AM1),
  57. });
  58. MockApiClient.addMockResponse({
  59. url: `/subscriptions/${organization.slug}/`,
  60. method: 'GET',
  61. });
  62. MockApiClient.addMockResponse({
  63. url: `/customers/${organization.slug}/billing-details/`,
  64. method: 'GET',
  65. });
  66. MockApiClient.addMockResponse({
  67. url: `/subscriptions/${organization.slug}/`,
  68. method: 'GET',
  69. body: subscription,
  70. });
  71. MockApiClient.addMockResponse({
  72. url: `/organizations/${organization.slug}/promotions/trigger-check/`,
  73. method: 'POST',
  74. });
  75. MockApiClient.addMockResponse({
  76. url: `/customers/${organization.slug}/plan-migrations/`,
  77. method: 'GET',
  78. body: [],
  79. });
  80. MockApiClient.addMockResponse({
  81. url: `/organizations/${organization.slug}/prompts-activity/`,
  82. body: {},
  83. });
  84. });
  85. it('renders an error for non-billing roles', async function () {
  86. const org = {...organization, access: OrganizationFixture().access};
  87. MockApiClient.addMockResponse({
  88. url: `/organizations/${org.slug}/members/`,
  89. body: [],
  90. });
  91. render(
  92. <BillingDetailsView
  93. organization={org}
  94. subscription={subscription}
  95. location={router.location}
  96. />
  97. );
  98. await screen.findByText('Insufficient Access');
  99. expect(
  100. screen.queryByRole('textbox', {name: /street address 1/i})
  101. ).not.toBeInTheDocument();
  102. });
  103. it('renders with subscription', async function () {
  104. render(
  105. <BillingDetailsView
  106. organization={organization}
  107. subscription={subscription}
  108. location={router.location}
  109. />
  110. );
  111. const section = await screen.findByTestId('account-balance');
  112. expect(within(section).getByText(/account balance/i)).toBeInTheDocument();
  113. expect(within(section).getByText('$100 credit')).toBeInTheDocument();
  114. });
  115. it('renders without credit if account balance > 0', async function () {
  116. const sub: TSubscription = {...subscription, accountBalance: 10_000};
  117. SubscriptionStore.set(organization.slug, sub);
  118. render(
  119. <BillingDetailsView
  120. organization={organization}
  121. subscription={sub}
  122. location={router.location}
  123. />
  124. );
  125. const section = await screen.findByTestId('account-balance');
  126. expect(within(section).getByText(/account balance/i)).toBeInTheDocument();
  127. expect(within(section).getByText('$100')).toBeInTheDocument();
  128. expect(within(section).queryByText('credit')).not.toBeInTheDocument();
  129. });
  130. it('hides account balance when it is 0', async function () {
  131. const sub = {...subscription, accountBalance: 0};
  132. SubscriptionStore.set(organization.slug, sub);
  133. render(
  134. <BillingDetailsView
  135. organization={organization}
  136. subscription={sub}
  137. location={router.location}
  138. />
  139. );
  140. await screen.findByRole('textbox', {name: /street address 1/i});
  141. expect(screen.queryByText(/account balance/i)).not.toBeInTheDocument();
  142. });
  143. it('renders credit card details', async () => {
  144. render(
  145. <BillingDetailsView
  146. organization={organization}
  147. subscription={subscription}
  148. location={router.location}
  149. />
  150. );
  151. await screen.findByRole('textbox', {name: 'Street Address 1'});
  152. expect(screen.getByRole('textbox', {name: 'Postal Code'})).toBeInTheDocument();
  153. expect(screen.getByText('94242')).toBeInTheDocument();
  154. expect(screen.getByText(/credit card number/i)).toBeInTheDocument();
  155. expect(screen.getByText('xxxx xxxx xxxx 4242')).toBeInTheDocument();
  156. });
  157. it('can update credit card with setupintent', async function () {
  158. const updateMock = MockApiClient.addMockResponse({
  159. url: `/customers/${organization.slug}/`,
  160. method: 'PUT',
  161. body: {
  162. ...subscription,
  163. paymentSource: {
  164. last4: '1111',
  165. countryCode: 'US',
  166. zipCode: '94107',
  167. },
  168. },
  169. });
  170. MockApiClient.addMockResponse({
  171. url: `/organizations/${organization.slug}/payments/setup/`,
  172. method: 'POST',
  173. body: {
  174. id: '123',
  175. clientSecret: 'seti_abc123',
  176. status: 'require_payment_method',
  177. lastError: null,
  178. },
  179. });
  180. render(
  181. <BillingDetailsView
  182. organization={organization}
  183. subscription={subscription}
  184. location={router.location}
  185. />
  186. );
  187. await screen.findByRole('textbox', {name: /street address 1/i});
  188. await userEvent.click(screen.getByRole('button', {name: 'Update card'}));
  189. const {waitForModalToHide} = renderGlobalModal();
  190. const modal = await screen.findByRole('dialog');
  191. const inModal = within(modal);
  192. // Postal code input is not handled by Stripe elements. We need to fill it
  193. // before submit will pass to Stripe
  194. await userEvent.type(inModal.getByRole('textbox', {name: 'Postal Code'}), '94107');
  195. // Save the updated credit card details
  196. await userEvent.click(inModal.getByRole('button', {name: 'Save Changes'}));
  197. await waitForModalToHide();
  198. // Save billing details
  199. await userEvent.click(screen.getByRole('button', {name: 'Save Changes'}));
  200. expect(updateMock).toHaveBeenCalledWith(
  201. `/customers/${organization.slug}/`,
  202. expect.objectContaining({
  203. data: expect.objectContaining({
  204. paymentMethod: 'pm_abc123',
  205. }),
  206. })
  207. );
  208. expect(screen.getByText('xxxx xxxx xxxx 1111')).toBeInTheDocument();
  209. expect(screen.getByText('94107')).toBeInTheDocument();
  210. SubscriptionStore.get(subscription.slug, function (sub) {
  211. expect(sub.paymentSource?.last4).toBe('1111');
  212. });
  213. });
  214. it('rejects update credit card if zip code is not included with setupintent', async function () {
  215. const mock = MockApiClient.addMockResponse({
  216. url: `/customers/${organization.slug}/`,
  217. method: 'PUT',
  218. });
  219. MockApiClient.addMockResponse({
  220. url: `/organizations/${organization.slug}/payments/setup/`,
  221. method: 'POST',
  222. body: {
  223. id: '123',
  224. clientSecret: 'seti_abc123',
  225. status: 'require_payment_method',
  226. lastError: null,
  227. },
  228. });
  229. render(
  230. <BillingDetailsView
  231. organization={organization}
  232. subscription={subscription}
  233. location={router.location}
  234. />
  235. );
  236. await screen.findByRole('textbox', {name: /street address 1/i});
  237. await userEvent.click(screen.getByRole('button', {name: 'Update card'}));
  238. renderGlobalModal();
  239. const modal = await screen.findByRole('dialog');
  240. await userEvent.click(within(modal).getByRole('button', {name: 'Save Changes'}));
  241. expect(modal).toHaveTextContent('Postal code is required');
  242. expect(mock).not.toHaveBeenCalledWith();
  243. });
  244. it('shows an error if the setupintent creation fails', async function () {
  245. MockApiClient.addMockResponse({
  246. url: `/organizations/${organization.slug}/payments/setup/`,
  247. method: 'POST',
  248. statusCode: 400,
  249. });
  250. render(
  251. <BillingDetailsView
  252. organization={organization}
  253. subscription={subscription}
  254. location={router.location}
  255. />
  256. );
  257. await screen.findByRole('textbox', {name: /street address 1/i});
  258. await userEvent.click(screen.getByRole('button', {name: 'Update card'}));
  259. renderGlobalModal();
  260. const modal = await screen.findByRole('dialog');
  261. expect(modal).toHaveTextContent('Unable to initialize payment setup');
  262. });
  263. it('shows an error when confirmSetup fails', async function () {
  264. MockApiClient.addMockResponse({
  265. url: `/organizations/${organization.slug}/payments/setup/`,
  266. method: 'POST',
  267. body: {
  268. id: '999',
  269. clientSecret: 'ERROR', // Interacts with the mocks above.
  270. status: 'require_payment_method',
  271. lastError: null,
  272. },
  273. });
  274. render(
  275. <BillingDetailsView
  276. organization={organization}
  277. subscription={subscription}
  278. location={router.location}
  279. />
  280. );
  281. await screen.findByRole('textbox', {name: /street address 1/i});
  282. await userEvent.click(screen.getByRole('button', {name: 'Update card'}));
  283. renderGlobalModal();
  284. const modal = await screen.findByRole('dialog');
  285. const inModal = within(modal);
  286. // Postal code input is not handled by Stripe elements. We need to fill it
  287. // before submit will pass to Stripe
  288. await userEvent.type(inModal.getByRole('textbox', {name: 'Postal Code'}), '94107');
  289. // Save the updated credit card details
  290. await userEvent.click(inModal.getByRole('button', {name: 'Save Changes'}));
  291. expect(await screen.findByText('card invalid')).toBeInTheDocument();
  292. });
  293. it('renders open credit card modal with billing failure query', async function () {
  294. router.location = {
  295. ...router.location,
  296. query: {referrer: 'billing-failure'},
  297. };
  298. MockApiClient.addMockResponse({
  299. url: `/organizations/${organization.slug}/payments/setup/`,
  300. method: 'POST',
  301. body: {},
  302. });
  303. renderGlobalModal();
  304. render(
  305. <BillingDetailsView
  306. organization={organization}
  307. subscription={subscription}
  308. location={router.location}
  309. />
  310. );
  311. await screen.findByRole('textbox', {name: /street address 1/i});
  312. expect(
  313. screen.getByText(/Your credit card will be charged upon update./)
  314. ).toBeInTheDocument();
  315. expect(screen.getByText(/Manage Subscription/)).toBeInTheDocument();
  316. expect(screen.getByText(/Update Credit Card/)).toBeInTheDocument();
  317. expect(
  318. screen.getByText(/Payments are processed securely through/)
  319. ).toBeInTheDocument();
  320. expect(screen.getByTestId('modal-backdrop')).toBeInTheDocument();
  321. expect(screen.getByTestId('submit')).toBeInTheDocument();
  322. expect(screen.getByTestId('cancel')).toBeInTheDocument();
  323. });
  324. });
  325. describe('Billing details form', function () {
  326. const {router} = initializeOrg();
  327. const organization = OrganizationFixture({
  328. access: ['org:billing'],
  329. });
  330. const subscription = SubscriptionFixture({organization});
  331. let updateMock: any;
  332. beforeEach(() => {
  333. MockApiClient.clearMockResponses();
  334. MockApiClient.addMockResponse({
  335. url: `/customers/${organization.slug}/billing-config/`,
  336. method: 'GET',
  337. body: BillingConfigFixture(PlanTier.AM1),
  338. });
  339. MockApiClient.addMockResponse({
  340. url: `/subscriptions/${organization.slug}/`,
  341. method: 'GET',
  342. body: subscription,
  343. });
  344. MockApiClient.addMockResponse({
  345. url: `/customers/${organization.slug}/billing-details/`,
  346. method: 'GET',
  347. });
  348. updateMock = MockApiClient.addMockResponse({
  349. url: `/customers/${organization.slug}/billing-details/`,
  350. method: 'PUT',
  351. });
  352. MockApiClient.addMockResponse({
  353. url: `/organizations/${organization.slug}/promotions/trigger-check/`,
  354. method: 'POST',
  355. });
  356. MockApiClient.addMockResponse({
  357. url: `/customers/${organization.slug}/plan-migrations/`,
  358. query: {scheduled: 1, applied: 0},
  359. method: 'GET',
  360. body: [],
  361. });
  362. MockApiClient.addMockResponse({
  363. url: `/organizations/${organization.slug}/prompts-activity/`,
  364. body: {},
  365. });
  366. });
  367. it('renders billing details form', async function () {
  368. render(
  369. <BillingDetailsView
  370. organization={organization}
  371. subscription={subscription}
  372. location={router.location}
  373. />
  374. );
  375. await screen.findByRole('textbox', {name: 'Street Address 1'});
  376. expect(screen.getByRole('textbox', {name: 'Street Address 2'})).toBeInTheDocument();
  377. expect(screen.getByRole('textbox', {name: 'City'})).toBeInTheDocument();
  378. expect(screen.getByRole('textbox', {name: 'State / Region'})).toBeInTheDocument();
  379. expect(screen.getByRole('textbox', {name: 'Postal Code'})).toBeInTheDocument();
  380. expect(screen.getByRole('textbox', {name: 'Company Name'})).toBeInTheDocument();
  381. expect(screen.getByRole('textbox', {name: 'Billing Email'})).toBeInTheDocument();
  382. expect(screen.queryByRole('textbox', {name: 'Vat Number'})).not.toBeInTheDocument();
  383. });
  384. it('can submit form', async function () {
  385. MockApiClient.addMockResponse({
  386. url: `/customers/${organization.slug}/billing-details/`,
  387. method: 'GET',
  388. body: BillingDetailsFixture(),
  389. });
  390. render(
  391. <BillingDetailsView
  392. organization={organization}
  393. subscription={subscription}
  394. location={router.location}
  395. />
  396. );
  397. await screen.findByRole('textbox', {name: /street address 1/i});
  398. // renders initial data
  399. expect(screen.getByDisplayValue('123 Street')).toBeInTheDocument();
  400. expect(screen.getByDisplayValue('San Francisco')).toBeInTheDocument();
  401. expect(screen.getByText('California')).toBeInTheDocument();
  402. expect(screen.getByText('United States')).toBeInTheDocument();
  403. expect(screen.getByDisplayValue('12345')).toBeInTheDocument();
  404. // update field
  405. await userEvent.clear(screen.getByRole('textbox', {name: /postal code/i}));
  406. await userEvent.type(screen.getByRole('textbox', {name: /postal code/i}), '98765');
  407. await userEvent.click(screen.getByRole('button', {name: /save changes/i}));
  408. expect(updateMock).toHaveBeenCalledWith(
  409. `/customers/${organization.slug}/billing-details/`,
  410. expect.objectContaining({
  411. method: 'PUT',
  412. data: {...BillingDetailsFixture(), postalCode: '98765'},
  413. })
  414. );
  415. });
  416. });