diff --git a/static/gsAdmin/components/changePlanAction.spec.tsx b/static/gsAdmin/components/changePlanAction.spec.tsx index 853533bf617312..92bc6503b49fd4 100644 --- a/static/gsAdmin/components/changePlanAction.spec.tsx +++ b/static/gsAdmin/components/changePlanAction.spec.tsx @@ -89,7 +89,7 @@ describe('ChangePlanAction', () => { async function openAndLoadModal(props = {}) { triggerChangePlanAction({ subscription, - orgId: mockOrg.slug, + organization: mockOrg, onSuccess: jest.fn(), partnerPlanId: null, ...props, @@ -184,6 +184,7 @@ describe('ChangePlanAction', () => { }); it('completes form submission flow', async () => { + mockOrg.features = []; // Mock the PUT endpoint response const putMock = MockApiClient.addMockResponse({ url: `/customers/${mockOrg.slug}/subscription/`, @@ -212,6 +213,8 @@ describe('ChangePlanAction', () => { '1' ); + expect(screen.queryByText('Available Products')).not.toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Change Plan'})).toBeEnabled(); await userEvent.click(screen.getByRole('button', {name: 'Change Plan'})); @@ -221,6 +224,44 @@ describe('ChangePlanAction', () => { expect(requestData).toHaveProperty('plan', 'am3_business'); }); + it('completes form with seer', async () => { + mockOrg.features = ['seer-billing']; + const putMock = MockApiClient.addMockResponse({ + url: `/customers/${mockOrg.slug}/subscription/`, + method: 'PUT', + body: {success: true}, + }); + + openAndLoadModal(); + + await waitFor(() => { + expect(screen.getByRole('tab', {name: 'AM3'})).toBeInTheDocument(); + }); + + await userEvent.click(screen.getAllByRole('radio')[0] as HTMLElement); + + await selectEvent.select(screen.getByRole('textbox', {name: 'Errors'}), '100,000'); + await selectEvent.select(screen.getByRole('textbox', {name: 'Replays'}), '50'); + await selectEvent.select(screen.getByRole('textbox', {name: 'Spans'}), '10,000,000'); + await selectEvent.select(screen.getByRole('textbox', {name: 'Cron monitors'}), '1'); + await selectEvent.select(screen.getByRole('textbox', {name: 'Uptime monitors'}), '1'); + await selectEvent.select( + screen.getByRole('textbox', {name: 'Attachments (GB)'}), + '1' + ); + + expect(screen.getByText('Available Products')).toBeInTheDocument(); + await userEvent.click(screen.getByText('Seer')); + + expect(screen.getByRole('button', {name: 'Change Plan'})).toBeEnabled(); + await userEvent.click(screen.getByRole('button', {name: 'Change Plan'})); + + expect(putMock).toHaveBeenCalled(); + const requestData = putMock.mock.calls[0][1].data; + expect(requestData).toHaveProperty('plan', 'am3_business'); + expect(requestData).toHaveProperty('seer', true); + }); + it('updates plan list when switching between tiers', async () => { openAndLoadModal(); @@ -314,4 +355,69 @@ describe('ChangePlanAction', () => { expect(requestData).toHaveProperty('reservedErrors', 50000); expect(requestData).toHaveProperty('reservedTransactions', 25000); }); + + describe('Seer Budget', () => { + beforeEach(() => { + mockOrg.features = ['seer-billing']; + jest.clearAllMocks(); + MockApiClient.clearMockResponses(); + + const user = UserFixture(); + user.permissions = new Set(['billing.provision']); + ConfigStore.set('user', user); + SubscriptionStore.set(mockOrg.slug, subscription); + + // Set up default subscription response + MockApiClient.addMockResponse({ + url: `/subscriptions/${mockOrg.slug}/`, + body: subscription, + }); + + MockApiClient.addMockResponse({ + url: `/customers/${mockOrg.slug}/billing-config/?tier=all`, + body: BILLING_CONFIG, + }); + }); + + it('shows Seer budget checkbox for AM tiers', async () => { + openAndLoadModal(); + + await waitFor(() => { + expect(screen.getByRole('tab', {name: 'AM3'})).toBeInTheDocument(); + }); + await userEvent.click(screen.getAllByRole('radio')[0] as HTMLElement); + + expect(screen.getByText('Seer')).toBeInTheDocument(); + + const am2Tab = screen.getByRole('tab', {name: 'AM2'}); + await userEvent.click(am2Tab); + await userEvent.click(screen.getAllByRole('radio')[0] as HTMLElement); + + expect(screen.getByText('Seer')).toBeInTheDocument(); + + const am1Tab = screen.getByRole('tab', {name: 'AM1'}); + await userEvent.click(am1Tab); + await userEvent.click(screen.getAllByRole('radio')[0] as HTMLElement); + + expect(screen.getByText('Seer')).toBeInTheDocument(); + }); + it('hides Seer budget checkbox for MM2 tier', async () => { + openAndLoadModal(); + + await waitFor(() => { + expect(screen.getByRole('tab', {name: 'AM3'})).toBeInTheDocument(); + }); + + const mm2Tab = screen.getByRole('tab', {name: 'MM2'}); + await userEvent.click(mm2Tab); + + await userEvent.click(screen.getAllByRole('radio')[0] as HTMLElement); + + expect( + screen.queryByRole('checkbox', { + name: 'Seer Budget', + }) + ).not.toBeInTheDocument(); + }); + }); }); diff --git a/static/gsAdmin/components/changePlanAction.tsx b/static/gsAdmin/components/changePlanAction.tsx index d5befa08b25946..1f3d60a3b89f16 100644 --- a/static/gsAdmin/components/changePlanAction.tsx +++ b/static/gsAdmin/components/changePlanAction.tsx @@ -16,6 +16,7 @@ import LoadingIndicator from 'sentry/components/loadingIndicator'; import ConfigStore from 'sentry/stores/configStore'; import {space} from 'sentry/styles/space'; import type {DataCategory} from 'sentry/types/core'; +import type {Organization} from 'sentry/types/organization'; import {useApiQuery} from 'sentry/utils/queryClient'; import {toTitleCase} from 'sentry/utils/string/toTitleCase'; import useApi from 'sentry/utils/useApi'; @@ -29,14 +30,14 @@ const ALLOWED_TIERS = [PlanTier.MM2, PlanTier.AM1, PlanTier.AM2, PlanTier.AM3]; type Props = { onSuccess: () => void; - orgId: string; + organization: Organization; partnerPlanId: string | null; subscription: Subscription; } & ModalRenderProps; function ChangePlanAction({ subscription, - orgId, + organization, partnerPlanId, onSuccess, closeModal, @@ -46,6 +47,7 @@ function ChangePlanAction({ const [activeTier, setActiveTier] = useState(PlanTier.AM3); const [activePlan, setActivePlan] = useState(null); const [formModel] = useState(() => new FormModel()); + const orgId = organization.slug; const api = useApi({persistInFlight: true}); const { @@ -156,7 +158,10 @@ function ChangePlanAction({ } Object.entries(subscription.categories).forEach(([category, metricHistory]) => { - if (metricHistory.reserved) { + if ( + metricHistory.reserved && + plan.checkoutCategories.includes(category as DataCategory) + ) { const closestTier = findClosestTier( plan, category as DataCategory, @@ -319,6 +324,7 @@ function ChangePlanAction({ formModel={formModel} activePlan={activePlan} subscription={subscription} + organization={organization} onSubmit={handleSubmit} onCancel={closeModal} onSubmitSuccess={(data: Data) => { @@ -340,7 +346,7 @@ function ChangePlanAction({ type Options = { onSuccess: () => void; - orgId: string; + organization: Organization; partnerPlanId: string | null; subscription: Subscription; }; diff --git a/static/gsAdmin/components/planList.tsx b/static/gsAdmin/components/planList.tsx index 94746bbc2b0da6..e18f8d0ab02f36 100644 --- a/static/gsAdmin/components/planList.tsx +++ b/static/gsAdmin/components/planList.tsx @@ -1,5 +1,6 @@ import styled from '@emotion/styled'; +import CheckboxField from 'sentry/components/forms/fields/checkboxField'; import InputField from 'sentry/components/forms/fields/inputField'; import RadioField from 'sentry/components/forms/fields/radioField'; import SelectField from 'sentry/components/forms/fields/selectField'; @@ -9,6 +10,7 @@ import type FormModel from 'sentry/components/forms/model'; import type {Data, OnSubmitCallback} from 'sentry/components/forms/types'; import {space} from 'sentry/styles/space'; import {DataCategory} from 'sentry/types/core'; +import type {Organization} from 'sentry/types/organization'; import {toTitleCase} from 'sentry/utils/string/toTitleCase'; import {ANNUAL} from 'getsentry/constants'; @@ -24,6 +26,7 @@ type Props = { onSubmit: OnSubmitCallback; onSubmitError: (error: any) => void; onSubmitSuccess: (data: Data) => void; + organization: Organization; subscription: Subscription; tierPlans: BillingConfig['planList']; }; @@ -31,6 +34,7 @@ type Props = { function PlanList({ activePlan, subscription, + organization, onSubmit, onCancel, onSubmitSuccess, @@ -74,6 +78,17 @@ function PlanList({ 100000: '100K', }; + const availableProducts = Object.values(activePlan?.availableReservedBudgetTypes || {}) + .filter( + productInfo => + productInfo.isFixed && // NOTE: for now, we only supported fixed budget products in checkout + productInfo.billingFlag && + organization.features.includes(productInfo.billingFlag) + ) + .map(productInfo => { + return productInfo; + }); + return (
)} + {availableProducts.length > 0 && ( + +

Available Products

+ {availableProducts.map(productInfo => { + return ( + { + formModel.setValue(productInfo.productName, value.target.checked); + }} + /> + ); + })} +
+ )} triggerChangePlanAction({ - orgId, + organization, subscription, partnerPlanId: subscription.partner?.isActive ? subscription.planDetails.id