From 5795fe8f255ed48247b3d19427ba0e1f18b868fb Mon Sep 17 00:00:00 2001 From: Alberto Leal Date: Tue, 18 Nov 2025 20:39:49 -0500 Subject: [PATCH 1/8] ref(billing): Convert InvoiceItemType to dynamic type union Replaces the hardcoded InvoiceItemType enum with a dynamically-generated type union that automatically syncs with DATA_CATEGORY_INFO. This eliminates the need for manual enum updates whenever new billing categories are added. The implementation follows the existing EventType pattern and uses TypeScript mapped types to generate ondemand_* and reserved_* invoice item types from all billed categories. Added CamelToSnake helper type to convert DataCategory's camelCase values to backend's snake_case format. This change fixes 4 missing invoice types (ondemand_profile_duration_ui, ondemand_log_bytes, ondemand_prevent_users, reserved_profile_duration_ui) and prevents future category additions from causing missing type bugs. Refs BIL-969 --- static/gsApp/__fixtures__/previewData.tsx | 3 +- .../gsApp/components/promotionModal.spec.tsx | 3 +- static/gsApp/types/index.tsx | 117 +++++++++++------- static/gsApp/utils/billing.spec.tsx | 9 +- static/gsApp/utils/billing.tsx | 39 +++--- static/gsApp/utils/promotionUtils.tsx | 17 ++- .../views/amCheckout/components/cart.spec.tsx | 17 +-- .../views/amCheckout/components/cart.tsx | 13 +- .../components/checkoutSuccess.spec.tsx | 7 +- .../amCheckout/components/checkoutSuccess.tsx | 29 ++--- static/gsApp/views/amCheckout/index.tsx | 30 ++--- .../views/amCheckout/steps/contractSelect.tsx | 4 +- .../amCheckout/steps/reviewAndConfirm.tsx | 3 +- static/gsApp/views/amCheckout/utils.spec.tsx | 10 +- static/gsApp/views/amCheckout/utils.tsx | 41 +++--- .../gsApp/views/invoiceDetails/index.spec.tsx | 11 +- static/gsApp/views/invoiceDetails/index.tsx | 4 +- .../views/invoiceDetails/paymentForm.spec.tsx | 3 +- .../headerCards/nextBillCard.spec.tsx | 13 +- .../headerCards/nextBillCard.tsx | 4 +- .../subscriptionPage/paymentHistory.spec.tsx | 4 +- .../getsentry-test/fixtures/invoicePreview.ts | 9 +- 22 files changed, 192 insertions(+), 198 deletions(-) diff --git a/static/gsApp/__fixtures__/previewData.tsx b/static/gsApp/__fixtures__/previewData.tsx index 0e0f68113151a3..3b3889c0b0e247 100644 --- a/static/gsApp/__fixtures__/previewData.tsx +++ b/static/gsApp/__fixtures__/previewData.tsx @@ -1,5 +1,4 @@ import type {PreviewData} from 'getsentry/types'; -import {InvoiceItemType} from 'getsentry/types'; export function PreviewDataFixture(fields: Partial): PreviewData { return { @@ -11,7 +10,7 @@ export function PreviewDataFixture(fields: Partial): PreviewData { invoiceItems: [ { amount: 8900, - type: InvoiceItemType.SUBSCRIPTION, + type: 'subscription', description: 'Subscription to Business', data: {}, period_end: '', diff --git a/static/gsApp/components/promotionModal.spec.tsx b/static/gsApp/components/promotionModal.spec.tsx index 382369df7d4a9e..7a4b4fa2fb3fec 100644 --- a/static/gsApp/components/promotionModal.spec.tsx +++ b/static/gsApp/components/promotionModal.spec.tsx @@ -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(); @@ -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", diff --git a/static/gsApp/types/index.tsx b/static/gsApp/types/index.tsx index 5a22e78ab6e53d..a8c0df2c3507ac 100644 --- a/static/gsApp/types/index.tsx +++ b/static/gsApp/types/index.tsx @@ -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'; @@ -36,7 +37,7 @@ declare global { } namespace React { - interface DOMAttributes { + interface DOMAttributes<_T> { 'data-test-id'?: string; } } @@ -649,51 +650,75 @@ 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. + * Examples: "monitorSeats" -> "monitor_seats", "errors" -> "errors" + */ +type CamelToSnake = S extends `${infer T}${infer U}` + ? `${T extends Capitalize ? '_' : ''}${Lowercase}${CamelToSnake}` + : 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]; + +/** + * Static invoice item types that are not tied to data categories. + * These must be manually maintained but change infrequently. + */ +type StaticInvoiceItemType = + | '' // UNKNOWN + | 'subscription' + | 'ondemand' // Legacy: generic ondemand for AM1 plans + | 'subscription_credit' + | 'balance_change' + | 'cancellation_fee' + | 'attachments' // Legacy: AM1 plans + | 'transactions' // Legacy: AM1 plans + | 'sales_tax' + | 'recurring_discount' + | 'discount' + | 'credit_applied' // Deprecated: replaced by balance_change + | 'daily_events' // Deprecated + | 'reserved' // Deprecated: legacy name for reserved_events + | 'reserved_seer_budget'; // Special case: shared budget for seer_autofix and seer_scanner + +/** + * 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', diff --git a/static/gsApp/utils/billing.spec.tsx b/static/gsApp/utils/billing.spec.tsx index 05c906a7044387..0fcf27e501d82c 100644 --- a/static/gsApp/utils/billing.spec.tsx +++ b/static/gsApp/utils/billing.spec.tsx @@ -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, @@ -1097,7 +1098,7 @@ describe('getCreditApplied', () => { creditApplied: 100, invoiceItems: [ { - type: InvoiceItemType.SUBSCRIPTION_CREDIT, + type: 'subscription_credit', ...commonCreditProps, }, ], @@ -1108,7 +1109,7 @@ describe('getCreditApplied', () => { creditApplied: 100, invoiceItems: [ { - type: InvoiceItemType.BALANCE_CHANGE, + type: 'balance_change', ...commonCreditProps, }, ], @@ -1119,7 +1120,7 @@ describe('getCreditApplied', () => { creditApplied: 100, invoiceItems: [ { - type: InvoiceItemType.BALANCE_CHANGE, + type: 'balance_change', ...commonCreditProps, amount: -50, }, diff --git a/static/gsApp/utils/billing.tsx b/static/gsApp/utils/billing.tsx index 3a6bdc58c4d97e..75716ce847361e 100644 --- a/static/gsApp/utils/billing.tsx +++ b/static/gsApp/utils/billing.tsx @@ -20,21 +20,22 @@ import { } from 'getsentry/constants'; import { AddOnCategory, - InvoiceItemType, 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, @@ -804,12 +805,12 @@ 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, + 'subscription_credit', + 'credit_applied', // TODO(isabella): This is deprecated and replaced by BALANCE_CHANGE + 'discount', + 'recurring_discount', ].includes(item.type) || - (item.type === InvoiceItemType.BALANCE_CHANGE && item.amount < 0) + (item.type === 'balance_change' && item.amount < 0) ); } @@ -826,7 +827,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; @@ -843,8 +844,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) + ['cancellation_fee', 'sales_tax'].includes(item.type) || + (item.type === 'balance_change' && item.amount > 0) ); } diff --git a/static/gsApp/utils/promotionUtils.tsx b/static/gsApp/utils/promotionUtils.tsx index 88412534ecc0ab..cb1374d9366c82 100644 --- a/static/gsApp/utils/promotionUtils.tsx +++ b/static/gsApp/utils/promotionUtils.tsx @@ -10,14 +10,13 @@ import { openPromotionModal, openPromotionReminderModal, } from 'getsentry/actionCreators/modal'; -import { - InvoiceItemType, - type DiscountInfo, - type Plan, - type Promotion, - type PromotionClaimed, - type PromotionData, - type Subscription, +import type { + DiscountInfo, + Plan, + Promotion, + PromotionClaimed, + PromotionData, + Subscription, } from 'getsentry/types'; import {isBizPlanFamily} from 'getsentry/utils/billing'; import {createPromotionCheckQueryKey} from 'getsentry/utils/usePromotionTriggerCheck'; @@ -98,7 +97,7 @@ export function showSubscriptionDiscount({ discountInfo?.durationText && discountInfo.discountType === 'percentPoints' && activePlan.billingInterval === discountInfo.billingInterval && - discountInfo.creditCategory === InvoiceItemType.SUBSCRIPTION + discountInfo.creditCategory === 'subscription' ); } diff --git a/static/gsApp/views/amCheckout/components/cart.spec.tsx b/static/gsApp/views/amCheckout/components/cart.spec.tsx index 0dde16b67e4f7b..bf1da81fe12cc4 100644 --- a/static/gsApp/views/amCheckout/components/cart.spec.tsx +++ b/static/gsApp/views/amCheckout/components/cart.spec.tsx @@ -18,12 +18,7 @@ import {resetMockDate, setMockDate} from 'sentry-test/utils'; import {PAYG_BUSINESS_DEFAULT} from 'getsentry/constants'; import SubscriptionStore from 'getsentry/stores/subscriptionStore'; -import { - AddOnCategory, - InvoiceItemType, - OnDemandBudgetMode, - PlanTier, -} from 'getsentry/types'; +import {AddOnCategory, OnDemandBudgetMode, PlanTier} from 'getsentry/types'; import AMCheckout from 'getsentry/views/amCheckout/'; import Cart from 'getsentry/views/amCheckout/components/cart'; import {type CheckoutFormData} from 'getsentry/views/amCheckout/types'; @@ -280,13 +275,13 @@ describe('Cart', () => { { amount: 2_00, description: 'Tax', - type: InvoiceItemType.SALES_TAX, + type: 'sales_tax', }, { amount: 89_00, description: 'Business Plan', period_end: moment(MOCK_TODAY).add(1, 'day').format('YYYY-MM-DD'), - type: InvoiceItemType.SUBSCRIPTION, + type: 'subscription', }, ], }, @@ -329,7 +324,7 @@ describe('Cart', () => { amount: 89_00, description: 'Business Plan', period_end: moment(MOCK_TODAY).add(2, 'year').format('YYYY-MM-DD'), - type: InvoiceItemType.SUBSCRIPTION, + type: 'subscription', }, ], }, @@ -613,13 +608,13 @@ describe('Cart', () => { amount: 25_00, description: '500 pay-as-you-go replays', data: {quantity: 500}, - type: InvoiceItemType.ONDEMAND_REPLAYS, + type: 'ondemand_replays', }, { amount: 25_00, description: '50 GB pay-as-you-go attachments', data: {quantity: 53687091200}, - type: InvoiceItemType.ONDEMAND_ATTACHMENTS, + type: 'ondemand_attachments', }, ], }, diff --git a/static/gsApp/views/amCheckout/components/cart.tsx b/static/gsApp/views/amCheckout/components/cart.tsx index a9468fe1e29336..e89624a0dfae68 100644 --- a/static/gsApp/views/amCheckout/components/cart.tsx +++ b/static/gsApp/views/amCheckout/components/cart.tsx @@ -22,13 +22,8 @@ import useMedia from 'sentry/utils/useMedia'; import {PAYG_BUSINESS_DEFAULT, PAYG_TEAM_DEFAULT} from 'getsentry/constants'; import {useBillingDetails} from 'getsentry/hooks/useBillingDetails'; import {useStripeInstance} from 'getsentry/hooks/useStripeInstance'; -import { - InvoiceItemType, - OnDemandBudgetMode, - type Plan, - type PreviewData, - type Subscription, -} from 'getsentry/types'; +import {OnDemandBudgetMode} from 'getsentry/types'; +import type {Plan, PreviewData, Subscription} from 'getsentry/types'; import { displayBudgetName, formatReservedWithUnits, @@ -772,9 +767,7 @@ function Cart({ // for immediate changes, effectiveAt is the current day const {effectiveAt, atPeriodEnd, invoiceItems, billedAmount, proratedAmount} = data; - const planItem = invoiceItems.find( - item => item.type === InvoiceItemType.SUBSCRIPTION - ); + const planItem = invoiceItems.find(item => item.type === 'subscription'); const renewalDate = moment( planItem?.period_end ?? subscription.contractPeriodEnd ) diff --git a/static/gsApp/views/amCheckout/components/checkoutSuccess.spec.tsx b/static/gsApp/views/amCheckout/components/checkoutSuccess.spec.tsx index 3a9f276334191b..ca64b207b48fd9 100644 --- a/static/gsApp/views/amCheckout/components/checkoutSuccess.spec.tsx +++ b/static/gsApp/views/amCheckout/components/checkoutSuccess.spec.tsx @@ -6,7 +6,6 @@ import {render, screen} from 'sentry-test/reactTestingLibrary'; import {resetMockDate, setMockDate} from 'sentry-test/utils'; import {PreviewDataFixture} from 'getsentry/__fixtures__/previewData'; -import {InvoiceItemType} from 'getsentry/types'; import CheckoutSuccess from 'getsentry/views/amCheckout/components/checkoutSuccess'; describe('CheckoutSuccess', () => { @@ -73,7 +72,7 @@ describe('CheckoutSuccess', () => { const invoiceWithOnDemand = InvoiceFixture({ items: [ { - type: InvoiceItemType.SUBSCRIPTION, + type: 'subscription', description: 'Subscription to Team Plan', amount: 31200, data: {quantity: null}, @@ -81,7 +80,7 @@ describe('CheckoutSuccess', () => { periodEnd: '2026-01-01T00:00:00Z', }, { - type: InvoiceItemType.ONDEMAND_ERRORS, + type: 'ondemand_errors', description: '4,901,066 pay-as-you-go errors', amount: 94022, data: {quantity: 4901066}, @@ -89,7 +88,7 @@ describe('CheckoutSuccess', () => { periodEnd: '2026-01-01T00:00:00Z', }, { - type: InvoiceItemType.ONDEMAND_MONITOR_SEATS, + type: 'ondemand_monitor_seats', description: '2 pay-as-you-go cron monitors', amount: 156, data: {quantity: 2}, diff --git a/static/gsApp/views/amCheckout/components/checkoutSuccess.tsx b/static/gsApp/views/amCheckout/components/checkoutSuccess.tsx index a3ae4d71211d73..d3038618ce1744 100644 --- a/static/gsApp/views/amCheckout/components/checkoutSuccess.tsx +++ b/static/gsApp/views/amCheckout/components/checkoutSuccess.tsx @@ -17,14 +17,13 @@ import {defined} from 'sentry/utils'; import {useFeedbackForm} from 'sentry/utils/useFeedbackForm'; import {GIGABYTE} from 'getsentry/constants'; -import { - InvoiceItemType, - type Charge, - type Invoice, - type InvoiceItem, - type Plan, - type PreviewData, - type PreviewInvoiceItem, +import type { + Charge, + Invoice, + InvoiceItem, + Plan, + PreviewData, + PreviewInvoiceItem, } from 'getsentry/types'; import { displayBudgetName, @@ -217,7 +216,7 @@ function ScheduledChanges({ })} {fees.map(item => { const adjustedAmount = - item.type === InvoiceItemType.BALANCE_CHANGE ? item.amount * -1 : item.amount; + item.type === 'balance_change' ? item.amount * -1 : item.amount; return ( { const adjustedAmount = - item.type === InvoiceItemType.BALANCE_CHANGE ? item.amount * -1 : item.amount; + item.type === 'balance_change' ? item.amount * -1 : item.amount; return ( item.type === InvoiceItemType.SUBSCRIPTION); + const planItem = invoiceItems.find(item => item.type === 'subscription'); const reservedVolume = invoiceItems.filter( item => item.type.startsWith('reserved_') && !item.type.endsWith('_budget') ); // TODO(prevent): This needs to be updated once we determine how to display Prevent enablement and PAYG changes on this page - const products = invoiceItems.filter( - item => item.type === InvoiceItemType.RESERVED_SEER_BUDGET - ); + const products = invoiceItems.filter(item => item.type === 'reserved_seer_budget'); const onDemandItems = getOnDemandItems({invoiceItems}); const fees = getFees({invoiceItems}); const credits = getCredits({invoiceItems}); - // TODO(isabella): PreviewData never has the InvoiceItemType.BALANCE_CHANGE type - // and instead populates creditApplied with the value of the InvoiceItemType.CREDIT_APPLIED type + // TODO(isabella): PreviewData never has the 'balance_change' type + // and instead populates creditApplied with the value of the 'credit_applied' type // this is a temporary fix to ensure we only display CreditApplied if it's not already in the credits array const creditApplied = getCreditApplied({ creditApplied: data?.creditApplied ?? 0, diff --git a/static/gsApp/views/amCheckout/index.tsx b/static/gsApp/views/amCheckout/index.tsx index 4600bc73b59263..dc08fc8d77f592 100644 --- a/static/gsApp/views/amCheckout/index.tsx +++ b/static/gsApp/views/amCheckout/index.tsx @@ -40,21 +40,17 @@ import { PAYG_BUSINESS_DEFAULT, PAYG_TEAM_DEFAULT, } from 'getsentry/constants'; -import { - CheckoutType, - InvoiceItemType, - OnDemandBudgetMode, - PlanName, - PlanTier, - type BillingConfig, - type CheckoutAddOns, - type EventBucket, - type Invoice, - type OnDemandBudgets, - type Plan, - type PreviewData, - type PromotionData, - type Subscription, +import {CheckoutType, OnDemandBudgetMode, PlanName, PlanTier} from 'getsentry/types'; +import type { + BillingConfig, + CheckoutAddOns, + EventBucket, + Invoice, + OnDemandBudgets, + Plan, + PreviewData, + PromotionData, + Subscription, } from 'getsentry/types'; import { hasActiveVCFeature, @@ -811,9 +807,7 @@ class AMCheckout extends Component { } if (isSubmitted && isNewCheckout) { - const purchasedPlanItem = invoice?.items.find( - item => item.type === InvoiceItemType.SUBSCRIPTION - ); + const purchasedPlanItem = invoice?.items.find(item => item.type === 'subscription'); const basePlan = purchasedPlanItem ? this.getPlan(purchasedPlanItem.data.plan) : this.getPlan(formData.plan); diff --git a/static/gsApp/views/amCheckout/steps/contractSelect.tsx b/static/gsApp/views/amCheckout/steps/contractSelect.tsx index 42a42745c8eb07..a52cc4c568390d 100644 --- a/static/gsApp/views/amCheckout/steps/contractSelect.tsx +++ b/static/gsApp/views/amCheckout/steps/contractSelect.tsx @@ -10,7 +10,7 @@ import {t, tct} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import {ANNUAL, MONTHLY} from 'getsentry/constants'; -import {InvoiceItemType, type Plan} from 'getsentry/types'; +import type {InvoiceItemType, Plan} from 'getsentry/types'; import PlanSelectRow from 'getsentry/views/amCheckout/components/planSelectRow'; import StepHeader from 'getsentry/views/amCheckout/components/stepHeader'; import type {StepProps} from 'getsentry/views/amCheckout/types'; @@ -99,7 +99,7 @@ class ContractSelect extends Component { } = { // default to subscription discount // since we need a credit category to calculate the price after discount - creditCategory: InvoiceItemType.SUBSCRIPTION, + creditCategory: 'subscription', }; if ( promotion?.showDiscountInfo && diff --git a/static/gsApp/views/amCheckout/steps/reviewAndConfirm.tsx b/static/gsApp/views/amCheckout/steps/reviewAndConfirm.tsx index ff24e219f723e4..4cf8f8f36cb3b4 100644 --- a/static/gsApp/views/amCheckout/steps/reviewAndConfirm.tsx +++ b/static/gsApp/views/amCheckout/steps/reviewAndConfirm.tsx @@ -17,7 +17,6 @@ import TextBlock from 'sentry/views/settings/components/text/textBlock'; import {useStripeInstance} from 'getsentry/hooks/useStripeInstance'; import type {PreviewData, Subscription} from 'getsentry/types'; -import {InvoiceItemType} from 'getsentry/types'; import {hasPartnerMigrationFeature} from 'getsentry/utils/billing'; import StepHeader from 'getsentry/views/amCheckout/components/stepHeader'; import type {StepPropsWithApi} from 'getsentry/views/amCheckout/types'; @@ -252,7 +251,7 @@ function ReviewAndConfirmItems({previewData}: Pick) { idx ) => { const price = displayPrice({cents: amount}); - const showDates = type === InvoiceItemType.SUBSCRIPTION; + const showDates = type === 'subscription'; return ( diff --git a/static/gsApp/views/amCheckout/utils.spec.tsx b/static/gsApp/views/amCheckout/utils.spec.tsx index fdadfd98f71282..e64ee9464557fe 100644 --- a/static/gsApp/views/amCheckout/utils.spec.tsx +++ b/static/gsApp/views/amCheckout/utils.spec.tsx @@ -1,6 +1,8 @@ import {PlanDetailsLookupFixture} from 'getsentry-test/fixtures/planDetailsLookup'; -import {AddOnCategory, InvoiceItemType, PlanTier} from 'getsentry/types'; +import {DataCategory} from 'sentry/types/core'; + +import {AddOnCategory, PlanTier} from 'getsentry/types'; import * as utils from 'getsentry/views/amCheckout/utils'; import {getCheckoutAPIData} from 'getsentry/views/amCheckout/utils'; @@ -140,7 +142,7 @@ describe('utils', () => { basePrice: 1000, amount: 10 * 100, discountType: 'percentPoints', - creditCategory: InvoiceItemType.SUBSCRIPTION, + creditCategory: 'subscription', }) ).toBe(900); expect( @@ -148,7 +150,7 @@ describe('utils', () => { basePrice: 8900, amount: 40 * 100, discountType: 'percentPoints', - creditCategory: InvoiceItemType.SUBSCRIPTION, + creditCategory: 'subscription', }) ).toBe(5340); expect( @@ -156,7 +158,7 @@ describe('utils', () => { basePrice: 10000, amount: 1000, discountType: 'amountCents', - creditCategory: InvoiceItemType.SUBSCRIPTION, + creditCategory: 'subscription', }) ).toBe(9000); }); diff --git a/static/gsApp/views/amCheckout/utils.tsx b/static/gsApp/views/amCheckout/utils.tsx index 9a8a9891a23945..25d3196b8a9e0b 100644 --- a/static/gsApp/views/amCheckout/utils.tsx +++ b/static/gsApp/views/amCheckout/utils.tsx @@ -1,5 +1,5 @@ import * as Sentry from '@sentry/react'; -import {type PaymentIntentResult, type Stripe} from '@stripe/stripe-js'; +import type {PaymentIntentResult, Stripe} from '@stripe/stripe-js'; import camelCase from 'lodash/camelCase'; import moment from 'moment-timezone'; @@ -11,7 +11,7 @@ import { import {fetchOrganizationDetails} from 'sentry/actionCreators/organization'; import {Client} from 'sentry/api'; import {t} from 'sentry/locale'; -import {DataCategory} from 'sentry/types/core'; +import type {DataCategory} from 'sentry/types/core'; import type {Organization} from 'sentry/types/organization'; import {browserHistory} from 'sentry/utils/browserHistory'; import {useMutation} from 'sentry/utils/queryClient'; @@ -27,18 +27,16 @@ import { SUPPORTED_TIERS, } from 'getsentry/constants'; import SubscriptionStore from 'getsentry/stores/subscriptionStore'; -import { - AddOnCategory, +import {AddOnCategory, PlanTier, ReservedBudgetCategoryType} from 'getsentry/types'; +import type { + BillingDetails, + CheckoutAddOns, + EventBucket, InvoiceItemType, - PlanTier, - type BillingDetails, - type CheckoutAddOns, - type EventBucket, - type OnDemandBudgets, - type Plan, - type PreviewData, - type ReservedBudgetCategoryType, - type Subscription, + OnDemandBudgets, + Plan, + PreviewData, + Subscription, } from 'getsentry/types'; import { getAmPlanTier, @@ -53,10 +51,10 @@ import { import trackGetsentryAnalytics from 'getsentry/utils/trackGetsentryAnalytics'; import trackMarketingEvent from 'getsentry/utils/trackMarketingEvent'; import type {State as CheckoutState} from 'getsentry/views/amCheckout/'; -import { - type CheckoutAPIData, - type CheckoutFormData, - type PlanContent, +import type { + CheckoutAPIData, + CheckoutFormData, + PlanContent, } from 'getsentry/views/amCheckout/types'; import { normalizeOnDemandBudget, @@ -325,10 +323,7 @@ export function getDiscountedPrice({ creditCategory, }: DiscountedPriceProps): number { let price = basePrice; - if ( - discountType === 'percentPoints' && - creditCategory === InvoiceItemType.SUBSCRIPTION - ) { + if (discountType === 'percentPoints' && creditCategory === 'subscription') { const discount = (basePrice * amount) / 10000; price = basePrice - discount; } else if (discountType === 'amountCents') { @@ -877,9 +872,9 @@ export function invoiceItemTypeToDataCategory( export function invoiceItemTypeToAddOn(type: InvoiceItemType): AddOnCategory | null { switch (type) { - case InvoiceItemType.RESERVED_SEER_BUDGET: + case 'reserved_seer_budget': return AddOnCategory.SEER; - case InvoiceItemType.RESERVED_PREVENT_USERS: + case 'reserved_prevent_users': return AddOnCategory.PREVENT; default: return null; diff --git a/static/gsApp/views/invoiceDetails/index.spec.tsx b/static/gsApp/views/invoiceDetails/index.spec.tsx index f3b762f855277f..a34b17adaa9999 100644 --- a/static/gsApp/views/invoiceDetails/index.spec.tsx +++ b/static/gsApp/views/invoiceDetails/index.spec.tsx @@ -12,7 +12,6 @@ import { import {PlanFixture} from 'getsentry/__fixtures__/plan'; import SubscriptionStore from 'getsentry/stores/subscriptionStore'; -import {InvoiceItemType} from 'getsentry/types'; import InvoiceDetails from 'getsentry/views/invoiceDetails'; describe('InvoiceDetails', () => { @@ -22,7 +21,7 @@ describe('InvoiceDetails', () => { dateCreated: '2021-09-20T22:33:38.042Z', items: [ { - type: InvoiceItemType.SUBSCRIPTION, + type: 'subscription', description: 'Subscription to Business', amount: 8900, periodEnd: '2021-10-21', @@ -40,7 +39,7 @@ describe('InvoiceDetails', () => { creditApplied: 500, items: [ { - type: InvoiceItemType.SUBSCRIPTION, + type: 'subscription', description: 'Subscription to Business', amount: 8900, periodEnd: '2021-10-21', @@ -48,7 +47,7 @@ describe('InvoiceDetails', () => { data: {}, }, { - type: InvoiceItemType.CREDIT_APPLIED, + type: 'credit_applied', description: 'Credit applied', amount: 500, periodEnd: '2021-10-21', @@ -163,7 +162,7 @@ describe('InvoiceDetails', () => { isPaid: false, items: [ { - type: InvoiceItemType.SUBSCRIPTION, + type: 'subscription', description: 'Subscription to Business', amount: 8900, periodEnd: '2021-10-21', @@ -235,7 +234,7 @@ describe('InvoiceDetails', () => { isPaid: false, items: [ { - type: InvoiceItemType.SUBSCRIPTION, + type: 'subscription', description: 'Subscription to Business', amount: 8900, periodEnd: '2021-10-21', diff --git a/static/gsApp/views/invoiceDetails/index.tsx b/static/gsApp/views/invoiceDetails/index.tsx index 86fc99ba1f9ac4..a5e54d93e1f923 100644 --- a/static/gsApp/views/invoiceDetails/index.tsx +++ b/static/gsApp/views/invoiceDetails/index.tsx @@ -15,8 +15,8 @@ import {keepPreviousData, useApiQuery} from 'sentry/utils/queryClient'; import useOrganization from 'sentry/utils/useOrganization'; import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader'; +import {InvoiceStatus} from 'getsentry/types'; import type {BillingDetails, Invoice} from 'getsentry/types'; -import {InvoiceItemType, InvoiceStatus} from 'getsentry/types'; import {getTaxFieldInfo} from 'getsentry/utils/salesTax'; import {displayPriceWithCents} from 'getsentry/views/amCheckout/utils'; import SubscriptionPageContainer from 'getsentry/views/subscriptionPage/components/subscriptionPageContainer'; @@ -230,7 +230,7 @@ function InvoiceDetailsContents({billingDetails, invoice}: ContentsProps) { {invoice.items.map((item, i) => { - if (item.type === InvoiceItemType.SUBSCRIPTION) { + if (item.type === 'subscription') { return ( diff --git a/static/gsApp/views/invoiceDetails/paymentForm.spec.tsx b/static/gsApp/views/invoiceDetails/paymentForm.spec.tsx index 2a87e78aa445ae..ee759a2e3bbc4a 100644 --- a/static/gsApp/views/invoiceDetails/paymentForm.spec.tsx +++ b/static/gsApp/views/invoiceDetails/paymentForm.spec.tsx @@ -7,7 +7,6 @@ import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrar import {ModalBody} from 'sentry/components/globalModal/components'; import SubscriptionStore from 'getsentry/stores/subscriptionStore'; -import {InvoiceItemType} from 'getsentry/types'; import InvoiceDetailsPaymentForm from 'getsentry/views/invoiceDetails/paymentForm'; // Stripe mocks handled by global setup.ts @@ -18,7 +17,7 @@ describe('InvoiceDetails > Payment Form', () => { { items: [ { - type: InvoiceItemType.SUBSCRIPTION, + type: 'subscription', description: 'Subscription to Business', amount: 8900, periodEnd: '2021-10-21', diff --git a/static/gsApp/views/subscriptionPage/headerCards/nextBillCard.spec.tsx b/static/gsApp/views/subscriptionPage/headerCards/nextBillCard.spec.tsx index 044048d090e513..12845e29eb1699 100644 --- a/static/gsApp/views/subscriptionPage/headerCards/nextBillCard.spec.tsx +++ b/static/gsApp/views/subscriptionPage/headerCards/nextBillCard.spec.tsx @@ -5,7 +5,6 @@ import {render, screen} from 'sentry-test/reactTestingLibrary'; import {resetMockDate, setMockDate} from 'sentry-test/utils'; import {PreviewDataFixture} from 'getsentry/__fixtures__/previewData'; -import {InvoiceItemType} from 'getsentry/types'; import NextBillCard from 'getsentry/views/subscriptionPage/headerCards/nextBillCard'; describe('NextBillCard', () => { @@ -49,7 +48,7 @@ describe('NextBillCard', () => { invoiceItems: [ { amount: 89_00, - type: InvoiceItemType.SUBSCRIPTION, + type: 'subscription', description: 'Subscription to Business', data: { plan: 'am3_business', @@ -59,7 +58,7 @@ describe('NextBillCard', () => { }, { amount: 7_50, - type: InvoiceItemType.RESERVED_REPLAYS, + type: 'reserved_replays', data: {}, period_start: '', period_end: '', @@ -67,7 +66,7 @@ describe('NextBillCard', () => { }, { amount: 5_00, - type: InvoiceItemType.RESERVED_ATTACHMENTS, + type: 'reserved_attachments', data: {}, period_start: '', period_end: '', @@ -75,7 +74,7 @@ describe('NextBillCard', () => { }, { amount: 1_00, - type: InvoiceItemType.ONDEMAND_ERRORS, + type: 'ondemand_errors', data: {}, period_start: '', period_end: '', @@ -83,7 +82,7 @@ describe('NextBillCard', () => { }, { amount: 11_00, - type: InvoiceItemType.ONDEMAND_REPLAYS, + type: 'ondemand_replays', data: {}, period_start: '', period_end: '', @@ -91,7 +90,7 @@ describe('NextBillCard', () => { }, { amount: 20_00, - type: InvoiceItemType.SALES_TAX, + type: 'sales_tax', data: {}, period_start: '', period_end: '', diff --git a/static/gsApp/views/subscriptionPage/headerCards/nextBillCard.tsx b/static/gsApp/views/subscriptionPage/headerCards/nextBillCard.tsx index e10bbc560ccc55..36f5cf6a4c113b 100644 --- a/static/gsApp/views/subscriptionPage/headerCards/nextBillCard.tsx +++ b/static/gsApp/views/subscriptionPage/headerCards/nextBillCard.tsx @@ -10,7 +10,7 @@ import type {Organization} from 'sentry/types/organization'; import getDaysSinceDate from 'sentry/utils/getDaysSinceDate'; import {useApiQuery} from 'sentry/utils/queryClient'; -import {InvoiceItemType, type PreviewData, type Subscription} from 'getsentry/types'; +import type {PreviewData, Subscription} from 'getsentry/types'; import { displayBudgetName, getCreditApplied, @@ -42,7 +42,7 @@ function NextBillCard({ // recurring fees, PAYG, and credits are grouped together // only additional fees (ie. taxes) are listed individually const invoiceItems = nextBill?.invoiceItems ?? []; - const planItem = invoiceItems.find(item => item.type === InvoiceItemType.SUBSCRIPTION); + const planItem = invoiceItems.find(item => item.type === 'subscription'); const plan = planItem?.data.plan; const isAnnualPlan = plan?.endsWith('_auf'); const reservedTotal = diff --git a/static/gsApp/views/subscriptionPage/paymentHistory.spec.tsx b/static/gsApp/views/subscriptionPage/paymentHistory.spec.tsx index da606a56ff8213..5ea411f6a24a53 100644 --- a/static/gsApp/views/subscriptionPage/paymentHistory.spec.tsx +++ b/static/gsApp/views/subscriptionPage/paymentHistory.spec.tsx @@ -7,7 +7,7 @@ import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription'; import {render, screen} from 'sentry-test/reactTestingLibrary'; import SubscriptionStore from 'getsentry/stores/subscriptionStore'; -import {InvoiceItemType, PlanTier} from 'getsentry/types'; +import {PlanTier} from 'getsentry/types'; import PaymentHistory from 'getsentry/views/subscriptionPage/paymentHistory'; describe('Subscription > PaymentHistory', () => { @@ -57,7 +57,7 @@ describe('Subscription > PaymentHistory', () => { dateCreated: '2021-09-20T22:33:38.042Z', items: [ { - type: InvoiceItemType.SUBSCRIPTION, + type: 'subscription', description: 'Subscription to Business', amount: 8900, periodEnd: '2021-10-21', diff --git a/tests/js/getsentry-test/fixtures/invoicePreview.ts b/tests/js/getsentry-test/fixtures/invoicePreview.ts index d28366bc52245a..47805c2b9f4d48 100644 --- a/tests/js/getsentry-test/fixtures/invoicePreview.ts +++ b/tests/js/getsentry-test/fixtures/invoicePreview.ts @@ -1,5 +1,4 @@ import type {PreviewData} from 'getsentry/types'; -import {InvoiceItemType} from 'getsentry/types'; export function InvoicePreviewFixture(params: Partial = {}): PreviewData { return { @@ -18,7 +17,7 @@ export function InvoicePreviewFixture(params: Partial = {}): Previe description: 'Subscription to Business', period_end: '2020-07-07', period_start: '2020-06-08', - type: InvoiceItemType.SUBSCRIPTION, + type: 'subscription', }, { amount: 0, @@ -26,7 +25,7 @@ export function InvoicePreviewFixture(params: Partial = {}): Previe description: '50,000 prepaid errors', period_end: '2020-07-07', period_start: '2020-06-08', - type: InvoiceItemType.RESERVED_ERRORS, + type: 'reserved_errors', }, { amount: 0, @@ -34,7 +33,7 @@ export function InvoicePreviewFixture(params: Partial = {}): Previe description: '150,000 prepaid transactions', period_end: '2020-07-07', period_start: '2020-06-08', - type: InvoiceItemType.RESERVED_TRANSACTIONS, + type: 'reserved_transactions', }, { amount: 0, @@ -42,7 +41,7 @@ export function InvoicePreviewFixture(params: Partial = {}): Previe description: '5 GB prepaid attachments', period_end: '2020-07-07', period_start: '2020-06-08', - type: InvoiceItemType.RESERVED_ATTACHMENTS, + type: 'reserved_attachments', }, ], ...params, From 5f799381caa9dadcf5aba188f6ba6d33a0f701de Mon Sep 17 00:00:00 2001 From: Alberto Leal Date: Wed, 19 Nov 2025 14:49:36 -0500 Subject: [PATCH 2/8] fix(billing): Add missing reserved_seer_users and activated_seer_users types Adds two special-case invoice item types that were missing from the initial dynamic type implementation: - reserved_seer_users: Used for PREVENT_USER category reserved billing. Backend maps PREVENT_USER to this instead of reserved_prevent_users due to naming conventions that will be unified later. - activated_seer_users: Used for activation-based billing model for Prevent users. This is a newer billing model that works similarly to PAYG but is tracked separately. These types don't follow the standard category naming pattern due to backend special-case logic in invoice_item_type.py for_reserved() and to_activated_attr() methods. Refs BIL-969 --- static/gsApp/types/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/static/gsApp/types/index.tsx b/static/gsApp/types/index.tsx index a8c0df2c3507ac..aa8e294673ff5d 100644 --- a/static/gsApp/types/index.tsx +++ b/static/gsApp/types/index.tsx @@ -705,7 +705,9 @@ type StaticInvoiceItemType = | 'credit_applied' // Deprecated: replaced by balance_change | 'daily_events' // Deprecated | 'reserved' // Deprecated: legacy name for reserved_events - | 'reserved_seer_budget'; // Special case: shared budget for seer_autofix and seer_scanner + | '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) /** * Complete invoice item type union. From 706cb7f7dc6e0cd3d96af24f83f969c4c95f242d Mon Sep 17 00:00:00 2001 From: Alberto Leal Date: Wed, 19 Nov 2025 15:45:24 -0500 Subject: [PATCH 3/8] refactor(billing): Organize invoice item types into semantic sub-categories - Created const arrays for credit, fee, seer, and legacy invoice types - Updated getCredits and getFees to use const arrays instead of hardcoded strings - Composed StaticInvoiceItemType from organized sub-types - Enables runtime usage in filters while maintaining type safety This eliminates duplication between type definitions and runtime values, providing a single source of truth for invoice item type categorization. --- static/gsApp/types/index.tsx | 86 +++++++++++++++++++++++++++------- static/gsApp/utils/billing.tsx | 11 ++--- 2 files changed, 73 insertions(+), 24 deletions(-) diff --git a/static/gsApp/types/index.tsx b/static/gsApp/types/index.tsx index aa8e294673ff5d..832cd21b8fe3af 100644 --- a/static/gsApp/types/index.tsx +++ b/static/gsApp/types/index.tsx @@ -686,28 +686,80 @@ type ReservedInvoiceItemType = { : never; }[keyof typeof DATA_CATEGORY_INFO]; +/** + * 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; + +export 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; + +export type FeeInvoiceItemType = (typeof FEE_INVOICE_ITEM_TYPES)[number]; + +/** + * Seer/AI-related invoice item types (special billing for AI features). + * Exported as const array to enable runtime usage in filters. + */ +export 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; + +export type SeerInvoiceItemType = (typeof SEER_INVOICE_ITEM_TYPES)[number]; + +/** + * Legacy/deprecated invoice item types (AM1 plans and old formats). + * Exported as const array to enable runtime usage in filters. + */ +export 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; + +export type LegacyInvoiceItemType = (typeof LEGACY_INVOICE_ITEM_TYPES)[number]; + +/** + * Core subscription type. + */ +export type SubscriptionInvoiceItemType = 'subscription'; + +/** + * Balance change can be both credit (negative) or fee (positive). + */ +export type BalanceChangeInvoiceItemType = 'balance_change'; + +/** + * Unknown invoice item type (empty string). + */ +export type UnknownInvoiceItemType = ''; + /** * Static invoice item types that are not tied to data categories. * These must be manually maintained but change infrequently. */ type StaticInvoiceItemType = - | '' // UNKNOWN - | 'subscription' - | 'ondemand' // Legacy: generic ondemand for AM1 plans - | 'subscription_credit' - | 'balance_change' - | 'cancellation_fee' - | 'attachments' // Legacy: AM1 plans - | 'transactions' // Legacy: AM1 plans - | 'sales_tax' - | 'recurring_discount' - | 'discount' - | 'credit_applied' // Deprecated: replaced by balance_change - | 'daily_events' // Deprecated - | 'reserved' // Deprecated: legacy name for reserved_events - | '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) + | UnknownInvoiceItemType + | SubscriptionInvoiceItemType + | BalanceChangeInvoiceItemType + | CreditInvoiceItemType + | FeeInvoiceItemType + | SeerInvoiceItemType + | LegacyInvoiceItemType; /** * Complete invoice item type union. diff --git a/static/gsApp/utils/billing.tsx b/static/gsApp/utils/billing.tsx index 75716ce847361e..2df749265d4c28 100644 --- a/static/gsApp/utils/billing.tsx +++ b/static/gsApp/utils/billing.tsx @@ -20,6 +20,8 @@ import { } from 'getsentry/constants'; import { AddOnCategory, + CREDIT_INVOICE_ITEM_TYPES, + FEE_INVOICE_ITEM_TYPES, OnDemandBudgetMode, PlanName, PlanTier, @@ -804,12 +806,7 @@ export function getCredits({ }) { return invoiceItems.filter( item => - [ - 'subscription_credit', - 'credit_applied', // TODO(isabella): This is deprecated and replaced by BALANCE_CHANGE - 'discount', - 'recurring_discount', - ].includes(item.type) || + CREDIT_INVOICE_ITEM_TYPES.includes(item.type as any) || (item.type === 'balance_change' && item.amount < 0) ); } @@ -844,7 +841,7 @@ export function getFees({ }) { return invoiceItems.filter( item => - ['cancellation_fee', 'sales_tax'].includes(item.type) || + FEE_INVOICE_ITEM_TYPES.includes(item.type as any) || (item.type === 'balance_change' && item.amount > 0) ); } From 6f2985e6a24a60526be10d67f89357c081861891 Mon Sep 17 00:00:00 2001 From: Alberto Leal Date: Wed, 19 Nov 2025 16:39:08 -0500 Subject: [PATCH 4/8] fix(billing): Fix CamelToSnake to handle consecutive capitals correctly The previous implementation incorrectly handled consecutive capital letters by inserting underscores between each letter. For example, 'profileDurationUI' was converted to 'profile_duration_u_i' instead of 'profile_duration_ui'. The fix tracks the previous character's case state and looks ahead at the next character to determine if we're at a word boundary: - Consecutive capitals (like 'UI') are kept together without underscores - Transitions from lowercase to uppercase still insert underscores - Transitions from uppercase to lowercase insert underscores before the capital This ensures backend type alignment: - ondemand_profile_duration_ui (was: ondemand_profile_duration_u_i) - reserved_profile_duration_ui (was: reserved_profile_duration_u_i) --- static/gsApp/types/index.tsx | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/static/gsApp/types/index.tsx b/static/gsApp/types/index.tsx index 832cd21b8fe3af..2da841ef17f58e 100644 --- a/static/gsApp/types/index.tsx +++ b/static/gsApp/types/index.tsx @@ -651,11 +651,25 @@ export type InvoiceItem = BaseInvoiceItem & { }; /** - * Converts camelCase string to snake_case. + * 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 `${infer T}${infer U}` - ? `${T extends Capitalize ? '_' : ''}${Lowercase}${CamelToSnake}` +type CamelToSnake< + S extends string, + Prev extends 'lower' | 'upper' | '' = '', +> = S extends `${infer First}${infer Rest}` + ? First extends Lowercase + ? `${First}${CamelToSnake}` + : First extends Uppercase + ? Rest extends '' + ? `${Prev extends '' ? '' : Prev extends 'lower' ? '_' : ''}${Lowercase}` + : Rest extends `${infer Next}${infer _Tail}` + ? Next extends Lowercase + ? `${Prev extends '' ? '' : '_'}${Lowercase}${CamelToSnake}` + : `${Prev extends 'lower' ? '_' : ''}${Lowercase}${CamelToSnake}` + : never + : `${First}${CamelToSnake}` : S; /** From 14d1a546b6e657c064a821dba88cbee1e269090a Mon Sep 17 00:00:00 2001 From: Alberto Leal Date: Wed, 19 Nov 2025 18:23:02 -0500 Subject: [PATCH 5/8] fix(eslint): Remove unnecessary type assertions Removed unnecessary type assertions that were flagged by ESLint: - inputField.tsx: Removed `as any` from e.target.value - numberField.tsx: Removed `as any` from e.target.value - onboarding.tsx: Removed `as number` assertions from stepper onClick handler These assertions were unnecessary because TypeScript already infers the correct types from the event handlers and component props. --- static/app/components/forms/fields/inputField.tsx | 2 +- static/app/components/forms/fields/numberField.tsx | 2 +- static/app/views/onboarding/onboarding.tsx | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/static/app/components/forms/fields/inputField.tsx b/static/app/components/forms/fields/inputField.tsx index d09c4ef6bfff8f..ade21f2737e952 100644 --- a/static/app/components/forms/fields/inputField.tsx +++ b/static/app/components/forms/fields/inputField.tsx @@ -49,7 +49,7 @@ function defaultField({ onBlur(e.target.value, e)} - onKeyDown={e => onKeyDown((e.target as any).value, e)} + onKeyDown={e => onKeyDown(e.target.value, e)} onChange={e => onChange(e.target.value, e)} name={name} {...rest} diff --git a/static/app/components/forms/fields/numberField.tsx b/static/app/components/forms/fields/numberField.tsx index abba5ad7ee22f2..8fab883553b577 100644 --- a/static/app/components/forms/fields/numberField.tsx +++ b/static/app/components/forms/fields/numberField.tsx @@ -62,7 +62,7 @@ function createFieldWithSuffix({suffix}: {suffix: React.ReactNode}) { onBlur(e.target.value, e)} - onKeyDown={e => onKeyDown((e.target as any).value, e)} + onKeyDown={e => onKeyDown(e.target.value, e)} onChange={e => onChange(e.target.value, e)} name={name} {...rest} diff --git a/static/app/views/onboarding/onboarding.tsx b/static/app/views/onboarding/onboarding.tsx index 2f5781ff2df11d..9a1486fbec7083 100644 --- a/static/app/views/onboarding/onboarding.tsx +++ b/static/app/views/onboarding/onboarding.tsx @@ -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]!); }} /> )} From 42341fa7b8b22e2017b18f3bc35d2395969a4cf2 Mon Sep 17 00:00:00 2001 From: Alberto Leal Date: Wed, 19 Nov 2025 19:25:59 -0500 Subject: [PATCH 6/8] fix(knip): Remove unused exports from invoice item types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed export keywords from unused constants and types: - SEER_INVOICE_ITEM_TYPES → _SEER_INVOICE_ITEM_TYPES (only used for type derivation) - LEGACY_INVOICE_ITEM_TYPES → _LEGACY_INVOICE_ITEM_TYPES (only used for type derivation) - CreditInvoiceItemType, FeeInvoiceItemType, etc. (only used internally to compose StaticInvoiceItemType) Only CREDIT_INVOICE_ITEM_TYPES and FEE_INVOICE_ITEM_TYPES remain exported as they are actively used in billing.tsx for runtime filtering. --- static/gsApp/types/index.tsx | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/static/gsApp/types/index.tsx b/static/gsApp/types/index.tsx index 2da841ef17f58e..89138682fa5885 100644 --- a/static/gsApp/types/index.tsx +++ b/static/gsApp/types/index.tsx @@ -711,7 +711,7 @@ export const CREDIT_INVOICE_ITEM_TYPES = [ 'credit_applied', // Deprecated: replaced by balance_change ] as const; -export type CreditInvoiceItemType = (typeof CREDIT_INVOICE_ITEM_TYPES)[number]; +type CreditInvoiceItemType = (typeof CREDIT_INVOICE_ITEM_TYPES)[number]; /** * Fee-related invoice item types (taxes, penalties). @@ -719,25 +719,23 @@ export type CreditInvoiceItemType = (typeof CREDIT_INVOICE_ITEM_TYPES)[number]; */ export const FEE_INVOICE_ITEM_TYPES = ['sales_tax', 'cancellation_fee'] as const; -export type FeeInvoiceItemType = (typeof FEE_INVOICE_ITEM_TYPES)[number]; +type FeeInvoiceItemType = (typeof FEE_INVOICE_ITEM_TYPES)[number]; /** * Seer/AI-related invoice item types (special billing for AI features). - * Exported as const array to enable runtime usage in filters. */ -export const SEER_INVOICE_ITEM_TYPES = [ +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; -export type SeerInvoiceItemType = (typeof SEER_INVOICE_ITEM_TYPES)[number]; +type SeerInvoiceItemType = (typeof _SEER_INVOICE_ITEM_TYPES)[number]; /** * Legacy/deprecated invoice item types (AM1 plans and old formats). - * Exported as const array to enable runtime usage in filters. */ -export const LEGACY_INVOICE_ITEM_TYPES = [ +const _LEGACY_INVOICE_ITEM_TYPES = [ 'ondemand', // Legacy: generic ondemand for AM1 plans 'attachments', // Legacy: AM1 plans 'transactions', // Legacy: AM1 plans @@ -745,22 +743,22 @@ export const LEGACY_INVOICE_ITEM_TYPES = [ 'reserved', // Deprecated: legacy name for reserved_events ] as const; -export type LegacyInvoiceItemType = (typeof LEGACY_INVOICE_ITEM_TYPES)[number]; +type LegacyInvoiceItemType = (typeof _LEGACY_INVOICE_ITEM_TYPES)[number]; /** * Core subscription type. */ -export type SubscriptionInvoiceItemType = 'subscription'; +type SubscriptionInvoiceItemType = 'subscription'; /** * Balance change can be both credit (negative) or fee (positive). */ -export type BalanceChangeInvoiceItemType = 'balance_change'; +type BalanceChangeInvoiceItemType = 'balance_change'; /** * Unknown invoice item type (empty string). */ -export type UnknownInvoiceItemType = ''; +type UnknownInvoiceItemType = ''; /** * Static invoice item types that are not tied to data categories. From 7e5b580bfe1aac3c67f723ea58ca3fa1059e020e Mon Sep 17 00:00:00 2001 From: Alberto Leal Date: Wed, 19 Nov 2025 21:11:24 -0500 Subject: [PATCH 7/8] fix(typescript): Fix TypeScript errors caused by DOMAttributes change Root cause: Changing DOMAttributes to DOMAttributes<_T> broke React's module augmentation, causing all React components to lose standard props. Fixes: 1. Reverted DOMAttributes<_T> back to DOMAttributes with eslint-disable - Module augmentation requires matching generic parameter names - Added eslint-disable-next-line for the unused-vars false positive 2. Fixed inputField and numberField type assertions - Restored (e.target as HTMLInputElement) for onKeyDown handlers - e.target is EventTarget, needs cast to access .value property 3. Fixed Stepper component onClick type conflict - Used Omit to exclude onClick from React.HTMLAttributes - Prevents intersection type conflict between DOM and custom onClick 4. Removed unused DataCategory import from utils.spec.tsx All TypeScript checks now pass with exit code 0. --- static/app/components/forms/fields/inputField.tsx | 2 +- static/app/components/forms/fields/numberField.tsx | 2 +- static/app/views/onboarding/components/stepper.tsx | 2 +- static/gsApp/types/index.tsx | 3 ++- static/gsApp/views/amCheckout/utils.spec.tsx | 2 -- 5 files changed, 5 insertions(+), 6 deletions(-) diff --git a/static/app/components/forms/fields/inputField.tsx b/static/app/components/forms/fields/inputField.tsx index ade21f2737e952..ef1492a9f52659 100644 --- a/static/app/components/forms/fields/inputField.tsx +++ b/static/app/components/forms/fields/inputField.tsx @@ -49,7 +49,7 @@ function defaultField({ onBlur(e.target.value, e)} - onKeyDown={e => onKeyDown(e.target.value, e)} + onKeyDown={e => onKeyDown((e.target as HTMLInputElement).value, e)} onChange={e => onChange(e.target.value, e)} name={name} {...rest} diff --git a/static/app/components/forms/fields/numberField.tsx b/static/app/components/forms/fields/numberField.tsx index 8fab883553b577..ade8cee1aad3d1 100644 --- a/static/app/components/forms/fields/numberField.tsx +++ b/static/app/components/forms/fields/numberField.tsx @@ -62,7 +62,7 @@ function createFieldWithSuffix({suffix}: {suffix: React.ReactNode}) { onBlur(e.target.value, e)} - onKeyDown={e => onKeyDown(e.target.value, e)} + onKeyDown={e => onKeyDown((e.target as HTMLInputElement).value, e)} onChange={e => onChange(e.target.value, e)} name={name} {...rest} diff --git a/static/app/views/onboarding/components/stepper.tsx b/static/app/views/onboarding/components/stepper.tsx index ef6e5f4b15f1a4..15ba34ca7ed954 100644 --- a/static/app/views/onboarding/components/stepper.tsx +++ b/static/app/views/onboarding/components/stepper.tsx @@ -27,7 +27,7 @@ const StepperTransitionIndicator = styled(motion.span)` position: absolute; `; -type Props = React.HTMLAttributes & { +type Props = Omit, 'onClick'> & { currentStepIndex: number; numSteps: number; onClick: (stepIndex: number) => void; diff --git a/static/gsApp/types/index.tsx b/static/gsApp/types/index.tsx index 89138682fa5885..be1116d0522a37 100644 --- a/static/gsApp/types/index.tsx +++ b/static/gsApp/types/index.tsx @@ -37,7 +37,8 @@ declare global { } namespace React { - interface DOMAttributes<_T> { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface DOMAttributes { 'data-test-id'?: string; } } diff --git a/static/gsApp/views/amCheckout/utils.spec.tsx b/static/gsApp/views/amCheckout/utils.spec.tsx index e64ee9464557fe..99ce834b7f2634 100644 --- a/static/gsApp/views/amCheckout/utils.spec.tsx +++ b/static/gsApp/views/amCheckout/utils.spec.tsx @@ -1,7 +1,5 @@ import {PlanDetailsLookupFixture} from 'getsentry-test/fixtures/planDetailsLookup'; -import {DataCategory} from 'sentry/types/core'; - import {AddOnCategory, PlanTier} from 'getsentry/types'; import * as utils from 'getsentry/views/amCheckout/utils'; import {getCheckoutAPIData} from 'getsentry/views/amCheckout/utils'; From 7f56e6d5695f2666f48537e41006928098c07f6d Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Thu, 20 Nov 2025 02:15:42 +0000 Subject: [PATCH 8/8] :hammer_and_wrench: apply pre-commit fixes --- static/gsApp/types/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/static/gsApp/types/index.tsx b/static/gsApp/types/index.tsx index be1116d0522a37..f0d916b86ee2fc 100644 --- a/static/gsApp/types/index.tsx +++ b/static/gsApp/types/index.tsx @@ -37,7 +37,6 @@ declare global { } namespace React { - // eslint-disable-next-line @typescript-eslint/no-unused-vars interface DOMAttributes { 'data-test-id'?: string; }