BillingCustomerData.tsx256 lines · main
1import { Elements } from '@stripe/react-stripe-js'
2import { loadStripe, StripeAddressElement, StripeElementsOptions } from '@stripe/stripe-js'
3import { PermissionAction } from '@supabase/shared-types/out/constants'
4import { useQueryClient } from '@tanstack/react-query'
5import { useParams } from 'common'
6import { useTheme } from 'next-themes'
7import { useEffect, useMemo, useRef, useState } from 'react'
8import { toast } from 'sonner'
9import { Button, Card, CardFooter, Form } from 'ui'
10import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
11
12import { BillingCustomerDataForm } from './BillingCustomerDataForm'
13import { useBillingCustomerDataForm } from './useBillingCustomerDataForm'
14import {
15 getAddressElementAppearanceOptions,
16 STRIPE_ELEMENT_FONTS,
17} from '@/components/interfaces/Billing/Payment/Payment.utils'
18import {
19 ScaffoldSection,
20 ScaffoldSectionContent,
21 ScaffoldSectionDetail,
22} from '@/components/layouts/Scaffold'
23import AlertError from '@/components/ui/AlertError'
24import NoPermission from '@/components/ui/NoPermission'
25import PartnerManagedResource from '@/components/ui/PartnerManagedResource'
26import { organizationKeys } from '@/data/organizations/keys'
27import { isPartnerBillingOrganization } from '@/data/organizations/managed-by-utils'
28import { useOrganizationCustomerProfileQuery } from '@/data/organizations/organization-customer-profile-query'
29import { useOrganizationCustomerProfileUpdateMutation } from '@/data/organizations/organization-customer-profile-update-mutation'
30import { useOrganizationTaxIdQuery } from '@/data/organizations/organization-tax-id-query'
31import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
32import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
33import { STRIPE_PUBLIC_KEY } from '@/lib/constants'
34
35const stripePromise = loadStripe(STRIPE_PUBLIC_KEY)
36
37export const BillingCustomerData = () => {
38 const { slug } = useParams()
39 const queryClient = useQueryClient()
40 const { resolvedTheme } = useTheme()
41 const { data: selectedOrganization } = useSelectedOrganizationQuery()
42
43 const { can: canReadBillingCustomerData, isSuccess: isPermissionsLoaded } =
44 useAsyncCheckPermissions(PermissionAction.BILLING_READ, 'stripe.customer')
45 const { can: canUpdateBillingCustomerData } = useAsyncCheckPermissions(
46 PermissionAction.BILLING_WRITE,
47 'stripe.customer'
48 )
49
50 const {
51 data: customerProfile,
52 error,
53 isPending: isLoading,
54 isSuccess,
55 } = useOrganizationCustomerProfileQuery({ slug }, { enabled: canReadBillingCustomerData })
56
57 const {
58 data: taxId,
59 error: errorLoadingTaxId,
60 isPending: isLoadingTaxId,
61 isSuccess: loadedTaxId,
62 } = useOrganizationTaxIdQuery({ slug })
63
64 const { mutateAsync: updateCustomerProfile } = useOrganizationCustomerProfileUpdateMutation({
65 onError: () => {},
66 })
67
68 const [isSubmitting, setIsSubmitting] = useState(false)
69 const addressElementRef = useRef<StripeAddressElement | null>(null)
70
71 const {
72 form,
73 handleSubmit,
74 handleReset,
75 isDirty,
76 resetKey,
77 onAddressChange,
78 applyAddressElementValue,
79 markCurrentValuesAsSaved,
80 addressCountry,
81 addressOptions,
82 } = useBillingCustomerDataForm({
83 customerProfile,
84 taxId,
85 onCustomerDataChange: async (data) => {
86 setIsSubmitting(true)
87
88 try {
89 await updateCustomerProfile({
90 slug,
91 address: data.address,
92 billing_name: data.billing_name,
93 tax_id: data.tax_id,
94 })
95
96 toast.success('Successfully updated billing data')
97
98 queryClient.setQueriesData<any[]>(
99 { queryKey: organizationKeys.list(), exact: true },
100 (prev) => {
101 if (!prev) return prev
102 return prev.map((org) =>
103 org.slug === slug
104 ? {
105 ...org,
106 ...(data.address !== undefined ? { organization_missing_address: false } : {}),
107 ...(data.tax_id !== undefined
108 ? { organization_missing_tax_id: data.tax_id == null }
109 : {}),
110 }
111 : org
112 )
113 }
114 )
115 } catch (error) {
116 toast.error(
117 `Failed updating billing data: ${error instanceof Error ? error.message : 'Unknown error'}`
118 )
119 throw error
120 } finally {
121 setIsSubmitting(false)
122 }
123 },
124 })
125
126 useEffect(() => {
127 addressElementRef.current = null
128 }, [resetKey])
129
130 const onFormSubmit = async (e: React.FormEvent) => {
131 e.preventDefault()
132 try {
133 if (addressElementRef.current) {
134 const addressResult = await addressElementRef.current.getValue()
135 applyAddressElementValue(addressResult)
136 }
137 const result = await handleSubmit()
138 if (result.status === 'error') {
139 toast.error(result.message)
140 return
141 }
142 markCurrentValuesAsSaved(
143 result.submittedState.addressValue,
144 result.submittedState.taxIdValues
145 )
146 } catch {
147 // Save failure toasts are handled inside onCustomerDataChange.
148 }
149 }
150
151 const isSubmitDisabled = !isDirty || !canUpdateBillingCustomerData || isSubmitting
152
153 const stripeElementsOptions: StripeElementsOptions = useMemo(
154 () =>
155 ({
156 mode: 'setup',
157 currency: 'usd',
158 appearance: getAddressElementAppearanceOptions(resolvedTheme),
159 fonts: STRIPE_ELEMENT_FONTS,
160 }) as any,
161 [resolvedTheme]
162 )
163 const isPartnerBilledOrganization = isPartnerBillingOrganization(
164 selectedOrganization?.billing_partner
165 )
166
167 return (
168 <ScaffoldSection>
169 <ScaffoldSectionDetail>
170 <div className="sticky space-y-2 top-12 pr-3">
171 <p className="text-foreground text-base m-0">Billing Address &amp; Tax ID</p>
172 <p className="text-sm text-foreground-light m-0">
173 Changes will be reflected in every upcoming invoice, past invoices are not affected
174 </p>
175 <p className="text-sm text-foreground-light m-0">
176 A Tax ID is only required for registered businesses.
177 </p>
178 </div>
179 </ScaffoldSectionDetail>
180 <ScaffoldSectionContent>
181 {selectedOrganization && isPartnerBilledOrganization ? (
182 <PartnerManagedResource
183 managedBy={selectedOrganization?.managed_by}
184 resource="Billing Addresses"
185 cta={{
186 installationId: selectedOrganization?.partner_id,
187 }}
188 />
189 ) : isPermissionsLoaded && !canReadBillingCustomerData ? (
190 <NoPermission resourceText="view this organization's billing address" />
191 ) : (
192 <>
193 {(isLoading || isLoadingTaxId) && (
194 <div className="space-y-2">
195 <ShimmeringLoader />
196 <ShimmeringLoader className="w-3/4" />
197 <ShimmeringLoader className="w-1/2" />
198 </div>
199 )}
200
201 {(error || errorLoadingTaxId) && (
202 <AlertError
203 subject="Failed to retrieve organization customer profile"
204 error={(error || errorLoadingTaxId) as any}
205 />
206 )}
207
208 {isSuccess && loadedTaxId && (
209 <Elements stripe={stripePromise} options={stripeElementsOptions}>
210 <Card>
211 <Form {...form}>
212 <form onSubmit={onFormSubmit}>
213 <BillingCustomerDataForm
214 className="p-8"
215 form={form}
216 disabled={!canUpdateBillingCustomerData}
217 addressOptions={addressOptions}
218 resetKey={resetKey}
219 onAddressChange={onAddressChange}
220 onAddressReady={(element) => {
221 addressElementRef.current = element
222 }}
223 addressCountry={addressCountry}
224 />
225 <CardFooter className="border-t justify-end px-8">
226 {!canUpdateBillingCustomerData && (
227 <span className="text-sm text-foreground-lighter mr-auto">
228 You need additional permissions to manage this organization's billing
229 address
230 </span>
231 )}
232 <div className="flex items-center gap-2">
233 <Button type="default" onClick={handleReset} disabled={isSubmitDisabled}>
234 Cancel
235 </Button>
236 <Button
237 type="primary"
238 htmlType="submit"
239 disabled={isSubmitDisabled}
240 loading={isSubmitting}
241 >
242 Save
243 </Button>
244 </div>
245 </CardFooter>
246 </form>
247 </Form>
248 </Card>
249 </Elements>
250 )}
251 </>
252 )}
253 </ScaffoldSectionContent>
254 </ScaffoldSection>
255 )
256}