From 53f00888290eeb75df8446d1cf28098913a2b84e Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Thu, 2 Oct 2025 15:32:12 -0400 Subject: [PATCH 1/6] feat(sub v3): Billing Info header card --- .../components/partnerPlanEndingBanner.tsx | 22 ++- .../headerCards/headerCards.tsx | 155 +++++++++++++++--- .../headerCards/subscriptionCard.tsx | 36 +--- .../headerCards/subscriptionHeaderCard.tsx | 19 ++- .../views/subscriptionPage/managedNote.tsx | 11 +- .../views/subscriptionPage/pendingChanges.tsx | 38 ++--- .../subscriptionPage/subscriptionHeader.tsx | 7 +- .../views/subscriptionPage/trialAlert.tsx | 11 +- 8 files changed, 195 insertions(+), 104 deletions(-) diff --git a/static/gsApp/components/partnerPlanEndingBanner.tsx b/static/gsApp/components/partnerPlanEndingBanner.tsx index 073dbe98d3bf74..945e81276a1f6e 100644 --- a/static/gsApp/components/partnerPlanEndingBanner.tsx +++ b/static/gsApp/components/partnerPlanEndingBanner.tsx @@ -3,6 +3,7 @@ import PartnerPlanEndingBackground from 'getsentry-images/partnership/plan-endin import {Tag} from 'sentry/components/core/badge/tag'; import {LinkButton} from 'sentry/components/core/button/linkButton'; +import {Flex} from 'sentry/components/core/layout'; import {IconClock} from 'sentry/icons'; import {t, tn} from 'sentry/locale'; import {space} from 'sentry/styles/space'; @@ -52,7 +53,14 @@ function PartnerPlanEndingBanner({ : 'Business'; return ( - +
@@ -80,20 +88,10 @@ function PartnerPlanEndingBanner({
-
+ ); } -const PartnerPlanEndingBannerWrapper = styled('div')` - border: 1px solid ${p => p.theme.border}; - border-radius: ${p => p.theme.borderRadius}; - background: ${p => p.theme.background}; - margin-bottom: ${space(2)}; - display: flex; - justify-content: space-between; - align-items: center; -`; - const PartnerPlanEndingText = styled('div')` padding: ${space(2)}; display: flex; diff --git a/static/gsApp/views/subscriptionPage/headerCards/headerCards.tsx b/static/gsApp/views/subscriptionPage/headerCards/headerCards.tsx index b745d9dde13702..08410d6a57ff94 100644 --- a/static/gsApp/views/subscriptionPage/headerCards/headerCards.tsx +++ b/static/gsApp/views/subscriptionPage/headerCards/headerCards.tsx @@ -1,13 +1,18 @@ -import {css} from '@emotion/react'; -import styled from '@emotion/styled'; +import {useMemo} from 'react'; +import moment from 'moment-timezone'; +import {Container, Flex, Grid} from 'sentry/components/core/layout'; +import {Text} from 'sentry/components/core/text'; import ErrorBoundary from 'sentry/components/errorBoundary'; -import Panel from 'sentry/components/panels/panel'; -import {space} from 'sentry/styles/space'; +import Placeholder from 'sentry/components/placeholder'; +import {IconSettings, IconUser} from 'sentry/icons'; +import {t, tct} from 'sentry/locale'; import type {Organization} from 'sentry/types/organization'; +import {useBillingDetails} from 'getsentry/hooks/useBillingDetails'; import type {Subscription} from 'getsentry/types'; import {hasNewBillingUI} from 'getsentry/utils/billing'; +import SubscriptionHeaderCard from 'getsentry/views/subscriptionPage/headerCards/subscriptionHeaderCard'; import SeerAutomationAlert from 'getsentry/views/subscriptionPage/seerAutomationAlert'; import {SubscriptionCard} from './subscriptionCard'; @@ -18,29 +23,133 @@ interface HeaderCardsProps { subscription: Subscription; } -export function HeaderCards({organization, subscription}: HeaderCardsProps) { +function HeaderCards({organization, subscription}: HeaderCardsProps) { + const isNewBillingUI = hasNewBillingUI(organization); + + const cards = [ + , + ].filter(card => card !== null); + return ( - - - - + {isNewBillingUI ? ( + + {cards} + + ) : ( + + + + + )} ); } -// TODO(checkout v3): update this with the real layout -const HeaderCardWrapper = styled(Panel)<{hasNewCheckout: boolean}>` - display: grid; - margin-bottom: ${space(2)}; - - ${p => - !p.hasNewCheckout && - css` - @media (min-width: ${p.theme.breakpoints.lg}) { - grid-template-columns: auto minmax(0, 600px); - gap: ${space(2)}; - } - `} -`; +function BillingInfoCard({ + subscription, + organization, +}: { + organization: Organization; + subscription: Subscription; +}) { + const {data: billingDetails, isLoading} = useBillingDetails(); + const {paymentSource} = subscription; + + const BillingDetailsInfo = useMemo(() => { + if (isLoading) { + return ( + + + + + + + ); + } + + if (!billingDetails) { + return ( + + {t('No billing details on file')} + + ); + } + + return ( + + {billingDetails.companyName && ( + + {billingDetails.companyName} + + )} + {billingDetails.billingEmail && ( + + {billingDetails.billingEmail} + + )} + {billingDetails.displayAddress && ( + + {billingDetails.displayAddress} + + )} + + ); + }, [billingDetails, isLoading]); + + if (subscription.isSelfServePartner || !subscription.canSelfServe) { + return null; + } + + const paymentSourceExpiryDate = paymentSource + ? moment(new Date(paymentSource.expYear, paymentSource.expMonth - 1)) + : null; + + const PaymentSourceInfo = paymentSource ? ( + + {tct('Card ending in [last4]', {last4: paymentSource.last4})} + + {tct('Expires [expMonth]/[expYear]', { + expMonth: paymentSourceExpiryDate?.format('MM'), + expYear: paymentSourceExpiryDate?.format('YY'), + })} + + + ) : ( + {t('No payment method on file')} + ); + + return ( + } + sections={[BillingDetailsInfo, PaymentSourceInfo]} + button={{ + ariaLabel: t('Edit billing information'), + label: t('Edit billing information'), + linkTo: `/settings/${organization.slug}/billing/details/`, + icon: , + priority: 'default', + }} + /> + ); +} + +export default HeaderCards; diff --git a/static/gsApp/views/subscriptionPage/headerCards/subscriptionCard.tsx b/static/gsApp/views/subscriptionPage/headerCards/subscriptionCard.tsx index eb04a90a95117f..092be263feb107 100644 --- a/static/gsApp/views/subscriptionPage/headerCards/subscriptionCard.tsx +++ b/static/gsApp/views/subscriptionPage/headerCards/subscriptionCard.tsx @@ -7,21 +7,14 @@ import moment from 'moment-timezone'; import {Tag} from 'sentry/components/core/badge/tag'; import {LinkButton} from 'sentry/components/core/button/linkButton'; -import {IconEdit, IconGrid} from 'sentry/icons'; -import {t, tct} from 'sentry/locale'; +import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type {Organization} from 'sentry/types/organization'; import {ANNUAL} from 'getsentry/constants'; import type {Subscription} from 'getsentry/types'; -import { - hasNewBillingUI, - isDeveloperPlan, - isEnterprise, - isTeamPlan, -} from 'getsentry/utils/billing'; +import {isDeveloperPlan, isEnterprise, isTeamPlan} from 'getsentry/utils/billing'; import formatCurrency from 'getsentry/utils/formatCurrency'; -import SubscriptionHeaderCard from 'getsentry/views/subscriptionPage/headerCards/subscriptionHeaderCard'; import {shouldSeeSpendVisibility} from 'getsentry/views/subscriptionPage/utils'; interface SubscriptionCardProps { @@ -88,31 +81,6 @@ export function SubscriptionCard({subscription, organization}: SubscriptionCardP const hasBillingPerms = organization.access?.includes('org:billing'); - const hasNewCheckout = hasNewBillingUI(organization); - - if (hasNewCheckout) { - // TODO(checkout v3): update this with the real layout, this is just a placeholder for knip - return ( - } - subtitle={tct('[startDate] - [endDate]', { - startDate: moment(subscription.contractPeriodStart).format('MMM D, YYYY'), - endDate: moment(subscription.contractPeriodEnd).format('MMM D, YYYY'), - })} - sections={[ - {t('%s Plan', subscription.planDetails?.name)}, - ]} - button={{ - ariaLabel: t('Edit plan'), - label: t('Edit plan'), - linkTo: `/settings/${organization.slug}/billing/checkout/?referrer=edit_plan`, - icon: , - }} - /> - ); - } - return ( diff --git a/static/gsApp/views/subscriptionPage/headerCards/subscriptionHeaderCard.tsx b/static/gsApp/views/subscriptionPage/headerCards/subscriptionHeaderCard.tsx index d21b1d7cb3e1bd..c2b3e9df46e067 100644 --- a/static/gsApp/views/subscriptionPage/headerCards/subscriptionHeaderCard.tsx +++ b/static/gsApp/views/subscriptionPage/headerCards/subscriptionHeaderCard.tsx @@ -10,6 +10,7 @@ import type {SVGIconProps} from 'sentry/icons/svgIcon'; interface ButtonInfo { ariaLabel: string; label: React.ReactNode; + priority: 'primary' | 'default'; icon?: React.ReactNode; linkTo?: string; onClick?: () => void; @@ -42,9 +43,7 @@ function SubscriptionHeaderCard({ {isValidElement(button.icon) ? cloneElement(button.icon, {size: 'sm'} as SVGIconProps) : null} - - {button.label} - + {button.label} ); }; @@ -68,7 +67,7 @@ function SubscriptionHeaderCard({ )} {subtitle && {subtitle}} - + {sections.map((section, index) => { const isLast = index === sections.length - 1; return ( @@ -81,11 +80,19 @@ function SubscriptionHeaderCard({ {button && (button.linkTo ? ( - + {getButtonContent()} ) : ( - ))} diff --git a/static/gsApp/views/subscriptionPage/managedNote.tsx b/static/gsApp/views/subscriptionPage/managedNote.tsx index 94b6669b981196..2b60cc340e90cc 100644 --- a/static/gsApp/views/subscriptionPage/managedNote.tsx +++ b/static/gsApp/views/subscriptionPage/managedNote.tsx @@ -1,4 +1,4 @@ -import Panel from 'sentry/components/panels/panel'; +import {Container} from 'sentry/components/core/layout'; import PanelBody from 'sentry/components/panels/panelBody'; import {tct} from 'sentry/locale'; import TextBlock from 'sentry/views/settings/components/text/textBlock'; @@ -83,7 +83,12 @@ function ManagedNote({subscription}: Props) { (subscription.customPrice !== null && subscription.customPrice > 0); return ( - + {isSalesAccount @@ -96,7 +101,7 @@ function ManagedNote({subscription}: Props) { DEFAULT_MESSAGE)} - + ); } diff --git a/static/gsApp/views/subscriptionPage/pendingChanges.tsx b/static/gsApp/views/subscriptionPage/pendingChanges.tsx index 1b451b3632f2c8..93713fcb19f767 100644 --- a/static/gsApp/views/subscriptionPage/pendingChanges.tsx +++ b/static/gsApp/views/subscriptionPage/pendingChanges.tsx @@ -397,26 +397,24 @@ class PendingChanges extends Component { } return ( - - - - {Object.entries(changes).map(([effectiveDate, items]) => ( -
- {tct('The following changes will take effect on [date]:', { - date: {moment(effectiveDate).format('ll')}, - })} - - {items.map((item, itemIdx) => ( -
  • - {item} -
  • - ))} -
    -
    - ))} -
    -
    -
    + + + {Object.entries(changes).map(([effectiveDate, items]) => ( +
    + {tct('The following changes will take effect on [date]:', { + date: {moment(effectiveDate).format('ll')}, + })} + + {items.map((item, itemIdx) => ( +
  • + {item} +
  • + ))} +
    +
    + ))} +
    +
    ); } } diff --git a/static/gsApp/views/subscriptionPage/subscriptionHeader.tsx b/static/gsApp/views/subscriptionPage/subscriptionHeader.tsx index e71a40b63b2c77..3a193d67f28896 100644 --- a/static/gsApp/views/subscriptionPage/subscriptionHeader.tsx +++ b/static/gsApp/views/subscriptionPage/subscriptionHeader.tsx @@ -4,6 +4,7 @@ import styled from '@emotion/styled'; import {Button} from 'sentry/components/core/button'; import {LinkButton} from 'sentry/components/core/button/linkButton'; +import {Flex} from 'sentry/components/core/layout'; import {TabList, Tabs} from 'sentry/components/core/tabs'; import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle'; import {IconCodecov} from 'sentry/icons'; @@ -25,7 +26,7 @@ import { import {isDisabledByPartner} from 'getsentry/utils/partnerships'; import PartnershipNote from 'getsentry/views/subscriptionPage/partnershipNote'; -import {HeaderCards} from './headerCards/headerCards'; +import HeaderCards from './headerCards/headerCards'; import DecidePendingChanges from './decidePendingChanges'; import ManagedNote from './managedNote'; import {SubscriptionUpsellBanner} from './subscriptionUpsellBanner'; @@ -179,7 +180,7 @@ const TabsContainer = styled('div')` */ function BodyWithBillingPerms({organization, subscription}: any) { return ( - + {subscription.pendingChanges ? ( ) : null} @@ -192,7 +193,7 @@ function BodyWithBillingPerms({organization, subscription}: any) { )} - +
    ); } diff --git a/static/gsApp/views/subscriptionPage/trialAlert.tsx b/static/gsApp/views/subscriptionPage/trialAlert.tsx index c0db8f84502d33..361c26c58432e0 100644 --- a/static/gsApp/views/subscriptionPage/trialAlert.tsx +++ b/static/gsApp/views/subscriptionPage/trialAlert.tsx @@ -1,7 +1,7 @@ import styled from '@emotion/styled'; import {Button} from 'sentry/components/core/button'; -import Panel from 'sentry/components/panels/panel'; +import {Container} from 'sentry/components/core/layout'; import {t, tct} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type {Organization} from 'sentry/types/organization'; @@ -41,7 +41,12 @@ function TrialAlert({organization, subscription}: Props) { : 'business plan'; return ( - + @@ -67,7 +72,7 @@ function TrialAlert({organization, subscription}: Props) { )} - + ); } From d2ddfd2cb73ec7219a0fb9521a45d6d4f3aeceac Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Thu, 2 Oct 2025 15:33:56 -0400 Subject: [PATCH 2/6] consider legacy invoiced od --- .../gsApp/views/subscriptionPage/headerCards/headerCards.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/static/gsApp/views/subscriptionPage/headerCards/headerCards.tsx b/static/gsApp/views/subscriptionPage/headerCards/headerCards.tsx index 08410d6a57ff94..e5cc2d197b67f9 100644 --- a/static/gsApp/views/subscriptionPage/headerCards/headerCards.tsx +++ b/static/gsApp/views/subscriptionPage/headerCards/headerCards.tsx @@ -114,7 +114,10 @@ function BillingInfoCard({ ); }, [billingDetails, isLoading]); - if (subscription.isSelfServePartner || !subscription.canSelfServe) { + if ( + subscription.isSelfServePartner || + (!subscription.canSelfServe && !subscription.onDemandInvoiced) + ) { return null; } From 1b7ecf725e9ae73812efbaae80f17f9526afde0c Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Thu, 2 Oct 2025 15:53:06 -0400 Subject: [PATCH 3/6] cleanup --- .../gsApp/components/billingDetails/panel.tsx | 1 + .../gsApp/components/creditCardEdit/panel.tsx | 1 + .../billingInformation.spec.tsx | 15 ++- .../headerCards/billingInfoCard.spec.tsx | 67 +++++++++++ .../headerCards/billingInfoCard.tsx | 107 ++++++++++++++++++ .../headerCards/headerCards.tsx | 104 +---------------- .../subscriptionHeader.spec.tsx | 87 ++++++++++++++ 7 files changed, 275 insertions(+), 107 deletions(-) create mode 100644 static/gsApp/views/subscriptionPage/headerCards/billingInfoCard.spec.tsx create mode 100644 static/gsApp/views/subscriptionPage/headerCards/billingInfoCard.tsx diff --git a/static/gsApp/components/billingDetails/panel.tsx b/static/gsApp/components/billingDetails/panel.tsx index 6da6de010638e5..3733b37aa0ae3e 100644 --- a/static/gsApp/components/billingDetails/panel.tsx +++ b/static/gsApp/components/billingDetails/panel.tsx @@ -162,6 +162,7 @@ function BillingDetailsPanel({ background="primary" border="primary" radius="md" + data-test-id="billing-details-panel" > diff --git a/static/gsApp/components/creditCardEdit/panel.tsx b/static/gsApp/components/creditCardEdit/panel.tsx index 3a36d4faa15013..c6840531a5eb8d 100644 --- a/static/gsApp/components/creditCardEdit/panel.tsx +++ b/static/gsApp/components/creditCardEdit/panel.tsx @@ -153,6 +153,7 @@ function CreditCardPanel({ background="primary" border="primary" radius="md" + data-test-id="credit-card-panel" > diff --git a/static/gsApp/views/subscriptionPage/billingInformation.spec.tsx b/static/gsApp/views/subscriptionPage/billingInformation.spec.tsx index fe608d117cb16c..5e55890626b32f 100644 --- a/static/gsApp/views/subscriptionPage/billingInformation.spec.tsx +++ b/static/gsApp/views/subscriptionPage/billingInformation.spec.tsx @@ -151,11 +151,16 @@ describe('Subscription > BillingInformation', () => { /> ); - await screen.findByText('Payment method'); - expect(screen.getByText('No payment method on file')).toBeInTheDocument(); - expect(screen.queryByText(/\*\*\*\*4242/)).not.toBeInTheDocument(); - expect(screen.getByText('Business address')).toBeInTheDocument(); - expect(screen.getByText('No business address on file')).toBeInTheDocument(); + const cardPanel = await screen.findByTestId('credit-card-panel'); + expect(cardPanel).toBeInTheDocument(); + expect(within(cardPanel).getByText('No payment method on file')).toBeInTheDocument(); + expect(within(cardPanel).queryByText(/\*\*\*\*4242/)).not.toBeInTheDocument(); + + const billingDetailsPanel = await screen.findByTestId('billing-details-panel'); + expect(billingDetailsPanel).toBeInTheDocument(); + expect( + within(billingDetailsPanel).getByText('No business address on file') + ).toBeInTheDocument(); }); it('opens credit card form with billing failure query for new billing UI', async () => { diff --git a/static/gsApp/views/subscriptionPage/headerCards/billingInfoCard.spec.tsx b/static/gsApp/views/subscriptionPage/headerCards/billingInfoCard.spec.tsx new file mode 100644 index 00000000000000..ce7642de984ac4 --- /dev/null +++ b/static/gsApp/views/subscriptionPage/headerCards/billingInfoCard.spec.tsx @@ -0,0 +1,67 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; + +import {BillingDetailsFixture} from 'getsentry-test/fixtures/billingDetails'; +import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription'; +import {render, screen} from 'sentry-test/reactTestingLibrary'; + +import BillingInfoCard from 'getsentry/views/subscriptionPage/headerCards/billingInfoCard'; + +describe('BillingInfoCard', () => { + const organization = OrganizationFixture({access: ['org:billing']}); + + beforeEach(() => { + MockApiClient.clearMockResponses(); + MockApiClient.addMockResponse({ + url: `/customers/${organization.slug}/billing-details/`, + method: 'GET', + }); + }); + + it('renders with pre-existing info', async () => { + MockApiClient.addMockResponse({ + url: `/customers/${organization.slug}/billing-details/`, + method: 'GET', + body: BillingDetailsFixture(), + }); + const subscription = SubscriptionFixture({organization}); + render(); + + expect(screen.getByText('Billing information')).toBeInTheDocument(); + await screen.findByText('Test company'); + expect(screen.getByText('Card ending in 4242')).toBeInTheDocument(); + }); + + it('renders without pre-existing info', async () => { + const subscription = SubscriptionFixture({organization, paymentSource: null}); + render(); + + expect(screen.getByText('Billing information')).toBeInTheDocument(); + await screen.findByText('No billing details on file'); + expect(screen.getByText('No payment method on file')).toBeInTheDocument(); + }); + + it('does not render for self-serve partner customers', () => { + const subscription = SubscriptionFixture({organization, isSelfServePartner: true}); + render(); + + expect(screen.queryByText('Billing information')).not.toBeInTheDocument(); + }); + + it('does not render for managed customers', () => { + const subscription = SubscriptionFixture({organization, canSelfServe: false}); + render(); + + expect(screen.queryByText('Billing information')).not.toBeInTheDocument(); + }); + + it('renders for managed customers with legacy invoiced OD', () => { + const subscription = SubscriptionFixture({ + organization, + canSelfServe: false, + onDemandInvoiced: true, + }); + render(); + + expect(screen.getByText('Billing information')).toBeInTheDocument(); + }); +}); diff --git a/static/gsApp/views/subscriptionPage/headerCards/billingInfoCard.tsx b/static/gsApp/views/subscriptionPage/headerCards/billingInfoCard.tsx new file mode 100644 index 00000000000000..f8cd8eca1f1c10 --- /dev/null +++ b/static/gsApp/views/subscriptionPage/headerCards/billingInfoCard.tsx @@ -0,0 +1,107 @@ +import {useMemo} from 'react'; +import moment from 'moment-timezone'; + +import {Container, Flex} from 'sentry/components/core/layout'; +import {Text} from 'sentry/components/core/text'; +import Placeholder from 'sentry/components/placeholder'; +import {IconSettings, IconUser} from 'sentry/icons'; +import {t, tct} from 'sentry/locale'; +import type {Organization} from 'sentry/types/organization'; + +import {useBillingDetails} from 'getsentry/hooks/useBillingDetails'; +import type {Subscription} from 'getsentry/types'; +import SubscriptionHeaderCard from 'getsentry/views/subscriptionPage/headerCards/subscriptionHeaderCard'; + +function BillingInfoCard({ + subscription, + organization, +}: { + organization: Organization; + subscription: Subscription; +}) { + const {data: billingDetails, isLoading} = useBillingDetails(); + const {paymentSource} = subscription; + + const BillingDetailsInfo = useMemo(() => { + if (isLoading) { + return ( + + + + + + + ); + } + + if (!billingDetails) { + return ( + + {t('No billing details on file')} + + ); + } + + return ( + + {billingDetails.companyName && ( + + {billingDetails.companyName} + + )} + {billingDetails.billingEmail && ( + + {billingDetails.billingEmail} + + )} + {billingDetails.displayAddress && ( + + {billingDetails.displayAddress} + + )} + + ); + }, [billingDetails, isLoading]); + + if ( + subscription.isSelfServePartner || + (!subscription.canSelfServe && !subscription.onDemandInvoiced) + ) { + return null; + } + + const paymentSourceExpiryDate = paymentSource + ? moment(new Date(paymentSource.expYear, paymentSource.expMonth - 1)) + : null; + + const PaymentSourceInfo = paymentSource ? ( + + {tct('Card ending in [last4]', {last4: paymentSource.last4})} + + {tct('Expires [expMonth]/[expYear]', { + expMonth: paymentSourceExpiryDate?.format('MM'), + expYear: paymentSourceExpiryDate?.format('YY'), + })} + + + ) : ( + {t('No payment method on file')} + ); + + return ( + } + sections={[BillingDetailsInfo, PaymentSourceInfo]} + button={{ + ariaLabel: t('Edit billing information'), + label: t('Edit billing information'), + linkTo: `/settings/${organization.slug}/billing/details/`, + icon: , + priority: 'default', + }} + /> + ); +} + +export default BillingInfoCard; diff --git a/static/gsApp/views/subscriptionPage/headerCards/headerCards.tsx b/static/gsApp/views/subscriptionPage/headerCards/headerCards.tsx index e5cc2d197b67f9..661e3b044c23eb 100644 --- a/static/gsApp/views/subscriptionPage/headerCards/headerCards.tsx +++ b/static/gsApp/views/subscriptionPage/headerCards/headerCards.tsx @@ -1,18 +1,10 @@ -import {useMemo} from 'react'; -import moment from 'moment-timezone'; - -import {Container, Flex, Grid} from 'sentry/components/core/layout'; -import {Text} from 'sentry/components/core/text'; +import {Grid} from 'sentry/components/core/layout'; import ErrorBoundary from 'sentry/components/errorBoundary'; -import Placeholder from 'sentry/components/placeholder'; -import {IconSettings, IconUser} from 'sentry/icons'; -import {t, tct} from 'sentry/locale'; import type {Organization} from 'sentry/types/organization'; -import {useBillingDetails} from 'getsentry/hooks/useBillingDetails'; import type {Subscription} from 'getsentry/types'; import {hasNewBillingUI} from 'getsentry/utils/billing'; -import SubscriptionHeaderCard from 'getsentry/views/subscriptionPage/headerCards/subscriptionHeaderCard'; +import BillingInfoCard from 'getsentry/views/subscriptionPage/headerCards/billingInfoCard'; import SeerAutomationAlert from 'getsentry/views/subscriptionPage/seerAutomationAlert'; import {SubscriptionCard} from './subscriptionCard'; @@ -63,96 +55,4 @@ function HeaderCards({organization, subscription}: HeaderCardsProps) { ); } -function BillingInfoCard({ - subscription, - organization, -}: { - organization: Organization; - subscription: Subscription; -}) { - const {data: billingDetails, isLoading} = useBillingDetails(); - const {paymentSource} = subscription; - - const BillingDetailsInfo = useMemo(() => { - if (isLoading) { - return ( - - - - - - - ); - } - - if (!billingDetails) { - return ( - - {t('No billing details on file')} - - ); - } - - return ( - - {billingDetails.companyName && ( - - {billingDetails.companyName} - - )} - {billingDetails.billingEmail && ( - - {billingDetails.billingEmail} - - )} - {billingDetails.displayAddress && ( - - {billingDetails.displayAddress} - - )} - - ); - }, [billingDetails, isLoading]); - - if ( - subscription.isSelfServePartner || - (!subscription.canSelfServe && !subscription.onDemandInvoiced) - ) { - return null; - } - - const paymentSourceExpiryDate = paymentSource - ? moment(new Date(paymentSource.expYear, paymentSource.expMonth - 1)) - : null; - - const PaymentSourceInfo = paymentSource ? ( - - {tct('Card ending in [last4]', {last4: paymentSource.last4})} - - {tct('Expires [expMonth]/[expYear]', { - expMonth: paymentSourceExpiryDate?.format('MM'), - expYear: paymentSourceExpiryDate?.format('YY'), - })} - - - ) : ( - {t('No payment method on file')} - ); - - return ( - } - sections={[BillingDetailsInfo, PaymentSourceInfo]} - button={{ - ariaLabel: t('Edit billing information'), - label: t('Edit billing information'), - linkTo: `/settings/${organization.slug}/billing/details/`, - icon: , - priority: 'default', - }} - /> - ); -} - export default HeaderCards; diff --git a/static/gsApp/views/subscriptionPage/subscriptionHeader.spec.tsx b/static/gsApp/views/subscriptionPage/subscriptionHeader.spec.tsx index 55000bba30f145..c1763dc7dbcf5b 100644 --- a/static/gsApp/views/subscriptionPage/subscriptionHeader.spec.tsx +++ b/static/gsApp/views/subscriptionPage/subscriptionHeader.spec.tsx @@ -19,6 +19,10 @@ describe('SubscriptionHeader', () => { method: 'GET', body: BillingConfigFixture(PlanTier.AM1), }); + MockApiClient.addMockResponse({ + url: `/customers/org-slug/billing-details/`, + method: 'GET', + }); MockApiClient.addMockResponse({ url: `/subscriptions/org-slug/`, method: 'GET', @@ -40,6 +44,89 @@ describe('SubscriptionHeader', () => { }); }); + async function assertNewHeaderCards({ + hasBillingInfoCard, + }: { + hasBillingInfoCard: boolean; + }) { + await screen.findByRole('heading', {name: 'Subscription'}); + + if (hasBillingInfoCard) { + await screen.findByText('Billing information'); + screen.getByRole('button', {name: 'Edit billing information'}); + } + } + + it('renders new header cards for self-serve customers', () => { + const organization = OrganizationFixture({ + features: ['subscriptions-v3'], + access: ['org:billing'], + }); + const subscription = SubscriptionFixture({ + organization, + plan: 'am3_f', + }); + SubscriptionStore.set(organization.slug, subscription); + render( + + ); + assertNewHeaderCards({hasBillingInfoCard: true}); + }); + + it('renders new header cards for self-serve partner customers', () => { + const organization = OrganizationFixture({ + features: ['subscriptions-v3'], + access: ['org:billing'], + }); + const subscription = SubscriptionFixture({ + organization, + plan: 'am3_f', + isSelfServePartner: true, + }); + SubscriptionStore.set(organization.slug, subscription); + render( + + ); + assertNewHeaderCards({hasBillingInfoCard: false}); + }); + + it('renders new header cards for managed customers', () => { + const organization = OrganizationFixture({ + features: ['subscriptions-v3'], + access: ['org:billing'], + }); + const subscription = SubscriptionFixture({ + organization, + plan: 'am3_f', + canSelfServe: false, + }); + SubscriptionStore.set(organization.slug, subscription); + render( + + ); + assertNewHeaderCards({hasBillingInfoCard: false}); + }); + + it('renders new header cards for managed customers with legacy invoiced OD', () => { + const organization = OrganizationFixture({ + features: ['subscriptions-v3'], + access: ['org:billing'], + }); + const subscription = SubscriptionFixture({ + organization, + plan: 'am3_f', + canSelfServe: false, + onDemandInvoiced: true, + }); + SubscriptionStore.set(organization.slug, subscription); + render( + + ); + assertNewHeaderCards({hasBillingInfoCard: true}); + }); + + it('renders new header cards for self-serve customers and user without billing perms', () => {}); + it('does not render editable sections for YY partnership', async () => { const organization = OrganizationFixture({ features: ['usage-log'], From b0e22688933029e516a5bba75713932b4a3544a4 Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Thu, 2 Oct 2025 15:57:57 -0400 Subject: [PATCH 4/6] fix test --- .../subscriptionHeader.spec.tsx | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/static/gsApp/views/subscriptionPage/subscriptionHeader.spec.tsx b/static/gsApp/views/subscriptionPage/subscriptionHeader.spec.tsx index c1763dc7dbcf5b..5945ae4aa5c441 100644 --- a/static/gsApp/views/subscriptionPage/subscriptionHeader.spec.tsx +++ b/static/gsApp/views/subscriptionPage/subscriptionHeader.spec.tsx @@ -54,6 +54,11 @@ describe('SubscriptionHeader', () => { if (hasBillingInfoCard) { await screen.findByText('Billing information'); screen.getByRole('button', {name: 'Edit billing information'}); + } else { + expect(screen.queryByText('Billing information')).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', {name: 'Edit billing information'}) + ).not.toBeInTheDocument(); } } @@ -125,7 +130,20 @@ describe('SubscriptionHeader', () => { assertNewHeaderCards({hasBillingInfoCard: true}); }); - it('renders new header cards for self-serve customers and user without billing perms', () => {}); + it('renders new header cards for self-serve customers and user without billing perms', () => { + const organization = OrganizationFixture({ + features: ['subscriptions-v3'], + }); + const subscription = SubscriptionFixture({ + organization, + plan: 'am3_f', + }); + SubscriptionStore.set(organization.slug, subscription); + render( + + ); + assertNewHeaderCards({hasBillingInfoCard: false}); + }); it('does not render editable sections for YY partnership', async () => { const organization = OrganizationFixture({ From a491ac3ceb154240dff5a2cc1d84422047444e63 Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Thu, 2 Oct 2025 16:05:10 -0400 Subject: [PATCH 5/6] fix rendering --- .../headerCards/headerCards.tsx | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/static/gsApp/views/subscriptionPage/headerCards/headerCards.tsx b/static/gsApp/views/subscriptionPage/headerCards/headerCards.tsx index 661e3b044c23eb..4bcb18bc993954 100644 --- a/static/gsApp/views/subscriptionPage/headerCards/headerCards.tsx +++ b/static/gsApp/views/subscriptionPage/headerCards/headerCards.tsx @@ -1,4 +1,4 @@ -import {Grid} from 'sentry/components/core/layout'; +import {Container, Grid} from 'sentry/components/core/layout'; import ErrorBoundary from 'sentry/components/errorBoundary'; import type {Organization} from 'sentry/types/organization'; @@ -15,16 +15,32 @@ interface HeaderCardsProps { subscription: Subscription; } +function getCards(organization: Organization, subscription: Subscription) { + const cards: React.ReactNode[] = []; + + cards.push( + + + + ); + + if (subscription.canSelfServe || subscription.onDemandInvoiced) { + cards.push( + + ); + } + + return cards; +} + function HeaderCards({organization, subscription}: HeaderCardsProps) { const isNewBillingUI = hasNewBillingUI(organization); - const cards = [ - , - ].filter(card => card !== null); + const cards = getCards(organization, subscription); return ( @@ -36,6 +52,7 @@ function HeaderCards({organization, subscription}: HeaderCardsProps) { sm: `repeat(${Math.min(cards.length, 2)}, 1fr)`, md: `repeat(${cards.length}, 1fr)`, }} + gap="xl" > {cards} From d82f1aba7787281ca8e5324e427e41332010e28e Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Fri, 3 Oct 2025 13:42:06 -0400 Subject: [PATCH 6/6] make const components functions --- .../headerCards/billingInfoCard.tsx | 127 +++++++++--------- 1 file changed, 67 insertions(+), 60 deletions(-) diff --git a/static/gsApp/views/subscriptionPage/headerCards/billingInfoCard.tsx b/static/gsApp/views/subscriptionPage/headerCards/billingInfoCard.tsx index f8cd8eca1f1c10..6fdf29d5e91029 100644 --- a/static/gsApp/views/subscriptionPage/headerCards/billingInfoCard.tsx +++ b/static/gsApp/views/subscriptionPage/headerCards/billingInfoCard.tsx @@ -1,4 +1,3 @@ -import {useMemo} from 'react'; import moment from 'moment-timezone'; import {Container, Flex} from 'sentry/components/core/layout'; @@ -10,6 +9,7 @@ import type {Organization} from 'sentry/types/organization'; import {useBillingDetails} from 'getsentry/hooks/useBillingDetails'; import type {Subscription} from 'getsentry/types'; +import {hasSomeBillingDetails} from 'getsentry/utils/billing'; import SubscriptionHeaderCard from 'getsentry/views/subscriptionPage/headerCards/subscriptionHeaderCard'; function BillingInfoCard({ @@ -19,62 +19,86 @@ function BillingInfoCard({ organization: Organization; subscription: Subscription; }) { - const {data: billingDetails, isLoading} = useBillingDetails(); - const {paymentSource} = subscription; + if ( + subscription.isSelfServePartner || + (!subscription.canSelfServe && !subscription.onDemandInvoiced) + ) { + return null; + } - const BillingDetailsInfo = useMemo(() => { - if (isLoading) { - return ( - - - - - - - ); - } + return ( + } + sections={[ + , + , + ]} + button={{ + ariaLabel: t('Edit billing information'), + label: t('Edit billing information'), + linkTo: `/settings/${organization.slug}/billing/details/`, + icon: , + priority: 'default', + }} + /> + ); +} - if (!billingDetails) { - return ( - - {t('No billing details on file')} - - ); - } +function BillingDetailsInfo() { + const {data: billingDetails, isLoading} = useBillingDetails(); + if (isLoading) { return ( - {billingDetails.companyName && ( - - {billingDetails.companyName} - - )} - {billingDetails.billingEmail && ( - - {billingDetails.billingEmail} - - )} - {billingDetails.displayAddress && ( - - {billingDetails.displayAddress} - - )} + + + + ); - }, [billingDetails, isLoading]); + } - if ( - subscription.isSelfServePartner || - (!subscription.canSelfServe && !subscription.onDemandInvoiced) - ) { - return null; + if (!billingDetails || !hasSomeBillingDetails(billingDetails)) { + return ( + + {t('No billing details on file')} + + ); } + return ( + + {billingDetails.companyName && ( + + {billingDetails.companyName} + + )} + {billingDetails.billingEmail && ( + + {billingDetails.billingEmail} + + )} + {billingDetails.displayAddress && ( + + {billingDetails.displayAddress} + + )} + + ); +} + +function PaymentSourceInfo({subscription}: {subscription: Subscription}) { + const {paymentSource} = subscription; const paymentSourceExpiryDate = paymentSource ? moment(new Date(paymentSource.expYear, paymentSource.expMonth - 1)) : null; - const PaymentSourceInfo = paymentSource ? ( + if (!paymentSource) { + return {t('No payment method on file')}; + } + + return ( {tct('Card ending in [last4]', {last4: paymentSource.last4})} @@ -84,23 +108,6 @@ function BillingInfoCard({ })} - ) : ( - {t('No payment method on file')} - ); - - return ( - } - sections={[BillingDetailsInfo, PaymentSourceInfo]} - button={{ - ariaLabel: t('Edit billing information'), - label: t('Edit billing information'), - linkTo: `/settings/${organization.slug}/billing/details/`, - icon: , - priority: 'default', - }} - /> ); }