Skip to content

Commit 2feed9c

Browse files
brendanhsentryGPT-5.4
andcommitted
fix(billing): Skip missing plan buckets
Return null from getBucket when plan data is missing so billing views and checkout flows can keep rendering. Fall back to zero-priced bucket values for unsupported categories and cover the regression with focused tests. Co-Authored-By: GPT-5.4 <noreply@example.com> Made-with: Cursor
1 parent 3fc672d commit 2feed9c

File tree

11 files changed

+118
-51
lines changed

11 files changed

+118
-51
lines changed

static/gsApp/components/upgradeNowModal/useUpgradeNowParams.tsx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -79,12 +79,13 @@ export function useUpgradeNowParams({organization, subscription, enabled = true}
7979
let events = currentHistory?.reserved ?? 0;
8080

8181
if (canCompare) {
82-
const price = getBucket({events, buckets: eventBuckets}).price;
83-
const eventsByPrice = getBucket({
84-
price,
85-
// @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
86-
buckets: am2Plan.planCategories[category],
87-
}).events;
82+
const price = getBucket({events, buckets: eventBuckets})?.price ?? 0;
83+
const eventsByPrice =
84+
getBucket({
85+
price,
86+
// @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
87+
buckets: am2Plan.planCategories[category],
88+
})?.events ?? 0;
8889
events = Math.max(events, eventsByPrice);
8990
}
9091
return [category, events];

static/gsApp/views/amCheckout/components/volumeSliders.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ export function VolumeSliders({
102102
events: currentSliderValues[category],
103103
buckets: activePlan.planCategories[category],
104104
});
105+
if (!eventBucket) {
106+
return null;
107+
}
105108

106109
const categoryInfo = getCategoryInfoFromPlural(category);
107110

static/gsApp/views/amCheckout/index.tsx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,7 @@ function AMCheckout(props: Props) {
317317
events: value,
318318
buckets: plan.planCategories[category as DataCategory],
319319
shouldMinimize: hasPartnerMigrationFeature(organization),
320-
}).events,
320+
})?.events ?? value,
321321
])
322322
);
323323

@@ -390,11 +390,12 @@ function AMCheckout(props: Props) {
390390
let events = (!isTrialPlan(planDetails.id) && currentHistory?.reserved) || 0;
391391

392392
if (canCompare) {
393-
const price = getBucket({events, buckets: eventBuckets}).price;
394-
const eventsByPrice = getBucket({
395-
price,
396-
buckets: initialPlan.planCategories[category],
397-
}).events;
393+
const price = getBucket({events, buckets: eventBuckets})?.price ?? 0;
394+
const eventsByPrice =
395+
getBucket({
396+
price,
397+
buckets: initialPlan.planCategories[category],
398+
})?.events ?? 0;
398399
events = Math.max(events, eventsByPrice);
399400
}
400401
return [category, events];

static/gsApp/views/amCheckout/steps/reserveAdditionalVolume.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export function ReserveAdditionalVolume({
4646
getBucket({
4747
buckets: activePlan.planCategories[category],
4848
events: reserved ?? 0,
49-
}).price > 0
49+
})?.price > 0
5050
)
5151
);
5252
const [reserved, setReserved] = useState<Partial<Record<DataCategory, number>>>(

static/gsApp/views/amCheckout/utils.spec.tsx

Lines changed: 40 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,8 @@ describe('utils', () => {
9393
it('can get exact bucket by events', () => {
9494
const events = 100_000;
9595
const bucket = utils.getBucket({events, buckets: bizPlan.planCategories.errors});
96-
expect(bucket.events).toBe(events);
96+
expect(bucket).not.toBeNull();
97+
expect(bucket!.events).toBe(events);
9798
});
9899

99100
it('can get exact bucket by events with minimize strategy', () => {
@@ -103,13 +104,15 @@ describe('utils', () => {
103104
buckets: bizPlan.planCategories.errors,
104105
shouldMinimize: true,
105106
});
106-
expect(bucket.events).toBe(events);
107+
expect(bucket).not.toBeNull();
108+
expect(bucket!.events).toBe(events);
107109
});
108110

109111
it('can get approximate bucket if event level does not exist', () => {
110112
const events = 90_000;
111113
const bucket = utils.getBucket({events, buckets: bizPlan.planCategories.errors});
112-
expect(bucket.events).toBeGreaterThanOrEqual(events);
114+
expect(bucket).not.toBeNull();
115+
expect(bucket!.events).toBeGreaterThanOrEqual(events);
113116
});
114117

115118
it('can get approximate bucket if event level does not exist with minimize strategy', () => {
@@ -119,7 +122,8 @@ describe('utils', () => {
119122
buckets: bizPlan.planCategories.errors,
120123
shouldMinimize: true,
121124
});
122-
expect(bucket.events).toBeLessThanOrEqual(events);
125+
expect(bucket).not.toBeNull();
126+
expect(bucket!.events).toBeLessThanOrEqual(events);
123127
});
124128

125129
it('can get first bucket by events', () => {
@@ -128,7 +132,8 @@ describe('utils', () => {
128132
events,
129133
buckets: teamPlan.planCategories.transactions,
130134
});
131-
expect(bucket.events).toBeGreaterThanOrEqual(events);
135+
expect(bucket).not.toBeNull();
136+
expect(bucket!.events).toBeGreaterThanOrEqual(events);
132137
});
133138

134139
it('can get first bucket by events with minimize strategy', () => {
@@ -138,7 +143,8 @@ describe('utils', () => {
138143
buckets: teamPlan.planCategories.transactions,
139144
shouldMinimize: true,
140145
});
141-
expect(bucket.events).toBeGreaterThanOrEqual(events);
146+
expect(bucket).not.toBeNull();
147+
expect(bucket!.events).toBeGreaterThanOrEqual(events);
142148
});
143149

144150
it('can get last bucket by events', () => {
@@ -147,7 +153,8 @@ describe('utils', () => {
147153
events,
148154
buckets: teamPlan.planCategories.attachments,
149155
});
150-
expect(bucket.events).toBeLessThanOrEqual(events);
156+
expect(bucket).not.toBeNull();
157+
expect(bucket!.events).toBeLessThanOrEqual(events);
151158
});
152159

153160
it('can get last bucket by events with minimize strategy', () => {
@@ -157,7 +164,8 @@ describe('utils', () => {
157164
buckets: teamPlan.planCategories.attachments,
158165
shouldMinimize: true,
159166
});
160-
expect(bucket.events).toBeLessThanOrEqual(events);
167+
expect(bucket).not.toBeNull();
168+
expect(bucket!.events).toBeLessThanOrEqual(events);
161169
});
162170

163171
it('can get exact bucket by price', () => {
@@ -166,8 +174,9 @@ describe('utils', () => {
166174
price,
167175
buckets: bizPlan.planCategories.transactions,
168176
});
169-
expect(bucket.price).toBe(price);
170-
expect(bucket.events).toBe(3_500_000);
177+
expect(bucket).not.toBeNull();
178+
expect(bucket!.price).toBe(price);
179+
expect(bucket!.events).toBe(3_500_000);
171180
});
172181

173182
it('can get exact bucket by price with minimize strategy', () => {
@@ -177,8 +186,9 @@ describe('utils', () => {
177186
buckets: bizPlan.planCategories.transactions,
178187
shouldMinimize: true,
179188
});
180-
expect(bucket.price).toBe(price);
181-
expect(bucket.events).toBe(3_500_000);
189+
expect(bucket).not.toBeNull();
190+
expect(bucket!.price).toBe(price);
191+
expect(bucket!.events).toBe(3_500_000);
182192
});
183193

184194
it('can get approximate bucket if price level does not exist', () => {
@@ -187,8 +197,9 @@ describe('utils', () => {
187197
price,
188198
buckets: bizPlan.planCategories.transactions,
189199
});
190-
expect(bucket.price).toBeGreaterThanOrEqual(price);
191-
expect(bucket.events).toBe(4_500_000);
200+
expect(bucket).not.toBeNull();
201+
expect(bucket!.price).toBeGreaterThanOrEqual(price);
202+
expect(bucket!.events).toBe(4_500_000);
192203
});
193204

194205
it('can get approximate bucket if price level does not exist with minimize strategy', () => {
@@ -198,8 +209,9 @@ describe('utils', () => {
198209
buckets: bizPlan.planCategories.transactions,
199210
shouldMinimize: true,
200211
});
201-
expect(bucket.price).toBeLessThanOrEqual(price);
202-
expect(bucket.events).toBe(4_000_000);
212+
expect(bucket).not.toBeNull();
213+
expect(bucket!.price).toBeLessThanOrEqual(price);
214+
expect(bucket!.events).toBe(4_000_000);
203215
});
204216

205217
it('can get first bucket by price', () => {
@@ -208,7 +220,8 @@ describe('utils', () => {
208220
price,
209221
buckets: teamPlan.planCategories.transactions,
210222
});
211-
expect(bucket.price).toBe(price);
223+
expect(bucket).not.toBeNull();
224+
expect(bucket!.price).toBe(price);
212225
});
213226

214227
it('can get first bucket by price with minimize strategy', () => {
@@ -218,7 +231,8 @@ describe('utils', () => {
218231
buckets: teamPlan.planCategories.transactions,
219232
shouldMinimize: true,
220233
});
221-
expect(bucket.price).toBe(price);
234+
expect(bucket).not.toBeNull();
235+
expect(bucket!.price).toBe(price);
222236
});
223237

224238
it('can get last bucket by price', () => {
@@ -227,7 +241,8 @@ describe('utils', () => {
227241
price,
228242
buckets: teamPlan.planCategories.transactions,
229243
});
230-
expect(bucket.price).toBeLessThanOrEqual(price);
244+
expect(bucket).not.toBeNull();
245+
expect(bucket!.price).toBeLessThanOrEqual(price);
231246
});
232247

233248
it('can get last bucket by price with minimize strategy', () => {
@@ -237,7 +252,12 @@ describe('utils', () => {
237252
buckets: teamPlan.planCategories.transactions,
238253
shouldMinimize: true,
239254
});
240-
expect(bucket.price).toBeLessThanOrEqual(price);
255+
expect(bucket).not.toBeNull();
256+
expect(bucket!.price).toBeLessThanOrEqual(price);
257+
});
258+
259+
it('returns null when buckets are missing', () => {
260+
expect(utils.getBucket({events: 1000, buckets: undefined})).toBeNull();
241261
});
242262
});
243263

static/gsApp/views/amCheckout/utils.tsx

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -178,14 +178,14 @@ export function getBucket({
178178
events?: number;
179179
price?: number;
180180
shouldMinimize?: boolean; // the slot strategy when `events` does not exist in `buckets`
181-
}): EventBucket {
181+
}): EventBucket | null {
182182
if (buckets) {
183183
const slot = getSlot(events, price, buckets, shouldMinimize);
184184
if (slot in buckets) {
185185
return buckets[slot]!;
186186
}
187187
}
188-
throw new Error('Invalid data category for plan');
188+
return null;
189189
}
190190

191191
type ReservedTotalProps = {
@@ -217,7 +217,7 @@ function getReservedPriceForReservedBudgetCategory({
217217
events: RESERVED_BUDGET_QUOTA,
218218
buckets: plan.planCategories[dataCategory],
219219
});
220-
return acc + bucket.price;
220+
return acc + (bucket?.price ?? 0);
221221
}, 0);
222222
}
223223

@@ -246,10 +246,11 @@ export function getReservedPriceCents({
246246

247247
Object.entries(reserved).forEach(
248248
([category, quantity]) =>
249-
(reservedCents += getBucket({
250-
events: quantity,
251-
buckets: plan.planCategories[category as DataCategory],
252-
}).price)
249+
(reservedCents +=
250+
getBucket({
251+
events: quantity,
252+
buckets: plan.planCategories[category as DataCategory],
253+
})?.price ?? 0)
253254
);
254255

255256
Object.entries(addOns ?? {}).forEach(([apiName, {enabled}]) => {

static/gsApp/views/spendLimits/spendLimitSettings.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ function getPaygPpe({
112112
events: reserved === RESERVED_BUDGET_QUOTA ? reserved : reserved + 1, // +1 to get the next bucket, if any
113113
shouldMinimize: false,
114114
});
115-
return bucket.onDemandPrice ?? 0;
115+
return bucket?.onDemandPrice ?? 0;
116116
}
117117

118118
function SpendLimitInput({

static/gsApp/views/subscriptionPage/reservedUsageChart.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ function calculateCategoryPrepaidUsage(
164164
// This will be 0 when they are using the included amount
165165
const prepaidPrice = hasReservedBudget
166166
? prepaid
167-
: (prepaidPriceBucket.price ?? 0) / (isMonthly ? 1 : 12);
167+
: (prepaidPriceBucket?.price ?? 0) / (isMonthly ? 1 : 12);
168168

169169
// Calculate spend based on percentage used
170170
const prepaidSpend = (prepaidPercentUsed / 100) * prepaidPrice;

static/gsApp/views/subscriptionPage/usageOverview/components/table.spec.tsx

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

44
import {CustomerUsageFixture} from 'getsentry-test/fixtures/customerUsage';
5+
import {MetricHistoryFixture} from 'getsentry-test/fixtures/metricHistory';
56
import {
67
SubscriptionFixture,
78
SubscriptionWithLegacySeerFixture,
@@ -498,4 +499,31 @@ describe('UsageOverviewTable', () => {
498499
// All disabled rows must appear after all enabled rows
499500
expect(lastEnabledIndex).toBeLessThan(firstDisabledIndex);
500501
});
502+
503+
it('renders categories that are missing plan buckets without crashing', async () => {
504+
const sub = SubscriptionFixture({organization, plan: 'am2_business'});
505+
sub.categories.spansIndexed = MetricHistoryFixture({
506+
category: DataCategory.SPANS_INDEXED,
507+
reserved: 1000,
508+
prepaid: 1000,
509+
usage: 500,
510+
order: 99,
511+
});
512+
SubscriptionStore.set(organization.slug, sub);
513+
514+
render(
515+
<UsageOverviewTable
516+
subscription={sub}
517+
organization={organization}
518+
usageData={usageData}
519+
onRowClick={jest.fn()}
520+
selectedProduct={DataCategory.ERRORS}
521+
/>
522+
);
523+
524+
await screen.findByRole('columnheader', {name: 'Feature'});
525+
526+
expect(screen.getByTestId('product-row-errors')).toBeInTheDocument();
527+
expect(screen.getByTestId('product-row-spansIndexed')).toBeInTheDocument();
528+
});
501529
});

static/gsApp/views/subscriptionPage/usageOverview/components/tableRow.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -198,12 +198,16 @@ export function UsageOverviewTableRow({
198198

199199
paygSpend = normalizedMetricHistory.onDemandSpendUsed ?? 0;
200200
}
201-
const bucket = getBucket({
202-
events: reserved ?? 0, // buckets use the converted unit reserved amount (ie. in GB for byte categories)
203-
buckets: subscription.planDetails.planCategories[billedCategory],
204-
});
201+
const buckets = subscription.planDetails.planCategories[billedCategory];
202+
const bucket =
203+
buckets && buckets.length > 0
204+
? getBucket({
205+
events: reserved ?? 0,
206+
buckets,
207+
})
208+
: null;
205209
otherSpend = calculateSeerUserSpend(normalizedMetricHistory);
206-
const recurringReservedSpend = isChildProduct ? 0 : (bucket.price ?? 0);
210+
const recurringReservedSpend = isChildProduct ? 0 : (bucket?.price ?? 0);
207211
const additionalSpend = recurringReservedSpend + paygSpend + otherSpend;
208212

209213
const formattedSoftCapType =

0 commit comments

Comments
 (0)