Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions static/gsApp/hooks/useProductBillingMetadata.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import type {DataCategory} from 'sentry/types/core';
import {toTitleCase} from 'sentry/utils/string/toTitleCase';

import type {AddOn, AddOnCategory, ProductTrial, Subscription} from 'getsentry/types';
import {
checkIsAddOn,
getActiveProductTrial,
getBilledCategory,
getPotentialProductTrial,
productIsEnabled,
} from 'getsentry/utils/billing';
import {getPlanCategoryName} from 'getsentry/utils/dataCategory';

interface ProductBillingMetadata {
/**
* The active product trial for the given product, if any.
* Always null when excludeProductTrials is true.
*/
activeProductTrial: ProductTrial | null;
/**
* The billed category for the given product.
* When product is a DataCategory, we just return product.
* When product is an AddOnCategory, we return the billed category for the
* add-on.
*/
billedCategory: DataCategory | null;
/**
* The display name for the given product in title case.
*/
displayName: string;
/**
* Whether the product is an add-on.
*/
isAddOn: boolean;
/**
* Whether the product is enabled for the subscription.
*/
isEnabled: boolean;
/**
* The longest available product trial for the given product, if any.
* Always null when excludeProductTrials is true.
*/
potentialProductTrial: ProductTrial | null;
/**
* Whether the usage for the given product has exceeded the limit.
*/
usageExceeded: boolean;
/**
* The add-on information for the given product from the subscription,
* if any.
*/
addOnInfo?: AddOn;
}

const EMPTY_PRODUCT_BILLING_METADATA: ProductBillingMetadata = {
billedCategory: null,
displayName: '',
isAddOn: false,
isEnabled: false,
usageExceeded: false,
activeProductTrial: null,
potentialProductTrial: null,
};

export function useProductBillingMetadata(
subscription: Subscription,
product: DataCategory | AddOnCategory,
parentProduct?: DataCategory | AddOnCategory,
excludeProductTrials?: boolean
): ProductBillingMetadata {
const isAddOn = checkIsAddOn(parentProduct ?? product);
const billedCategory = getBilledCategory(subscription, product);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Inconsistent parameter usage in billing metadata hook

The getBilledCategory call uses only product while isAddOn uses parentProduct ?? product. When parentProduct is an add-on category and product is a data category, isAddOn correctly evaluates to true, but getBilledCategory receives the data category instead of the add-on category. This causes getBilledCategory to return the data category unchanged rather than determining the correct billed category for the add-on, leading to incorrect billing metadata.

Fix in Cursor Fix in Web

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is intentional as we render different things for add ons and their child categories, see #103983 for full usage


if (!billedCategory) {
return EMPTY_PRODUCT_BILLING_METADATA;
}

let displayName = '';
let addOnInfo = undefined;

if (isAddOn) {
const category = (parentProduct ?? product) as AddOnCategory;
addOnInfo = subscription.addOns?.[category];
if (!addOnInfo) {
return EMPTY_PRODUCT_BILLING_METADATA;
}
displayName = parentProduct
? getPlanCategoryName({
plan: subscription.planDetails,
category: billedCategory,
title: true,
})
: toTitleCase(addOnInfo.productName, {allowInnerUpperCase: true});
} else {
displayName = getPlanCategoryName({
plan: subscription.planDetails,
category: billedCategory,
title: true,
});
}

return {
displayName,
billedCategory,
isAddOn,
isEnabled: productIsEnabled(subscription, parentProduct ?? product),
addOnInfo,
usageExceeded: subscription.categories[billedCategory]?.usageExceeded ?? false,
activeProductTrial: excludeProductTrials
? null
: getActiveProductTrial(subscription.productTrials ?? null, billedCategory),
potentialProductTrial: excludeProductTrials
? null
: getPotentialProductTrial(subscription.productTrials ?? null, billedCategory),
};
}
2 changes: 1 addition & 1 deletion static/gsApp/types/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ export type AddOnCategoryInfo = {
productName: string;
};

type AddOn = AddOnCategoryInfo & {
export type AddOn = AddOnCategoryInfo & {
enabled: boolean;
};

Expand Down
140 changes: 138 additions & 2 deletions static/gsApp/utils/billing.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,16 @@ import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription';
import {DataCategory} from 'sentry/types/core';

import {BILLION, GIGABYTE, MILLION, UNLIMITED} from 'getsentry/constants';
import {OnDemandBudgetMode} from 'getsentry/types';
import type {ProductTrial} from 'getsentry/types';
import {AddOnCategory, OnDemandBudgetMode} from 'getsentry/types';
import type {ProductTrial, Subscription} from 'getsentry/types';
import {
checkIsAddOn,
convertUsageToReservedUnit,
formatReservedWithUnits,
formatUsageWithUnits,
getActiveProductTrial,
getBestActionToIncreaseEventLimits,
getBilledCategory,
getCreditApplied,
getOnDemandCategories,
getProductTrial,
Expand All @@ -27,6 +29,7 @@ import {
isNewPayingCustomer,
isTeamPlanFamily,
MILLISECONDS_IN_HOUR,
productIsEnabled,
trialPromptIsDismissed,
UsageAction,
} from 'getsentry/utils/billing';
Expand Down Expand Up @@ -185,6 +188,11 @@ describe('formatReservedWithUnits', () => {
useUnitScaling: true,
})
).toBe(UNLIMITED);
expect(
formatReservedWithUnits(-1, DataCategory.ATTACHMENTS, {
useUnitScaling: true,
})
).toBe(UNLIMITED);
});

it('returns correct string for Profile Duration', () => {
Expand Down Expand Up @@ -253,6 +261,11 @@ describe('formatReservedWithUnits', () => {
useUnitScaling: true,
})
).toBe(UNLIMITED);
expect(
formatReservedWithUnits(-1, DataCategory.LOG_BYTE, {
useUnitScaling: true,
})
).toBe(UNLIMITED);

expect(
formatReservedWithUnits(1234, DataCategory.LOG_BYTE, {
Expand Down Expand Up @@ -1129,3 +1142,126 @@ describe('getCreditApplied', () => {
).toBe(0);
});
});

describe('checkIsAddOn', () => {
it('returns true for add-on', () => {
expect(checkIsAddOn(AddOnCategory.LEGACY_SEER)).toBe(true);
expect(checkIsAddOn(AddOnCategory.SEER)).toBe(true);
});

it('returns false for data category', () => {
expect(checkIsAddOn(DataCategory.ERRORS)).toBe(false);
expect(checkIsAddOn(DataCategory.SEER_AUTOFIX)).toBe(false);
});
});

describe('getBilledCategory', () => {
const organization = OrganizationFixture();
const subscription = SubscriptionFixture({organization, plan: 'am3_team'});

it('returns correct billed category for data category', () => {
subscription.planDetails.categories.forEach(category => {
expect(getBilledCategory(subscription, category)).toBe(category);
});
});

it('returns correct billed category for add-on', () => {
expect(getBilledCategory(subscription, AddOnCategory.LEGACY_SEER)).toBe(
DataCategory.SEER_AUTOFIX
);
expect(getBilledCategory(subscription, AddOnCategory.SEER)).toBe(
DataCategory.SEER_USER
);
});
});

describe('productIsEnabled', () => {
const organization = OrganizationFixture();
let subscription: Subscription;

beforeEach(() => {
subscription = SubscriptionFixture({organization, plan: 'am3_team'});
});

it('returns true for active product trial', () => {
subscription.productTrials = [
{
// not started
category: DataCategory.PROFILE_DURATION,
isStarted: false,
reasonCode: 1001,
startDate: undefined,
endDate: moment().utc().add(20, 'years').format(),
},
{
// started
category: DataCategory.REPLAYS,
isStarted: true,
reasonCode: 1001,
startDate: moment().utc().subtract(10, 'days').format(),
endDate: moment().utc().add(20, 'days').format(),
},
{
// started
category: DataCategory.SEER_AUTOFIX,
isStarted: true,
reasonCode: 1001,
startDate: moment().utc().subtract(10, 'days').format(),
endDate: moment().utc().add(20, 'days').format(),
},
];

expect(productIsEnabled(subscription, DataCategory.PROFILE_DURATION)).toBe(false);
expect(productIsEnabled(subscription, DataCategory.REPLAYS)).toBe(true);
expect(productIsEnabled(subscription, DataCategory.SEER_AUTOFIX)).toBe(true);
expect(productIsEnabled(subscription, AddOnCategory.LEGACY_SEER)).toBe(true); // because there is a product trial for the billed category
});

it('uses subscription add-on info for add-on', () => {
subscription.addOns!.seer = {
...subscription.addOns?.seer!,
enabled: true,
};

expect(productIsEnabled(subscription, AddOnCategory.SEER)).toBe(true);
expect(productIsEnabled(subscription, AddOnCategory.LEGACY_SEER)).toBe(false);
});

it('returns true for non-PAYG-only data categories', () => {
expect(productIsEnabled(subscription, DataCategory.ERRORS)).toBe(true);
});

it('uses PAYG budgets for PAYG-only data categories', () => {
expect(productIsEnabled(subscription, DataCategory.PROFILE_DURATION)).toBe(false);

// shared PAYG
subscription.onDemandBudgets = {
budgetMode: OnDemandBudgetMode.SHARED,
sharedMaxBudget: 1000,
enabled: true,
onDemandSpendUsed: 0,
};
expect(productIsEnabled(subscription, DataCategory.PROFILE_DURATION)).toBe(true);

// per-category PAYG
subscription.onDemandBudgets = {
budgetMode: OnDemandBudgetMode.PER_CATEGORY,
enabled: true,
budgets: {
errors: 1000,
},
usedSpends: {},
};
expect(productIsEnabled(subscription, DataCategory.PROFILE_DURATION)).toBe(false);

subscription.onDemandBudgets.budgets = {
...subscription.onDemandBudgets.budgets,
profileDuration: 1000,
};
subscription.categories.profileDuration = {
...subscription.categories.profileDuration!,
onDemandBudget: 1000,
};
expect(productIsEnabled(subscription, DataCategory.PROFILE_DURATION)).toBe(true);
});
});
Loading
Loading