Skip to content

Commit 740eeb8

Browse files
authored
feat: mandatory address input for paying customers (supabase#37337)
- Mandatory address input when adding a new payment method - Removed the global HCaptcha store that wasn't used consistently and would sometimes block payment method changes - Remove the custom billing address data & tax id form on org creation and plan upgrades in favour of Stripe's `AddressElement` - Unify usage of the Stripe payment elements into a single component - Customers can mark "Purchasing as a business" and will then be able to put down a tax id - Adjusted billing address form to have better labels + tax id is filtered down to selected country - Adjusted Stripe Elements styling to use floating labels (otherwise very hard to use with address element) + additional styling changes - New flag to filter out payment methods that do not have an address for org upgrades and credit top ups, this will be enforced a few days after rolling this out - Added Google Maps Places API integration for address auto-completion via Stripe AddressElement - Upgraded Stripe dependencies - Slight adjustments to styling of plan upgrade modal
1 parent 7505c29 commit 740eeb8

34 files changed

+939
-829
lines changed

apps/studio/components/interfaces/Billing/Payment/AddNewPaymentMethodModal.tsx

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import { Modal } from 'ui'
99
import { useOrganizationPaymentMethodSetupIntent } from 'data/organizations/organization-payment-method-setup-intent-mutation'
1010
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
1111
import { STRIPE_PUBLIC_KEY } from 'lib/constants'
12-
import { useIsHCaptchaLoaded } from 'stores/hcaptcha-loaded-store'
1312
import AddPaymentMethodForm from './AddPaymentMethodForm'
1413
import { getStripeElementsAppearanceOptions } from './Payment.utils'
1514

@@ -18,8 +17,6 @@ interface AddNewPaymentMethodModalProps {
1817
returnUrl: string
1918
onCancel: () => void
2019
onConfirm: () => void
21-
showSetDefaultCheckbox?: boolean
22-
autoMarkAsDefaultPaymentMethod?: boolean
2320
}
2421

2522
const stripePromise = loadStripe(STRIPE_PUBLIC_KEY)
@@ -29,14 +26,11 @@ const AddNewPaymentMethodModal = ({
2926
returnUrl,
3027
onCancel,
3128
onConfirm,
32-
showSetDefaultCheckbox,
33-
autoMarkAsDefaultPaymentMethod,
3429
}: AddNewPaymentMethodModalProps) => {
3530
const { resolvedTheme } = useTheme()
3631
const [intent, setIntent] = useState<any>()
3732
const selectedOrganization = useSelectedOrganization()
3833

39-
const captchaLoaded = useIsHCaptchaLoaded()
4034
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
4135
const [captchaRef, setCaptchaRef] = useState<HCaptcha | null>(null)
4236

@@ -64,7 +58,7 @@ const AddNewPaymentMethodModal = ({
6458
}
6559

6660
const loadPaymentForm = async () => {
67-
if (visible && captchaRef && captchaLoaded) {
61+
if (visible && captchaRef) {
6862
let token = captchaToken
6963

7064
try {
@@ -82,7 +76,7 @@ const AddNewPaymentMethodModal = ({
8276
}
8377

8478
loadPaymentForm()
85-
}, [visible, captchaRef, captchaLoaded])
79+
}, [visible, captchaRef])
8680

8781
const resetCaptcha = () => {
8882
setCaptchaToken(null)
@@ -142,8 +136,6 @@ const AddNewPaymentMethodModal = ({
142136
returnUrl={returnUrl}
143137
onCancel={onLocalCancel}
144138
onConfirm={onLocalConfirm}
145-
showSetDefaultCheckbox={showSetDefaultCheckbox}
146-
autoMarkAsDefaultPaymentMethod={autoMarkAsDefaultPaymentMethod}
147139
/>
148140
</Elements>
149141
</Modal>

apps/studio/components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx

Lines changed: 107 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,77 @@
1-
import { PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js'
21
import { useQueryClient } from '@tanstack/react-query'
2+
import {
3+
NewPaymentMethodElement,
4+
type PaymentMethodElementRef,
5+
} from 'components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement'
36
import { organizationKeys } from 'data/organizations/keys'
7+
import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query'
8+
import { useOrganizationCustomerProfileUpdateMutation } from 'data/organizations/organization-customer-profile-update-mutation'
49
import { useOrganizationPaymentMethodMarkAsDefaultMutation } from 'data/organizations/organization-payment-method-default-mutation'
10+
import { useOrganizationTaxIdQuery } from 'data/organizations/organization-tax-id-query'
11+
import { useOrganizationTaxIdUpdateMutation } from 'data/organizations/organization-tax-id-update-mutation'
512
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
6-
import { useState } from 'react'
13+
import { isEqual } from 'lodash'
14+
import { useRef, useState } from 'react'
715
import { toast } from 'sonner'
816
import { Button, Checkbox_Shadcn_, Label_Shadcn_, Modal } from 'ui'
17+
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
918

1019
interface AddPaymentMethodFormProps {
1120
returnUrl: string
1221
onCancel: () => void
1322
onConfirm: () => void
14-
showSetDefaultCheckbox?: boolean
15-
autoMarkAsDefaultPaymentMethod?: boolean
1623
}
1724

1825
// Stripe docs recommend to use the new SetupIntent flow over
1926
// manually creating and attaching payment methods via the API
2027
// Small UX annoyance here, that the page will be refreshed
2128

22-
const AddPaymentMethodForm = ({
23-
returnUrl,
24-
onCancel,
25-
onConfirm,
26-
showSetDefaultCheckbox = false,
27-
autoMarkAsDefaultPaymentMethod = false,
28-
}: AddPaymentMethodFormProps) => {
29-
const stripe = useStripe()
30-
const elements = useElements()
29+
const AddPaymentMethodForm = ({ onCancel, onConfirm }: AddPaymentMethodFormProps) => {
3130
const selectedOrganization = useSelectedOrganization()
3231

32+
const { data: customerProfile, isLoading: customerProfileLoading } =
33+
useOrganizationCustomerProfileQuery({
34+
slug: selectedOrganization?.slug,
35+
})
36+
3337
const [isSaving, setIsSaving] = useState(false)
34-
const [isDefault, setIsDefault] = useState(showSetDefaultCheckbox)
38+
const [isDefaultPaymentMethod, setIsDefaultPaymentMethod] = useState(true)
39+
const [isPrimaryBillingAddress, setIsPrimaryBillingAddress] = useState(true)
3540

3641
const queryClient = useQueryClient()
3742
const { mutateAsync: markAsDefault } = useOrganizationPaymentMethodMarkAsDefaultMutation()
43+
const { mutateAsync: updateCustomerProfile } = useOrganizationCustomerProfileUpdateMutation()
44+
const { mutateAsync: updateTaxId } = useOrganizationTaxIdUpdateMutation()
45+
const { data: taxId, isLoading: isCustomerTaxIdLoading } = useOrganizationTaxIdQuery({
46+
slug: selectedOrganization?.slug,
47+
})
48+
49+
const paymentRef = useRef<PaymentMethodElementRef | null>(null)
3850

3951
const handleSubmit = async (event: any) => {
4052
event.preventDefault()
4153

42-
if (!stripe || !elements) {
43-
console.error('Stripe.js has not loaded')
44-
return
45-
}
46-
4754
setIsSaving(true)
4855

4956
if (document !== undefined) {
5057
// [Joshen] This is to ensure that any 3DS popup from Stripe remains clickable
5158
document.body.classList.add('!pointer-events-auto')
5259
}
5360

54-
const { error, setupIntent } = await stripe.confirmSetup({
55-
elements,
56-
redirect: 'if_required',
57-
confirmParams: { return_url: returnUrl },
58-
})
61+
const result = await paymentRef.current?.confirmSetup()
5962

60-
if (error) {
63+
if (!result) {
6164
setIsSaving(false)
62-
toast.error(error?.message ?? ' Failed to save card details')
6365
} else {
6466
if (
65-
(isDefault || autoMarkAsDefaultPaymentMethod) &&
67+
isDefaultPaymentMethod &&
6668
selectedOrganization &&
67-
typeof setupIntent?.payment_method === 'string'
69+
typeof result.setupIntent?.payment_method === 'string'
6870
) {
6971
try {
7072
await markAsDefault({
7173
slug: selectedOrganization.slug,
72-
paymentMethodId: setupIntent.payment_method,
74+
paymentMethodId: result.setupIntent.payment_method,
7375
})
7476

7577
await queryClient.invalidateQueries(
@@ -82,10 +84,10 @@ const AddPaymentMethodForm = ({
8284
if (!prev) return prev
8385
return {
8486
...prev,
85-
defaultPaymentMethodId: setupIntent.payment_method,
87+
defaultPaymentMethodId: result.setupIntent.payment_method,
8688
data: prev.data.map((pm: any) => ({
8789
...pm,
88-
is_default: pm.id === setupIntent.payment_method,
90+
is_default: pm.id === result.setupIntent.payment_method,
8991
})),
9092
}
9193
}
@@ -101,6 +103,28 @@ const AddPaymentMethodForm = ({
101103
}
102104
}
103105

106+
if (isPrimaryBillingAddress) {
107+
try {
108+
if (
109+
result.address &&
110+
(!isEqual(result.address, customerProfile?.address) ||
111+
customerProfile?.billing_name !== result.customerName)
112+
) {
113+
await updateCustomerProfile({
114+
slug: selectedOrganization?.slug,
115+
billing_name: result.customerName,
116+
address: result.address,
117+
})
118+
}
119+
120+
if (result.taxId && !isEqual(result.taxId, taxId)) {
121+
await updateTaxId({ taxId: result.taxId, slug: selectedOrganization?.slug })
122+
}
123+
} catch (error) {
124+
toast.error('Failed to update billing address')
125+
}
126+
}
127+
104128
setIsSaving(false)
105129
onConfirm()
106130
}
@@ -110,33 +134,64 @@ const AddPaymentMethodForm = ({
110134
}
111135
}
112136

137+
if (customerProfileLoading || isCustomerTaxIdLoading) {
138+
return (
139+
<Modal.Content>
140+
<div className="space-y-2">
141+
<ShimmeringLoader />
142+
<ShimmeringLoader className="w-3/4" />
143+
<ShimmeringLoader className="w-1/2" />
144+
<ShimmeringLoader />
145+
<ShimmeringLoader />
146+
<ShimmeringLoader />
147+
</div>
148+
</Modal.Content>
149+
)
150+
}
151+
113152
return (
114153
<div>
115154
<Modal.Content
116155
className={`transition ${isSaving ? 'pointer-events-none opacity-75' : 'opacity-100'}`}
117156
>
118-
<PaymentElement
119-
className="[.p-LinkAutofillPrompt]:pt-0"
120-
options={{
121-
defaultValues: { billingDetails: { email: selectedOrganization?.billing_email ?? '' } },
122-
}}
157+
<NewPaymentMethodElement
158+
readOnly={isSaving}
159+
email={selectedOrganization?.billing_email}
160+
currentAddress={customerProfile?.address}
161+
customerName={customerProfile?.billing_name}
162+
currentTaxId={taxId}
163+
ref={paymentRef}
123164
/>
124-
{showSetDefaultCheckbox && (
125-
<div className="flex items-center gap-x-2 mt-4 mb-2">
126-
<Checkbox_Shadcn_
127-
id="save-as-default"
128-
checked={isDefault}
129-
onCheckedChange={(checked) => {
130-
if (typeof checked === 'boolean') {
131-
setIsDefault(checked)
132-
}
133-
}}
134-
/>
135-
<Label_Shadcn_ htmlFor="save-as-default" className="text-foreground-light">
136-
Save as default payment method
137-
</Label_Shadcn_>
138-
</div>
139-
)}
165+
166+
<div className="flex items-center gap-x-2 mt-4 mb-2">
167+
<Checkbox_Shadcn_
168+
id="save-as-default"
169+
checked={isDefaultPaymentMethod}
170+
onCheckedChange={(checked) => {
171+
if (typeof checked === 'boolean') {
172+
setIsDefaultPaymentMethod(checked)
173+
}
174+
}}
175+
/>
176+
<Label_Shadcn_ htmlFor="save-as-default" className="text-foreground-light">
177+
Save as default payment method
178+
</Label_Shadcn_>
179+
</div>
180+
181+
<div className="flex items-center gap-x-2 mt-4 mb-2">
182+
<Checkbox_Shadcn_
183+
id="is-primary-billing-address"
184+
checked={isPrimaryBillingAddress}
185+
onCheckedChange={(checked) => {
186+
if (typeof checked === 'boolean') {
187+
setIsPrimaryBillingAddress(checked)
188+
}
189+
}}
190+
/>
191+
<Label_Shadcn_ htmlFor="is-primary-billing-address" className="text-foreground-light">
192+
Use the billing address as my organization's primary address
193+
</Label_Shadcn_>
194+
</div>
140195
</Modal.Content>
141196
<Modal.Separator />
142197
<Modal.Content className="flex items-center space-x-2">

apps/studio/components/interfaces/Billing/Payment/Payment.utils.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export const getStripeElementsAppearanceOptions = (
44
resolvedTheme: string | undefined
55
): Appearance => {
66
return {
7+
labels: 'floating',
78
theme: (resolvedTheme?.includes('dark') ? 'night' : 'flat') as 'night' | 'flat',
89
variables: {
910
fontSizeBase: '14px',
@@ -12,18 +13,27 @@ export const getStripeElementsAppearanceOptions = (
1213
: 'hsl(0deg 0% 95.3%)',
1314
fontFamily:
1415
'var(--font-custom, Circular, custom-font, Helvetica Neue, Helvetica, Arial, sans-serif)',
15-
spacingUnit: '4px',
16-
borderRadius: '.375rem',
17-
gridRowSpacing: '4px',
1816
},
1917
rules: {
20-
'.Label': {
21-
// Hide labels - it is obvious enough what the fields are for
22-
fontSize: '0',
23-
},
2418
'.TermsText': {
2519
fontSize: '12px',
2620
},
21+
'.Label--floating': {
22+
fontSize: '14px',
23+
},
24+
'.Label--resting': {
25+
fontSize: '14px',
26+
color: 'rgb(137, 137, 137)',
27+
},
28+
'.Input': {
29+
boxShadow: 'none',
30+
height: '34px',
31+
lineHeight: '16px',
32+
padding: '8px 12px',
33+
},
34+
'.AccordionItem': {
35+
boxShadow: 'none',
36+
},
2737
},
2838
}
2939
}

apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.tsx

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,18 @@ import {
2020
useAsyncCheckProjectPermissions,
2121
useCheckPermissions,
2222
} from 'hooks/misc/useCheckPermissions'
23-
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
23+
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
2424
import { Button, Card, CardFooter, Form_Shadcn_ as Form } from 'ui'
25-
import { BillingCustomerDataForm } from './BillingCustomerDataForm'
25+
import {
26+
BillingCustomerDataForm,
27+
type BillingCustomerDataFormValues,
28+
} from './BillingCustomerDataForm'
2629
import { TAX_IDS } from './TaxID.constants'
2730
import { useBillingCustomerDataForm } from './useBillingCustomerDataForm'
2831

2932
export const BillingCustomerData = () => {
3033
const { slug } = useParams()
31-
const selectedOrganization = useSelectedOrganization()
34+
const { data: selectedOrganization } = useSelectedOrganizationQuery()
3235

3336
const { isSuccess: isPermissionsLoaded, can: canReadBillingCustomerData } =
3437
useAsyncCheckProjectPermissions(PermissionAction.BILLING_READ, 'stripe.customer')
@@ -51,9 +54,13 @@ export const BillingCustomerData = () => {
5154
isSuccess: loadedTaxId,
5255
} = useOrganizationTaxIdQuery({ slug })
5356

54-
const initialCustomerData = useMemo(
57+
const initialCustomerData = useMemo<Partial<BillingCustomerDataFormValues>>(
5558
() => ({
56-
...customerProfile?.address,
59+
city: customerProfile?.address?.city ?? undefined,
60+
country: customerProfile?.address?.country,
61+
line1: customerProfile?.address?.line1,
62+
line2: customerProfile?.address?.line2 ?? undefined,
63+
postal_code: customerProfile?.address?.postal_code ?? undefined,
5764
billing_name: customerProfile?.billing_name,
5865
tax_id_type: taxId?.type,
5966
tax_id_value: taxId?.value,

0 commit comments

Comments
 (0)