Skip to content

Commit 08da41c

Browse files
fix(billing): Show prepaid volumes for add-ons (#104893)
Fixes https://www.notion.so/sentry/Reserved-quota-not-shown-for-sponsored-enterprise-2c68b10e4b5d808ca08fec7f142aa0fe?source=copy_link and https://www.notion.so/sentry/Show-X-Y-active-contributors-when-Y-0-2c78b10e4b5d8051ae74c959eab62215?source=copy_link Seer is also considered enabled when an org has X prepaid volume. This PR ensures we display that prepaid volume in the table and in panel when Seer is selected. <img width="3350" height="655" alt="Screenshot 2025-12-15 at 11 45 18 AM" src="https://github.com/user-attachments/assets/c72de37f-e58c-45bc-8444-3baf01ff58a0" /> I also added an empty state for the active contributors table since it felt cut off without one. <img width="1133" height="475" alt="Screenshot 2025-12-12 at 4 31 24 PM" src="https://github.com/user-attachments/assets/3e5da68c-d1c2-4bae-998b-64db318077ae" /> Finally this also corrects how we render unlimited prepaid volume in the panel, opting to use `Unlimited` instead of the infinity sign to match the tag we show in the table: <img width="2462" height="744" alt="Screenshot 2025-12-12 at 4 34 00 PM" src="https://github.com/user-attachments/assets/01d50a45-6100-417c-9f98-78fc1ecb5406" />
1 parent 8dbc68f commit 08da41c

File tree

13 files changed

+334
-76
lines changed

13 files changed

+334
-76
lines changed

static/gsAdmin/components/provisionSubscriptionAction.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,7 @@ class ProvisionSubscriptionModal extends Component<ModalProps, ModalState> {
372372
hasCompleteSeerBudget = () =>
373373
this.isSettingSeerBudget() &&
374374
Object.entries(this.state.data)
375-
.filter(([key, _]) => key.startsWith('reservedSeer'))
375+
.filter(([key, _]) => key.startsWith('reservedSeer') && key !== 'reservedSeerUsers')
376376
.every(([_, value]) => value === RESERVED_BUDGET_QUOTA) &&
377377
this.state.data.seerBudget;
378378

static/gsApp/components/upgradeNowModal/usePreviewData.spec.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ const mockReservations: Reservations = {
2424
reservedSpans: undefined,
2525
reservedSeerAutofix: 0,
2626
reservedSeerScanner: 0,
27-
reservedSeerUsers: undefined,
27+
reservedSeerUsers: 0,
2828
reservedPreventUsers: undefined,
2929
};
3030

static/gsApp/components/upgradeNowModal/useUpgradeNowParams.spec.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ describe('useUpgradeNowParams', () => {
5757
reservedSpans: undefined,
5858
reservedSeerAutofix: 0,
5959
reservedSeerScanner: 0,
60-
reservedSeerUsers: undefined,
60+
reservedSeerUsers: 0,
6161
reservedPreventUsers: undefined,
6262
},
6363
})

static/gsApp/utils/dataCategory.spec.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,10 @@ describe('hasCategoryFeature', () => {
112112
const org = {...organization, features: []};
113113
expect(hasCategoryFeature('unknown' as DataCategory, subscription, org)).toBe(false);
114114
});
115+
});
115116

117+
describe('sortCategories', () => {
118+
const organization = OrganizationFixture();
116119
it('returns sorted categories', () => {
117120
const sub = SubscriptionFixture({organization, plan: 'am1_team'});
118121
expect(sortCategories(sub.categories)).toStrictEqual([
@@ -164,6 +167,12 @@ describe('hasCategoryFeature', () => {
164167
prepaid: 0,
165168
order: 15,
166169
}),
170+
MetricHistoryFixture({
171+
category: DataCategory.SEER_USER,
172+
reserved: 0,
173+
prepaid: 0,
174+
order: 16,
175+
}),
167176
]);
168177
});
169178

@@ -242,6 +251,15 @@ describe('hasCategoryFeature', () => {
242251
order: 15,
243252
}),
244253
],
254+
[
255+
'seerUsers',
256+
MetricHistoryFixture({
257+
category: DataCategory.SEER_USER,
258+
reserved: 0,
259+
prepaid: 0,
260+
order: 16,
261+
}),
262+
],
245263
]);
246264
});
247265
});

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

Lines changed: 37 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {Fragment} from 'react';
22
import styled from '@emotion/styled';
33

44
import {Container} from '@sentry/scraps/layout';
5+
import {Text} from '@sentry/scraps/text';
56

67
import LoadingError from 'sentry/components/loadingError';
78
import LoadingIndicator from 'sentry/components/loadingIndicator';
@@ -14,6 +15,7 @@ import type {Organization} from 'sentry/types/organization';
1415
import {defined} from 'sentry/utils';
1516
import {useApiQuery} from 'sentry/utils/queryClient';
1617

18+
import {UNLIMITED_RESERVED} from 'getsentry/constants';
1719
import {useProductBillingMetadata} from 'getsentry/hooks/useProductBillingMetadata';
1820
import {
1921
AddOnCategory,
@@ -30,7 +32,7 @@ function BilledSeats({
3032
selectedProduct: DataCategory | AddOnCategory;
3133
subscription: Subscription;
3234
}) {
33-
const {billedCategory, isEnabled} = useProductBillingMetadata(
35+
const {billedCategory, isEnabled, activeProductTrial} = useProductBillingMetadata(
3436
subscription,
3537
selectedProduct
3638
);
@@ -55,39 +57,56 @@ function BilledSeats({
5557
return null;
5658
}
5759

60+
const metricHistory = subscription.categories[billedCategory];
61+
if (!metricHistory) {
62+
return null;
63+
}
64+
5865
return (
5966
<Fragment>
60-
<Table hasBorderTop={(billedSeats?.length ?? 0) > 0}>
61-
<SimpleTable.Header
62-
style={{
63-
borderBottom: billedSeats?.length === 0 ? 'none' : undefined,
64-
}}
65-
>
67+
<Table
68+
hasBorderTop={
69+
// add a top border if there is info above this component in the panel
70+
// we can infer this by checking if there is at least one billed seat
71+
// (info includes accumulated spend) or by checking that the prepaid
72+
// volume for the seat category is greater than 0 (info includes reserved
73+
// and gifted volumes)
74+
(billedSeats?.length ?? 0) > 0 ||
75+
!!activeProductTrial ||
76+
(defined(metricHistory.prepaid) &&
77+
(metricHistory.prepaid > 0 || metricHistory.prepaid === UNLIMITED_RESERVED))
78+
}
79+
>
80+
<SimpleTable.Header>
6681
<SimpleTable.HeaderCell style={{textTransform: 'uppercase'}}>
6782
{tct('Active Contributors ([count])', {count: billedSeats?.length ?? 0})}
6883
</SimpleTable.HeaderCell>
6984
<SimpleTable.HeaderCell style={{textTransform: 'uppercase'}}>
7085
{t('Date Added')}
7186
</SimpleTable.HeaderCell>
7287
</SimpleTable.Header>
73-
{seatsError && (
88+
{seatsError ? (
7489
<SimpleTable.Empty>
7590
<LoadingError onRetry={refetch} />
7691
</SimpleTable.Empty>
77-
)}
78-
{seatsLoading && (
92+
) : seatsLoading ? (
7993
<SimpleTable.Empty>
8094
<LoadingIndicator />
8195
</SimpleTable.Empty>
96+
) : billedSeats && billedSeats.length > 0 ? (
97+
billedSeats.map(seat => (
98+
<SimpleTable.Row key={seat.id}>
99+
<SimpleTable.RowCell>@{seat.displayName}</SimpleTable.RowCell>
100+
<SimpleTable.RowCell>
101+
<TimeSince date={seat.created} />
102+
</SimpleTable.RowCell>
103+
</SimpleTable.Row>
104+
))
105+
) : (
106+
<SimpleTable.Empty>
107+
<Text variant="muted">{t('No billed usage recorded yet.')}</Text>
108+
</SimpleTable.Empty>
82109
)}
83-
{billedSeats?.map(seat => (
84-
<SimpleTable.Row key={seat.id}>
85-
<SimpleTable.RowCell>@{seat.displayName}</SimpleTable.RowCell>
86-
<SimpleTable.RowCell>
87-
<TimeSince date={seat.created} />
88-
</SimpleTable.RowCell>
89-
</SimpleTable.Row>
90-
))}
91110
</Table>
92111
{billedSeats && billedSeats.length > 0 && (
93112
<Container padding="0 lg lg" borderTop="primary">

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

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -200,38 +200,36 @@ function DataCategoryUsageBreakdownInfo({
200200
const productCanUsePayg =
201201
plan.onDemandCategories.includes(category) &&
202202
hasPaygBudgetForCategory(subscription, category);
203-
const platformReserved =
204-
plan.planCategories[category]?.find(
205-
bucket => bucket.price === 0 && bucket.events >= 0
206-
)?.events ?? 0;
207-
const platformReservedField = tct('[planName] plan', {planName: plan.name});
208203
const reserved = metricHistory.reserved ?? 0;
209-
const isUnlimited = reserved === UNLIMITED_RESERVED;
210-
const isAddOnChildCategory = checkIsAddOnChildCategory(subscription, category, true);
204+
const platformReserved = subscription.canSelfServe
205+
? (plan.planCategories[category]?.find(
206+
bucket => bucket.price === 0 && bucket.events >= 0
207+
)?.events ?? 0)
208+
: reserved;
209+
const platformReservedField = tct('[planName] plan', {planName: plan.name});
211210

212211
const additionalReserved = Math.max(0, reserved - platformReserved);
213-
const shouldShowAdditionalReserved =
214-
!isAddOnChildCategory &&
215-
!isUnlimited &&
216-
subscription.canSelfServe &&
217-
additionalReserved > 0;
212+
const shouldShowAdditionalReserved = additionalReserved > 0;
218213
const formattedAdditionalReserved = shouldShowAdditionalReserved
219214
? formatReservedWithUnits(additionalReserved, category)
220215
: null;
221216
const formattedPlatformReserved =
222-
isAddOnChildCategory || !reserved
223-
? null
224-
: formatReservedWithUnits(
217+
reserved > 0
218+
? formatReservedWithUnits(
225219
shouldShowAdditionalReserved ? platformReserved : reserved,
226220
category
227-
);
221+
)
222+
: reserved === UNLIMITED_RESERVED
223+
? t('Unlimited')
224+
: null;
228225

229226
const gifted = metricHistory.free ?? 0;
230227
const formattedGifted = gifted ? formatReservedWithUnits(gifted, category) : null;
231228

232229
const paygSpend = metricHistory.onDemandSpendUsed ?? 0;
233230
const paygCategoryBudget = metricHistory.onDemandBudget ?? 0;
234231

232+
const isAddOnChildCategory = checkIsAddOnChildCategory(subscription, category, true);
235233
const recurringReservedSpend = isAddOnChildCategory
236234
? null
237235
: (plan.planCategories[category]?.find(bucket => bucket.events === reserved)?.price ??

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

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {OrganizationFixture} from 'sentry-fixture/organization';
44
import {CustomerUsageFixture} from 'getsentry-test/fixtures/customerUsage';
55
import {MetricHistoryFixture} from 'getsentry-test/fixtures/metricHistory';
66
import {
7+
InvoicedSubscriptionFixture,
78
SubscriptionFixture,
89
SubscriptionWithLegacySeerFixture,
910
} from 'getsentry-test/fixtures/subscription';
@@ -12,6 +13,7 @@ import {resetMockDate, setMockDate} from 'sentry-test/utils';
1213

1314
import {DataCategory} from 'sentry/types/core';
1415

16+
import {UNLIMITED_RESERVED} from 'getsentry/constants';
1517
import SubscriptionStore from 'getsentry/stores/subscriptionStore';
1618
import {AddOnCategory, OnDemandBudgetMode, type Subscription} from 'getsentry/types';
1719
import ProductBreakdownPanel from 'getsentry/views/subscriptionPage/usageOverview/components/panel';
@@ -78,6 +80,32 @@ describe('ProductBreakdownPanel', () => {
7880
expect(screen.getByText('$245.00 / month')).toBeInTheDocument();
7981
});
8082

83+
it('renders for data category with unlimited reserved', async () => {
84+
subscription.categories.errors = {
85+
...subscription.categories.errors!,
86+
reserved: UNLIMITED_RESERVED,
87+
prepaid: UNLIMITED_RESERVED,
88+
};
89+
render(
90+
<ProductBreakdownPanel
91+
subscription={subscription}
92+
organization={organization}
93+
usageData={usageData}
94+
selectedProduct={DataCategory.ERRORS}
95+
/>
96+
);
97+
98+
await screen.findByRole('heading', {name: 'Errors'});
99+
expect(screen.getByText('Included volume')).toBeInTheDocument();
100+
expect(screen.getByText('Business plan')).toBeInTheDocument();
101+
expect(screen.getByText('Unlimited')).toBeInTheDocument();
102+
expect(screen.queryByText('Additional reserved')).not.toBeInTheDocument();
103+
expect(screen.queryByText('Gifted')).not.toBeInTheDocument();
104+
expect(screen.queryByText('Additional spend')).not.toBeInTheDocument();
105+
expect(screen.queryByText('Pay-as-you-go')).not.toBeInTheDocument();
106+
expect(screen.queryByText('Reserved spend')).not.toBeInTheDocument();
107+
});
108+
81109
it('renders for data category with per-category PAYG set', async () => {
82110
subscription.categories.errors = {
83111
...subscription.categories.errors!,
@@ -381,6 +409,9 @@ describe('ProductBreakdownPanel', () => {
381409

382410
await screen.findByRole('heading', {name: 'Replays'});
383411
expect(screen.getByText('Trial - 20 days left')).toBeInTheDocument();
412+
expect(screen.getByText('Included volume')).toBeInTheDocument();
413+
expect(screen.getByText('Trial')).toBeInTheDocument();
414+
expect(screen.getByText('Unlimited')).toBeInTheDocument();
384415
});
385416

386417
it('renders usage exceeded status without PAYG set', async () => {
@@ -520,4 +551,83 @@ describe('ProductBreakdownPanel', () => {
520551

521552
await screen.findByText('Active Contributors (3)'); // wait for billed seats to be loaded
522553
});
554+
555+
it('renders for Seer add-on for non-self-serve subscription', async () => {
556+
const enterpriseSubscription = InvoicedSubscriptionFixture({
557+
plan: 'am3_business_ent_auf',
558+
organization,
559+
});
560+
MockApiClient.addMockResponse({
561+
url: `/customers/${organization.slug}/billing-seats/current/?billingMetric=seerUsers`,
562+
method: 'GET',
563+
body: [
564+
{
565+
billingMetric: DataCategory.SEER_USER,
566+
created: '2021-01-01',
567+
displayName: 'johndoe',
568+
id: 1,
569+
isTrialSeat: false,
570+
projectId: 1,
571+
seatIdentifier: '1234567890',
572+
status: 'ASSIGNED',
573+
},
574+
575+
{
576+
billingMetric: DataCategory.SEER_USER,
577+
created: '2021-01-01',
578+
displayName: 'janedoe',
579+
id: 2,
580+
isTrialSeat: false,
581+
projectId: 1,
582+
seatIdentifier: '1234567890',
583+
status: 'ASSIGNED',
584+
},
585+
586+
{
587+
billingMetric: DataCategory.SEER_USER,
588+
created: '2021-01-01',
589+
displayName: 'alicebob',
590+
id: 3,
591+
isTrialSeat: false,
592+
projectId: 1,
593+
seatIdentifier: '1234567890',
594+
status: 'ASSIGNED',
595+
},
596+
],
597+
});
598+
enterpriseSubscription.categories.seerUsers = MetricHistoryFixture({
599+
category: DataCategory.SEER_USER,
600+
usage: 3,
601+
free: 1,
602+
prepaid: 3,
603+
reserved: 2,
604+
});
605+
enterpriseSubscription.addOns!.seer = {
606+
...subscription.addOns!.seer!,
607+
enabled: true,
608+
};
609+
SubscriptionStore.set(organization.slug, enterpriseSubscription);
610+
render(
611+
<ProductBreakdownPanel
612+
subscription={enterpriseSubscription}
613+
organization={organization}
614+
usageData={usageData}
615+
selectedProduct={AddOnCategory.SEER}
616+
/>
617+
);
618+
await screen.findByRole('heading', {name: 'Seer'});
619+
expect(screen.getByText('Included volume')).toBeInTheDocument();
620+
expect(screen.getByText('Enterprise (Business) plan')).toBeInTheDocument();
621+
expect(screen.getByText('1')).toBeInTheDocument();
622+
expect(screen.queryByText('Additional reserved')).not.toBeInTheDocument();
623+
expect(screen.getByText('Gifted')).toBeInTheDocument();
624+
expect(screen.getByText('1')).toBeInTheDocument();
625+
expect(screen.queryByText('Additional spend')).not.toBeInTheDocument();
626+
expect(screen.queryByText('Pay-as-you-go')).not.toBeInTheDocument();
627+
expect(screen.queryByText('Reserved spend')).not.toBeInTheDocument();
628+
expect(screen.queryByText('Active contributors spend')).not.toBeInTheDocument();
629+
expect(screen.getByRole('button', {name: 'Configure Seer'})).toBeInTheDocument();
630+
631+
await screen.findByText('Active Contributors (3)'); // wait for billed seats to be loaded
632+
});
523633
});

0 commit comments

Comments
 (0)