123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383 |
- import {Component} from 'react';
- import styled from '@emotion/styled';
- import moment from 'moment-timezone';
- import {Alert} from 'sentry/components/core/alert';
- import {DATA_CATEGORY_INFO} from 'sentry/constants';
- import {tct} from 'sentry/locale';
- import {space} from 'sentry/styles/space';
- import {DataCategory} from 'sentry/types/core';
- import type {Organization} from 'sentry/types/organization';
- import oxfordizeArray from 'sentry/utils/oxfordizeArray';
- import {RESERVED_BUDGET_QUOTA} from 'getsentry/constants';
- import {type PendingOnDemandBudgets, PlanTier, type Subscription} from 'getsentry/types';
- import {
- formatReservedWithUnits,
- getAmPlanTier,
- hasPerformance,
- isAm3DsPlan,
- } from 'getsentry/utils/billing';
- import {
- getPlanCategoryName,
- getReservedBudgetDisplayName,
- } from 'getsentry/utils/dataCategory';
- import formatCurrency from 'getsentry/utils/formatCurrency';
- import {
- formatOnDemandBudget,
- hasOnDemandBudgetsFeature,
- isOnDemandBudgetsEqual,
- parseOnDemandBudgets,
- parseOnDemandBudgetsFromSubscription,
- } from 'getsentry/views/onDemandBudgets/utils';
- type Props = {
- organization: Organization;
- subscription: Subscription;
- };
- class PendingChanges extends Component<Props> {
- hasChange(pendingChangeKey: string, subscriptionKey: string | null = null) {
- const {subscription} = this.props;
- const {pendingChanges} = subscription;
- if (!pendingChanges) {
- return false;
- }
- subscriptionKey = subscriptionKey ?? pendingChangeKey;
- const pendingChange = this.getNestedValue(pendingChanges, pendingChangeKey);
- const currentValue = this.getNestedValue(subscription, subscriptionKey);
- return pendingChange !== null && pendingChange !== currentValue;
- }
- getNestedValue<T = any>(object: Record<string, any>, keys: string): T | null {
- return keys.split('.').reduce((acc, key) => acc?.[key] ?? null, object) as T | null;
- }
- getOnDemandChanges() {
- const {subscription, organization} = this.props;
- const {pendingChanges} = subscription;
- const results: React.ReactNode[] = [];
- if (!pendingChanges) {
- return results;
- }
- if (
- hasOnDemandBudgetsFeature(organization, subscription) ||
- (pendingChanges.onDemandBudgets && subscription.partner?.isActive)
- ) {
- const nextOnDemandBudgets = this.getNestedValue<PendingOnDemandBudgets>(
- pendingChanges,
- 'onDemandBudgets'
- );
- if (nextOnDemandBudgets) {
- const pendingOnDemandBudgets = parseOnDemandBudgets(nextOnDemandBudgets);
- const currentOnDemandBudgets = parseOnDemandBudgetsFromSubscription(subscription);
- const planTier = getAmPlanTier(pendingChanges.plan);
- if (!isOnDemandBudgetsEqual(pendingOnDemandBudgets, currentOnDemandBudgets)) {
- results.push(
- tct(
- '[budgetType] budget change from [currentOnDemandBudgets] to [nextOnDemandBudgets]',
- {
- budgetType: planTier === PlanTier.AM3 ? 'Pay-as-you-go' : 'On-demand',
- currentOnDemandBudgets: formatOnDemandBudget(
- subscription.planDetails,
- subscription.planTier,
- currentOnDemandBudgets,
- subscription.planDetails.onDemandCategories
- ),
- nextOnDemandBudgets: formatOnDemandBudget(
- subscription.planDetails,
- planTier?.toString() || subscription.planTier,
- nextOnDemandBudgets,
- pendingChanges.planDetails.onDemandCategories
- ),
- }
- )
- );
- }
- }
- } else if (this.hasChange('onDemandMaxSpend')) {
- const nextOnDemandMaxSpend =
- this.getNestedValue<number>(pendingChanges, 'onDemandMaxSpend') ?? 0;
- const currentOnDemandMaxSpend =
- this.getNestedValue<number>(subscription, 'onDemandMaxSpend') ?? 0;
- results.push(
- tct('[budgetType] spend change from [currentAmount] to [newAmount]', {
- budgetType:
- subscription.planTier === PlanTier.AM3 ? 'Pay-as-you-go' : 'On-demand',
- newAmount: formatCurrency(nextOnDemandMaxSpend),
- currentAmount: formatCurrency(currentOnDemandMaxSpend),
- })
- );
- }
- return results;
- }
- getPlanChanges() {
- const {subscription} = this.props;
- const {pendingChanges} = subscription;
- const results: React.ReactNode[] = [];
- if (!pendingChanges) {
- return results;
- }
- if (this.hasChange('plan')) {
- results.push(
- tct('Plan change to [name]', {
- name: pendingChanges.planDetails.name,
- })
- );
- }
- if (hasPerformance(subscription.pendingChanges?.planDetails)) {
- results.push(...this.getAMPlanChanges());
- } else if (this.hasChange('reservedEvents')) {
- results.push(
- tct('Reserved errors change to [quantity]', {
- quantity: pendingChanges.reservedEvents.toLocaleString(),
- })
- );
- }
- if (this.hasChange('planDetails.contractInterval')) {
- results.push(
- tct('Contract period change to [contractInterval]', {
- contractInterval: pendingChanges.planDetails.contractInterval,
- })
- );
- }
- if (this.hasChange('planDetails.billingInterval')) {
- results.push(
- tct('Billing period change to [billingInterval]', {
- billingInterval: pendingChanges.planDetails.billingInterval,
- })
- );
- }
- if (isAm3DsPlan(subscription.pendingChanges?.plan)) {
- results.push(...this.getReservedBudgetChanges());
- }
- return results;
- }
- getAMPlanChanges() {
- const {subscription} = this.props;
- const {pendingChanges} = subscription;
- const results: React.ReactNode[] = [];
- if (!pendingChanges) {
- return results;
- }
- Object.values(DATA_CATEGORY_INFO)
- .filter(categoryInfo => categoryInfo.isBilledCategory)
- .forEach(categoryInfo => {
- const plural = categoryInfo.plural;
- if (
- this.hasChange(`reserved.${plural}`, `categories.${plural}.reserved`) &&
- pendingChanges.reserved[plural] !== RESERVED_BUDGET_QUOTA
- ) {
- results.push(
- tct('Reserved [displayName] change to [quantity]', {
- displayName: getPlanCategoryName({
- plan: pendingChanges.planDetails,
- category: plural,
- capitalize: false,
- }),
- quantity: formatReservedWithUnits(
- pendingChanges.reserved[plural] ?? null,
- plural
- ),
- })
- );
- }
- });
- return results;
- }
- hasReservedBudgetChange() {
- const {subscription} = this.props;
- const {pendingChanges} = subscription;
- if (!pendingChanges) {
- return false;
- }
- const pendingChange = pendingChanges.reservedBudgets;
- const currentValue = subscription.reservedBudgets ?? [];
- if (pendingChange.length !== currentValue.length) {
- return true;
- }
- const sortedPendingBudgets = pendingChange.sort((a, b) => {
- return a.reservedBudget - b.reservedBudget;
- });
- const sortedCurrentBudgets = currentValue.sort((a, b) => {
- return a.reservedBudget - b.reservedBudget;
- });
- for (let i = 0; i < sortedPendingBudgets.length; i++) {
- if (
- sortedPendingBudgets[i]?.reservedBudget !==
- sortedCurrentBudgets[i]?.reservedBudget
- ) {
- return true;
- }
- const pendingBudgetCategories = Object.keys(
- sortedPendingBudgets[i]?.categories ?? {}
- ).sort();
- const currentBudgetCategories = Object.keys(
- sortedCurrentBudgets[i]?.categories ?? {}
- ).sort();
- if (pendingBudgetCategories.length !== currentBudgetCategories.length) {
- return true;
- }
- for (let j = 0; j < pendingBudgetCategories.length; j++) {
- if (pendingBudgetCategories[j] !== currentBudgetCategories[j]) {
- return true;
- }
- }
- }
- return false;
- }
- getReservedBudgetChanges() {
- const {subscription} = this.props;
- const {pendingChanges} = subscription;
- const results: React.ReactNode[] = [];
- if (!pendingChanges) {
- return results;
- }
- if (this.hasReservedBudgetChange()) {
- const reservedBudgetChanges = pendingChanges.reservedBudgets.map(budget => {
- const budgetCategories = Object.keys(budget.categories);
- const isSpansBudget =
- budgetCategories.length === 2 &&
- budgetCategories.includes(DataCategory.SPANS) &&
- budgetCategories.includes(DataCategory.SPANS_INDEXED);
- const adjustedCategories =
- isSpansBudget && !subscription.hadCustomDynamicSampling
- ? [DataCategory.SPANS]
- : budgetCategories;
- const newAmount = formatCurrency(budget.reservedBudget);
- const budgetName = getReservedBudgetDisplayName({
- plan: pendingChanges.planDetails,
- categories: adjustedCategories,
- hadCustomDynamicSampling: subscription.hadCustomDynamicSampling,
- });
- return `${newAmount} for ${budgetName}`;
- });
- results.push(
- tct('Reserved [budgetWord] updated to [reservedBudgets]', {
- budgetWord: reservedBudgetChanges.length === 1 ? 'budget' : 'budgets',
- reservedBudgets: oxfordizeArray(reservedBudgetChanges),
- })
- );
- }
- return results;
- }
- getChanges() {
- const {subscription} = this.props;
- const {pendingChanges} = subscription;
- const results: {
- [key: string]: React.ReactNode[];
- } = {};
- if (!pendingChanges) {
- return results;
- }
- const onDemandChanges = this.getOnDemandChanges();
- const planChanges = this.getPlanChanges();
- // the on-demand effective date should always be before
- // or the same as the plan effective date
- if (onDemandChanges.length && pendingChanges.onDemandEffectiveDate) {
- results[pendingChanges.onDemandEffectiveDate] = onDemandChanges;
- }
- if (planChanges.length && pendingChanges.effectiveDate) {
- if (pendingChanges.effectiveDate in results) {
- results[pendingChanges.effectiveDate]!.unshift(...planChanges);
- } else {
- results[pendingChanges.effectiveDate] = planChanges;
- }
- }
- return results;
- }
- render() {
- const {subscription} = this.props;
- const {pendingChanges} = subscription;
- if (!pendingChanges) {
- return null;
- }
- const changes = this.getChanges();
- if (!Object.keys(changes)?.length) {
- return null;
- }
- return (
- <Alert.Container>
- <Alert type="info" showIcon>
- <PendingLists>
- {Object.entries(changes).map(([effectiveDate, items]) => (
- <div key={effectiveDate} data-test-id="pending-list">
- {tct('The following changes will take effect on [date]:', {
- date: <strong>{moment(effectiveDate).format('ll')}</strong>,
- })}
- <ItemList>
- {items.map((item, itemIdx) => (
- <li key={itemIdx} data-test-id="pending-item">
- {item}
- </li>
- ))}
- </ItemList>
- </div>
- ))}
- </PendingLists>
- </Alert>
- </Alert.Container>
- );
- }
- }
- const PendingLists = styled('div')`
- display: grid;
- grid-auto-rows: auto;
- gap: ${space(1.5)};
- `;
- const ItemList = styled('ul')`
- margin-bottom: 0;
- `;
- export default PendingChanges;
|