useBillingCustomerDataForm.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. import { zodResolver } from '@hookform/resolvers/zod'
  2. import type {
  3. StripeAddressElement,
  4. StripeAddressElementChangeEvent,
  5. StripeAddressElementOptions,
  6. } from '@stripe/stripe-js'
  7. import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
  8. import { useForm } from 'react-hook-form'
  9. import { TaxIdFormValues, TaxIdSchema } from './BillingCustomerDataForm'
  10. import { TAX_IDS } from './TaxID.constants'
  11. import { getEffectiveTaxCountry, resolveStoredTaxId, sanitizeTaxIdValue } from './TaxID.utils'
  12. import type { CustomerAddress, CustomerTaxId } from '@/data/organizations/types'
  13. type StripeAddressValue = StripeAddressElementChangeEvent['value']
  14. type StripeAddressValidationState = 'unknown' | 'complete' | 'incomplete'
  15. type StripeAddressElementValueResult = Awaited<ReturnType<StripeAddressElement['getValue']>>
  16. interface UseBillingCustomerDataFormProps {
  17. customerProfile?: {
  18. address?: CustomerAddress | null
  19. billing_name?: string
  20. } | null
  21. taxId?: CustomerTaxId | null
  22. onCustomerDataChange: (data: BillingAddressPayload) => Promise<void>
  23. }
  24. export type BillingAddressPayload = {
  25. address: CustomerAddress | undefined
  26. billing_name: string
  27. tax_id: CustomerTaxId | null
  28. }
  29. type SubmittedBillingFormState = {
  30. addressValue: StripeAddressValue
  31. taxIdValues: TaxIdFormValues
  32. }
  33. type BillingFormSubmitResult =
  34. | {
  35. status: 'error'
  36. message: string
  37. }
  38. | {
  39. status: 'success'
  40. submittedState: SubmittedBillingFormState
  41. }
  42. export function useBillingCustomerDataForm({
  43. customerProfile,
  44. taxId,
  45. onCustomerDataChange,
  46. }: UseBillingCustomerDataFormProps) {
  47. const initialStripeAddressValue: StripeAddressValue = useMemo(
  48. () => ({
  49. name: customerProfile?.billing_name ?? '',
  50. address: {
  51. line1: customerProfile?.address?.line1 ?? '',
  52. line2: customerProfile?.address?.line2 ?? '',
  53. city: customerProfile?.address?.city ?? '',
  54. state: customerProfile?.address?.state ?? '',
  55. postal_code: customerProfile?.address?.postal_code ?? '',
  56. country: customerProfile?.address?.country ?? '',
  57. },
  58. }),
  59. [customerProfile]
  60. )
  61. const addressOptions: StripeAddressElementOptions = useMemo(
  62. () => ({
  63. mode: 'billing',
  64. fields: { phone: 'never' },
  65. autocomplete: {
  66. apiKey: process.env.NEXT_PUBLIC_GOOGLE_MAPS_KEY!,
  67. mode: 'google_maps_api' as const,
  68. },
  69. display: { name: 'full' as const },
  70. defaultValues: initialStripeAddressValue,
  71. }),
  72. [initialStripeAddressValue]
  73. )
  74. const initialTaxIdValues = useMemo(
  75. () => ({
  76. tax_id_type: taxId?.type || '',
  77. tax_id_value: taxId?.value || '',
  78. tax_id_name: taxId
  79. ? (resolveStoredTaxId(taxId.type, taxId.country, customerProfile?.address?.country)?.name ??
  80. '')
  81. : '',
  82. }),
  83. [customerProfile, taxId]
  84. )
  85. const form = useForm<TaxIdFormValues>({
  86. resolver: zodResolver(TaxIdSchema as any),
  87. defaultValues: initialTaxIdValues,
  88. })
  89. const stripeAddressRef = useRef<StripeAddressValue>(initialStripeAddressValue)
  90. const savedStripeAddressRef = useRef<StripeAddressValue>(initialStripeAddressValue)
  91. const stripeAddressValidationRef = useRef<StripeAddressValidationState>('unknown')
  92. const savedTaxIdValuesRef = useRef<TaxIdFormValues>(initialTaxIdValues)
  93. const [isAddressDirty, setIsAddressDirty] = useState(false)
  94. const [addressCountry, setAddressCountry] = useState<string | undefined>(
  95. initialStripeAddressValue.address.country || undefined
  96. )
  97. const [resetKey, setResetKey] = useState(0)
  98. useEffect(() => {
  99. savedStripeAddressRef.current = initialStripeAddressValue
  100. savedTaxIdValuesRef.current = initialTaxIdValues
  101. form.reset(initialTaxIdValues)
  102. stripeAddressRef.current = initialStripeAddressValue
  103. stripeAddressValidationRef.current = 'unknown'
  104. setIsAddressDirty(false)
  105. setAddressCountry(initialStripeAddressValue.address.country || undefined)
  106. setResetKey((c) => c + 1)
  107. // eslint-disable-next-line react-hooks/exhaustive-deps
  108. }, [initialStripeAddressValue, initialTaxIdValues])
  109. const onAddressChange = useCallback((evt: StripeAddressElementChangeEvent) => {
  110. stripeAddressRef.current = evt.value
  111. stripeAddressValidationRef.current = evt.complete ? 'complete' : 'incomplete'
  112. setAddressCountry(evt.value.address.country || undefined)
  113. setIsAddressDirty(!isAddressEqual(evt.value, savedStripeAddressRef.current))
  114. }, [])
  115. const applyAddressElementValue = useCallback((result: StripeAddressElementValueResult) => {
  116. stripeAddressRef.current = result.value
  117. stripeAddressValidationRef.current = result.complete ? 'complete' : 'incomplete'
  118. setAddressCountry(result.value.address.country || undefined)
  119. setIsAddressDirty(!isAddressEqual(result.value, savedStripeAddressRef.current))
  120. }, [])
  121. const isDirty = isAddressDirty || form.formState.isDirty
  122. const syncCurrentState = useCallback(
  123. (addressValue: StripeAddressValue, taxIdValues: TaxIdFormValues) => {
  124. form.reset(taxIdValues)
  125. stripeAddressRef.current = addressValue
  126. stripeAddressValidationRef.current = 'unknown'
  127. setIsAddressDirty(false)
  128. setAddressCountry(addressValue.address.country || undefined)
  129. },
  130. [form]
  131. )
  132. const handleSubmit = async (): Promise<BillingFormSubmitResult> => {
  133. const address = stripeAddressRef.current
  134. const addressWasEdited =
  135. isAddressDirty || !isAddressEqual(address, savedStripeAddressRef.current)
  136. if (!address.name?.trim()) {
  137. return { status: 'error', message: 'Full name is required.' }
  138. }
  139. if (!address.address.country?.trim()) {
  140. return { status: 'error', message: 'Country is required.' }
  141. }
  142. if (!address.address.line1?.trim()) {
  143. return { status: 'error', message: 'Address Line 1 is required.' }
  144. }
  145. if (addressWasEdited && stripeAddressValidationRef.current === 'incomplete') {
  146. return { status: 'error', message: 'Please enter a valid billing address.' }
  147. }
  148. const taxIdValues = form.getValues()
  149. const selectedTaxId = TAX_IDS.find((option) => option.name === taxIdValues.tax_id_name)
  150. const payload = {
  151. address: {
  152. line1: address.address.line1.trim(),
  153. line2: address.address.line2?.trim() || undefined,
  154. city: address.address.city.trim(),
  155. state: address.address.state?.trim() || undefined,
  156. postal_code: address.address.postal_code.trim(),
  157. country: address.address.country.trim(),
  158. },
  159. billing_name: address.name.trim(),
  160. tax_id:
  161. selectedTaxId && taxIdValues.tax_id_type?.length && taxIdValues.tax_id_value?.length
  162. ? {
  163. type: taxIdValues.tax_id_type,
  164. value: sanitizeTaxIdValue({
  165. value: taxIdValues.tax_id_value,
  166. name: taxIdValues.tax_id_name,
  167. }),
  168. country: getEffectiveTaxCountry(selectedTaxId),
  169. }
  170. : null,
  171. }
  172. await onCustomerDataChange(payload)
  173. return {
  174. status: 'success',
  175. submittedState: { addressValue: address, taxIdValues },
  176. }
  177. }
  178. const markCurrentValuesAsSaved = (
  179. addressValue: StripeAddressValue,
  180. taxIdValues: TaxIdFormValues
  181. ) => {
  182. savedStripeAddressRef.current = addressValue
  183. savedTaxIdValuesRef.current = taxIdValues
  184. syncCurrentState(addressValue, taxIdValues)
  185. }
  186. const handleReset = () => {
  187. syncCurrentState(savedStripeAddressRef.current, savedTaxIdValuesRef.current)
  188. setResetKey((c) => c + 1)
  189. }
  190. return {
  191. form,
  192. handleSubmit,
  193. handleReset,
  194. isDirty,
  195. resetKey,
  196. onAddressChange,
  197. applyAddressElementValue,
  198. markCurrentValuesAsSaved,
  199. addressCountry,
  200. addressOptions,
  201. }
  202. }
  203. function isAddressEqual(current: StripeAddressValue, original: StripeAddressValue): boolean {
  204. return (
  205. normalize(current.name) === normalize(original.name) &&
  206. normalize(current.address.line1) === normalize(original.address.line1) &&
  207. normalize(current.address.line2) === normalize(original.address.line2) &&
  208. normalize(current.address.city) === normalize(original.address.city) &&
  209. normalize(current.address.state) === normalize(original.address.state) &&
  210. normalize(current.address.postal_code) === normalize(original.address.postal_code) &&
  211. normalize(current.address.country) === normalize(original.address.country)
  212. )
  213. }
  214. function normalize(val: string | null | undefined): string {
  215. return (val ?? '').trim()
  216. }