diff --git a/static/gsApp/types/index.tsx b/static/gsApp/types/index.tsx index 807bbb0fbe41e5..5ba22da873bc20 100644 --- a/static/gsApp/types/index.tsx +++ b/static/gsApp/types/index.tsx @@ -84,6 +84,10 @@ export type ReservedBudgetCategory = { * The API name of the budget */ apiName: ReservedBudgetCategoryType; + /** + * The feature flag determining if the product is available for billing + */ + billingFlag: string | null; /** * Backend name of the category (all caps, snake case) */ diff --git a/static/gsApp/views/amCheckout/checkoutOverview.spec.tsx b/static/gsApp/views/amCheckout/checkoutOverview.spec.tsx index e7dd0d4fc47237..7aa0ae69dffcce 100644 --- a/static/gsApp/views/amCheckout/checkoutOverview.spec.tsx +++ b/static/gsApp/views/amCheckout/checkoutOverview.spec.tsx @@ -10,7 +10,7 @@ import SubscriptionStore from 'getsentry/stores/subscriptionStore'; import {OnDemandBudgetMode, PlanTier} from 'getsentry/types'; import AMCheckout from 'getsentry/views/amCheckout/'; import CheckoutOverview from 'getsentry/views/amCheckout/checkoutOverview'; -import type {CheckoutFormData} from 'getsentry/views/amCheckout/types'; +import {type CheckoutFormData, SelectableProduct} from 'getsentry/views/amCheckout/types'; describe('CheckoutOverview', function () { const api = new MockApiClient(); @@ -175,12 +175,16 @@ describe('CheckoutOverview', function () { expect(screen.queryByTestId('on-demand-additional-cost')).not.toBeInTheDocument(); }); - it('displays Seer Agent AI when enabled and feature flag is present', () => { + it('displays Seer Agent AI when enabled', () => { const orgWithSeerFeature = {...organization, features: ['seer-billing']}; const formData: CheckoutFormData = { plan: 'am2_team', reserved: {errors: 100000, transactions: 500000, attachments: 25}, - seerEnabled: true, + selectedProducts: { + [SelectableProduct.SEER]: { + enabled: true, + }, + }, }; render( @@ -194,19 +198,20 @@ describe('CheckoutOverview', function () { /> ); - expect(screen.getByTestId('seer')).toBeInTheDocument(); - expect(screen.getByText('Seer: Sentry AI Enhancements')).toBeInTheDocument(); - expect( - screen.getByText('Surface insights and propose solutions to fix bugs faster.') - ).toBeInTheDocument(); + expect(screen.getByTestId('seer-reserved')).toBeInTheDocument(); + expect(screen.getByText('Seer')).toBeInTheDocument(); }); - it('does not display Seer Agent AI when seerEnabled is false', () => { + it('does not display Seer Agent AI when not bought', () => { const orgWithSeerFeature = {...organization, features: ['seer-billing']}; const formData: CheckoutFormData = { plan: 'am2_team', reserved: {errors: 100000, transactions: 500000, attachments: 25}, - seerEnabled: false, + selectedProducts: { + [SelectableProduct.SEER]: { + enabled: false, + }, + }, }; render( @@ -221,31 +226,6 @@ describe('CheckoutOverview', function () { ); expect(screen.queryByTestId('seer')).not.toBeInTheDocument(); - expect(screen.queryByText('Seer: Sentry AI Enhancements')).not.toBeInTheDocument(); - }); - - it('does not display Seer Agent AI when enabled but feature flag is missing', () => { - const orgWithoutSeerFeature = {...organization, features: []}; - const formData: CheckoutFormData = { - plan: 'am2_team', - reserved: {errors: 100000, transactions: 500000, attachments: 25}, - seerEnabled: true, - }; - - jest.spyOn(CheckoutOverview.prototype, 'renderSeer').mockReturnValue(null); - - render( - - ); - - expect(screen.queryByTestId('seer')).not.toBeInTheDocument(); - expect(screen.queryByText('Seer: Sentry AI Enhancements')).not.toBeInTheDocument(); + expect(screen.queryByText('Seer')).not.toBeInTheDocument(); }); }); diff --git a/static/gsApp/views/amCheckout/checkoutOverview.tsx b/static/gsApp/views/amCheckout/checkoutOverview.tsx index 354cf965e9bbbb..92373b8b2fbc46 100644 --- a/static/gsApp/views/amCheckout/checkoutOverview.tsx +++ b/static/gsApp/views/amCheckout/checkoutOverview.tsx @@ -7,9 +7,16 @@ import PanelBody from 'sentry/components/panels/panelBody'; import {t, tct} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type {Organization} from 'sentry/types/organization'; - -import {ANNUAL, MONTHLY, SEER_MONTHLY_PRICE_CENTS} from 'getsentry/constants'; -import type {BillingConfig, Plan, Promotion, Subscription} from 'getsentry/types'; +import {toTitleCase} from 'sentry/utils/string/toTitleCase'; + +import {ANNUAL, MONTHLY} from 'getsentry/constants'; +import type { + BillingConfig, + Plan, + Promotion, + ReservedBudgetCategoryType, + Subscription, +} from 'getsentry/types'; import {OnDemandBudgetMode} from 'getsentry/types'; import {formatReservedWithUnits} from 'getsentry/utils/billing'; import {getPlanCategoryName} from 'getsentry/utils/dataCategory'; @@ -64,6 +71,35 @@ class CheckoutOverview extends Component { } }; + renderProducts = () => { + const {formData, activePlan} = this.props; + + return Object.entries(formData.selectedProducts ?? {}).map(([apiName, product]) => { + const productInfo = + activePlan.availableReservedBudgetTypes[apiName as ReservedBudgetCategoryType]; + if (!productInfo || !product.enabled) { + return null; + } + const price = utils.displayPrice({ + cents: utils.getReservedPriceForReservedBudgetCategory({ + plan: activePlan, + reservedBudgetCategory: productInfo.apiName, + }), + }); + return ( + + {toTitleCase(productInfo.productName)} + + {price}/{this.shortInterval} + + + ); + }); + }; + renderDataOptions = () => { const {formData, activePlan} = this.props; @@ -174,47 +210,6 @@ class CheckoutOverview extends Component { ); } - renderAdditionalFeature({ - featureKey, - title, - description, - priceCents, - enabledField, - }: { - description: string; - enabledField: string; - featureKey: string; - priceCents: number; - title: string; - }) { - const {formData} = this.props; - const isEnabled = formData[enabledField as keyof CheckoutFormData]; - - if (!isEnabled) { - return null; - } - - return ( - - - {title} - {description} - - {`${utils.displayPrice({cents: priceCents})}/mo`} - - ); - } - - renderSeer() { - return this.renderAdditionalFeature({ - featureKey: 'seer', - title: t('Seer: Sentry AI Enhancements'), - description: t('Surface insights and propose solutions to fix bugs faster.'), - priceCents: SEER_MONTHLY_PRICE_CENTS, - enabledField: 'seerEnabled', - }); - } - renderDetailItems = () => { const {activePlan, discountInfo} = this.props; @@ -264,8 +259,8 @@ class CheckoutOverview extends Component { )} + {this.renderProducts()} {this.renderDataOptions()} - {this.renderSeer()} {this.renderOnDemand()} ); diff --git a/static/gsApp/views/amCheckout/checkoutOverviewV2.spec.tsx b/static/gsApp/views/amCheckout/checkoutOverviewV2.spec.tsx index 7f34576e9ae7e9..57287438e7167f 100644 --- a/static/gsApp/views/amCheckout/checkoutOverviewV2.spec.tsx +++ b/static/gsApp/views/amCheckout/checkoutOverviewV2.spec.tsx @@ -8,7 +8,7 @@ import SubscriptionStore from 'getsentry/stores/subscriptionStore'; import {PlanTier} from 'getsentry/types'; import AMCheckout from 'getsentry/views/amCheckout/'; import CheckoutOverviewV2 from 'getsentry/views/amCheckout/checkoutOverviewV2'; -import type {CheckoutFormData} from 'getsentry/views/amCheckout/types'; +import {type CheckoutFormData, SelectableProduct} from 'getsentry/views/amCheckout/types'; describe('CheckoutOverviewV2', function () { const api = new MockApiClient(); @@ -81,6 +81,11 @@ describe('CheckoutOverviewV2', function () { uptime: 1, }, onDemandMaxSpend: 5000, + selectedProducts: { + [SelectableProduct.SEER]: { + enabled: true, + }, + }, }; render( @@ -94,8 +99,8 @@ describe('CheckoutOverviewV2', function () { /> ); - expect(screen.getByText('Sentry Team Plan')).toBeInTheDocument(); expect(screen.getByText('All Sentry Products')).toBeInTheDocument(); + expect(screen.getByTestId('seer-reserved')).toHaveTextContent('Seer$216/yr'); expect(screen.getByText('Total Annual Charges')).toBeInTheDocument(); expect(screen.getByText('$312/yr')).toBeInTheDocument(); expect(screen.getByTestId('additional-monthly-charge')).toHaveTextContent( @@ -161,4 +166,69 @@ describe('CheckoutOverviewV2', function () { expect(screen.queryByTestId('additional-monthly-charge')).not.toBeInTheDocument(); expect(screen.getAllByText('Product not available')[0]).toBeInTheDocument(); }); + + it('does not show seer when not enabled', function () { + const formData: CheckoutFormData = { + plan: 'am3_team_auf', + reserved: { + errors: 100000, + attachments: 25, + replays: 50, + spans: 10_000_000, + monitorSeats: 1, + profileDuration: 0, + profileDurationUI: 0, + uptime: 1, + }, + onDemandMaxSpend: 5000, + selectedProducts: { + [SelectableProduct.SEER]: { + enabled: false, + }, + }, + }; + + render( + + ); + + expect(screen.queryByTestId('seer-reserved')).not.toBeInTheDocument(); + }); + + it('does not show seer when not included in formData', function () { + const formData: CheckoutFormData = { + plan: 'am3_team_auf', + reserved: { + errors: 100000, + attachments: 25, + replays: 50, + spans: 10_000_000, + monitorSeats: 1, + profileDuration: 0, + profileDurationUI: 0, + uptime: 1, + }, + onDemandMaxSpend: 5000, + }; + + render( + + ); + + expect(screen.queryByTestId('seer-reserved')).not.toBeInTheDocument(); + }); }); diff --git a/static/gsApp/views/amCheckout/checkoutOverviewV2.tsx b/static/gsApp/views/amCheckout/checkoutOverviewV2.tsx index edb6c76435e087..a5738647527e57 100644 --- a/static/gsApp/views/amCheckout/checkoutOverviewV2.tsx +++ b/static/gsApp/views/amCheckout/checkoutOverviewV2.tsx @@ -10,16 +10,13 @@ import {t, tct} from 'sentry/locale'; 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 { - PAYG_BUSINESS_DEFAULT, - PAYG_TEAM_DEFAULT, - SEER_MONTHLY_PRICE_CENTS, -} from 'getsentry/constants'; +import {PAYG_BUSINESS_DEFAULT, PAYG_TEAM_DEFAULT} from 'getsentry/constants'; import type {BillingConfig, Plan, Promotion, Subscription} from 'getsentry/types'; import {formatReservedWithUnits, isBizPlanFamily} from 'getsentry/utils/billing'; import {getPlanCategoryName, getSingularCategoryName} from 'getsentry/utils/dataCategory'; -import type {CheckoutFormData} from 'getsentry/views/amCheckout/types'; +import type {CheckoutFormData, SelectableProduct} from 'getsentry/views/amCheckout/types'; import * as utils from 'getsentry/views/amCheckout/utils'; type Props = { @@ -32,12 +29,7 @@ type Props = { discountInfo?: Promotion['discountInfo']; }; -function CheckoutOverviewV2({ - activePlan, - formData, - onUpdate: _onUpdate, - organization, -}: Props) { +function CheckoutOverviewV2({activePlan, formData, onUpdate: _onUpdate}: Props) { const shortInterval = useMemo(() => { return utils.getShortInterval(activePlan.billingInterval); }, [activePlan.billingInterval]); @@ -54,9 +46,6 @@ function CheckoutOverviewV2({ [formData.onDemandMaxSpend] ); - const hasSeerEnabled = !!formData.seerEnabled; - const hasSeerFeature = organization.features.includes('seer-billing'); - const renderPlanDetails = () => { return ( @@ -80,56 +69,6 @@ function CheckoutOverviewV2({ ); }; - const renderAdditionalFeatureSummary = ({ - featureKey, - featureEnabled, - featureAvailable, - title, - tooltipTitle, - priceCents, - }: { - featureAvailable: boolean; - featureEnabled: boolean; - featureKey: string; - priceCents: number; - title: string; - tooltipTitle: string; - }) => { - return ( - featureAvailable && - featureEnabled && ( - - - - - - {title} - - - - - - - {`+${utils.displayPrice({cents: priceCents})}/mo`} - Additional usage billed separately - - - - ) - ); - }; - - const renderSeerSummary = () => { - return renderAdditionalFeatureSummary({ - featureKey: 'seer', - featureEnabled: hasSeerEnabled, - featureAvailable: hasSeerFeature, - title: t('Sentry AI Agent'), - tooltipTitle: t('Additional Seer information.'), - priceCents: SEER_MONTHLY_PRICE_CENTS, - }); - }; - const renderPayAsYouGoBudget = (paygBudgetTotal: number) => { return ( @@ -171,6 +110,71 @@ function CheckoutOverviewV2({ }; const renderProductBreakdown = () => { + const hasAtLeastOneSelectedProduct = Object.values( + activePlan.availableReservedBudgetTypes + ).some(budgetTypeInfo => { + return formData.selectedProducts?.[ + budgetTypeInfo.apiName as string as SelectableProduct + ]?.enabled; + }); + + if (!hasAtLeastOneSelectedProduct) { + return null; + } + + return ( + + + + + {Object.values(activePlan.availableReservedBudgetTypes).map( + budgetTypeInfo => { + const formDataForProduct = + formData.selectedProducts?.[ + budgetTypeInfo.apiName as string as SelectableProduct + ]; + if (!formDataForProduct) { + return null; + } + + if (formDataForProduct.enabled) { + return ( + + + {toTitleCase(budgetTypeInfo.productName)} + + + + {utils.displayPrice({ + cents: utils.getReservedPriceForReservedBudgetCategory({ + plan: activePlan, + reservedBudgetCategory: budgetTypeInfo.apiName, + }), + })} + /{shortInterval} + + + ); + } + return null; + } + )} + + + + ); + }; + + const renderObservabilityProductBreakdown = () => { const paygCategories = [ DataCategory.MONITOR_SEATS, DataCategory.PROFILE_DURATION, @@ -178,12 +182,19 @@ function CheckoutOverviewV2({ DataCategory.UPTIME, ]; + const budgetCategories = Object.values( + activePlan.availableReservedBudgetTypes + ).reduce((acc, type) => { + acc.push(...type.dataCategories); + return acc; + }, [] as DataCategory[]); + return ( {t('All Sentry Products')} {activePlan.categories - .filter(category => activePlan.planCategories[category]) + .filter(category => !budgetCategories.includes(category)) .map(category => { const eventBucket = activePlan.planCategories[category] && @@ -325,18 +336,14 @@ function CheckoutOverviewV2({ return ( {renderPlanDetails()} - - {hasSeerEnabled && renderSeerSummary()} + {renderProductBreakdown()} + {/* {hasSeerEnabled && renderSeerSummary()} */} {renderPayAsYouGoBudget(paygMonthlyBudget)} - {renderProductBreakdown()} + {renderObservabilityProductBreakdown()} - {renderTotals( - committedTotal + - (hasSeerFeature && formData.seerEnabled ? SEER_MONTHLY_PRICE_CENTS : 0), - paygMonthlyBudget - )} + {renderTotals(committedTotal, paygMonthlyBudget)} ); } @@ -391,11 +398,11 @@ const ReservedVolumes = styled('div')` gap: ${space(1.5)}; `; -const ReservedItem = styled(Title)` +const ReservedItem = styled(Title)<{isIndividualProduct?: boolean}>` display: flex; gap: ${space(0.5)}; align-items: center; - color: ${p => p.theme.subText}; + color: ${p => (p.isIndividualProduct ? p.theme.textColor : p.theme.subText)}; `; const Section = styled(PanelChild)` diff --git a/static/gsApp/views/amCheckout/index.tsx b/static/gsApp/views/amCheckout/index.tsx index 5127be2431b3b5..fc38609609acd2 100644 --- a/static/gsApp/views/amCheckout/index.tsx +++ b/static/gsApp/views/amCheckout/index.tsx @@ -70,7 +70,11 @@ import OnDemandSpend from 'getsentry/views/amCheckout/steps/onDemandSpend'; import PlanSelect from 'getsentry/views/amCheckout/steps/planSelect'; import ReviewAndConfirm from 'getsentry/views/amCheckout/steps/reviewAndConfirm'; import SetPayAsYouGo from 'getsentry/views/amCheckout/steps/setPayAsYouGo'; -import type {CheckoutFormData} from 'getsentry/views/amCheckout/types'; +import type { + CheckoutFormData, + SelectedProductData, +} from 'getsentry/views/amCheckout/types'; +import {SelectableProduct} from 'getsentry/views/amCheckout/types'; import {getBucket} from 'getsentry/views/amCheckout/utils'; import { getTotalBudget, @@ -431,7 +435,15 @@ class AMCheckout extends Component { }, ...(onDemandMaxSpend > 0 && {onDemandMaxSpend}), onDemandBudget: parseOnDemandBudgetsFromSubscription(subscription), - seerEnabled: false, + selectedProducts: Object.values(SelectableProduct).reduce( + (acc, product) => { + acc[product] = { + enabled: false, + }; + return acc; + }, + {} as Record + ), }; if ( @@ -448,13 +460,25 @@ class AMCheckout extends Component { }; } + subscription.reservedBudgets?.forEach(budget => { + if ( + Object.values(SelectableProduct).includes( + budget.apiName as string as SelectableProduct + ) + ) { + data.selectedProducts[budget.apiName as string as SelectableProduct] = { + enabled: budget.reservedBudget > 0, + }; + } + }); + return this.getValidData(initialPlan, data); } getValidData(plan: Plan, data: Omit): CheckoutFormData { const {subscription, organization, checkoutTier} = this.props; - const {onDemandMaxSpend, onDemandBudget, seerEnabled} = data; + const {onDemandMaxSpend, onDemandBudget, selectedProducts} = data; // Verify next plan data volumes before updating form data // finds the approximate bucket if event level does not exist @@ -500,7 +524,7 @@ class AMCheckout extends Component { onDemandMaxSpend: newOnDemandMaxSpend, onDemandBudget: newOnDemandBudget, reserved: nextReserved, - seerEnabled, + selectedProducts, }; } diff --git a/static/gsApp/views/amCheckout/steps/contractSelect.tsx b/static/gsApp/views/amCheckout/steps/contractSelect.tsx index 8e82abeada50a8..ec679f2a9591b1 100644 --- a/static/gsApp/views/amCheckout/steps/contractSelect.tsx +++ b/static/gsApp/views/amCheckout/steps/contractSelect.tsx @@ -109,6 +109,7 @@ class ContractSelect extends Component { const price = getReservedTotal({ plan, reserved: formData.reserved, + selectedProducts: formData.selectedProducts, ...discountData, }); diff --git a/static/gsApp/views/amCheckout/steps/onDemandBudgets.spec.tsx b/static/gsApp/views/amCheckout/steps/onDemandBudgets.spec.tsx index 577e42d2ddf19c..9dc2e461745152 100644 --- a/static/gsApp/views/amCheckout/steps/onDemandBudgets.spec.tsx +++ b/static/gsApp/views/amCheckout/steps/onDemandBudgets.spec.tsx @@ -243,6 +243,7 @@ describe('OnDemandBudgets AM Checkout', function () { reservedSpans: undefined, reservedTransactions: 100000, reservedUptime: 1, + seer: false, }, }) ); diff --git a/static/gsApp/views/amCheckout/steps/planSelect.tsx b/static/gsApp/views/amCheckout/steps/planSelect.tsx index e9b36962bd6bf4..e0fecc6855efb8 100644 --- a/static/gsApp/views/amCheckout/steps/planSelect.tsx +++ b/static/gsApp/views/amCheckout/steps/planSelect.tsx @@ -1,23 +1,18 @@ -import {useState} from 'react'; import styled from '@emotion/styled'; import cloneDeep from 'lodash/cloneDeep'; import moment from 'moment-timezone'; -import {SeerIcon} from 'sentry/components/ai/SeerIcon'; -import {FeatureBadge} from 'sentry/components/core/badge/featureBadge'; import {Tag} from 'sentry/components/core/badge/tag'; import {Button} from 'sentry/components/core/button'; import Panel from 'sentry/components/panels/panel'; import PanelBody from 'sentry/components/panels/panelBody'; import PanelFooter from 'sentry/components/panels/panelFooter'; -import QuestionTooltip from 'sentry/components/questionTooltip'; -import {IconAdd, IconCheckmark, IconWarning} from 'sentry/icons'; +import {IconWarning} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import getDaysSinceDate from 'sentry/utils/getDaysSinceDate'; import {Oxfordize} from 'sentry/utils/oxfordizeArray'; -import {SEER_MONTHLY_PRICE_CENTS} from 'getsentry/constants'; import {type Plan, PlanTier} from 'getsentry/types'; import { getBusinessPlanOfTier, @@ -33,10 +28,10 @@ import { import trackGetsentryAnalytics from 'getsentry/utils/trackGetsentryAnalytics'; import usePromotionTriggerCheck from 'getsentry/utils/usePromotionTriggerCheck'; import PlanSelectRow from 'getsentry/views/amCheckout/steps/planSelectRow'; +import ProductSelect from 'getsentry/views/amCheckout/steps/productSelect'; import StepHeader from 'getsentry/views/amCheckout/steps/stepHeader'; import type {StepProps} from 'getsentry/views/amCheckout/types'; import {formatPrice, getDiscountedPrice} from 'getsentry/views/amCheckout/utils'; -import * as utils from 'getsentry/views/amCheckout/utils'; export type PlanContent = { description: React.ReactNode; @@ -140,10 +135,6 @@ function PlanSelect({ onCompleteStep, }: StepProps) { const {data: promotionData, refetch} = usePromotionTriggerCheck(organization); - - const [seerIsEnabled, setSeerIsEnabled] = useState(Boolean(formData.seerEnabled)); - const hasSeerFeature = organization.features.includes('seer-billing'); - const discountInfo = promotion?.discountInfo; let trailingItems: React.ReactNode = null; if (showSubscriptionDiscount({activePlan, discountInfo}) && discountInfo) { @@ -247,88 +238,6 @@ function PlanSelect({ ); }; - const renderAdditionalFeature = ({ - featureKey, - featureEnabled, - featureAvailable, - title, - description, - icon, - priceCents, - featureItems, - tooltipTitle, - isNew = false, - setFeatureEnabled, - }: { - description: string; - featureAvailable: boolean; - featureEnabled: boolean; - featureItems: string[]; - featureKey: string; - icon: React.ReactNode; - priceCents: number; - setFeatureEnabled: (enabled: boolean) => void; - title: string; - tooltipTitle: string; - isNew?: boolean; - }) => { - return ( - featureAvailable && ( - - - - {icon} - {title} - {isNew && } - - {description} - - {featureItems.map((item, index) => ( - {item} - ))} - - - : } - onClick={() => { - // Update the UI state - setFeatureEnabled(!featureEnabled); - // Update the form data - onUpdate({...formData, [`${featureKey}Enabled`]: !featureEnabled}); - }} - data-test-id={`${featureKey}-toggle-button`} - > - {featureEnabled - ? t('Added to plan') - : t('%s/mo', utils.displayPrice({cents: priceCents}))} - - - - Extra usage requires PAYG - - - - ) - ); - }; - - const renderSeer = () => { - return renderAdditionalFeature({ - featureKey: 'seer', - featureEnabled: seerIsEnabled, - featureAvailable: hasSeerFeature, - title: 'Add Seer, our AI Agent', - description: 'Insights and solutions to fix bugs faster', - icon: , - priceCents: SEER_MONTHLY_PRICE_CENTS, - featureItems: ['Root Cause Analysis', 'Autofix PRs', 'AI Issue Priority'], - tooltipTitle: t('Additional Seer information.'), - isNew: true, - setFeatureEnabled: setSeerIsEnabled, - }); - }; - const renderFooter = () => { const bizPlanContent = getContentForPlan('business', checkoutTier); let missingFeatures: string[] = []; @@ -420,7 +329,14 @@ function PlanSelect({ organization={organization} /> {isActive && renderBody()} - {isActive && renderSeer()} + {isActive && ( + + )} {isActive && renderFooter()} ); @@ -440,38 +356,3 @@ const FooterWarningWrapper = styled('div')` align-items: center; gap: ${space(1)}; `; - -const FeatureHeader = styled('div')` - margin: 0; - gap: ${space(1)}; - display: flex; - align-items: center; - font-weight: 900; - font-size: large; -`; - -const FeatureContainer = styled('div')` - display: flex; - flex-direction: column; - gap: ${space(1.5)}; -`; - -const FeatureDescription = styled('p')` - margin: 0; - color: ${p => p.theme.subText}; -`; - -const FeatureList = styled('ul')` - list-style: disc; - padding-left: ${space(2)}; - margin: ${space(1)} 0 0 0; - color: ${p => p.theme.subText}; -`; - -const FeatureItem = styled('li')` - margin-bottom: ${space(0.5)}; -`; - -const ButtonWrapper = styled('div')` - margin-top: ${space(2)}; -`; diff --git a/static/gsApp/views/amCheckout/steps/productSelect.spec.tsx b/static/gsApp/views/amCheckout/steps/productSelect.spec.tsx new file mode 100644 index 00000000000000..3bf7bd1270c8b5 --- /dev/null +++ b/static/gsApp/views/amCheckout/steps/productSelect.spec.tsx @@ -0,0 +1,169 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {RouteComponentPropsFixture} from 'sentry-fixture/routeComponentPropsFixture'; + +import {BillingConfigFixture} from 'getsentry-test/fixtures/billingConfig'; +import {SeerReservedBudgetFixture} from 'getsentry-test/fixtures/reservedBudget'; +import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription'; +import {render, screen, userEvent, within} from 'sentry-test/reactTestingLibrary'; + +import SubscriptionStore from 'getsentry/stores/subscriptionStore'; +import {PlanTier} from 'getsentry/types'; +import AMCheckout from 'getsentry/views/amCheckout/'; + +describe('ProductSelect', function () { + const api = new MockApiClient(); + const organization = OrganizationFixture({}); + const subscription = SubscriptionFixture({organization}); + const params = {}; + + beforeEach(function () { + organization.features = ['seer-billing']; + SubscriptionStore.set(organization.slug, subscription); + + MockApiClient.addMockResponse({ + url: `/subscriptions/${organization.slug}/`, + method: 'GET', + body: {}, + }); + MockApiClient.addMockResponse({ + url: `/customers/${organization.slug}/billing-config/`, + method: 'GET', + body: BillingConfigFixture(PlanTier.AM3), + }); + MockApiClient.addMockResponse({ + method: 'POST', + url: '/_experiment/log_exposure/', + body: {}, + }); + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/promotions/trigger-check/`, + method: 'POST', + body: {}, + }); + MockApiClient.addMockResponse({ + url: `/customers/${organization.slug}/plan-migrations/?applied=0`, + method: 'GET', + body: {}, + }); + }); + + it('renders', async function () { + const freeSubscription = SubscriptionFixture({ + organization, + plan: 'am3_f', + isFree: true, + }); + SubscriptionStore.set(organization.slug, freeSubscription); + + render( + , + {organization} + ); + + expect(await screen.findByTestId('body-choose-your-plan')).toBeInTheDocument(); + expect(screen.getByTestId('product-option-seer')).toBeInTheDocument(); + expect(screen.getAllByTestId(/product-option/)).toHaveLength(1); + expect(screen.getByText('$20/mo')).toBeInTheDocument(); + expect(screen.getByTestId('footer-choose-your-plan')).toBeInTheDocument(); + }); + + it('does not render products if flags are missing', async function () { + organization.features = []; + render( + , + {organization} + ); + + expect(await screen.findByTestId('body-choose-your-plan')).toBeInTheDocument(); + expect(screen.queryAllByTestId(/product-option/)).toHaveLength(0); + }); + + it('renders with correct monthly price for products', async function () { + render( + , + {organization} + ); + + expect(await screen.findByTestId('product-option-seer')).toHaveTextContent('$20/mo'); + }); + + it('renders with correct annual price for products', async function () { + const annualSubscription = SubscriptionFixture({ + organization, + plan: 'am3_team_auf', + }); + SubscriptionStore.set(organization.slug, annualSubscription); + + render( + , + {organization} + ); + + expect(await screen.findByTestId('product-option-seer')).toHaveTextContent('$216/yr'); + }); + + it('renders with product selected based on current subscription', async function () { + subscription.reservedBudgets = [SeerReservedBudgetFixture({id: '2'})]; + SubscriptionStore.set(organization.slug, subscription); + + render( + , + {organization} + ); + + expect(await screen.findByTestId('product-option-seer')).toHaveTextContent( + 'Added to plan' + ); + + subscription.reservedBudgets = []; // clear + }); + + it('can enable and disable products', async function () { + render( + , + {organization} + ); + + const seerProduct = await screen.findByTestId('product-option-seer'); + const seerButton = within(seerProduct).getByRole('button'); + expect(seerButton).toHaveTextContent('$20/mo'); + await userEvent.click(seerButton); + expect(seerButton).toHaveTextContent('Added to plan'); + }); +}); diff --git a/static/gsApp/views/amCheckout/steps/productSelect.tsx b/static/gsApp/views/amCheckout/steps/productSelect.tsx new file mode 100644 index 00000000000000..08149a34578232 --- /dev/null +++ b/static/gsApp/views/amCheckout/steps/productSelect.tsx @@ -0,0 +1,291 @@ +import {Fragment} from 'react'; +import {useTheme} from '@emotion/react'; +import styled from '@emotion/styled'; + +import bannerStars from 'sentry-images/spot/ai-suggestion-banner-stars.svg'; + +import {SeerIcon} from 'sentry/components/ai/SeerIcon'; +import {Button} from 'sentry/components/core/button'; +import PanelItem from 'sentry/components/panels/panelItem'; +import QuestionTooltip from 'sentry/components/questionTooltip'; +import {IconAdd, IconCheckmark} from 'sentry/icons'; +import {t, tct} from 'sentry/locale'; +import {space} from 'sentry/styles/space'; +import {toTitleCase} from 'sentry/utils/string/toTitleCase'; +import type {Color} from 'sentry/utils/theme'; + +import formatCurrency from 'getsentry/utils/formatCurrency'; +import {SelectableProduct, type StepProps} from 'getsentry/views/amCheckout/types'; +import * as utils from 'getsentry/views/amCheckout/utils'; + +function ProductSelect({ + activePlan, + // billingConfig, + formData, + // onEdit, + onUpdate, + // subscription, + organization, +}: Pick) { + 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; + }); + + const theme = useTheme(); + const PRODUCT_CHECKOUT_INFO = { + [SelectableProduct.SEER]: { + icon: , + color: theme.pink400 as Color, + gradientEndColor: theme.pink100 as Color, + description: t('Detect and fix issues faster with our AI debugging agent.'), + features: [ + t('Issue scan'), + t('Root cause analysis'), + t('Solution and code changes'), + ], + }, + }; + const billingInterval = utils.getShortInterval(activePlan.billingInterval); + + return ( + + + {availableProducts.map(productInfo => { + const checkoutInfo = + PRODUCT_CHECKOUT_INFO[productInfo.apiName as string as SelectableProduct]; + if (!checkoutInfo) { + return null; + } + + return ( + + + + + {checkoutInfo.icon} + {toTitleCase(productInfo.productName)} + + + {checkoutInfo.description} + {checkoutInfo.features.map(feature => ( + + + {feature} + + ))} + + + + + onUpdate({ + selectedProducts: { + ...formData.selectedProducts, + [productInfo.apiName]: { + enabled: + !formData.selectedProducts?.[ + productInfo.apiName as string as SelectableProduct + ]?.enabled, + }, + }, + }) + } + > + + {formData.selectedProducts?.[ + productInfo.apiName as string as SelectableProduct + ]?.enabled ? ( + + {t('Added to plan')} + + ) : ( + + {' '} + {formatCurrency( + utils.getReservedPriceForReservedBudgetCategory({ + plan: activePlan, + reservedBudgetCategory: productInfo.apiName, + }) + )} + /{billingInterval} + + )} + + + + {tct('Extra usage requires [budgetTerm] ', { + budgetTerm: + activePlan.budgetTerm === 'pay-as-you-go' + ? 'PAYG' + : activePlan.budgetTerm, + })} + + + + + + + + + ); + })} + + ); +} + +export default ProductSelect; + +const Separator = styled('div')` + border-top: 1px solid ${p => p.theme.innerBorder}; + margin: 0; +`; + +const ProductOption = styled(PanelItem)<{isSelected?: boolean}>` + margin: ${space(1.5)}; + padding: 0; + display: inherit; +`; + +const ProductOptionContent = styled('div')<{gradientColor: string; enabled?: boolean}>` + padding: ${space(2)}; + background-color: ${p => (p.enabled ? p.gradientColor : p.theme.backgroundSecondary)}; + display: flex; + gap: ${space(4)}; + justify-content: space-between; + border: 1px solid ${p => (p.enabled ? p.gradientColor : p.theme.innerBorder)}; + border-radius: ${p => p.theme.borderRadius}; + + button { + border-color: ${p => (p.enabled ? p.gradientColor : p.theme.innerBorder)}; + } + + @keyframes gradient { + 0% { + background-position: 0% 50%; + } + 100% { + background-position: 100% 50%; + } + } + + &:hover { + background: linear-gradient( + 0.33turn, + ${p => p.gradientColor}, + ${p => p.theme.background}, + ${p => p.gradientColor}, + ${p => p.theme.background} + ); + background-size: 400% 400%; + animation: gradient 4s ease-in-out infinite; + border-color: ${p => p.gradientColor}; + + button { + border-color: ${p => p.gradientColor}; + } + } +`; + +const Column = styled('div')<{alignItems?: string}>` + display: flex; + flex-direction: column; + gap: ${space(0.75)}; + align-items: ${p => p.alignItems}; +`; + +const ProductLabel = styled('div')<{productColor: string}>` + display: flex; + align-items: center; + flex-direction: row; + flex-wrap: nowrap; + gap: ${space(1)}; + color: ${p => p.productColor}; +`; + +const ProductName = styled('div')` + font-size: ${p => p.theme.fontSizeExtraLarge}; + font-weight: 600; +`; + +const Subtitle = styled('p')` + font-size: ${p => p.theme.fontSizeSmall}; + color: ${p => p.theme.subText}; +`; + +const ButtonContent = styled('div')<{color: string}>` + display: flex; + align-items: center; + gap: ${space(1)}; + color: ${p => p.color}; +`; + +const Feature = styled('div')` + display: flex; + gap: ${space(1)}; + align-items: center; + align-content: center; + svg { + flex-shrink: 0; + } +`; + +const IllustrationContainer = styled('div')` + display: none; + + @media (min-width: ${p => p.theme.breakpoints.large}) { + display: block; + position: absolute; + bottom: 83px; + right: 12px; + top: 0; + width: 600px; + overflow: hidden; + border-radius: 0 ${p => p.theme.borderRadius} ${p => p.theme.borderRadius} 0; + pointer-events: none; + } +`; + +const Stars = styled('img')` + pointer-events: none; + position: absolute; + right: -400px; + bottom: -70px; + overflow: hidden; + height: 250px; +`; diff --git a/static/gsApp/views/amCheckout/steps/reviewAndConfirm.spec.tsx b/static/gsApp/views/amCheckout/steps/reviewAndConfirm.spec.tsx index 21290d33744636..1aaca6e623502f 100644 --- a/static/gsApp/views/amCheckout/steps/reviewAndConfirm.spec.tsx +++ b/static/gsApp/views/amCheckout/steps/reviewAndConfirm.spec.tsx @@ -17,6 +17,7 @@ import SubscriptionStore from 'getsentry/stores/subscriptionStore'; import {PlanTier} from 'getsentry/types'; import trackGetsentryAnalytics from 'getsentry/utils/trackGetsentryAnalytics'; import AMCheckout from 'getsentry/views/amCheckout/'; +import {SelectableProduct} from 'getsentry/views/amCheckout/types'; import {getCheckoutAPIData} from 'getsentry/views/amCheckout/utils'; import ReviewAndConfirm from './reviewAndConfirm'; @@ -264,6 +265,11 @@ describe('AmCheckout > ReviewAndConfirm', function () { const updatedData = { ...formData, reserved: {...formData.reserved, errors: reservedErrors}, + selectedProducts: { + [SelectableProduct.SEER]: { + enabled: true, + }, + }, }; render(, { deprecatedRouterMocks: true, @@ -287,6 +293,7 @@ describe('AmCheckout > ReviewAndConfirm', function () { ) ); + // TODO(seer): Add seer analytics expect(trackGetsentryAnalytics).toHaveBeenCalledWith('checkout.upgrade', { organization, subscription, diff --git a/static/gsApp/views/amCheckout/types.tsx b/static/gsApp/views/amCheckout/types.tsx index 843376eb0ebb1d..0589fa1e36c53e 100644 --- a/static/gsApp/views/amCheckout/types.tsx +++ b/static/gsApp/views/amCheckout/types.tsx @@ -11,20 +11,28 @@ import type { Subscription, } from 'getsentry/types'; +export enum SelectableProduct { + SEER = 'seer', // should match ReservedBudgetCategoryType.SEER +} + type BaseCheckoutData = { plan: string; applyNow?: boolean; onDemandBudget?: OnDemandBudgets; onDemandMaxSpend?: number; - seerBudget?: number; - seerEnabled?: boolean; + selectedProducts?: Record; +}; + +export type SelectedProductData = { + enabled: boolean; + budget?: number; // if not provided, the default budget will be used }; export type CheckoutFormData = BaseCheckoutData & { reserved: Partial>; }; -export type CheckoutAPIData = BaseCheckoutData & { +export type CheckoutAPIData = Omit & { paymentIntent?: string; previewToken?: string; referrer?: string; @@ -37,7 +45,7 @@ export type CheckoutAPIData = BaseCheckoutData & { reservedSpans?: number; reservedTransactions?: number; reservedUptime?: number; - seer?: boolean; + seer?: boolean; // TODO: in future, we should just use selectedProducts }; export type StepProps = { diff --git a/static/gsApp/views/amCheckout/utils.spec.tsx b/static/gsApp/views/amCheckout/utils.spec.tsx index 563f8753313e83..25109a08c61474 100644 --- a/static/gsApp/views/amCheckout/utils.spec.tsx +++ b/static/gsApp/views/amCheckout/utils.spec.tsx @@ -3,6 +3,7 @@ import {PlanDetailsLookupFixture} from 'getsentry-test/fixtures/planDetailsLooku import {DataCategory} from 'sentry/types/core'; import {InvoiceItemType, PlanTier} from 'getsentry/types'; +import {SelectableProduct} from 'getsentry/views/amCheckout/types'; import * as utils from 'getsentry/views/amCheckout/utils'; import {getCheckoutAPIData} from 'getsentry/views/amCheckout/utils'; @@ -11,6 +12,13 @@ describe('utils', function () { const teamPlanAnnual = PlanDetailsLookupFixture('am1_team_auf')!; const bizPlan = PlanDetailsLookupFixture('am1_business')!; const bizPlanAnnual = PlanDetailsLookupFixture('am1_business_auf')!; + const am3TeamPlan = PlanDetailsLookupFixture('am3_team')!; + const am3TeamPlanAnnual = PlanDetailsLookupFixture('am3_team_auf')!; + const DEFAULT_SELECTED_PRODUCTS = { + [SelectableProduct.SEER]: { + enabled: false, + }, + }; describe('getReservedTotal', function () { it('can get base price for team plan', function () { @@ -21,6 +29,7 @@ describe('utils', function () { transactions: 100_000, attachments: 1, }, + selectedProducts: DEFAULT_SELECTED_PRODUCTS, }); expect(priceDollars).toBe('29'); }); @@ -33,6 +42,7 @@ describe('utils', function () { transactions: 100_000, attachments: 1, }, + selectedProducts: DEFAULT_SELECTED_PRODUCTS, }); expect(priceDollars).toBe('312'); }); @@ -45,6 +55,7 @@ describe('utils', function () { transactions: 100_000, attachments: 1, }, + selectedProducts: DEFAULT_SELECTED_PRODUCTS, }); expect(priceDollars).toBe('89'); }); @@ -57,6 +68,7 @@ describe('utils', function () { transactions: 100_000, attachments: 1, }, + selectedProducts: DEFAULT_SELECTED_PRODUCTS, }); expect(priceDollars).toBe('960'); }); @@ -69,24 +81,47 @@ describe('utils', function () { transactions: 100_000, attachments: 1, }, + selectedProducts: DEFAULT_SELECTED_PRODUCTS, }); expect(priceDollars).toBe('1,992'); }); - it('includes Seer budget in the total when enabled', function () { + it('includes Seer price in the total when enabled', function () { const priceDollars = utils.getReservedTotal({ - plan: teamPlan, + plan: am3TeamPlan, reserved: { errors: 50_000, - transactions: 100_000, + spans: 10_000_000, + replays: 50, attachments: 1, }, - seerEnabled: true, - seerBudget: 2000, + selectedProducts: { + [SelectableProduct.SEER]: { + enabled: true, + }, + }, }); expect(priceDollars).toBe('49'); }); + it('includes Seer annual price in the total when enabled', function () { + const priceDollars = utils.getReservedTotal({ + plan: am3TeamPlanAnnual, + reserved: { + errors: 50_000, + spans: 10_000_000, + replays: 50, + attachments: 1, + }, + selectedProducts: { + [SelectableProduct.SEER]: { + enabled: true, + }, + }, + }); + expect(priceDollars).toBe('528'); + }); + it('does not include Seer budget when not enabled', function () { const priceDollars = utils.getReservedTotal({ plan: teamPlan, @@ -95,8 +130,7 @@ describe('utils', function () { transactions: 100_000, attachments: 1, }, - seerEnabled: false, - seerBudget: 2000, + selectedProducts: DEFAULT_SELECTED_PRODUCTS, }); expect(priceDollars).toBe('29'); }); @@ -369,6 +403,7 @@ describe('utils', function () { attachments: 70, profileDuration: 80, }, + selectedProducts: DEFAULT_SELECTED_PRODUCTS, }; expect(getCheckoutAPIData({formData})).toEqual({ @@ -383,6 +418,7 @@ describe('utils', function () { reservedUptime: 60, reservedAttachments: 70, reservedProfileDuration: 80, + seer: false, }); }); }); diff --git a/static/gsApp/views/amCheckout/utils.tsx b/static/gsApp/views/amCheckout/utils.tsx index df57f8d9a641b6..b749be693a6f0f 100644 --- a/static/gsApp/views/amCheckout/utils.tsx +++ b/static/gsApp/views/amCheckout/utils.tsx @@ -14,7 +14,12 @@ import type {Organization} from 'sentry/types/organization'; import {browserHistory} from 'sentry/utils/browserHistory'; import normalizeUrl from 'sentry/utils/url/normalizeUrl'; -import {DEFAULT_TIER, MONTHLY, SUPPORTED_TIERS} from 'getsentry/constants'; +import { + DEFAULT_TIER, + MONTHLY, + RESERVED_BUDGET_QUOTA, + SUPPORTED_TIERS, +} from 'getsentry/constants'; import SubscriptionStore from 'getsentry/stores/subscriptionStore'; import type { EventBucket, @@ -22,13 +27,19 @@ import type { Plan, PlanTier, PreviewData, + ReservedBudgetCategoryType, Subscription, } from 'getsentry/types'; import {InvoiceItemType} from 'getsentry/types'; import {getSlot} from 'getsentry/utils/billing'; import trackGetsentryAnalytics from 'getsentry/utils/trackGetsentryAnalytics'; import trackMarketingEvent from 'getsentry/utils/trackMarketingEvent'; -import type {CheckoutAPIData, CheckoutFormData} from 'getsentry/views/amCheckout/types'; +import type { + CheckoutAPIData, + CheckoutFormData, + SelectableProduct, + SelectedProductData, +} from 'getsentry/views/amCheckout/types'; import { normalizeOnDemandBudget, parseOnDemandBudgetsFromSubscription, @@ -179,10 +190,32 @@ type ReservedTotalProps = { creditCategory?: InvoiceItemType; discountType?: string; maxDiscount?: number; - seerBudget?: number; - seerEnabled?: boolean; + selectedProducts?: Record; }; +/** + * Returns the price for a reserved budget category (ie. Seer) in cents. + */ +export function getReservedPriceForReservedBudgetCategory({ + plan, + reservedBudgetCategory, +}: { + plan: Plan; + reservedBudgetCategory: ReservedBudgetCategoryType; +}): number { + const budgetTypeInfo = plan.availableReservedBudgetTypes[reservedBudgetCategory]; + if (!budgetTypeInfo) { + return 0; + } + return budgetTypeInfo.dataCategories.reduce((acc, dataCategory) => { + const bucket = getBucket({ + events: RESERVED_BUDGET_QUOTA, + buckets: plan.planCategories[dataCategory], + }); + return acc + bucket.price; + }, 0); +} + /** * Returns the total plan price (including prices for reserved categories) in cents. */ @@ -193,8 +226,7 @@ export function getReservedPriceCents({ discountType, maxDiscount, creditCategory, - seerEnabled, - seerBudget, + selectedProducts, }: ReservedTotalProps): number { let reservedCents = plan.basePrice; @@ -215,16 +247,24 @@ export function getReservedPriceCents({ }).price) ); + Object.entries(selectedProducts ?? {}).forEach(([apiName, selectedProductData]) => { + if (selectedProductData.enabled) { + const budgetTypeInfo = + plan.availableReservedBudgetTypes[apiName as ReservedBudgetCategoryType]; + if (budgetTypeInfo) { + reservedCents += getReservedPriceForReservedBudgetCategory({ + plan, + reservedBudgetCategory: apiName as ReservedBudgetCategoryType, + }); + } + } + }); + if (amount && maxDiscount) { const discount = Math.min(maxDiscount, (reservedCents * amount) / 10000); reservedCents -= discount; } - // Add Seer budget to the total if it's enabled - if (seerEnabled && seerBudget) { - reservedCents += seerBudget; - } - return reservedCents; } @@ -239,8 +279,7 @@ export function getReservedTotal({ discountType, maxDiscount, creditCategory, - seerEnabled, - seerBudget, + selectedProducts, }: ReservedTotalProps): string { return formatPrice({ cents: getReservedPriceCents({ @@ -250,8 +289,7 @@ export function getReservedTotal({ discountType, maxDiscount, creditCategory, - seerEnabled, - seerBudget, + selectedProducts, }), }); } @@ -479,9 +517,7 @@ export function getCheckoutAPIData({ referrer: referrer || 'billing', ...(previewToken && {previewToken}), ...(paymentIntent && {paymentIntent}), - ...(formData.seerEnabled && { - seer: formData.seerEnabled, - }), + seer: formData.selectedProducts?.seer?.enabled, // TODO: in future, we should just be able to pass selectedProducts }; if (formData.applyNow) { diff --git a/tests/js/getsentry-test/fixtures/am1Plans.ts b/tests/js/getsentry-test/fixtures/am1Plans.ts index 5b5ebff31ce6e5..737948f1c87b26 100644 --- a/tests/js/getsentry-test/fixtures/am1Plans.ts +++ b/tests/js/getsentry-test/fixtures/am1Plans.ts @@ -42,6 +42,7 @@ const AM1_AVAILABLE_RESERVED_BUDGET_TYPES = { productName: 'seer', canProductTrial: true, apiName: ReservedBudgetCategoryType.SEER, + billingFlag: 'seer-billing', }, }; diff --git a/tests/js/getsentry-test/fixtures/am2Plans.ts b/tests/js/getsentry-test/fixtures/am2Plans.ts index 0b9a8b6d967143..28afd5fbbfb431 100644 --- a/tests/js/getsentry-test/fixtures/am2Plans.ts +++ b/tests/js/getsentry-test/fixtures/am2Plans.ts @@ -52,6 +52,7 @@ const AM2_AVAILABLE_RESERVED_BUDGET_TYPES = { productName: 'seer', canProductTrial: true, apiName: ReservedBudgetCategoryType.SEER, + billingFlag: 'seer-billing', }, }; @@ -114,6 +115,68 @@ const AM2_TRIAL_FEATURES = AM2_BUSINESS_FEATURES.filter( const BUDGET_TERM = 'on-demand'; +const SEER_TIERS = { + seerAutofix: [ + { + events: -2, + unitPrice: 0, + price: 20_00, + onDemandPrice: 125, + }, + { + events: 0, + unitPrice: 0, + price: 0, + onDemandPrice: 125, + }, + ], + seerScanner: [ + { + events: -2, + unitPrice: 0, + price: 0, + onDemandPrice: 1.25, + }, + { + events: 0, + unitPrice: 0, + price: 0, + onDemandPrice: 1.25, + }, + ], +}; + +const SEER_TIERS_ANNUAL = { + seerAutofix: [ + { + events: -2, + unitPrice: 0, + price: 216_00, + onDemandPrice: 125, + }, + { + events: 0, + unitPrice: 0, + price: 0, + onDemandPrice: 125, + }, + ], + seerScanner: [ + { + events: -2, + unitPrice: 0, + price: 0, + onDemandPrice: 1.25, + }, + { + events: 0, + unitPrice: 0, + price: 0, + onDemandPrice: 1.25, + }, + ], +}; + // TODO: Update with correct pricing and structure const AM2_PLANS: Record = { am2_business: { @@ -831,6 +894,7 @@ const AM2_PLANS: Record = { price: 0, }, ], + ...SEER_TIERS, }, budgetTerm: BUDGET_TERM, availableReservedBudgetTypes: AM2_AVAILABLE_RESERVED_BUDGET_TYPES, @@ -1637,6 +1701,8 @@ const AM2_PLANS: Record = { price: 0, }, ], + + ...SEER_TIERS, }, budgetTerm: BUDGET_TERM, availableReservedBudgetTypes: AM2_AVAILABLE_RESERVED_BUDGET_TYPES, @@ -2408,6 +2474,7 @@ const AM2_PLANS: Record = { price: 0, }, ], + ...SEER_TIERS_ANNUAL, }, }, am2_business_auf: { @@ -3089,6 +3156,7 @@ const AM2_PLANS: Record = { price: 0, }, ], + ...SEER_TIERS_ANNUAL, }, availableReservedBudgetTypes: AM2_AVAILABLE_RESERVED_BUDGET_TYPES, }, diff --git a/tests/js/getsentry-test/fixtures/am3Plans.ts b/tests/js/getsentry-test/fixtures/am3Plans.ts index 57d92ef6caad99..cded233e2f6f20 100644 --- a/tests/js/getsentry-test/fixtures/am3Plans.ts +++ b/tests/js/getsentry-test/fixtures/am3Plans.ts @@ -47,6 +47,7 @@ const AM3_AVAILABLE_RESERVED_BUDGET_TYPES = { productName: 'seer', canProductTrial: true, apiName: ReservedBudgetCategoryType.SEER, + billingFlag: 'seer-billing', }, }; @@ -62,6 +63,7 @@ const AM3_DS_AVAILABLE_RESERVED_BUDGET_TYPES = { productName: 'dynamic sampling', canProductTrial: false, apiName: ReservedBudgetCategoryType.DYNAMIC_SAMPLING, + billingFlag: null, }, }; @@ -154,6 +156,68 @@ const AM3_DS_FEATURES = [ 'dynamic-sampling-custom', ]; +const SEER_TIERS = { + seerAutofix: [ + { + events: -2, + unitPrice: 0, + price: 20_00, + onDemandPrice: 125, + }, + { + events: 0, + unitPrice: 0, + price: 0, + onDemandPrice: 125, + }, + ], + seerScanner: [ + { + events: -2, + unitPrice: 0, + price: 0, + onDemandPrice: 1.25, + }, + { + events: 0, + unitPrice: 0, + price: 0, + onDemandPrice: 1.25, + }, + ], +}; + +const SEER_TIERS_ANNUAL = { + seerAutofix: [ + { + events: -2, + unitPrice: 0, + price: 216_00, + onDemandPrice: 125, + }, + { + events: 0, + unitPrice: 0, + price: 0, + onDemandPrice: 125, + }, + ], + seerScanner: [ + { + events: -2, + unitPrice: 0, + price: 0, + onDemandPrice: 1.25, + }, + { + events: 0, + unitPrice: 0, + price: 0, + onDemandPrice: 1.25, + }, + ], +}; + const BUDGET_TERM = 'pay-as-you-go'; const AM3_PLANS: Record = { @@ -866,6 +930,7 @@ const AM3_PLANS: Record = { onDemandPrice: 0.0004, }, ], + ...SEER_TIERS, }, categoryDisplayNames: AM3_CATEGORY_DISPLAY_NAMES, availableReservedBudgetTypes: AM3_AVAILABLE_RESERVED_BUDGET_TYPES, @@ -1363,6 +1428,7 @@ const AM3_PLANS: Record = { onDemandPrice: 31.25, }, ], + ...SEER_TIERS_ANNUAL, }, categoryDisplayNames: AM3_CATEGORY_DISPLAY_NAMES, availableReservedBudgetTypes: AM3_AVAILABLE_RESERVED_BUDGET_TYPES, @@ -1458,6 +1524,7 @@ const AM3_PLANS: Record = { onDemandPrice: 0.0, }, ], + ...SEER_TIERS, }, categoryDisplayNames: AM3_CATEGORY_DISPLAY_NAMES, availableReservedBudgetTypes: AM3_AVAILABLE_RESERVED_BUDGET_TYPES, @@ -2549,6 +2616,7 @@ const AM3_PLANS: Record = { onDemandPrice: 31.25, }, ], + ...SEER_TIERS, }, categoryDisplayNames: AM3_CATEGORY_DISPLAY_NAMES, availableReservedBudgetTypes: AM3_AVAILABLE_RESERVED_BUDGET_TYPES, @@ -3046,6 +3114,7 @@ const AM3_PLANS: Record = { onDemandPrice: 31.25, }, ], + ...SEER_TIERS_ANNUAL, }, categoryDisplayNames: AM3_CATEGORY_DISPLAY_NAMES, availableReservedBudgetTypes: AM3_AVAILABLE_RESERVED_BUDGET_TYPES, diff --git a/tests/js/getsentry-test/fixtures/reservedBudget.ts b/tests/js/getsentry-test/fixtures/reservedBudget.ts index ea9a60c6c01cf6..ec8c8fce8eebff 100644 --- a/tests/js/getsentry-test/fixtures/reservedBudget.ts +++ b/tests/js/getsentry-test/fixtures/reservedBudget.ts @@ -38,6 +38,7 @@ export function ReservedBudgetFixture(props: BudgetProps) { dataCategories: [], productName: '', canProductTrial: false, + billingFlag: null, }; return { @@ -91,6 +92,8 @@ export function SeerReservedBudgetFixture(props: BudgetProps) { dataCategories: [DataCategory.SEER_AUTOFIX, DataCategory.SEER_SCANNER], productName: 'seer', canProductTrial: true, + billingFlag: 'seer-billing', + apiName: ReservedBudgetCategoryType.SEER, ...props, }; @@ -119,6 +122,8 @@ export function DynamicSamplingReservedBudgetFixture(props: BudgetProps) { dataCategories: [DataCategory.SPANS, DataCategory.SPANS_INDEXED], productName: 'dynamic sampling', canProductTrial: false, + billingFlag: null, + apiName: ReservedBudgetCategoryType.DYNAMIC_SAMPLING, ...props, }; diff --git a/tests/js/getsentry-test/fixtures/subscription.ts b/tests/js/getsentry-test/fixtures/subscription.ts index 16a685a93583d8..70f53fc2997f7e 100644 --- a/tests/js/getsentry-test/fixtures/subscription.ts +++ b/tests/js/getsentry-test/fixtures/subscription.ts @@ -1,7 +1,7 @@ import {MetricHistoryFixture} from 'getsentry-test/fixtures/metricHistory'; import {PlanDetailsLookupFixture} from 'getsentry-test/fixtures/planDetailsLookup'; import { - ReservedBudgetFixture, + DynamicSamplingReservedBudgetFixture, ReservedBudgetMetricHistoryFixture, } from 'getsentry-test/fixtures/reservedBudget'; @@ -10,7 +10,7 @@ import type {Organization} from 'sentry/types/organization'; import {RESERVED_BUDGET_QUOTA} from 'getsentry/constants'; import type {Plan, Subscription as TSubscription} from 'getsentry/types'; -import {BillingType, ReservedBudgetCategoryType} from 'getsentry/types'; +import {BillingType} from 'getsentry/types'; type Props = Partial & {organization: Organization}; @@ -404,17 +404,8 @@ export function Am3DsEnterpriseSubscriptionFixture(props: Props): TSubscription DataCategory.SPANS_INDEXED, ]; subscription.reservedBudgets = [ - ReservedBudgetFixture({ + DynamicSamplingReservedBudgetFixture({ id: '11', - apiName: ReservedBudgetCategoryType.DYNAMIC_SAMPLING, - budgetCategoryType: 'DYNAMIC_SAMPLING', - canProductTrial: false, - dataCategories: [DataCategory.SPANS, DataCategory.SPANS_INDEXED], - defaultBudget: null, - docLink: '', - isFixed: false, - name: 'spans budget', - productName: 'dynamic sampling', reservedBudget: 100_000_00, totalReservedSpend: 60_000_00, freeBudget: 0,
{checkoutInfo.description}