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