Skip to content
2 changes: 1 addition & 1 deletion static/app/components/forms/fields/inputField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ function defaultField({
<InputGroup>
<InputGroup.Input
onBlur={e => onBlur(e.target.value, e)}
onKeyDown={e => onKeyDown((e.target as any).value, e)}
onKeyDown={e => onKeyDown((e.target as HTMLInputElement).value, e)}
onChange={e => onChange(e.target.value, e)}
name={name}
{...rest}
Expand Down
2 changes: 1 addition & 1 deletion static/app/components/forms/fields/numberField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ function createFieldWithSuffix({suffix}: {suffix: React.ReactNode}) {
<InputGroup>
<InputGroup.Input
onBlur={e => onBlur(e.target.value, e)}
onKeyDown={e => onKeyDown((e.target as any).value, e)}
onKeyDown={e => onKeyDown((e.target as HTMLInputElement).value, e)}
onChange={e => onChange(e.target.value, e)}
name={name}
{...rest}
Expand Down
2 changes: 1 addition & 1 deletion static/app/views/onboarding/components/stepper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const StepperTransitionIndicator = styled(motion.span)`
position: absolute;
`;

type Props = React.HTMLAttributes<HTMLDivElement> & {
type Props = Omit<React.HTMLAttributes<HTMLDivElement>, 'onClick'> & {
currentStepIndex: number;
numSteps: number;
onClick: (stepIndex: number) => void;
Expand Down
6 changes: 3 additions & 3 deletions static/app/views/onboarding/onboarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -215,12 +215,12 @@ export function OnboardingWithoutContext(props: Props) {
numSteps={onboardingSteps.length}
currentStepIndex={stepIndex}
onClick={i => {
if ((i as number) < stepIndex && shallProjectBeDeleted) {
handleGoBack(i as number);
if (i < stepIndex && shallProjectBeDeleted) {
handleGoBack(i);
return;
}

goToStep(onboardingSteps[i as number]!);
goToStep(onboardingSteps[i]!);
}}
/>
)}
Expand Down
3 changes: 1 addition & 2 deletions static/gsApp/__fixtures__/previewData.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type {PreviewData} from 'getsentry/types';
import {InvoiceItemType} from 'getsentry/types';

export function PreviewDataFixture(fields: Partial<PreviewData>): PreviewData {
return {
Expand All @@ -11,7 +10,7 @@ export function PreviewDataFixture(fields: Partial<PreviewData>): PreviewData {
invoiceItems: [
{
amount: 8900,
type: InvoiceItemType.SUBSCRIPTION,
type: 'subscription',
description: 'Subscription to Business',
data: {},
period_end: '',
Expand Down
3 changes: 1 addition & 2 deletions static/gsApp/components/promotionModal.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {PromotionFixture} from 'getsentry-test/fixtures/promotion';
import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';

import PromotionModal from 'getsentry/components/promotionModal';
import {InvoiceItemType} from 'getsentry/types';

describe('Promotion Modal', () => {
const organization = OrganizationFixture();
Expand All @@ -24,7 +23,7 @@ describe('Promotion Modal', () => {
amount: 2500,
billingInterval: 'monthly',
billingPeriods: 3,
creditCategory: InvoiceItemType.SUBSCRIPTION,
creditCategory: 'subscription',
discountType: 'percentPoints',
disclaimerText:
"*Receive 40% off the monthly price of Sentry's Team or Business plan subscriptions for your first three months if you upgrade today",
Expand Down
181 changes: 136 additions & 45 deletions static/gsApp/types/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {StripeConstructor} from '@stripe/stripe-js';

import type {DATA_CATEGORY_INFO} from 'sentry/constants';
import type {DataCategory, DataCategoryInfo} from 'sentry/types/core';
import type {User} from 'sentry/types/user';

Expand Down Expand Up @@ -649,51 +650,141 @@ export type InvoiceItem = BaseInvoiceItem & {
periodStart: string;
};

// TODO(data categories): BIL-969
export enum InvoiceItemType {
UNKNOWN = '',
SUBSCRIPTION = 'subscription',
ONDEMAND = 'ondemand',
RESERVED_EVENTS = 'reserved',
DAILY_EVENTS = 'daily_events',
BALANCE_CHANGE = 'balance_change',
CANCELLATION_FEE = 'cancellation_fee',
SUBSCRIPTION_CREDIT = 'subscription_credit',
CREDIT_APPLIED = 'credit_applied',
RECURRING_DISCOUNT = 'recurring_discount',
DISCOUNT = 'discount',
SALES_TAX = 'sales_tax',
/**
* Used for AM plans
*/
ATTACHMENTS = 'attachments',
TRANSACTIONS = 'transactions',
ONDEMAND_ATTACHMENTS = 'ondemand_attachments',
ONDEMAND_ERRORS = 'ondemand_errors',
ONDEMAND_TRANSACTIONS = 'ondemand_transactions',
ONDEMAND_REPLAYS = 'ondemand_replays',
ONDEMAND_SPANS = 'ondemand_spans',
ONDEMAND_SPANS_INDEXED = 'ondemand_spans_indexed',
ONDEMAND_MONITOR_SEATS = 'ondemand_monitor_seats',
ONDEMAND_UPTIME = 'ondemand_uptime',
ONDEMAND_PROFILE_DURATION = 'ondemand_profile_duration',
ONDEMAND_SEER_AUTOFIX = 'ondemand_seer_autofix',
ONDEMAND_SEER_SCANNER = 'ondemand_seer_scanner',
RESERVED_ATTACHMENTS = 'reserved_attachments',
RESERVED_ERRORS = 'reserved_errors',
RESERVED_TRANSACTIONS = 'reserved_transactions',
RESERVED_REPLAYS = 'reserved_replays',
RESERVED_SPANS = 'reserved_spans',
RESERVED_SPANS_INDEXED = 'reserved_spans_indexed',
RESERVED_MONITOR_SEATS = 'reserved_monitor_seats',
RESERVED_UPTIME = 'reserved_uptime',
RESERVED_PROFILE_DURATION = 'reserved_profile_duration',
RESERVED_SEER_AUTOFIX = 'reserved_seer_autofix',
RESERVED_SEER_SCANNER = 'reserved_seer_scanner',
RESERVED_SEER_BUDGET = 'reserved_seer_budget',
RESERVED_PREVENT_USERS = 'reserved_prevent_users',
RESERVED_LOG_BYTES = 'reserved_log_bytes',
}
/**
* Converts camelCase string to snake_case. Consecutive capitals are treated as
* a single acronym (e.g. "profileDurationUI" -> "profile_duration_ui").
* Examples: "monitorSeats" -> "monitor_seats", "errors" -> "errors"
*/
type CamelToSnake<
S extends string,
Prev extends 'lower' | 'upper' | '' = '',
> = S extends `${infer First}${infer Rest}`
? First extends Lowercase<First>
? `${First}${CamelToSnake<Rest, 'lower'>}`
: First extends Uppercase<First>
? Rest extends ''
? `${Prev extends '' ? '' : Prev extends 'lower' ? '_' : ''}${Lowercase<First>}`
: Rest extends `${infer Next}${infer _Tail}`
? Next extends Lowercase<Next>
? `${Prev extends '' ? '' : '_'}${Lowercase<First>}${CamelToSnake<Rest, 'upper'>}`
: `${Prev extends 'lower' ? '_' : ''}${Lowercase<First>}${CamelToSnake<Rest, 'upper'>}`
: never
: `${First}${CamelToSnake<Rest, Prev>}`
: S;

/**
* Dynamically generate ondemand invoice item types from DATA_CATEGORY_INFO.
* This automatically includes new billing categories without manual enum updates.
*
* Follows the pattern: `ondemand_${snake_case_plural}`
* Example: DATA_CATEGORY_INFO.ERROR (plural: "errors") -> "ondemand_errors"
* Example: DATA_CATEGORY_INFO.MONITOR_SEAT (plural: "monitorSeats") -> "ondemand_monitor_seats"
*/
type OnDemandInvoiceItemType = {
[K in keyof typeof DATA_CATEGORY_INFO]: (typeof DATA_CATEGORY_INFO)[K]['isBilledCategory'] extends true
? `ondemand_${CamelToSnake<(typeof DATA_CATEGORY_INFO)[K]['plural']>}`
: never;
}[keyof typeof DATA_CATEGORY_INFO];

/**
* Dynamically generate reserved invoice item types from DATA_CATEGORY_INFO.
* This automatically includes new billing categories without manual enum updates.
*
* Follows the pattern: `reserved_${snake_case_plural}`
* Example: DATA_CATEGORY_INFO.ERROR (plural: "errors") -> "reserved_errors"
* Example: DATA_CATEGORY_INFO.MONITOR_SEAT (plural: "monitorSeats") -> "reserved_monitor_seats"
*/
type ReservedInvoiceItemType = {
[K in keyof typeof DATA_CATEGORY_INFO]: (typeof DATA_CATEGORY_INFO)[K]['isBilledCategory'] extends true
? `reserved_${CamelToSnake<(typeof DATA_CATEGORY_INFO)[K]['plural']>}`
: never;
}[keyof typeof DATA_CATEGORY_INFO];
Copy link
Contributor

Choose a reason for hiding this comment

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

Bug: Dynamic type generates incorrect prevent user invoice types

The PREVENT_USER category has isBilledCategory: true which causes the dynamic type system to generate reserved_prevent_users and ondemand_prevent_users invoice types. However, the backend actually uses reserved_seer_users for prevent users (as noted in the comment on line 730). This mismatch means the dynamically generated types won't match actual backend invoice types, and the invoiceItemTypeToAddOn function checking for reserved_prevent_users won't match invoices with type reserved_seer_users.

Fix in Cursor Fix in Web


/**
* Credit-related invoice item types (discounts, credits, refunds).
* Exported as const array to enable runtime usage in filters.
*/
export const CREDIT_INVOICE_ITEM_TYPES = [
'subscription_credit',
'recurring_discount',
'discount',
'credit_applied', // Deprecated: replaced by balance_change
] as const;

type CreditInvoiceItemType = (typeof CREDIT_INVOICE_ITEM_TYPES)[number];

/**
* Fee-related invoice item types (taxes, penalties).
* Exported as const array to enable runtime usage in filters.
*/
export const FEE_INVOICE_ITEM_TYPES = ['sales_tax', 'cancellation_fee'] as const;

type FeeInvoiceItemType = (typeof FEE_INVOICE_ITEM_TYPES)[number];

/**
* Seer/AI-related invoice item types (special billing for AI features).
*/
const _SEER_INVOICE_ITEM_TYPES = [
'reserved_seer_budget', // Special case: shared budget for seer_autofix and seer_scanner
'reserved_seer_users', // Special case: reserved prevent users (PREVENT_USER category maps to this)
'activated_seer_users', // Activation-based prevent users billing (PREVENT_USER category)
] as const;

type SeerInvoiceItemType = (typeof _SEER_INVOICE_ITEM_TYPES)[number];

/**
* Legacy/deprecated invoice item types (AM1 plans and old formats).
*/
const _LEGACY_INVOICE_ITEM_TYPES = [
'ondemand', // Legacy: generic ondemand for AM1 plans
'attachments', // Legacy: AM1 plans
'transactions', // Legacy: AM1 plans
'daily_events', // Deprecated
'reserved', // Deprecated: legacy name for reserved_events
] as const;

type LegacyInvoiceItemType = (typeof _LEGACY_INVOICE_ITEM_TYPES)[number];

/**
* Core subscription type.
*/
type SubscriptionInvoiceItemType = 'subscription';

/**
* Balance change can be both credit (negative) or fee (positive).
*/
type BalanceChangeInvoiceItemType = 'balance_change';

/**
* Unknown invoice item type (empty string).
*/
type UnknownInvoiceItemType = '';

/**
* Static invoice item types that are not tied to data categories.
* These must be manually maintained but change infrequently.
*/
type StaticInvoiceItemType =
Copy link
Member

Choose a reason for hiding this comment

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

we'll just need to add activated_seer_users

Copy link
Member Author

Choose a reason for hiding this comment

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

Added the missing invoice types.

| UnknownInvoiceItemType
| SubscriptionInvoiceItemType
| BalanceChangeInvoiceItemType
| CreditInvoiceItemType
| FeeInvoiceItemType
| SeerInvoiceItemType
| LegacyInvoiceItemType;

/**
* Complete invoice item type union.
* Automatically stays in sync with backend when new billing categories are added.
*
* Migration from enum: Use string literals instead of enum members.
* Before: InvoiceItemType.SUBSCRIPTION
* After: 'subscription'
*/
export type InvoiceItemType =
| OnDemandInvoiceItemType
| ReservedInvoiceItemType
| StaticInvoiceItemType;

export enum InvoiceStatus {
PAID = 'paid',
Expand Down
9 changes: 5 additions & 4 deletions static/gsApp/utils/billing.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription';
import {DataCategory} from 'sentry/types/core';

import {BILLION, GIGABYTE, MILLION, UNLIMITED} from 'getsentry/constants';
import {InvoiceItemType, OnDemandBudgetMode, type ProductTrial} from 'getsentry/types';
import {OnDemandBudgetMode} from 'getsentry/types';
import type {ProductTrial} from 'getsentry/types';
import {
convertUsageToReservedUnit,
formatReservedWithUnits,
Expand Down Expand Up @@ -1097,7 +1098,7 @@ describe('getCreditApplied', () => {
creditApplied: 100,
invoiceItems: [
{
type: InvoiceItemType.SUBSCRIPTION_CREDIT,
type: 'subscription_credit',
...commonCreditProps,
},
],
Expand All @@ -1108,7 +1109,7 @@ describe('getCreditApplied', () => {
creditApplied: 100,
invoiceItems: [
{
type: InvoiceItemType.BALANCE_CHANGE,
type: 'balance_change',
...commonCreditProps,
},
],
Expand All @@ -1119,7 +1120,7 @@ describe('getCreditApplied', () => {
creditApplied: 100,
invoiceItems: [
{
type: InvoiceItemType.BALANCE_CHANGE,
type: 'balance_change',
...commonCreditProps,
amount: -50,
},
Expand Down
40 changes: 19 additions & 21 deletions static/gsApp/utils/billing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,24 @@ import {
} from 'getsentry/constants';
import {
AddOnCategory,
InvoiceItemType,
CREDIT_INVOICE_ITEM_TYPES,
FEE_INVOICE_ITEM_TYPES,
OnDemandBudgetMode,
PlanName,
PlanTier,
ReservedBudgetCategoryType,
type BillingConfig,
type BillingDetails,
type BillingMetricHistory,
type BillingStatTotal,
type EventBucket,
type InvoiceItem,
type Plan,
type PreviewInvoiceItem,
type ProductTrial,
type Subscription,
} from 'getsentry/types';
import type {
BillingConfig,
BillingDetails,
BillingMetricHistory,
BillingStatTotal,
EventBucket,
InvoiceItem,
Plan,
PreviewInvoiceItem,
ProductTrial,
Subscription,
} from 'getsentry/types';
import {
getCategoryInfoFromPlural,
Expand Down Expand Up @@ -803,13 +806,8 @@ export function getCredits({
}) {
return invoiceItems.filter(
item =>
[
InvoiceItemType.SUBSCRIPTION_CREDIT,
InvoiceItemType.CREDIT_APPLIED, // TODO(isabella): This is deprecated and replaced by BALANCE_CHANGE
InvoiceItemType.DISCOUNT,
InvoiceItemType.RECURRING_DISCOUNT,
].includes(item.type) ||
(item.type === InvoiceItemType.BALANCE_CHANGE && item.amount < 0)
CREDIT_INVOICE_ITEM_TYPES.includes(item.type as any) ||
(item.type === 'balance_change' && item.amount < 0)
);
}

Expand All @@ -826,7 +824,7 @@ export function getCreditApplied({
invoiceItems: InvoiceItem[] | PreviewInvoiceItem[];
}) {
const credits = getCredits({invoiceItems});
if (credits.some(item => item.type === InvoiceItemType.BALANCE_CHANGE)) {
if (credits.some(item => item.type === 'balance_change')) {
return 0;
}
return creditApplied;
Expand All @@ -843,8 +841,8 @@ export function getFees({
}) {
return invoiceItems.filter(
item =>
[InvoiceItemType.CANCELLATION_FEE, InvoiceItemType.SALES_TAX].includes(item.type) ||
(item.type === InvoiceItemType.BALANCE_CHANGE && item.amount > 0)
FEE_INVOICE_ITEM_TYPES.includes(item.type as any) ||
(item.type === 'balance_change' && item.amount > 0)
);
}

Expand Down
Loading
Loading