Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/docs/public/humans.txt
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Chris Chandler
Chris Copplestone
Chris Gwilliams
Chris Stockton
Chris Ward
Craig Cannon
Danny White
Darren Cunningham
Expand Down Expand Up @@ -54,6 +55,7 @@ Inian P
Ivan Vasilov
Jenny Kibiri
Jess Shears
Jim Chanco Jr
John Pena
Jon M
Jonny Summers-Muir
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import { Modal } from 'ui'
import { useOrganizationPaymentMethodSetupIntent } from 'data/organizations/organization-payment-method-setup-intent-mutation'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { STRIPE_PUBLIC_KEY } from 'lib/constants'
import { useIsHCaptchaLoaded } from 'stores/hcaptcha-loaded-store'
import AddPaymentMethodForm from './AddPaymentMethodForm'
import { getStripeElementsAppearanceOptions } from './Payment.utils'

Expand All @@ -18,8 +17,6 @@ interface AddNewPaymentMethodModalProps {
returnUrl: string
onCancel: () => void
onConfirm: () => void
showSetDefaultCheckbox?: boolean
autoMarkAsDefaultPaymentMethod?: boolean
}

const stripePromise = loadStripe(STRIPE_PUBLIC_KEY)
Expand All @@ -29,14 +26,11 @@ const AddNewPaymentMethodModal = ({
returnUrl,
onCancel,
onConfirm,
showSetDefaultCheckbox,
autoMarkAsDefaultPaymentMethod,
}: AddNewPaymentMethodModalProps) => {
const { resolvedTheme } = useTheme()
const [intent, setIntent] = useState<any>()
const selectedOrganization = useSelectedOrganization()

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

Expand Down Expand Up @@ -64,7 +58,7 @@ const AddNewPaymentMethodModal = ({
}

const loadPaymentForm = async () => {
if (visible && captchaRef && captchaLoaded) {
if (visible && captchaRef) {
let token = captchaToken

try {
Expand All @@ -82,7 +76,7 @@ const AddNewPaymentMethodModal = ({
}

loadPaymentForm()
}, [visible, captchaRef, captchaLoaded])
}, [visible, captchaRef])

const resetCaptcha = () => {
setCaptchaToken(null)
Expand Down Expand Up @@ -142,8 +136,6 @@ const AddNewPaymentMethodModal = ({
returnUrl={returnUrl}
onCancel={onLocalCancel}
onConfirm={onLocalConfirm}
showSetDefaultCheckbox={showSetDefaultCheckbox}
autoMarkAsDefaultPaymentMethod={autoMarkAsDefaultPaymentMethod}
/>
</Elements>
</Modal>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,75 +1,77 @@
import { PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js'
import { useQueryClient } from '@tanstack/react-query'
import {
NewPaymentMethodElement,
type PaymentMethodElementRef,
} from 'components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement'
import { organizationKeys } from 'data/organizations/keys'
import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query'
import { useOrganizationCustomerProfileUpdateMutation } from 'data/organizations/organization-customer-profile-update-mutation'
import { useOrganizationPaymentMethodMarkAsDefaultMutation } from 'data/organizations/organization-payment-method-default-mutation'
import { useOrganizationTaxIdQuery } from 'data/organizations/organization-tax-id-query'
import { useOrganizationTaxIdUpdateMutation } from 'data/organizations/organization-tax-id-update-mutation'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useState } from 'react'
import { isEqual } from 'lodash'
import { useRef, useState } from 'react'
import { toast } from 'sonner'
import { Button, Checkbox_Shadcn_, Label_Shadcn_, Modal } from 'ui'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'

interface AddPaymentMethodFormProps {
returnUrl: string
onCancel: () => void
onConfirm: () => void
showSetDefaultCheckbox?: boolean
autoMarkAsDefaultPaymentMethod?: boolean
}

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

const AddPaymentMethodForm = ({
returnUrl,
onCancel,
onConfirm,
showSetDefaultCheckbox = false,
autoMarkAsDefaultPaymentMethod = false,
}: AddPaymentMethodFormProps) => {
const stripe = useStripe()
const elements = useElements()
const AddPaymentMethodForm = ({ onCancel, onConfirm }: AddPaymentMethodFormProps) => {
const selectedOrganization = useSelectedOrganization()

const { data: customerProfile, isLoading: customerProfileLoading } =
useOrganizationCustomerProfileQuery({
slug: selectedOrganization?.slug,
})

const [isSaving, setIsSaving] = useState(false)
const [isDefault, setIsDefault] = useState(showSetDefaultCheckbox)
const [isDefaultPaymentMethod, setIsDefaultPaymentMethod] = useState(true)
const [isPrimaryBillingAddress, setIsPrimaryBillingAddress] = useState(true)

const queryClient = useQueryClient()
const { mutateAsync: markAsDefault } = useOrganizationPaymentMethodMarkAsDefaultMutation()
const { mutateAsync: updateCustomerProfile } = useOrganizationCustomerProfileUpdateMutation()
const { mutateAsync: updateTaxId } = useOrganizationTaxIdUpdateMutation()
const { data: taxId, isLoading: isCustomerTaxIdLoading } = useOrganizationTaxIdQuery({
slug: selectedOrganization?.slug,
})

const paymentRef = useRef<PaymentMethodElementRef | null>(null)

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

if (!stripe || !elements) {
console.error('Stripe.js has not loaded')
return
}

setIsSaving(true)

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

const { error, setupIntent } = await stripe.confirmSetup({
elements,
redirect: 'if_required',
confirmParams: { return_url: returnUrl },
})
const result = await paymentRef.current?.confirmSetup()

if (error) {
if (!result) {
setIsSaving(false)
toast.error(error?.message ?? ' Failed to save card details')
} else {
if (
(isDefault || autoMarkAsDefaultPaymentMethod) &&
isDefaultPaymentMethod &&
selectedOrganization &&
typeof setupIntent?.payment_method === 'string'
typeof result.setupIntent?.payment_method === 'string'
) {
try {
await markAsDefault({
slug: selectedOrganization.slug,
paymentMethodId: setupIntent.payment_method,
paymentMethodId: result.setupIntent.payment_method,
})

await queryClient.invalidateQueries(
Expand All @@ -82,10 +84,10 @@ const AddPaymentMethodForm = ({
if (!prev) return prev
return {
...prev,
defaultPaymentMethodId: setupIntent.payment_method,
defaultPaymentMethodId: result.setupIntent.payment_method,
data: prev.data.map((pm: any) => ({
...pm,
is_default: pm.id === setupIntent.payment_method,
is_default: pm.id === result.setupIntent.payment_method,
})),
}
}
Expand All @@ -101,6 +103,28 @@ const AddPaymentMethodForm = ({
}
}

if (isPrimaryBillingAddress) {
try {
if (
result.address &&
(!isEqual(result.address, customerProfile?.address) ||
customerProfile?.billing_name !== result.customerName)
) {
await updateCustomerProfile({
slug: selectedOrganization?.slug,
billing_name: result.customerName,
address: result.address,
})
}

if (result.taxId && !isEqual(result.taxId, taxId)) {
await updateTaxId({ taxId: result.taxId, slug: selectedOrganization?.slug })
}
} catch (error) {
toast.error('Failed to update billing address')
}
}

setIsSaving(false)
onConfirm()
}
Expand All @@ -110,33 +134,64 @@ const AddPaymentMethodForm = ({
}
}

if (customerProfileLoading || isCustomerTaxIdLoading) {
return (
<Modal.Content>
<div className="space-y-2">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
<ShimmeringLoader />
<ShimmeringLoader />
<ShimmeringLoader />
</div>
</Modal.Content>
)
}

return (
<div>
<Modal.Content
className={`transition ${isSaving ? 'pointer-events-none opacity-75' : 'opacity-100'}`}
>
<PaymentElement
className="[.p-LinkAutofillPrompt]:pt-0"
options={{
defaultValues: { billingDetails: { email: selectedOrganization?.billing_email ?? '' } },
}}
<NewPaymentMethodElement
readOnly={isSaving}
email={selectedOrganization?.billing_email}
currentAddress={customerProfile?.address}
customerName={customerProfile?.billing_name}
currentTaxId={taxId}
ref={paymentRef}
/>
{showSetDefaultCheckbox && (
<div className="flex items-center gap-x-2 mt-4 mb-2">
<Checkbox_Shadcn_
id="save-as-default"
checked={isDefault}
onCheckedChange={(checked) => {
if (typeof checked === 'boolean') {
setIsDefault(checked)
}
}}
/>
<Label_Shadcn_ htmlFor="save-as-default" className="text-foreground-light">
Save as default payment method
</Label_Shadcn_>
</div>
)}

<div className="flex items-center gap-x-2 mt-4 mb-2">
<Checkbox_Shadcn_
id="save-as-default"
checked={isDefaultPaymentMethod}
onCheckedChange={(checked) => {
if (typeof checked === 'boolean') {
setIsDefaultPaymentMethod(checked)
}
}}
/>
<Label_Shadcn_ htmlFor="save-as-default" className="text-foreground-light">
Save as default payment method
</Label_Shadcn_>
</div>

<div className="flex items-center gap-x-2 mt-4 mb-2">
<Checkbox_Shadcn_
id="is-primary-billing-address"
checked={isPrimaryBillingAddress}
onCheckedChange={(checked) => {
if (typeof checked === 'boolean') {
setIsPrimaryBillingAddress(checked)
}
}}
/>
<Label_Shadcn_ htmlFor="is-primary-billing-address" className="text-foreground-light">
Use the billing address as my organization's primary address
</Label_Shadcn_>
</div>
</Modal.Content>
<Modal.Separator />
<Modal.Content className="flex items-center space-x-2">
Expand Down
24 changes: 17 additions & 7 deletions apps/studio/components/interfaces/Billing/Payment/Payment.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export const getStripeElementsAppearanceOptions = (
resolvedTheme: string | undefined
): Appearance => {
return {
labels: 'floating',
theme: (resolvedTheme?.includes('dark') ? 'night' : 'flat') as 'night' | 'flat',
variables: {
fontSizeBase: '14px',
Expand All @@ -12,18 +13,27 @@ export const getStripeElementsAppearanceOptions = (
: 'hsl(0deg 0% 95.3%)',
fontFamily:
'var(--font-custom, Circular, custom-font, Helvetica Neue, Helvetica, Arial, sans-serif)',
spacingUnit: '4px',
borderRadius: '.375rem',
gridRowSpacing: '4px',
},
rules: {
'.Label': {
// Hide labels - it is obvious enough what the fields are for
fontSize: '0',
},
'.TermsText': {
fontSize: '12px',
},
'.Label--floating': {
fontSize: '14px',
},
'.Label--resting': {
fontSize: '14px',
color: 'rgb(137, 137, 137)',
},
'.Input': {
boxShadow: 'none',
height: '34px',
lineHeight: '16px',
padding: '8px 12px',
},
'.AccordionItem': {
boxShadow: 'none',
},
},
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,18 @@ import {
useAsyncCheckProjectPermissions,
useCheckPermissions,
} from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { Button, Card, CardFooter, Form_Shadcn_ as Form } from 'ui'
import { BillingCustomerDataForm } from './BillingCustomerDataForm'
import {
BillingCustomerDataForm,
type BillingCustomerDataFormValues,
} from './BillingCustomerDataForm'
import { TAX_IDS } from './TaxID.constants'
import { useBillingCustomerDataForm } from './useBillingCustomerDataForm'

export const BillingCustomerData = () => {
const { slug } = useParams()
const selectedOrganization = useSelectedOrganization()
const { data: selectedOrganization } = useSelectedOrganizationQuery()

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

const initialCustomerData = useMemo(
const initialCustomerData = useMemo<Partial<BillingCustomerDataFormValues>>(
() => ({
...customerProfile?.address,
city: customerProfile?.address?.city ?? undefined,
country: customerProfile?.address?.country,
line1: customerProfile?.address?.line1,
line2: customerProfile?.address?.line2 ?? undefined,
postal_code: customerProfile?.address?.postal_code ?? undefined,
billing_name: customerProfile?.billing_name,
tax_id_type: taxId?.type,
tax_id_value: taxId?.value,
Expand Down
Loading
Loading