import {OrganizationFixture} from 'sentry-fixture/organization'; import {BillingConfigFixture} from 'getsentry-test/fixtures/billingConfig'; import {BillingDetailsFixture} from 'getsentry-test/fixtures/billingDetails'; import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription'; import {initializeOrg} from 'sentry-test/initializeOrg'; import { render, renderGlobalModal, screen, userEvent, within, } from 'sentry-test/reactTestingLibrary'; import SubscriptionStore from 'getsentry/stores/subscriptionStore'; import type {Subscription as TSubscription} from 'getsentry/types'; import {PlanTier} from 'getsentry/types'; import {BillingDetails as BillingDetailsView} from 'getsentry/views/subscriptionPage/billingDetails'; jest.mock('getsentry/utils/stripe', () => ({ loadStripe: (cb: any) => { cb(() => ({ createToken: jest.fn( () => new Promise(resolve => { resolve({token: {id: 'STRIPE_TOKEN'}}); }) ), confirmCardSetup(secretKey: string, _options: any) { if (secretKey !== 'ERROR') { return new Promise(resolve => { resolve({setupIntent: {payment_method: 'pm_abc123'}}); }); } return new Promise(resolve => { resolve({error: {message: 'card invalid'}}); }); }, elements: jest.fn(() => ({ create: jest.fn(() => ({ mount: jest.fn(), on: jest.fn(), update: jest.fn(), })), })), })); }, })); describe('Subscription > BillingDetails', function () { const {organization, router} = initializeOrg({ organization: {access: ['org:billing']}, }); const subscription = SubscriptionFixture({organization}); beforeEach(() => { MockApiClient.clearMockResponses(); MockApiClient.addMockResponse({ url: `/customers/${organization.slug}/billing-config/`, method: 'GET', body: BillingConfigFixture(PlanTier.AM1), }); MockApiClient.addMockResponse({ url: `/subscriptions/${organization.slug}/`, method: 'GET', }); MockApiClient.addMockResponse({ url: `/customers/${organization.slug}/billing-details/`, method: 'GET', }); MockApiClient.addMockResponse({ url: `/subscriptions/${organization.slug}/`, method: 'GET', body: subscription, }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/promotions/trigger-check/`, method: 'POST', }); MockApiClient.addMockResponse({ url: `/customers/${organization.slug}/plan-migrations/`, method: 'GET', body: [], }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/prompts-activity/`, body: {}, }); }); it('renders an error for non-billing roles', async function () { const org = {...organization, access: OrganizationFixture().access}; MockApiClient.addMockResponse({ url: `/organizations/${org.slug}/members/`, body: [], }); render( ); await screen.findByText('Insufficient Access'); expect( screen.queryByRole('textbox', {name: /street address 1/i}) ).not.toBeInTheDocument(); }); it('renders with subscription', async function () { render( ); const section = await screen.findByTestId('account-balance'); expect(within(section).getByText(/account balance/i)).toBeInTheDocument(); expect(within(section).getByText('$100 credit')).toBeInTheDocument(); }); it('renders without credit if account balance > 0', async function () { const sub: TSubscription = {...subscription, accountBalance: 10_000}; SubscriptionStore.set(organization.slug, sub); render( ); const section = await screen.findByTestId('account-balance'); expect(within(section).getByText(/account balance/i)).toBeInTheDocument(); expect(within(section).getByText('$100')).toBeInTheDocument(); expect(within(section).queryByText('credit')).not.toBeInTheDocument(); }); it('hides account balance when it is 0', async function () { const sub = {...subscription, accountBalance: 0}; SubscriptionStore.set(organization.slug, sub); render( ); await screen.findByRole('textbox', {name: /street address 1/i}); expect(screen.queryByText(/account balance/i)).not.toBeInTheDocument(); }); it('renders credit card details', async () => { render( ); await screen.findByRole('textbox', {name: 'Street Address 1'}); expect(screen.getByRole('textbox', {name: 'Postal Code'})).toBeInTheDocument(); expect(screen.getByText('94242')).toBeInTheDocument(); expect(screen.getByText(/credit card number/i)).toBeInTheDocument(); expect(screen.getByText('xxxx xxxx xxxx 4242')).toBeInTheDocument(); }); it('can update credit card with setupintent', async function () { const updateMock = MockApiClient.addMockResponse({ url: `/customers/${organization.slug}/`, method: 'PUT', body: { ...subscription, paymentSource: { last4: '1111', countryCode: 'US', zipCode: '94107', }, }, }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/payments/setup/`, method: 'POST', body: { id: '123', clientSecret: 'seti_abc123', status: 'require_payment_method', lastError: null, }, }); render( ); await screen.findByRole('textbox', {name: /street address 1/i}); await userEvent.click(screen.getByRole('button', {name: 'Update card'})); const {waitForModalToHide} = renderGlobalModal(); const modal = await screen.findByRole('dialog'); const inModal = within(modal); // Postal code input is not handled by Stripe elements. We need to fill it // before submit will pass to Stripe await userEvent.type(inModal.getByRole('textbox', {name: 'Postal Code'}), '94107'); // Save the updated credit card details await userEvent.click(inModal.getByRole('button', {name: 'Save Changes'})); await waitForModalToHide(); // Save billing details await userEvent.click(screen.getByRole('button', {name: 'Save Changes'})); expect(updateMock).toHaveBeenCalledWith( `/customers/${organization.slug}/`, expect.objectContaining({ data: expect.objectContaining({ paymentMethod: 'pm_abc123', }), }) ); expect(screen.getByText('xxxx xxxx xxxx 1111')).toBeInTheDocument(); expect(screen.getByText('94107')).toBeInTheDocument(); SubscriptionStore.get(subscription.slug, function (sub) { expect(sub.paymentSource?.last4).toBe('1111'); }); }); it('rejects update credit card if zip code is not included with setupintent', async function () { const mock = MockApiClient.addMockResponse({ url: `/customers/${organization.slug}/`, method: 'PUT', }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/payments/setup/`, method: 'POST', body: { id: '123', clientSecret: 'seti_abc123', status: 'require_payment_method', lastError: null, }, }); render( ); await screen.findByRole('textbox', {name: /street address 1/i}); await userEvent.click(screen.getByRole('button', {name: 'Update card'})); renderGlobalModal(); const modal = await screen.findByRole('dialog'); await userEvent.click(within(modal).getByRole('button', {name: 'Save Changes'})); expect(modal).toHaveTextContent('Postal code is required'); expect(mock).not.toHaveBeenCalledWith(); }); it('shows an error if the setupintent creation fails', async function () { MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/payments/setup/`, method: 'POST', statusCode: 400, }); render( ); await screen.findByRole('textbox', {name: /street address 1/i}); await userEvent.click(screen.getByRole('button', {name: 'Update card'})); renderGlobalModal(); const modal = await screen.findByRole('dialog'); expect(modal).toHaveTextContent('Unable to initialize payment setup'); }); it('shows an error when confirmSetup fails', async function () { MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/payments/setup/`, method: 'POST', body: { id: '999', clientSecret: 'ERROR', // Interacts with the mocks above. status: 'require_payment_method', lastError: null, }, }); render( ); await screen.findByRole('textbox', {name: /street address 1/i}); await userEvent.click(screen.getByRole('button', {name: 'Update card'})); renderGlobalModal(); const modal = await screen.findByRole('dialog'); const inModal = within(modal); // Postal code input is not handled by Stripe elements. We need to fill it // before submit will pass to Stripe await userEvent.type(inModal.getByRole('textbox', {name: 'Postal Code'}), '94107'); // Save the updated credit card details await userEvent.click(inModal.getByRole('button', {name: 'Save Changes'})); expect(await screen.findByText('card invalid')).toBeInTheDocument(); }); it('renders open credit card modal with billing failure query', async function () { router.location = { ...router.location, query: {referrer: 'billing-failure'}, }; MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/payments/setup/`, method: 'POST', body: {}, }); renderGlobalModal(); render( ); await screen.findByRole('textbox', {name: /street address 1/i}); expect( screen.getByText(/Your credit card will be charged upon update./) ).toBeInTheDocument(); expect(screen.getByText(/Manage Subscription/)).toBeInTheDocument(); expect(screen.getByText(/Update Credit Card/)).toBeInTheDocument(); expect( screen.getByText(/Payments are processed securely through/) ).toBeInTheDocument(); expect(screen.getByTestId('modal-backdrop')).toBeInTheDocument(); expect(screen.getByTestId('submit')).toBeInTheDocument(); expect(screen.getByTestId('cancel')).toBeInTheDocument(); }); }); describe('Billing details form', function () { const {router} = initializeOrg(); const organization = OrganizationFixture({ access: ['org:billing'], }); const subscription = SubscriptionFixture({organization}); let updateMock: any; beforeEach(() => { MockApiClient.clearMockResponses(); MockApiClient.addMockResponse({ url: `/customers/${organization.slug}/billing-config/`, method: 'GET', body: BillingConfigFixture(PlanTier.AM1), }); MockApiClient.addMockResponse({ url: `/subscriptions/${organization.slug}/`, method: 'GET', body: subscription, }); MockApiClient.addMockResponse({ url: `/customers/${organization.slug}/billing-details/`, method: 'GET', }); updateMock = MockApiClient.addMockResponse({ url: `/customers/${organization.slug}/billing-details/`, method: 'PUT', }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/promotions/trigger-check/`, method: 'POST', }); MockApiClient.addMockResponse({ url: `/customers/${organization.slug}/plan-migrations/`, query: {scheduled: 1, applied: 0}, method: 'GET', body: [], }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/prompts-activity/`, body: {}, }); }); it('renders billing details form', async function () { render( ); await screen.findByRole('textbox', {name: 'Street Address 1'}); expect(screen.getByRole('textbox', {name: 'Street Address 2'})).toBeInTheDocument(); expect(screen.getByRole('textbox', {name: 'City'})).toBeInTheDocument(); expect(screen.getByRole('textbox', {name: 'State / Region'})).toBeInTheDocument(); expect(screen.getByRole('textbox', {name: 'Postal Code'})).toBeInTheDocument(); expect(screen.getByRole('textbox', {name: 'Company Name'})).toBeInTheDocument(); expect(screen.getByRole('textbox', {name: 'Billing Email'})).toBeInTheDocument(); expect(screen.queryByRole('textbox', {name: 'Vat Number'})).not.toBeInTheDocument(); }); it('can submit form', async function () { MockApiClient.addMockResponse({ url: `/customers/${organization.slug}/billing-details/`, method: 'GET', body: BillingDetailsFixture(), }); render( ); await screen.findByRole('textbox', {name: /street address 1/i}); // renders initial data expect(screen.getByDisplayValue('123 Street')).toBeInTheDocument(); expect(screen.getByDisplayValue('San Francisco')).toBeInTheDocument(); expect(screen.getByText('California')).toBeInTheDocument(); expect(screen.getByText('United States')).toBeInTheDocument(); expect(screen.getByDisplayValue('12345')).toBeInTheDocument(); // update field await userEvent.clear(screen.getByRole('textbox', {name: /postal code/i})); await userEvent.type(screen.getByRole('textbox', {name: /postal code/i}), '98765'); await userEvent.click(screen.getByRole('button', {name: /save changes/i})); expect(updateMock).toHaveBeenCalledWith( `/customers/${organization.slug}/billing-details/`, expect.objectContaining({ method: 'PUT', data: {...BillingDetailsFixture(), postalCode: '98765'}, }) ); }); });