Skip to content

Commit d4fdb9e

Browse files
feat(billing): Use Stripe PaymentElement (#99384)
# updating credit card <img width="613" height="448" alt="Screenshot 2025-09-12 at 12 26 46 PM" src="https://github.com/user-attachments/assets/59b1ab1a-5fc2-4461-a48d-b6789afe1e1d" /> # manually making a payment <img width="606" height="416" alt="Screenshot 2025-09-12 at 4 15 32 PM" src="https://github.com/user-attachments/assets/948663aa-e383-4b3b-9cc9-b9c0406233cb" />
1 parent 85445e2 commit d4fdb9e

15 files changed

+691
-93
lines changed

static/gsApp/components/billingDetailsForm.tsx

Lines changed: 4 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
import {useEffect, useMemo, useState} from 'react';
2-
import {useTheme} from '@emotion/react';
32
import styled from '@emotion/styled';
4-
import {AddressElement, Elements} from '@stripe/react-stripe-js';
3+
import {AddressElement} from '@stripe/react-stripe-js';
54

6-
import {debossedBackground} from 'sentry/components/core/chonk';
75
import {Flex} from 'sentry/components/core/layout';
86
import {Heading, Text} from 'sentry/components/core/text';
97
import type {FieldGroupProps} from 'sentry/components/forms/fieldGroup/types';
@@ -13,12 +11,11 @@ import FormModel from 'sentry/components/forms/model';
1311
import QuestionTooltip from 'sentry/components/questionTooltip';
1412
import {t, tct} from 'sentry/locale';
1513
import ConfigStore from 'sentry/stores/configStore';
16-
import {useLegacyStore} from 'sentry/stores/useLegacyStore';
1714
import type {Organization} from 'sentry/types/organization';
1815
import {defined} from 'sentry/utils';
1916

2017
import LegacyBillingDetailsForm from 'getsentry/components/legacyBillingDetailsForm';
21-
import {useStripeInstance} from 'getsentry/hooks/useStripeInstance';
18+
import StripeWrapper from 'getsentry/components/stripeWrapper';
2219
import type {BillingDetails} from 'getsentry/types';
2320
import {hasStripeComponentsFeature} from 'getsentry/utils/billing';
2421
import {countryCodes} from 'getsentry/utils/ISO3166codes';
@@ -130,10 +127,6 @@ function BillingDetailsForm({
130127
wrapper = DefaultWrapper,
131128
fieldProps,
132129
}: Props) {
133-
const theme = useTheme();
134-
const prefersDarkMode = useLegacyStore(ConfigStore).theme === 'dark';
135-
const stripe = useStripeInstance();
136-
137130
const transformData = (data: Record<string, any>) => {
138131
// Clear tax number if not applicable to country code.
139132
// This is done on save instead of on change to retain the field value
@@ -266,43 +259,7 @@ function BillingDetailsForm({
266259
fieldProps={fieldProps}
267260
/>
268261
)}
269-
<Elements
270-
stripe={stripe}
271-
options={{
272-
fonts: [
273-
{
274-
family: 'Rubik',
275-
cssSrc:
276-
'https://fonts.googleapis.com/css2?family=Rubik:ital,wght@0,300..900;1,300..900&display=swap',
277-
},
278-
],
279-
appearance: {
280-
theme: prefersDarkMode ? 'night' : 'stripe',
281-
variables: {
282-
fontFamily: theme.text.family,
283-
borderRadius: theme.borderRadius,
284-
colorBackground: theme.background,
285-
colorText: theme.textColor,
286-
colorDanger: theme.danger,
287-
colorSuccess: theme.success,
288-
colorWarning: theme.warning,
289-
iconColor: theme.textColor,
290-
},
291-
rules: {
292-
'.Input': {
293-
fontSize: theme.fontSize.md,
294-
boxShadow: `0px 2px 0px 0px ${theme.tokens.border.primary} inset`,
295-
backgroundColor: debossedBackground(theme as any).backgroundColor,
296-
padding: `${theme.space.lg} ${theme.space.xl}`,
297-
},
298-
'.Label': {
299-
fontSize: theme.fontSize.sm,
300-
color: theme.subText,
301-
},
302-
},
303-
},
304-
}}
305-
>
262+
<StripeWrapper>
306263
<AddressElement
307264
options={{
308265
mode: 'billing',
@@ -333,7 +290,7 @@ function BillingDetailsForm({
333290
}}
334291
onChange={handleStripeFormChange}
335292
/>
336-
</Elements>
293+
</StripeWrapper>
337294
{!!(state.showTaxNumber && taxFieldInfo) && (
338295
// TODO: use Stripe's TaxIdElement when it's generally available
339296
<CustomBillingDetailsFormField

static/gsApp/components/creditCardEditModal.tsx

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import type {Organization} from 'sentry/types/organization';
77
import {decodeScalar} from 'sentry/utils/queryString';
88

99
import CreditCardSetup from 'getsentry/components/creditCardSetup';
10+
import StripeCreditCardSetup from 'getsentry/components/stripeCreditCardSetup';
1011
import type {Subscription} from 'getsentry/types';
1112
import {FTCConsentLocation} from 'getsentry/types';
13+
import {hasStripeComponentsFeature} from 'getsentry/utils/billing';
1214
import trackGetsentryAnalytics from 'getsentry/utils/trackGetsentryAnalytics';
1315

1416
type Props = ModalRenderProps & {
@@ -29,27 +31,49 @@ function CreditCardEditModal({
2931
}: Props) {
3032
const referrer = decodeScalar(location?.query?.referrer);
3133
const budgetModeText = subscription.planDetails.budgetTerm;
34+
const shouldUseStripe = hasStripeComponentsFeature(organization);
35+
36+
const commonProps = {
37+
organization,
38+
location: FTCConsentLocation.BILLING_DETAILS,
39+
referrer,
40+
budgetModeText,
41+
buttonText: t('Save Changes'),
42+
};
43+
3244
return (
3345
<Fragment>
3446
<Header>{t('Update Credit Card')}</Header>
3547
<Body>
36-
<CreditCardSetup
37-
isModal
38-
organization={organization}
39-
onCancel={closeModal}
40-
onSuccess={data => {
41-
onSuccess(data);
42-
closeModal();
43-
trackGetsentryAnalytics('billing_details.updated_cc', {
44-
organization,
45-
referrer: decodeScalar(referrer),
46-
});
47-
}}
48-
buttonText={t('Save Changes')}
49-
referrer={referrer}
50-
location={FTCConsentLocation.BILLING_DETAILS}
51-
budgetModeText={budgetModeText}
52-
/>
48+
{shouldUseStripe ? (
49+
<StripeCreditCardSetup
50+
onCancel={closeModal}
51+
onSuccessWithSubscription={onSuccess}
52+
onSuccess={() => {
53+
closeModal();
54+
trackGetsentryAnalytics('billing_details.updated_cc', {
55+
organization,
56+
referrer: decodeScalar(referrer),
57+
isStripeComponent: true,
58+
});
59+
}}
60+
{...commonProps}
61+
/>
62+
) : (
63+
<CreditCardSetup
64+
isModal
65+
onCancel={closeModal}
66+
onSuccess={data => {
67+
onSuccess(data);
68+
closeModal();
69+
trackGetsentryAnalytics('billing_details.updated_cc', {
70+
organization,
71+
referrer: decodeScalar(referrer),
72+
});
73+
}}
74+
{...commonProps}
75+
/>
76+
)}
5377
</Body>
5478
</Fragment>
5579
);

static/gsApp/components/creditCardForm.tsx

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {useState} from 'react';
22
import {css, useTheme} from '@emotion/react';
33
import styled from '@emotion/styled';
4-
import {CardElement, Elements, useElements, useStripe} from '@stripe/react-stripe-js';
4+
import {CardElement, useElements, useStripe} from '@stripe/react-stripe-js';
55
import {type Stripe, type StripeCardElement} from '@stripe/stripe-js';
66

77
import {Alert} from 'sentry/components/core/alert';
@@ -15,8 +15,8 @@ import {NODE_ENV} from 'sentry/constants';
1515
import {t, tct} from 'sentry/locale';
1616
import {space} from 'sentry/styles/space';
1717

18-
import {useStripeInstance} from 'getsentry/hooks/useStripeInstance';
19-
import type {FTCConsentLocation} from 'getsentry/types';
18+
import StripeWrapper from 'getsentry/components/stripeWrapper';
19+
import {FTCConsentLocation} from 'getsentry/types';
2020

2121
export type SubmitData = {
2222
/**
@@ -93,12 +93,10 @@ type Props = {
9393
* and classic card flows.
9494
*/
9595
function CreditCardForm(props: Props) {
96-
const stripe = useStripeInstance();
97-
9896
return (
99-
<Elements stripe={stripe}>
97+
<StripeWrapper>
10098
<CreditCardFormInner {...props} />
101-
</Elements>
99+
</StripeWrapper>
102100
);
103101
}
104102

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type {Organization} from 'sentry/types/organization';
2+
3+
import StripeCreditCardForm from 'getsentry/components/stripeForms/stripeCreditCardForm';
4+
import type {FTCConsentLocation, Subscription} from 'getsentry/types';
5+
6+
export interface StripeCreditCardSetupProps {
7+
/**
8+
* Handler for cancellation.
9+
*/
10+
onCancel: () => void;
11+
/**
12+
* Handler for success.
13+
*/
14+
onSuccess: () => void;
15+
/**
16+
* The organization associated with the form
17+
*/
18+
organization: Organization;
19+
/**
20+
* budget mode text for fine print, if any.
21+
*/
22+
budgetModeText?: string;
23+
24+
/**
25+
* Text for the submit button.
26+
*/
27+
buttonText?: string;
28+
29+
/**
30+
* Location of form, if any.
31+
*/
32+
location?: FTCConsentLocation;
33+
/**
34+
* Handler for success called with new subscription state.
35+
*/
36+
onSuccessWithSubscription?: (subscription: Subscription) => void;
37+
/**
38+
* The URL referrer, if any.
39+
*/
40+
referrer?: string;
41+
}
42+
43+
function StripeCreditCardSetup(props: StripeCreditCardSetupProps) {
44+
return (
45+
<StripeCreditCardForm
46+
cardMode="setup"
47+
intentDataEndpoint={`/organizations/${props.organization.slug}/payments/setup/`}
48+
{...props}
49+
/>
50+
);
51+
}
52+
53+
export default StripeCreditCardSetup;
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import {useState} from 'react';
2+
import {PaymentElement, useElements, useStripe} from '@stripe/react-stripe-js';
3+
import type {StripePaymentElementChangeEvent} from '@stripe/stripe-js';
4+
5+
import {Button} from 'sentry/components/core/button';
6+
import {Flex} from 'sentry/components/core/layout';
7+
import {ExternalLink} from 'sentry/components/core/link';
8+
import {Text} from 'sentry/components/core/text';
9+
import Form from 'sentry/components/forms/form';
10+
import {t, tct} from 'sentry/locale';
11+
12+
import type {InnerIntentFormProps} from 'getsentry/components/stripeForms/types';
13+
14+
function InnerIntentForm({
15+
onCancel,
16+
budgetModeText,
17+
buttonText,
18+
location,
19+
handleSubmit,
20+
}: InnerIntentFormProps) {
21+
const elements = useElements();
22+
const stripe = useStripe();
23+
const [submitDisabled, setSubmitDisabled] = useState(true);
24+
25+
const handleFormChange = (formData: StripePaymentElementChangeEvent) => {
26+
if (formData.complete) {
27+
setSubmitDisabled(false);
28+
} else {
29+
setSubmitDisabled(true);
30+
}
31+
};
32+
33+
return (
34+
<Form
35+
onSubmit={() => handleSubmit({stripe, elements})}
36+
submitDisabled={submitDisabled}
37+
submitLabel={buttonText}
38+
extraButton={
39+
<Button aria-label={t('Cancel')} onClick={onCancel}>
40+
{t('Cancel')}
41+
</Button>
42+
}
43+
footerStyle={{
44+
display: 'flex',
45+
justifyContent: 'space-between',
46+
marginLeft: 0,
47+
}}
48+
>
49+
<Flex direction="column" gap="xl">
50+
<PaymentElement
51+
onChange={handleFormChange}
52+
options={{
53+
terms: {card: 'never'}, // we display the terms ourselves
54+
wallets: {applePay: 'never', googlePay: 'never'},
55+
}}
56+
/>
57+
<Flex direction="column" gap="sm">
58+
<small>
59+
{tct('Payments are processed securely through [stripe:Stripe].', {
60+
stripe: <ExternalLink href="https://stripe.com/" />,
61+
})}
62+
</small>
63+
{/* location is 0 on the checkout page which is why this isn't location && */}
64+
{location !== null && location !== undefined && (
65+
<Text size="xs" variant="muted">
66+
{tct(
67+
'By clicking [buttonText], you authorize Sentry to automatically charge you recurring subscription fees and applicable [budgetModeText] fees. Recurring charges occur at the start of your selected billing cycle for subscription fees and monthly for [budgetModeText] fees. You may cancel your subscription at any time [here:here].',
68+
{
69+
buttonText: <b>{buttonText}</b>,
70+
budgetModeText,
71+
here: (
72+
<ExternalLink href="https://sentry.io/settings/billing/cancel/" />
73+
),
74+
}
75+
)}
76+
</Text>
77+
)}
78+
</Flex>
79+
</Flex>
80+
</Form>
81+
);
82+
}
83+
84+
export default InnerIntentForm;

0 commit comments

Comments
 (0)