Skip to content

Commit ed65231

Browse files
fix(reserved budgets): Better formatting for pending changes
1 parent 785350e commit ed65231

File tree

4 files changed

+211
-55
lines changed

4 files changed

+211
-55
lines changed

static/gsApp/utils/dataCategory.tsx

Lines changed: 47 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {
1515
Plan,
1616
RecurringCredit,
1717
ReservedBudget,
18+
ReservedBudgetCategory,
1819
Subscription,
1920
} from 'getsentry/types';
2021

@@ -104,6 +105,23 @@ export function getSingularCategoryName({
104105
: categoryName;
105106
}
106107

108+
/**
109+
* Get the ReservedBudgetCategory from a list of categories and a plan,
110+
* if it exists.
111+
*/
112+
export function getReservedBudgetCategoryFromCategories(
113+
plan: Plan,
114+
categories: DataCategory[]
115+
): ReservedBudgetCategory | null {
116+
return (
117+
Object.values(plan?.availableReservedBudgetTypes ?? {}).find(
118+
budgetInfo =>
119+
categories.length === budgetInfo.dataCategories.length &&
120+
categories.every(category => budgetInfo.dataCategories.includes(category))
121+
) ?? null
122+
);
123+
}
124+
107125
/**
108126
* Convert a list of reserved budget categories to a display name for the budget
109127
*/
@@ -113,7 +131,8 @@ export function getReservedBudgetDisplayName({
113131
reservedBudget = null,
114132
pendingReservedBudget = null,
115133
shouldTitleCase = false,
116-
}: Omit<CategoryNameProps, 'category' | 'capitalize'> & {
134+
capitalize = false,
135+
}: Omit<CategoryNameProps, 'category'> & {
117136
pendingReservedBudget?: PendingReservedBudget | null;
118137
reservedBudget?: ReservedBudget | null;
119138
shouldTitleCase?: boolean;
@@ -123,36 +142,37 @@ export function getReservedBudgetDisplayName({
123142
(Object.keys(pendingReservedBudget?.categories ?? {}) as DataCategory[]);
124143
const name =
125144
reservedBudget?.name ??
126-
Object.values(plan?.availableReservedBudgetTypes ?? {}).find(
127-
budgetInfo =>
128-
categoryList.length === budgetInfo.dataCategories.length &&
129-
categoryList.every(category => budgetInfo.dataCategories.includes(category))
130-
)?.name ??
131-
'';
145+
(plan ? getReservedBudgetCategoryFromCategories(plan, categoryList)?.name : '');
132146

133147
if (name) {
134-
return shouldTitleCase ? toTitleCase(name, {allowInnerUpperCase: true}) : name;
148+
return shouldTitleCase
149+
? toTitleCase(name, {allowInnerUpperCase: true})
150+
: capitalize
151+
? upperFirst(name)
152+
: name;
135153
}
136154

137-
return (
138-
oxfordizeArray(
139-
categoryList
140-
.map(category => {
141-
const categoryName = getPlanCategoryName({
142-
plan,
143-
category,
144-
hadCustomDynamicSampling,
145-
capitalize: false,
146-
});
147-
return shouldTitleCase
148-
? toTitleCase(categoryName, {allowInnerUpperCase: true})
149-
: categoryName;
150-
})
151-
.sort((a, b) => {
152-
return a.localeCompare(b);
153-
})
154-
) + (shouldTitleCase ? ' Budget' : ' budget')
155-
);
155+
const formattedCategories = categoryList
156+
.map(category => {
157+
const categoryName = getPlanCategoryName({
158+
plan,
159+
category,
160+
hadCustomDynamicSampling,
161+
capitalize: false,
162+
});
163+
return shouldTitleCase
164+
? toTitleCase(categoryName, {allowInnerUpperCase: true})
165+
: categoryName;
166+
})
167+
.sort((a, b) => {
168+
return a.localeCompare(b);
169+
});
170+
171+
if (capitalize) {
172+
formattedCategories[0] = upperFirst(formattedCategories[0]);
173+
}
174+
175+
return oxfordizeArray(formattedCategories) + (shouldTitleCase ? ' Budget' : ' budget');
156176
}
157177

158178
/**

static/gsApp/views/subscriptionPage/pendingChanges.spec.tsx

Lines changed: 78 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {OrganizationFixture} from 'sentry-fixture/organization';
22

33
import {MetricHistoryFixture} from 'getsentry-test/fixtures/metricHistory';
44
import {PlanDetailsLookupFixture} from 'getsentry-test/fixtures/planDetailsLookup';
5+
import {SeerReservedBudgetFixture} from 'getsentry-test/fixtures/reservedBudget';
56
import {
67
Am3DsEnterpriseSubscriptionFixture,
78
SubscriptionFixture,
@@ -482,6 +483,32 @@ describe('Subscription > PendingChanges', function () {
482483
expect(screen.getByText('Feb 1, 2021')).toBeInTheDocument();
483484
});
484485

486+
it('does not render reserved budget changes', function () {
487+
const sub = SubscriptionFixture({
488+
organization,
489+
plan: 'am3_business',
490+
reservedBudgets: [SeerReservedBudgetFixture({})],
491+
pendingChanges: PendingChangesFixture({
492+
planDetails: PlanDetailsLookupFixture('am3_business'),
493+
plan: 'am3_business',
494+
planName: 'Business',
495+
reserved: {
496+
errors: 100_000,
497+
},
498+
}),
499+
});
500+
sub.categories = {
501+
...sub.categories,
502+
seerAutofix: MetricHistoryFixture({...sub.categories.seerAutofix, reserved: 0}),
503+
seerScanner: MetricHistoryFixture({...sub.categories.seerScanner, reserved: 0}),
504+
};
505+
506+
render(<PendingChanges organization={organization} subscription={sub} />);
507+
expect(screen.getByText('Reserved errors change to 100,000')).toBeInTheDocument();
508+
expect(screen.queryByText(/product access/)).not.toBeInTheDocument();
509+
expect(screen.queryByText(/budget change/)).not.toBeInTheDocument();
510+
});
511+
485512
it('renders reserved budgets with existing budgets without dynamic sampling', function () {
486513
const sub = Am3DsEnterpriseSubscriptionFixture({
487514
organization,
@@ -511,7 +538,10 @@ describe('Subscription > PendingChanges', function () {
511538
expect(screen.queryByText('accepted spans')).not.toBeInTheDocument();
512539
expect(screen.queryByText('stored spans')).not.toBeInTheDocument();
513540
expect(screen.queryByText('cost-per-event')).not.toBeInTheDocument();
514-
expect(screen.getByText('Spans Budget updated to $50,000')).toBeInTheDocument();
541+
expect(
542+
screen.getByText('Spans budget change from $100,000 to $50,000')
543+
).toBeInTheDocument();
544+
expect(screen.queryByText(/Reserved spans/)).not.toBeInTheDocument();
515545
});
516546

517547
it('renders reserved budgets with existing budgets and dynamic sampling', function () {
@@ -542,7 +572,46 @@ describe('Subscription > PendingChanges', function () {
542572
render(<PendingChanges organization={organization} subscription={sub} />);
543573

544574
expect(screen.queryByText('cost-per-event')).not.toBeInTheDocument();
545-
expect(screen.getByText('Spans Budget updated to $50,000')).toBeInTheDocument();
575+
expect(
576+
screen.getByText('Spans budget change from $100,000 to $50,000')
577+
).toBeInTheDocument();
578+
expect(screen.queryByText(/Reserved spans/)).not.toBeInTheDocument();
579+
});
580+
581+
it('renders fixed budget changes', function () {
582+
const sub = SubscriptionFixture({
583+
organization,
584+
plan: 'am3_team',
585+
hasReservedBudgets: true,
586+
reservedBudgets: [SeerReservedBudgetFixture({})],
587+
pendingChanges: PendingChangesFixture({
588+
planDetails: PlanDetailsLookupFixture('am3_team'),
589+
plan: 'am3_team',
590+
planName: 'Team',
591+
reserved: {
592+
seerAutofix: 0,
593+
seerScanner: 0,
594+
},
595+
}),
596+
});
597+
sub.categories = {
598+
...sub.categories,
599+
seerAutofix: MetricHistoryFixture({
600+
...sub.categories.seerAutofix,
601+
reserved: RESERVED_BUDGET_QUOTA,
602+
}),
603+
seerScanner: MetricHistoryFixture({
604+
...sub.categories.seerScanner,
605+
reserved: RESERVED_BUDGET_QUOTA,
606+
}),
607+
};
608+
609+
render(<PendingChanges organization={organization} subscription={sub} />);
610+
611+
expect(screen.getByText('Seer product access will be disabled')).toBeInTheDocument();
612+
expect(screen.queryByText('Seer budget')).not.toBeInTheDocument();
613+
expect(screen.queryByText(/Reserved issue fixes/)).not.toBeInTheDocument();
614+
expect(screen.queryByText(/Reserved issue scans/)).not.toBeInTheDocument();
546615
});
547616

548617
it('renders multiple reserved budgets', function () {
@@ -580,10 +649,11 @@ describe('Subscription > PendingChanges', function () {
580649

581650
expect(screen.queryByText('cost-per-event')).not.toBeInTheDocument();
582651
expect(
583-
screen.getByText(
584-
'Spans Budget updated to $50,000 and Errors Budget updated to $10,000'
585-
)
652+
screen.getByText('Spans budget change from $100,000 to $50,000')
586653
).toBeInTheDocument();
654+
expect(screen.getByText('Errors budget change to $10,000')).toBeInTheDocument();
655+
expect(screen.queryByText(/Reserved spans/)).not.toBeInTheDocument();
656+
expect(screen.queryByText(/Reserved errors/)).not.toBeInTheDocument();
587657
});
588658

589659
it('renders reserved budgets without existing budgets', function () {
@@ -615,7 +685,8 @@ describe('Subscription > PendingChanges', function () {
615685
expect(screen.queryByText('accepted spans')).not.toBeInTheDocument();
616686
expect(screen.queryByText('stored spans')).not.toBeInTheDocument();
617687
expect(screen.queryByText('cost-per-event')).not.toBeInTheDocument();
618-
expect(screen.getByText('Spans Budget updated to $50,000')).toBeInTheDocument();
688+
expect(screen.getByText('Spans budget change to $50,000')).toBeInTheDocument();
689+
expect(screen.queryByText(/Reserved spans/)).not.toBeInTheDocument();
619690
expect(screen.getByText('Plan change to Enterprise (Business)')).toBeInTheDocument();
620691
});
621692

@@ -637,7 +708,7 @@ describe('Subscription > PendingChanges', function () {
637708
expect(screen.queryByText('accepted spans')).not.toBeInTheDocument();
638709
expect(screen.queryByText('stored spans')).not.toBeInTheDocument();
639710
expect(screen.queryByText('cost-per-event')).not.toBeInTheDocument();
640-
expect(screen.queryByText('Spans Budget')).not.toBeInTheDocument();
711+
expect(screen.queryByText('Spans budget')).not.toBeInTheDocument();
641712
expect(screen.getByText('Reserved spans change to 10,000,000')).toBeInTheDocument();
642713
expect(screen.getByText('Plan change to Enterprise (Business)')).toBeInTheDocument();
643714
});

static/gsApp/views/subscriptionPage/pendingChanges.tsx

Lines changed: 85 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,18 @@ import {tct} from 'sentry/locale';
88
import {space} from 'sentry/styles/space';
99
import type {DataCategory} from 'sentry/types/core';
1010
import type {Organization} from 'sentry/types/organization';
11-
import oxfordizeArray from 'sentry/utils/oxfordizeArray';
11+
import {toTitleCase} from 'sentry/utils/string/toTitleCase';
1212

1313
import {RESERVED_BUDGET_QUOTA} from 'getsentry/constants';
1414
import type {PendingOnDemandBudgets, Subscription} from 'getsentry/types';
1515
import {
1616
displayBudgetName,
1717
formatReservedWithUnits,
1818
hasPerformance,
19-
isAm3DsPlan,
2019
} from 'getsentry/utils/billing';
2120
import {
2221
getPlanCategoryName,
22+
getReservedBudgetCategoryFromCategories,
2323
getReservedBudgetDisplayName,
2424
} from 'getsentry/utils/dataCategory';
2525
import formatCurrency from 'getsentry/utils/formatCurrency';
@@ -162,7 +162,7 @@ class PendingChanges extends Component<Props> {
162162
);
163163
}
164164

165-
if (isAm3DsPlan(subscription.pendingChanges?.plan)) {
165+
if (this.hasReservedBudgetChange()) {
166166
results.push(...this.getReservedBudgetChanges());
167167
}
168168

@@ -183,9 +183,11 @@ class PendingChanges extends Component<Props> {
183183
.filter(categoryInfo => categoryInfo.isBilledCategory)
184184
.forEach(categoryInfo => {
185185
const category = categoryInfo.plural as DataCategory;
186+
const pendingReserved = pendingChanges.reserved[category];
186187
if (
187188
this.hasChange(`reserved.${category}`, `categories.${category}.reserved`) &&
188-
pendingChanges.reserved[category] !== RESERVED_BUDGET_QUOTA
189+
pendingReserved !== RESERVED_BUDGET_QUOTA &&
190+
pendingReserved !== 0
189191
) {
190192
results.push(
191193
tct('Reserved [displayName] change to [quantity]', {
@@ -194,10 +196,7 @@ class PendingChanges extends Component<Props> {
194196
category,
195197
capitalize: false,
196198
}),
197-
quantity: formatReservedWithUnits(
198-
pendingChanges.reserved[category] ?? null,
199-
category
200-
),
199+
quantity: formatReservedWithUnits(pendingReserved ?? null, category),
201200
})
202201
);
203202
}
@@ -267,23 +266,89 @@ class PendingChanges extends Component<Props> {
267266
return results;
268267
}
269268

270-
if (this.hasReservedBudgetChange()) {
271-
const reservedBudgetChanges = pendingChanges.reservedBudgets.map(budget => {
272-
const newAmount = formatCurrency(budget.reservedBudget);
269+
const existingReservedBudgets = subscription.reservedBudgets ?? [];
270+
const pendingReservedBudgets = pendingChanges.reservedBudgets ?? [];
271+
const matchedExistingBudgets = new Set<string>();
272+
273+
pendingReservedBudgets.forEach(pendingBudget => {
274+
const pendingBudgetInfo = getReservedBudgetCategoryFromCategories(
275+
pendingChanges.planDetails,
276+
Object.keys(pendingBudget.categories) as DataCategory[]
277+
);
278+
279+
if (pendingBudgetInfo?.isFixed) {
280+
// if it's a fixed budget, we don't care about the existing budget state
281+
results.push(
282+
tct('[productName] product access will be [accessState]', {
283+
productName: toTitleCase(pendingBudgetInfo?.productName),
284+
accessState: pendingBudget.reservedBudget > 0 ? 'enabled' : 'disabled',
285+
})
286+
);
287+
} else {
288+
const matchedExistingBudget =
289+
existingReservedBudgets.find(
290+
existingBudget => existingBudget.apiName === pendingBudgetInfo?.apiName
291+
) ?? null;
273292
const budgetName = getReservedBudgetDisplayName({
274-
pendingReservedBudget: budget,
293+
pendingReservedBudget: pendingBudget,
275294
plan: pendingChanges.planDetails,
276295
hadCustomDynamicSampling: subscription.hadCustomDynamicSampling,
277-
shouldTitleCase: true,
296+
capitalize: true,
278297
});
279-
return `${budgetName} updated to ${newAmount}`;
280-
});
281-
results.push(
282-
tct('[reservedBudgets]', {
283-
reservedBudgets: oxfordizeArray(reservedBudgetChanges),
284-
})
298+
const newAmount = formatCurrency(pendingBudget.reservedBudget);
299+
300+
if (matchedExistingBudget) {
301+
matchedExistingBudgets.add(matchedExistingBudget.id);
302+
const oldAmount = formatCurrency(matchedExistingBudget.reservedBudget);
303+
results.push(
304+
tct('[budgetName] change from [oldAmount] to [newAmount]', {
305+
budgetName,
306+
oldAmount,
307+
newAmount,
308+
})
309+
);
310+
} else {
311+
results.push(
312+
tct('[budgetName] change to [newAmount]', {
313+
budgetName,
314+
newAmount,
315+
})
316+
);
317+
}
318+
}
319+
});
320+
321+
existingReservedBudgets.forEach(existingBudget => {
322+
if (matchedExistingBudgets.has(existingBudget.id)) {
323+
return;
324+
}
325+
326+
const willBeSetToZero = existingBudget.dataCategories.every(
327+
category => pendingChanges.reserved[category] === 0
285328
);
286-
}
329+
if (!willBeSetToZero) {
330+
// changes to a non-zero reserved volume are handled in getAMPlanChanges
331+
return;
332+
}
333+
334+
if (existingBudget.isFixed) {
335+
// if there is an existing fixed budget, and a pending zero reserved change,
336+
// the product is being disabled
337+
results.push(
338+
tct('[productName] product access will be disabled', {
339+
productName: toTitleCase(existingBudget.productName),
340+
})
341+
);
342+
} else {
343+
const oldAmount = formatCurrency(existingBudget.reservedBudget);
344+
results.push(
345+
tct('[budgetName] change from [oldAmount] to $0', {
346+
budgetName: existingBudget.name,
347+
oldAmount,
348+
})
349+
);
350+
}
351+
});
287352

288353
return results;
289354
}

0 commit comments

Comments
 (0)