Skip to content

Commit b0ab3fd

Browse files
feat(sub v3): Billing Info header card (#100825)
<img width="720" height="256" alt="Screenshot 2025-10-02 at 3 33 42 PM" src="https://github.com/user-attachments/assets/8d0fefd1-62fb-4b97-9910-94902ccace12" /> This PR also cleans up the component structure in the header cards from #100800 and removes the scrapped plan header card.
1 parent 26b8925 commit b0ab3fd

14 files changed

+450
-122
lines changed

static/gsApp/components/billingDetails/panel.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ function BillingDetailsPanel({
170170
background="primary"
171171
border="primary"
172172
radius="md"
173+
data-test-id="billing-details-panel"
173174
>
174175
<Flex direction="column" gap="lg" width="100%">
175176
<Heading as="h2" size="lg">

static/gsApp/components/creditCardEdit/panel.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ function CreditCardPanel({
157157
background="primary"
158158
border="primary"
159159
radius="md"
160+
data-test-id="credit-card-panel"
160161
>
161162
<Flex direction="column" gap="lg" width="100%">
162163
<Heading as="h2" size="lg">

static/gsApp/components/partnerPlanEndingBanner.tsx

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import PartnerPlanEndingBackground from 'getsentry-images/partnership/plan-endin
33

44
import {Tag} from 'sentry/components/core/badge/tag';
55
import {LinkButton} from 'sentry/components/core/button/linkButton';
6+
import {Flex} from 'sentry/components/core/layout';
67
import {IconClock} from 'sentry/icons';
78
import {t, tn} from 'sentry/locale';
89
import {space} from 'sentry/styles/space';
@@ -52,7 +53,14 @@ function PartnerPlanEndingBanner({
5253
: 'Business';
5354

5455
return (
55-
<PartnerPlanEndingBannerWrapper data-test-id="partner-plan-ending-banner">
56+
<Flex
57+
data-test-id="partner-plan-ending-banner"
58+
background="primary"
59+
border="primary"
60+
radius="md"
61+
justify="between"
62+
align="center"
63+
>
5664
<div>
5765
<PartnerPlanEndingText>
5866
<PartnerPlanEndingBannerTitle>
@@ -80,20 +88,10 @@ function PartnerPlanEndingBanner({
8088
</PartnerPlanEndingText>
8189
</div>
8290
<IllustrationContainer src={PartnerPlanEndingBackground} />
83-
</PartnerPlanEndingBannerWrapper>
91+
</Flex>
8492
);
8593
}
8694

87-
const PartnerPlanEndingBannerWrapper = styled('div')`
88-
border: 1px solid ${p => p.theme.border};
89-
border-radius: ${p => p.theme.borderRadius};
90-
background: ${p => p.theme.background};
91-
margin-bottom: ${space(2)};
92-
display: flex;
93-
justify-content: space-between;
94-
align-items: center;
95-
`;
96-
9795
const PartnerPlanEndingText = styled('div')`
9896
padding: ${space(2)};
9997
display: flex;

static/gsApp/views/subscriptionPage/billingInformation.spec.tsx

Lines changed: 46 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -125,25 +125,47 @@ describe('Subscription > BillingInformation', () => {
125125
await screen.findByText('Billing Information');
126126

127127
// panels are collapsed with pre-existing information
128-
expect(screen.getByText(/\*\*\*\*4242/)).toBeInTheDocument();
129-
expect(screen.getByRole('button', {name: 'Edit payment method'})).toBeInTheDocument();
130-
expect(screen.getByText('Business address')).toBeInTheDocument();
128+
const cardPanel = await screen.findByTestId('credit-card-panel');
129+
expect(within(cardPanel).getByText(/\*\*\*\*4242/)).toBeInTheDocument();
131130
expect(
132-
screen.getByRole('button', {name: 'Edit business address'})
131+
within(cardPanel).getByRole('button', {name: 'Edit payment method'})
133132
).toBeInTheDocument();
134-
expect(screen.queryByText('Address Line 1')).not.toBeInTheDocument();
135-
expect(screen.queryByRole('button', {name: 'Save Changes'})).not.toBeInTheDocument();
133+
expect(
134+
within(cardPanel).queryByRole('button', {name: 'Save Changes'})
135+
).not.toBeInTheDocument();
136+
137+
const billingDetailsPanel = await screen.findByTestId('billing-details-panel');
138+
expect(within(billingDetailsPanel).getByText('Business address')).toBeInTheDocument();
139+
expect(
140+
within(billingDetailsPanel).getByRole('button', {name: 'Edit business address'})
141+
).toBeInTheDocument();
142+
expect(
143+
within(billingDetailsPanel).queryByText('Address Line 1')
144+
).not.toBeInTheDocument();
145+
expect(
146+
within(billingDetailsPanel).queryByRole('button', {name: 'Save Changes'})
147+
).not.toBeInTheDocument();
136148

137149
// can edit both
138-
await userEvent.click(screen.getByRole('button', {name: 'Edit payment method'}));
150+
await userEvent.click(
151+
within(cardPanel).getByRole('button', {name: 'Edit payment method'})
152+
);
139153
expect(
140-
screen.queryByRole('button', {name: 'Edit payment method'})
154+
within(cardPanel).queryByRole('button', {name: 'Edit payment method'})
141155
).not.toBeInTheDocument();
142-
await userEvent.click(screen.getByRole('button', {name: 'Edit business address'}));
143156
expect(
144-
screen.queryByRole('button', {name: 'Edit business address'})
157+
within(cardPanel).getByRole('button', {name: 'Save Changes'})
158+
).toBeInTheDocument();
159+
160+
await userEvent.click(
161+
within(billingDetailsPanel).getByRole('button', {name: 'Edit business address'})
162+
);
163+
expect(
164+
within(billingDetailsPanel).queryByRole('button', {name: 'Edit business address'})
145165
).not.toBeInTheDocument();
146-
expect(screen.getAllByRole('button', {name: 'Save Changes'})).toHaveLength(2);
166+
expect(
167+
within(billingDetailsPanel).getByRole('button', {name: 'Save Changes'})
168+
).toBeInTheDocument();
147169
});
148170

149171
it('renders with no pre-existing information for new billing UI', async () => {
@@ -162,16 +184,23 @@ describe('Subscription > BillingInformation', () => {
162184
await screen.findByText('Billing Information');
163185

164186
// panels are expanded with no pre-existing information
165-
await waitFor(() => {
166-
// wait for both panels to be expanded
167-
expect(screen.getAllByRole('button', {name: 'Save Changes'})).toHaveLength(2);
168-
});
187+
const cardPanel = await screen.findByTestId('credit-card-panel');
188+
expect(cardPanel).toBeInTheDocument();
169189
expect(
170-
screen.queryByRole('button', {name: 'Edit payment method'})
190+
within(cardPanel).queryByRole('button', {name: 'Edit payment method'})
171191
).not.toBeInTheDocument();
172192
expect(
173-
screen.queryByRole('button', {name: 'Edit business address'})
193+
within(cardPanel).getByRole('button', {name: 'Save Changes'})
194+
).toBeInTheDocument();
195+
196+
const billingDetailsPanel = await screen.findByTestId('billing-details-panel');
197+
expect(billingDetailsPanel).toBeInTheDocument();
198+
expect(
199+
within(billingDetailsPanel).queryByRole('button', {name: 'Edit business address'})
174200
).not.toBeInTheDocument();
201+
expect(
202+
within(billingDetailsPanel).getByRole('button', {name: 'Save Changes'})
203+
).toBeInTheDocument();
175204
});
176205

177206
it('opens credit card form with billing failure query for new billing UI', async () => {
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import {OrganizationFixture} from 'sentry-fixture/organization';
2+
3+
import {BillingDetailsFixture} from 'getsentry-test/fixtures/billingDetails';
4+
import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription';
5+
import {render, screen} from 'sentry-test/reactTestingLibrary';
6+
7+
import BillingInfoCard from 'getsentry/views/subscriptionPage/headerCards/billingInfoCard';
8+
9+
describe('BillingInfoCard', () => {
10+
const organization = OrganizationFixture({access: ['org:billing']});
11+
12+
beforeEach(() => {
13+
MockApiClient.clearMockResponses();
14+
MockApiClient.addMockResponse({
15+
url: `/customers/${organization.slug}/billing-details/`,
16+
method: 'GET',
17+
});
18+
});
19+
20+
it('renders with pre-existing info', async () => {
21+
MockApiClient.addMockResponse({
22+
url: `/customers/${organization.slug}/billing-details/`,
23+
method: 'GET',
24+
body: BillingDetailsFixture(),
25+
});
26+
const subscription = SubscriptionFixture({organization});
27+
render(<BillingInfoCard organization={organization} subscription={subscription} />);
28+
29+
expect(screen.getByText('Billing information')).toBeInTheDocument();
30+
await screen.findByText('Test company');
31+
expect(screen.getByText('Card ending in 4242')).toBeInTheDocument();
32+
});
33+
34+
it('renders without pre-existing info', async () => {
35+
const subscription = SubscriptionFixture({organization, paymentSource: null});
36+
render(<BillingInfoCard organization={organization} subscription={subscription} />);
37+
38+
expect(screen.getByText('Billing information')).toBeInTheDocument();
39+
await screen.findByText('No billing details on file');
40+
expect(screen.getByText('No payment method on file')).toBeInTheDocument();
41+
});
42+
43+
it('does not render for self-serve partner customers', () => {
44+
const subscription = SubscriptionFixture({organization, isSelfServePartner: true});
45+
render(<BillingInfoCard organization={organization} subscription={subscription} />);
46+
47+
expect(screen.queryByText('Billing information')).not.toBeInTheDocument();
48+
});
49+
50+
it('does not render for managed customers', () => {
51+
const subscription = SubscriptionFixture({organization, canSelfServe: false});
52+
render(<BillingInfoCard organization={organization} subscription={subscription} />);
53+
54+
expect(screen.queryByText('Billing information')).not.toBeInTheDocument();
55+
});
56+
57+
it('renders for managed customers with legacy invoiced OD', () => {
58+
const subscription = SubscriptionFixture({
59+
organization,
60+
canSelfServe: false,
61+
onDemandInvoiced: true,
62+
});
63+
render(<BillingInfoCard organization={organization} subscription={subscription} />);
64+
65+
expect(screen.getByText('Billing information')).toBeInTheDocument();
66+
});
67+
});
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import moment from 'moment-timezone';
2+
3+
import {Container, Flex} from 'sentry/components/core/layout';
4+
import {Text} from 'sentry/components/core/text';
5+
import Placeholder from 'sentry/components/placeholder';
6+
import {IconSettings, IconUser} from 'sentry/icons';
7+
import {t, tct} from 'sentry/locale';
8+
import type {Organization} from 'sentry/types/organization';
9+
10+
import {useBillingDetails} from 'getsentry/hooks/useBillingDetails';
11+
import type {Subscription} from 'getsentry/types';
12+
import {hasSomeBillingDetails} from 'getsentry/utils/billing';
13+
import SubscriptionHeaderCard from 'getsentry/views/subscriptionPage/headerCards/subscriptionHeaderCard';
14+
15+
function BillingInfoCard({
16+
subscription,
17+
organization,
18+
}: {
19+
organization: Organization;
20+
subscription: Subscription;
21+
}) {
22+
if (
23+
subscription.isSelfServePartner ||
24+
(!subscription.canSelfServe && !subscription.onDemandInvoiced)
25+
) {
26+
return null;
27+
}
28+
29+
return (
30+
<SubscriptionHeaderCard
31+
title={t('Billing information')}
32+
icon={<IconUser />}
33+
sections={[
34+
<BillingDetailsInfo key="billing-details-info" />,
35+
<PaymentSourceInfo key="payment-source-info" subscription={subscription} />,
36+
]}
37+
button={{
38+
ariaLabel: t('Edit billing information'),
39+
label: t('Edit billing information'),
40+
linkTo: `/settings/${organization.slug}/billing/details/`,
41+
icon: <IconSettings />,
42+
priority: 'default',
43+
}}
44+
/>
45+
);
46+
}
47+
48+
function BillingDetailsInfo() {
49+
const {data: billingDetails, isLoading} = useBillingDetails();
50+
51+
if (isLoading) {
52+
return (
53+
<Flex direction="column" gap="xs">
54+
<Placeholder height="16px" />
55+
<Placeholder height="16px" />
56+
<Placeholder height="16px" />
57+
<Placeholder height="16px" />
58+
</Flex>
59+
);
60+
}
61+
62+
if (!billingDetails || !hasSomeBillingDetails(billingDetails)) {
63+
return (
64+
<Container>
65+
<Text variant="muted">{t('No billing details on file')}</Text>
66+
</Container>
67+
);
68+
}
69+
70+
return (
71+
<Flex direction="column" gap="xs">
72+
{billingDetails.companyName && (
73+
<Text variant="muted" size="sm">
74+
{billingDetails.companyName}
75+
</Text>
76+
)}
77+
{billingDetails.billingEmail && (
78+
<Text variant="muted" size="sm">
79+
{billingDetails.billingEmail}
80+
</Text>
81+
)}
82+
{billingDetails.displayAddress && (
83+
<Text variant="muted" size="sm">
84+
{billingDetails.displayAddress}
85+
</Text>
86+
)}
87+
</Flex>
88+
);
89+
}
90+
91+
function PaymentSourceInfo({subscription}: {subscription: Subscription}) {
92+
const {paymentSource} = subscription;
93+
const paymentSourceExpiryDate = paymentSource
94+
? moment(new Date(paymentSource.expYear, paymentSource.expMonth - 1))
95+
: null;
96+
97+
if (!paymentSource) {
98+
return <Text variant="muted">{t('No payment method on file')}</Text>;
99+
}
100+
101+
return (
102+
<Flex direction="column" gap="xs">
103+
<Text>{tct('Card ending in [last4]', {last4: paymentSource.last4})}</Text>
104+
<Text variant="muted" size="sm">
105+
{tct('Expires [expMonth]/[expYear]', {
106+
expMonth: paymentSourceExpiryDate?.format('MM'),
107+
expYear: paymentSourceExpiryDate?.format('YY'),
108+
})}
109+
</Text>
110+
</Flex>
111+
);
112+
}
113+
114+
export default BillingInfoCard;

0 commit comments

Comments
 (0)