BillingCustomerData.tsx 9.0 KB

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