diff --git a/static/gsApp/utils/billing.tsx b/static/gsApp/utils/billing.tsx index 6e97642bb2157c..37ed85370b6722 100644 --- a/static/gsApp/utils/billing.tsx +++ b/static/gsApp/utils/billing.tsx @@ -375,6 +375,7 @@ export function hasJustStartedPlanTrial(subscription: Subscription) { export const displayBudgetName = ( plan?: Plan | null, options: { + abbreviated?: boolean; pluralOndemand?: boolean; title?: boolean; withBudget?: boolean; @@ -382,6 +383,12 @@ export const displayBudgetName = ( ) => { const budgetTerm = plan?.budgetTerm ?? 'pay-as-you-go'; const text = `${budgetTerm}${options.withBudget ? ' budget' : ''}`; + if (options.abbreviated) { + if (budgetTerm === 'pay-as-you-go') { + return 'PAYG'; + } + return 'OD'; + } if (options.title) { if (budgetTerm === 'on-demand') { if (options.withBudget) { diff --git a/static/gsApp/views/amCheckout/steps/setSpendLimit.tsx b/static/gsApp/views/amCheckout/steps/setSpendLimit.tsx index 8b62048ff0f4df..e2c912c5db249b 100644 --- a/static/gsApp/views/amCheckout/steps/setSpendLimit.tsx +++ b/static/gsApp/views/amCheckout/steps/setSpendLimit.tsx @@ -54,6 +54,7 @@ function SetSpendCap({ ( ); } + +const modalCss = (theme: Theme) => css` + @media (min-width: ${theme.breakpoints.md}) { + width: 1000px; + } +`; diff --git a/static/gsApp/views/onDemandBudgets/onDemandBudgetEdit.tsx b/static/gsApp/views/onDemandBudgets/onDemandBudgetEdit.tsx index f34e35e46717ca..c9dab74f1af8fa 100644 --- a/static/gsApp/views/onDemandBudgets/onDemandBudgetEdit.tsx +++ b/static/gsApp/views/onDemandBudgets/onDemandBudgetEdit.tsx @@ -5,7 +5,9 @@ import styled from '@emotion/styled'; import {Alert} from 'sentry/components/core/alert'; import {Tag} from 'sentry/components/core/badge/tag'; import {Input} from 'sentry/components/core/input'; +import {Container} from 'sentry/components/core/layout'; import {Radio} from 'sentry/components/core/radio'; +import {Heading} from 'sentry/components/core/text'; import {Tooltip} from 'sentry/components/core/tooltip'; import PanelBody from 'sentry/components/panels/panelBody'; import PanelItem from 'sentry/components/panels/panelItem'; @@ -13,6 +15,7 @@ import {DATA_CATEGORY_INFO} from 'sentry/constants'; import {t, tct} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import {DataCategoryExact} from 'sentry/types/core'; +import type {DataCategory} from 'sentry/types/core'; import type {Organization} from 'sentry/types/organization'; import oxfordizeArray from 'sentry/utils/oxfordizeArray'; import {toTitleCase} from 'sentry/utils/string/toTitleCase'; @@ -21,8 +24,14 @@ import TextBlock from 'sentry/views/settings/components/text/textBlock'; import {CronsOnDemandStepWarning} from 'getsentry/components/cronsOnDemandStepWarning'; import type {OnDemandBudgets, Plan, Subscription} from 'getsentry/types'; import {OnDemandBudgetMode, PlanTier} from 'getsentry/types'; -import {displayBudgetName, getOnDemandCategories} from 'getsentry/utils/billing'; +import { + displayBudgetName, + getOnDemandCategories, + hasNewBillingUI, +} from 'getsentry/utils/billing'; import {getPlanCategoryName, listDisplayNames} from 'getsentry/utils/dataCategory'; +import {parseOnDemandBudgetsFromSubscription} from 'getsentry/views/onDemandBudgets/utils'; +import EmbeddedSpendLimitSettings from 'getsentry/views/spendLimits/embeddedSettings'; function coerceValue(value: number): number { return value / 100; @@ -224,8 +233,11 @@ class OnDemandBudgetEdit extends Component { setBudgetMode, activePlan, subscription, + organization, } = this.props; + const isNewBillingUI = hasNewBillingUI(organization); + const selectedBudgetMode = onDemandBudget.budgetMode; const perCategoryCategories = listDisplayNames({ plan: activePlan, @@ -235,98 +247,132 @@ class OnDemandBudgetEdit extends Component { }), }); - if (subscription.planDetails.budgetTerm === 'pay-as-you-go') { + if (!isNewBillingUI) { + if (subscription.planDetails.budgetTerm === 'pay-as-you-go') { + return ( + + + + {t( + "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.%s", + subscription.isSelfServePartner + ? ` This will be part of your ${subscription.partner?.partnership.displayName} bill.` + : '' + )} + + {this.renderInputFields(OnDemandBudgetMode.SHARED)} + + + ); + } + return ( - - - - {t( - "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.%s", - subscription.isSelfServePartner - ? ` This will be part of your ${subscription.partner?.partnership.displayName} bill.` - : '' - )} - - {this.renderInputFields(OnDemandBudgetMode.SHARED)} - - + + + + + + + + ); } + const addOnDataCategories = Object.values( + subscription.planDetails.addOnCategories + ).flatMap(addOn => addOn.dataCategories); + const currentReserved = Object.fromEntries( + Object.entries(subscription.categories) + .filter(([category]) => !addOnDataCategories.includes(category as DataCategory)) + .map(([category, categoryInfo]) => [category, categoryInfo.reserved ?? 0]) + ); + return ( - - - - - - - - + + + {tct('Set your [budgetTerm] limit', { + budgetTerm: displayBudgetName(subscription.planDetails), + })} + + } + activePlan={subscription.planDetails} + initialOnDemandBudgets={parseOnDemandBudgetsFromSubscription(subscription)} + currentReserved={currentReserved} + addOns={subscription.addOns ?? {}} + onUpdate={({onDemandBudgets}) => { + this.props.setOnDemandBudget(onDemandBudgets); + }} + /> + ); } } diff --git a/static/gsApp/views/onDemandBudgets/onDemandBudgetEditModal.tsx b/static/gsApp/views/onDemandBudgets/onDemandBudgetEditModal.tsx index f46c827b1d1bad..11cd88f068faed 100644 --- a/static/gsApp/views/onDemandBudgets/onDemandBudgetEditModal.tsx +++ b/static/gsApp/views/onDemandBudgets/onDemandBudgetEditModal.tsx @@ -14,7 +14,7 @@ import withApi from 'sentry/utils/withApi'; import SubscriptionStore from 'getsentry/stores/subscriptionStore'; import type {OnDemandBudgetMode, OnDemandBudgets, Subscription} from 'getsentry/types'; -import {displayBudgetName} from 'getsentry/utils/billing'; +import {displayBudgetName, hasNewBillingUI} from 'getsentry/utils/billing'; import OnDemandBudgetEdit from './onDemandBudgetEdit'; import { @@ -184,22 +184,25 @@ class OnDemandBudgetEditModal extends Component { render() { const {Header, Footer, subscription, organization} = this.props; + const isNewBillingUI = hasNewBillingUI(organization); const onDemandBudgets = subscription.onDemandBudgets!; return ( -
-

- {tct('[action] [budgetType]', { - action: onDemandBudgets.enabled ? t('Edit') : t('Set Up'), - budgetType: displayBudgetName(subscription.planDetails, { - title: true, - withBudget: true, - pluralOndemand: true, - }), - })} -

-
+ {!isNewBillingUI && ( +
+

+ {tct('[action] [budgetType]', { + action: onDemandBudgets.enabled ? t('Edit') : t('Set Up'), + budgetType: displayBudgetName(subscription.planDetails, { + title: true, + withBudget: true, + pluralOndemand: true, + }), + })} +

+
+ )} {this.renderError(this.state.updateError)} sum + (spend ?? 0), + 0 + ); + } + + return onDemandBudgets.onDemandSpendUsed ?? 0; +} + export function isOnDemandBudgetsEqual( value: OnDemandBudgets, other: OnDemandBudgets diff --git a/static/gsApp/views/spendLimits/embeddedSettings.tsx b/static/gsApp/views/spendLimits/embeddedSettings.tsx new file mode 100644 index 00000000000000..54ae516168c956 --- /dev/null +++ b/static/gsApp/views/spendLimits/embeddedSettings.tsx @@ -0,0 +1,36 @@ +import {useState} from 'react'; + +import type {OnDemandBudgets} from 'getsentry/types'; +import type {SpendLimitSettingsProps} from 'getsentry/views/spendLimits/spendLimitSettings'; +import SpendLimitSettings from 'getsentry/views/spendLimits/spendLimitSettings'; + +interface EmbeddedSpendLimitSettingsProps + extends Omit { + initialOnDemandBudgets: OnDemandBudgets; +} + +/** + * A wrapper for the SpendLimitSettings component that allows for embedded use in other components, + * without controlling state or mutations directly. + */ +function EmbeddedSpendLimitSettings(props: EmbeddedSpendLimitSettingsProps) { + const {initialOnDemandBudgets, onUpdate} = props; + const [currentOnDemandBudgets, setCurrentOnDemandBudgets] = + useState(initialOnDemandBudgets); + + const handleUpdate = ({onDemandBudgets}: {onDemandBudgets: OnDemandBudgets}) => { + setCurrentOnDemandBudgets(onDemandBudgets); + onUpdate({onDemandBudgets}); + }; + + return ( + + ); +} + +export default EmbeddedSpendLimitSettings; diff --git a/static/gsApp/views/spendLimits/modal.tsx b/static/gsApp/views/spendLimits/modal.tsx new file mode 100644 index 00000000000000..eb5a28c8562437 --- /dev/null +++ b/static/gsApp/views/spendLimits/modal.tsx @@ -0,0 +1,84 @@ +import {css, type Theme} from '@emotion/react'; + +import {openModal} from 'sentry/actionCreators/modal'; +import {Flex} from 'sentry/components/core/layout'; +import {Heading, Text} from 'sentry/components/core/text'; +import {tct} from 'sentry/locale'; +import type {DataCategory} from 'sentry/types/core'; +import type {Organization} from 'sentry/types/organization'; + +import type {Subscription} from 'getsentry/types'; +import {displayBudgetName} from 'getsentry/utils/billing'; +import {SharedSpendLimitPriceTable} from 'getsentry/views/spendLimits/spendLimitSettings'; + +function SpendLimitsPricingModal({ + subscription, + organization, +}: { + organization: Organization; + subscription: Subscription; +}) { + const addOnDataCategories = Object.values(subscription.addOns ?? {}).flatMap( + addOn => addOn.dataCategories + ); + const includedAddOns = Object.values(subscription.addOns ?? {}) + .filter(addOn => addOn.enabled) + .map(addOn => addOn.apiName); + const currentReserved = Object.fromEntries( + Object.entries(subscription.categories) + .filter(([category]) => !addOnDataCategories.includes(category as DataCategory)) + .map(([category, categoryInfo]) => [category, categoryInfo.reserved ?? 0]) + ); + return ( + + + {tct('[budgetTerm] pricing', { + budgetTerm: displayBudgetName(subscription.planDetails, {title: true}), + })} + + + {tct( + "[budgetTerm] lets you go beyond what's included in your plan. It applies across all products on a first-come, first-served basis, and you're only charged for what you use -- if your monthly usage stays within your plan, you won't pay extra.", + { + budgetTerm: displayBudgetName(subscription.planDetails, {title: true}), + } + )} + + + + ); +} + +export function openSpendLimitsPricingModal({ + subscription, + organization, + theme, +}: { + organization: Organization; + subscription: Subscription; + theme: Theme; +}) { + openModal( + modalProps => ( + + ), + { + modalCss: modalCss(theme), + } + ); +} + +const modalCss = (theme: Theme) => css` + @media (min-width: ${theme.breakpoints.md}) { + width: 850px; + } +`; diff --git a/static/gsApp/views/spendLimits/spendLimitSettings.tsx b/static/gsApp/views/spendLimits/spendLimitSettings.tsx index 7b051d4839e639..6cf2c95c168d4b 100644 --- a/static/gsApp/views/spendLimits/spendLimitSettings.tsx +++ b/static/gsApp/views/spendLimits/spendLimitSettings.tsx @@ -20,6 +20,7 @@ import { OnDemandBudgetMode, type OnDemandBudgets, type Plan, + type Subscription, } from 'getsentry/types'; import { displayBudgetName, @@ -46,7 +47,7 @@ type PartialSpendLimitUpdate = Partial> & { sharedMaxBudget?: number; }; -interface SpendLimitSettingsProps { +export interface SpendLimitSettingsProps { activePlan: Plan; addOns: Partial>; currentReserved: Partial>; @@ -54,6 +55,7 @@ interface SpendLimitSettingsProps { onDemandBudgets: OnDemandBudgets; onUpdate: ({onDemandBudgets}: {onDemandBudgets: OnDemandBudgets}) => void; organization: Organization; + subscription: Subscription; footer?: React.ReactNode; isOpen?: boolean; } @@ -61,10 +63,11 @@ interface SpendLimitSettingsProps { interface BudgetModeSettingsProps extends Omit< SpendLimitSettingsProps, - 'header' | 'currentReserved' | 'organization' | 'addOns' + 'header' | 'currentReserved' | 'organization' | 'addOns' | 'subscription' > {} -interface InnerSpendLimitSettingsProps extends Omit {} +interface InnerSpendLimitSettingsProps + extends Omit {} interface SharedSpendLimitPriceTableProps extends Pick< @@ -169,7 +172,7 @@ function SharedSpendLimitPriceTableRow({children}: {children: React.ReactNode}) ); } -function SharedSpendLimitPriceTable({ +export function SharedSpendLimitPriceTable({ activePlan, currentReserved, organization, @@ -369,7 +372,7 @@ function InnerSpendLimitSettings({ const getPerCategoryWarning = (productName: string) => { return ( - + {tct( @@ -433,7 +436,7 @@ function InnerSpendLimitSettings({ padding="lg 0" borderBottom={isLastInList ? undefined : 'primary'} > - + {upperFirst(pluralName)} {showPerformanceUnits ? renderPerformanceHovercard() @@ -444,7 +447,7 @@ function InnerSpendLimitSettings({ size="xs" /> )} - + {reserved === 0 ? t('None included') : tct('[reserved] included', { @@ -508,7 +511,7 @@ function InnerSpendLimitSettings({ padding="xl 0" borderBottom={isLastInList ? undefined : 'primary'} > - + {upperFirst(addOnInfo.productName)} {tooltipText && ( @@ -593,7 +596,7 @@ function BudgetModeSettings({ } return ( - + {Object.values(OnDemandBudgetMode).map(budgetMode => { const budgetModeName = capitalize(budgetMode.replace('_', '-')); const isSelected = onDemandBudgets.budgetMode === budgetMode; @@ -645,6 +648,7 @@ function SpendLimitSettings({ addOns, footer, organization, + subscription, }: SpendLimitSettingsProps) { return ( @@ -653,12 +657,17 @@ function SpendLimitSettings({ {tct( - "[budgetTerm] lets you go beyond what's included in your plan. It applies across all products on a first-come, first-served basis, and you're only charged for what you use -- if your monthly usage stays within your plan, you won't pay extra.", + "[budgetTerm] lets you go beyond what's included in your plan. It applies across all products on a first-come, first-served basis, and you're only charged for what you use -- if your monthly usage stays within your plan, you won't pay extra.[partnerMessage]", { budgetTerm: activePlan.budgetTerm === 'pay-as-you-go' ? `${displayBudgetName(activePlan, {title: true})} (PAYG)` : displayBudgetName(activePlan, {title: true}), + partnerMessage: subscription.isSelfServePartner + ? tct(' This will be part of your [partnerName] bill.', { + partnerName: subscription.partner?.partnership.displayName, + }) + : '', } )} @@ -703,7 +712,11 @@ const InnerContainer = styled(Flex)` const StyledInput = styled(Input)` padding-left: ${p => p.theme.space['3xl']}; - width: 344px; + width: 100px; + + @media (min-width: ${p => p.theme.breakpoints.md}) { + width: 344px; + } `; const Currency = styled('div')` diff --git a/static/gsApp/views/subscriptionPage/headerCards/headerCards.tsx b/static/gsApp/views/subscriptionPage/headerCards/headerCards.tsx index d7b2cd6dca0d5d..568913b08e9960 100644 --- a/static/gsApp/views/subscriptionPage/headerCards/headerCards.tsx +++ b/static/gsApp/views/subscriptionPage/headerCards/headerCards.tsx @@ -7,6 +7,7 @@ import {hasNewBillingUI} from 'getsentry/utils/billing'; import BillingInfoCard from 'getsentry/views/subscriptionPage/headerCards/billingInfoCard'; import LinksCard from 'getsentry/views/subscriptionPage/headerCards/linksCard'; import NextBillCard from 'getsentry/views/subscriptionPage/headerCards/nextBillCard'; +import PaygCard from 'getsentry/views/subscriptionPage/headerCards/paygCard'; import SeerAutomationAlert from 'getsentry/views/subscriptionPage/seerAutomationAlert'; import {SubscriptionCard} from './subscriptionCard'; @@ -31,6 +32,12 @@ function getCards(organization: Organization, subscription: Subscription) { ); } + if (subscription.supportsOnDemand && hasBillingPerms) { + cards.push( + + ); + } + if ( hasBillingPerms && (subscription.canSelfServe || subscription.onDemandInvoiced) && diff --git a/static/gsApp/views/subscriptionPage/headerCards/paygCard.spec.tsx b/static/gsApp/views/subscriptionPage/headerCards/paygCard.spec.tsx new file mode 100644 index 00000000000000..f6723f4a145dde --- /dev/null +++ b/static/gsApp/views/subscriptionPage/headerCards/paygCard.spec.tsx @@ -0,0 +1,148 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; + +import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription'; +import { + render, + renderGlobalModal, + screen, + userEvent, +} from 'sentry-test/reactTestingLibrary'; +import {resetMockDate, setMockDate} from 'sentry-test/utils'; + +import {OnDemandBudgetMode} from 'getsentry/types'; +import PaygCard from 'getsentry/views/subscriptionPage/headerCards/paygCard'; + +describe('PaygCard', () => { + const organization = OrganizationFixture({features: ['subscriptions-v3']}); + + beforeEach(() => { + setMockDate(new Date('2022-06-09')); + }); + + afterEach(() => { + resetMockDate(); + }); + + it('renders for plan with no budget modes', async () => { + const subscription = SubscriptionFixture({ + organization, + plan: 'am3_team', + onDemandBudgets: { + budgetMode: OnDemandBudgetMode.SHARED, + sharedMaxBudget: 10_00, + enabled: true, + onDemandSpendUsed: 2_51, + }, + }); + render(); + + expect(screen.getByRole('heading', {name: 'Pay-as-you-go'})).toBeInTheDocument(); + expect( + screen.queryByRole('heading', {name: 'Edit pay-as-you-go limit'}) + ).not.toBeInTheDocument(); + expect(screen.getByText('$10 limit')).toBeInTheDocument(); + expect(screen.getByText('$2.51')).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', {name: 'Edit limit'})); + expect( + screen.getByRole('heading', {name: 'Edit pay-as-you-go limit'}) + ).toBeInTheDocument(); + expect( + screen.queryByRole('heading', {name: 'Pay-as-you-go limit'}) + ).not.toBeInTheDocument(); + expect( + screen.getByRole('spinbutton', {name: 'Edit pay-as-you-go limit'}) + ).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'PAYG pricing'})).toBeInTheDocument(); + }); + + it('renders for plan with budget modes', async () => { + const subscription = SubscriptionFixture({ + organization, + plan: 'am2_business', + onDemandBudgets: { + budgetMode: OnDemandBudgetMode.SHARED, + sharedMaxBudget: 0, + enabled: true, + onDemandSpendUsed: 0, + }, + }); + render(); + + expect(screen.getByRole('heading', {name: 'On-Demand'})).toBeInTheDocument(); + expect( + screen.queryByRole('heading', {name: 'Edit on-demand limit'}) + ).not.toBeInTheDocument(); + expect(screen.getByText('$0 limit')).toBeInTheDocument(); + expect(screen.getByText('$0')).toBeInTheDocument(); + + renderGlobalModal(); + await userEvent.click(screen.getByRole('button', {name: 'Set limit'})); + await screen.findByRole('heading', {name: 'Set your on-demand limit'}); + expect(screen.queryByRole('button', {name: /pricing/})).not.toBeInTheDocument(); + }); + + it('renders per-category budget total', () => { + const subscription = SubscriptionFixture({ + organization, + plan: 'am2_business', + onDemandBudgets: { + budgetMode: OnDemandBudgetMode.PER_CATEGORY, + enabled: true, + budgets: { + errors: 1_00, + replays: 2_00, + attachments: 3_00, + }, + usedSpends: { + errors: 50, + attachments: 3_00, + }, + }, + }); + render(); + expect(screen.getByText('$6 limit (combined total)')).toBeInTheDocument(); + expect(screen.getByText('$3.50')).toBeInTheDocument(); + }); + + it('can update using inline input', async () => { + MockApiClient.addMockResponse({ + url: `/subscriptions/${organization.slug}/`, + method: 'GET', + }); + const mockApiCall = MockApiClient.addMockResponse({ + url: `/customers/${organization.slug}/ondemand-budgets/`, + method: 'POST', + statusCode: 200, + }); + const subscription = SubscriptionFixture({ + organization, + plan: 'am3_team', + }); + render(); + + expect(screen.getByRole('heading', {name: 'Pay-as-you-go'})).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', {name: 'Set limit'})); + expect( + screen.queryByRole('heading', {name: 'Pay-as-you-go'}) + ).not.toBeInTheDocument(); + await userEvent.type( + screen.getByRole('spinbutton', {name: 'Edit pay-as-you-go limit'}), + '100' + ); + await userEvent.click(screen.getByRole('button', {name: 'Save'})); + expect(mockApiCall).toHaveBeenCalledWith( + `/customers/${organization.slug}/ondemand-budgets/`, + expect.objectContaining({ + method: 'POST', + data: { + budgetMode: OnDemandBudgetMode.SHARED, + sharedMaxBudget: 100_00, + }, + }) + ); + + // closes inline edit + expect(screen.getByRole('heading', {name: 'Pay-as-you-go'})).toBeInTheDocument(); + }); +}); diff --git a/static/gsApp/views/subscriptionPage/headerCards/paygCard.tsx b/static/gsApp/views/subscriptionPage/headerCards/paygCard.tsx new file mode 100644 index 00000000000000..c990a9ab7a3edc --- /dev/null +++ b/static/gsApp/views/subscriptionPage/headerCards/paygCard.tsx @@ -0,0 +1,211 @@ +import {useState} from 'react'; +import {useTheme} from '@emotion/react'; +import styled from '@emotion/styled'; +import moment from 'moment-timezone'; + +import {Alert} from 'sentry/components/core/alert'; +import {Button} from 'sentry/components/core/button'; +import {Input} from 'sentry/components/core/input'; +import {Container, Flex} from 'sentry/components/core/layout'; +import {Heading, Text} from 'sentry/components/core/text'; +import ProgressBar from 'sentry/components/progressBar'; +import {t, tct} from 'sentry/locale'; +import type {Organization} from 'sentry/types/organization'; +import getDaysSinceDate from 'sentry/utils/getDaysSinceDate'; +import {useMutation} from 'sentry/utils/queryClient'; +import useApi from 'sentry/utils/useApi'; + +import SubscriptionStore from 'getsentry/stores/subscriptionStore'; +import {OnDemandBudgetMode, type Subscription} from 'getsentry/types'; +import {displayBudgetName} from 'getsentry/utils/billing'; +import {displayPrice} from 'getsentry/views/amCheckout/utils'; +import {openOnDemandBudgetEditModal} from 'getsentry/views/onDemandBudgets/editOnDemandButton'; +import { + getTotalBudget, + getTotalSpend, + parseOnDemandBudgetsFromSubscription, +} from 'getsentry/views/onDemandBudgets/utils'; +import {openSpendLimitsPricingModal} from 'getsentry/views/spendLimits/modal'; +import SubscriptionHeaderCard from 'getsentry/views/subscriptionPage/headerCards/subscriptionHeaderCard'; + +function PaygCard({ + subscription, + organization, +}: { + organization: Organization; + subscription: Subscription; +}) { + const api = useApi(); + const theme = useTheme(); + const paygBudget = parseOnDemandBudgetsFromSubscription(subscription); + const totalBudget = getTotalBudget(paygBudget); + const totalSpend = subscription.onDemandBudgets + ? getTotalSpend(subscription.onDemandBudgets) + : 0; + + const [isEditing, setIsEditing] = useState(false); + const [newBudgetDollars, setNewBudgetDollars] = useState( + Math.ceil(totalBudget / 100) + ); + const [error, setError] = useState(null); + + const {mutate: handleSubmit} = useMutation({ + mutationFn: () => { + return api.requestPromise(`/customers/${organization.slug}/ondemand-budgets/`, { + method: 'POST', + data: { + budgetMode: OnDemandBudgetMode.SHARED, + sharedMaxBudget: newBudgetDollars * 100, // convert to cents + }, + }); + }, + onSuccess: () => { + SubscriptionStore.loadData(subscription.slug); + setIsEditing(false); + }, + onError: err => { + setError(err.message); + }, + }); + + const formattedTotalBudget = displayPrice({cents: totalBudget}); + const formattedTotalSpend = displayPrice({cents: totalSpend}); + const daysLeft = + -1 * + getDaysSinceDate( + moment(subscription.onDemandPeriodEnd).add(1, 'days').format('YYYY-MM-DD') + ); + const isLegacy = subscription.planDetails.hasOnDemandModes; + + return ( + + {error && ( + + {error} + + )} + + setNewBudgetDollars(parseInt(e.target.value, 10) || 0)} + /> + + + + + + + + + , + ] + : [ + + + {displayBudgetName(subscription.planDetails, {title: true})} + + + , + + + {formattedTotalSpend} + + , + , + + + {tct('Resets in [daysLeft] days', {daysLeft})} + + + {tct('[formattedTotalBudget] limit[note]', { + formattedTotalBudget, + note: + paygBudget.budgetMode === OnDemandBudgetMode.PER_CATEGORY + ? t(' (combined total)') + : '', + })} + + , + ] + } + /> + ); +} + +function UsageBar({totalBudget, totalSpend}: {totalBudget: number; totalSpend: number}) { + const percentUsed = totalBudget > 0 ? Math.round((totalSpend / totalBudget) * 100) : 0; + + return ; +} + +export default PaygCard; + +const Currency = styled('div')` + &::before { + position: absolute; + padding: 9px ${p => p.theme.space.xl} ${p => p.theme.space.md}; + content: '$'; + color: ${p => p.theme.subText}; + font-size: ${p => p.theme.fontSize.sm}; + font-weight: bold; + } +`; + +const StyledInput = styled(Input)` + padding-left: ${p => p.theme.space['3xl']}; + font-weight: bold; +`; diff --git a/static/gsApp/views/subscriptionPage/subscriptionHeader.spec.tsx b/static/gsApp/views/subscriptionPage/subscriptionHeader.spec.tsx index 1db9129f2bcdb0..617477568e76cb 100644 --- a/static/gsApp/views/subscriptionPage/subscriptionHeader.spec.tsx +++ b/static/gsApp/views/subscriptionPage/subscriptionHeader.spec.tsx @@ -54,9 +54,11 @@ describe('SubscriptionHeader', () => { organization, hasNextBillCard, hasBillingInfoCard, + hasPaygCard, }: { hasBillingInfoCard: boolean; hasNextBillCard: boolean; + hasPaygCard: boolean; organization: Organization; }) { await screen.findByRole('heading', {name: 'Subscription'}); @@ -79,6 +81,16 @@ describe('SubscriptionHeader', () => { ).not.toBeInTheDocument(); } + if (hasPaygCard) { + await screen.findByRole('heading', {name: 'Pay-as-you-go'}); + screen.getByRole('button', {name: 'Set limit'}); + } else { + expect( + screen.queryByRole('heading', {name: 'Pay-as-you-go'}) + ).not.toBeInTheDocument(); + expect(screen.queryByRole('button', {name: 'Set limit'})).not.toBeInTheDocument(); + } + const hasBillingPerms = organization.access?.includes('org:billing'); // all subscriptions have links card @@ -113,6 +125,7 @@ describe('SubscriptionHeader', () => { organization, hasNextBillCard: true, hasBillingInfoCard: true, + hasPaygCard: true, }); }); @@ -134,6 +147,7 @@ describe('SubscriptionHeader', () => { organization, hasNextBillCard: true, hasBillingInfoCard: false, + hasPaygCard: true, }); }); @@ -146,6 +160,7 @@ describe('SubscriptionHeader', () => { organization, plan: 'am3_f', canSelfServe: false, + supportsOnDemand: false, }); SubscriptionStore.set(organization.slug, subscription); render( @@ -155,10 +170,11 @@ describe('SubscriptionHeader', () => { organization, hasNextBillCard: false, hasBillingInfoCard: false, + hasPaygCard: false, }); }); - it('renders new header cards for managed customers with legacy invoiced OD', async () => { + it('renders new header cards for managed customers with OD supported', async () => { const organization = OrganizationFixture({ features: ['subscriptions-v3'], access: ['org:billing'], @@ -168,6 +184,7 @@ describe('SubscriptionHeader', () => { plan: 'am3_f', canSelfServe: false, onDemandInvoiced: true, + supportsOnDemand: true, }); SubscriptionStore.set(organization.slug, subscription); render( @@ -177,6 +194,7 @@ describe('SubscriptionHeader', () => { organization, hasNextBillCard: false, hasBillingInfoCard: true, + hasPaygCard: true, }); }); @@ -196,6 +214,7 @@ describe('SubscriptionHeader', () => { organization, hasNextBillCard: false, hasBillingInfoCard: false, + hasPaygCard: false, }); });