useBillingCustomerDataForm.test.tsx581 lines · main
1import type { StripeAddressElementChangeEvent } from '@stripe/stripe-js'
2import { act, renderHook, waitFor } from '@testing-library/react'
3import { describe, expect, it, vi } from 'vitest'
4
5import { useBillingCustomerDataForm } from '@/components/interfaces/Organization/BillingSettings/BillingCustomerData/useBillingCustomerDataForm'
6import type { CustomerAddress, CustomerTaxId } from '@/data/organizations/types'
7
8type BillingProfile = ReturnType<typeof makeCustomerProfile>
9type CustomerChangeHandler = Parameters<
10 typeof useBillingCustomerDataForm
11>[0]['onCustomerDataChange']
12
13type HookPropsWithTaxId = {
14 customerProfile: BillingProfile
15 taxId: CustomerTaxId | null
16 onCustomerDataChange: CustomerChangeHandler
17}
18
19type HookPropsWithExistingTaxId = {
20 customerProfile: BillingProfile
21 taxId: CustomerTaxId
22 onCustomerDataChange: CustomerChangeHandler
23}
24
25type HookPropsWithoutTaxId = {
26 customerProfile: BillingProfile
27 onCustomerDataChange: CustomerChangeHandler
28}
29
30type HookPropsHydrated = {
31 customerProfile: BillingProfile | null | undefined
32 onCustomerDataChange: CustomerChangeHandler
33}
34
35const makeCustomerProfile = (
36 overrides?: Partial<{ address: CustomerAddress; billing_name: string }>
37) => ({
38 billing_name: 'Acme Inc',
39 address: {
40 line1: '123 Main St',
41 line2: 'Suite 100',
42 city: 'New York',
43 state: 'NY',
44 postal_code: '10001',
45 country: 'US',
46 },
47 ...overrides,
48})
49
50const makeTaxId = (overrides?: Partial<CustomerTaxId>): CustomerTaxId => ({
51 type: 'us_ein',
52 value: '12-3456789',
53 country: 'US',
54 ...overrides,
55})
56
57const makeAddressChangeEvent = (
58 overrides?: Partial<{
59 name: string
60 address: Partial<CustomerAddress>
61 complete: boolean
62 }>
63): StripeAddressElementChangeEvent => ({
64 complete: overrides?.complete ?? true,
65 elementType: 'address',
66 elementMode: 'billing',
67 empty: false,
68 isNewAddress: false,
69 value: {
70 address: {
71 city: overrides?.address?.city ?? 'San Francisco',
72 country: overrides?.address?.country ?? 'US',
73 line1: overrides?.address?.line1 ?? '500 Market St',
74 line2: overrides?.address?.line2 ?? '',
75 postal_code: overrides?.address?.postal_code ?? '94105',
76 state: overrides?.address?.state ?? 'CA',
77 },
78 name: overrides?.name ?? 'Updated Company',
79 },
80})
81
82const submitHook = async <TResult,>(submit: () => Promise<TResult>) => {
83 let result: TResult | undefined
84 await act(async () => {
85 result = await submit()
86 })
87 return result as TResult
88}
89
90describe('useBillingCustomerDataForm', () => {
91 it('initializes tax ID fields and address country from the provided profile', () => {
92 const customerProfile = makeCustomerProfile({
93 address: {
94 line1: '1 Infinite Loop',
95 line2: '',
96 city: 'Cupertino',
97 state: 'CA',
98 postal_code: '95014',
99 country: 'US',
100 },
101 })
102 const taxId = makeTaxId()
103
104 const { result } = renderHook(
105 ({ customerProfile, taxId, onCustomerDataChange }: HookPropsWithTaxId) =>
106 useBillingCustomerDataForm({
107 customerProfile,
108 taxId,
109 onCustomerDataChange,
110 }),
111 {
112 initialProps: { customerProfile, taxId, onCustomerDataChange: vi.fn() },
113 }
114 )
115
116 expect(result.current.addressCountry).toBe('US')
117 expect(result.current.form.getValues()).toEqual({
118 tax_id_name: 'US EIN',
119 tax_id_type: 'us_ein',
120 tax_id_value: '12-3456789',
121 })
122 expect(result.current.isDirty).toBe(false)
123 })
124
125 it('returns validation errors when the seeded address is incomplete', async () => {
126 const customerProfile = makeCustomerProfile({
127 billing_name: '',
128 address: {
129 line1: '',
130 line2: '',
131 city: '',
132 state: '',
133 postal_code: '',
134 country: '',
135 },
136 })
137
138 const { result } = renderHook(
139 ({ customerProfile, onCustomerDataChange }: HookPropsWithoutTaxId) =>
140 useBillingCustomerDataForm({
141 customerProfile,
142 taxId: null,
143 onCustomerDataChange,
144 }),
145 {
146 initialProps: { customerProfile, onCustomerDataChange: vi.fn() },
147 }
148 )
149
150 await expect(submitHook(result.current.handleSubmit)).resolves.toEqual({
151 status: 'error',
152 message: 'Full name is required.',
153 })
154 })
155
156 it('blocks submit when Stripe marks the address element as incomplete', async () => {
157 const onCustomerDataChange = vi.fn()
158 const customerProfile = makeCustomerProfile()
159
160 const { result } = renderHook(
161 ({ customerProfile, onCustomerDataChange }: HookPropsWithoutTaxId) =>
162 useBillingCustomerDataForm({
163 customerProfile,
164 taxId: null,
165 onCustomerDataChange,
166 }),
167 {
168 initialProps: { customerProfile, onCustomerDataChange },
169 }
170 )
171
172 act(() => {
173 result.current.onAddressChange(
174 makeAddressChangeEvent({
175 complete: false,
176 address: { postal_code: 'ABCDE' },
177 })
178 )
179 })
180
181 await expect(submitHook(result.current.handleSubmit)).resolves.toEqual({
182 status: 'error',
183 message: 'Please enter a valid billing address.',
184 })
185 expect(onCustomerDataChange).not.toHaveBeenCalled()
186 })
187
188 it('submits the seeded address for tax-id-only updates', async () => {
189 const onCustomerDataChange = vi.fn()
190 const customerProfile = makeCustomerProfile()
191
192 const { result } = renderHook(
193 ({ customerProfile, onCustomerDataChange }: HookPropsWithoutTaxId) =>
194 useBillingCustomerDataForm({
195 customerProfile,
196 taxId: null,
197 onCustomerDataChange,
198 }),
199 {
200 initialProps: { customerProfile, onCustomerDataChange },
201 }
202 )
203
204 act(() => {
205 result.current.form.setValue('tax_id_name', 'US EIN', { shouldDirty: true })
206 result.current.form.setValue('tax_id_type', 'us_ein', { shouldDirty: true })
207 result.current.form.setValue('tax_id_value', '12-3456789', { shouldDirty: true })
208 })
209
210 await waitFor(() => expect(result.current.isDirty).toBe(true))
211 const submitResult = await submitHook(result.current.handleSubmit)
212 expect(submitResult.status).toBe('success')
213 if (submitResult.status !== 'success') {
214 throw new Error('Expected successful submit result')
215 }
216 act(() => {
217 result.current.markCurrentValuesAsSaved(
218 submitResult.submittedState.addressValue,
219 submitResult.submittedState.taxIdValues
220 )
221 })
222
223 expect(onCustomerDataChange).toHaveBeenCalledWith({
224 address: customerProfile.address,
225 billing_name: 'Acme Inc',
226 tax_id: {
227 type: 'us_ein',
228 value: '12-3456789',
229 country: 'US',
230 },
231 })
232 expect(result.current.isDirty).toBe(false)
233 })
234
235 it('submits tax_id as null after an existing tax ID is removed', async () => {
236 const onCustomerDataChange = vi.fn()
237 const customerProfile = makeCustomerProfile()
238 const taxId = makeTaxId()
239
240 const { result } = renderHook(
241 ({ customerProfile, taxId, onCustomerDataChange }: HookPropsWithTaxId) =>
242 useBillingCustomerDataForm({
243 customerProfile,
244 taxId,
245 onCustomerDataChange,
246 }),
247 {
248 initialProps: { customerProfile, taxId, onCustomerDataChange },
249 }
250 )
251
252 act(() => {
253 result.current.form.setValue('tax_id_name', '', { shouldDirty: true })
254 result.current.form.setValue('tax_id_type', '', { shouldDirty: true })
255 result.current.form.setValue('tax_id_value', '', { shouldDirty: true })
256 })
257
258 await waitFor(() => expect(result.current.isDirty).toBe(true))
259 const submitResult = await submitHook(result.current.handleSubmit)
260 expect(submitResult).toEqual({
261 status: 'success',
262 submittedState: {
263 addressValue: {
264 name: 'Acme Inc',
265 address: customerProfile.address,
266 },
267 taxIdValues: {
268 tax_id_name: '',
269 tax_id_type: '',
270 tax_id_value: '',
271 },
272 },
273 })
274
275 expect(onCustomerDataChange).toHaveBeenCalledWith({
276 address: customerProfile.address,
277 billing_name: 'Acme Inc',
278 tax_id: null,
279 })
280 })
281
282 it('keeps a removed tax ID cleared after an intermediate rerender during save', async () => {
283 const customerProfile = makeCustomerProfile()
284 const taxId = makeTaxId()
285 let rerenderHook: ((props: HookPropsWithExistingTaxId) => void) | undefined
286
287 const onCustomerDataChange: CustomerChangeHandler = vi.fn(async () => {
288 rerenderHook?.({
289 customerProfile: makeCustomerProfile({
290 address: {
291 ...customerProfile.address,
292 line1: '500 Market St',
293 },
294 }),
295 taxId,
296 onCustomerDataChange,
297 })
298 await Promise.resolve()
299 })
300
301 const { result, rerender } = renderHook(
302 ({ customerProfile, taxId, onCustomerDataChange }: HookPropsWithTaxId) =>
303 useBillingCustomerDataForm({
304 customerProfile,
305 taxId,
306 onCustomerDataChange,
307 }),
308 {
309 initialProps: { customerProfile, taxId, onCustomerDataChange },
310 }
311 )
312
313 rerenderHook = rerender
314
315 act(() => {
316 result.current.form.setValue('tax_id_name', '', { shouldDirty: true })
317 result.current.form.setValue('tax_id_type', '', { shouldDirty: true })
318 result.current.form.setValue('tax_id_value', '', { shouldDirty: true })
319 })
320
321 const submitResult = await submitHook(result.current.handleSubmit)
322 expect(submitResult.status).toBe('success')
323 if (submitResult.status !== 'success') {
324 throw new Error('Expected successful submit result')
325 }
326
327 act(() => {
328 result.current.markCurrentValuesAsSaved(
329 submitResult.submittedState.addressValue,
330 submitResult.submittedState.taxIdValues
331 )
332 })
333
334 expect(result.current.form.getValues()).toEqual({
335 tax_id_name: '',
336 tax_id_type: '',
337 tax_id_value: '',
338 })
339 })
340
341 it('uses the latest address element value when building the submit payload', async () => {
342 const onCustomerDataChange = vi.fn()
343 const customerProfile = makeCustomerProfile()
344 const taxId = makeTaxId({ type: 'eu_vat', value: '12345678', country: 'AT' })
345
346 const { result } = renderHook(
347 ({ customerProfile, taxId, onCustomerDataChange }: HookPropsWithTaxId) =>
348 useBillingCustomerDataForm({
349 customerProfile,
350 taxId,
351 onCustomerDataChange,
352 }),
353 {
354 initialProps: { customerProfile, taxId, onCustomerDataChange },
355 }
356 )
357
358 act(() => {
359 result.current.onAddressChange(
360 makeAddressChangeEvent({
361 name: 'Updated GmbH',
362 address: { country: 'AT', city: 'Vienna', postal_code: '1010' },
363 })
364 )
365 result.current.form.setValue('tax_id_name', 'AT VAT', { shouldDirty: true })
366 result.current.form.setValue('tax_id_type', 'eu_vat', { shouldDirty: true })
367 result.current.form.setValue('tax_id_value', '12345678', { shouldDirty: true })
368 })
369
370 await waitFor(() => expect(result.current.addressCountry).toBe('AT'))
371 const submitResult = await submitHook(result.current.handleSubmit)
372 expect(submitResult).toEqual({
373 status: 'success',
374 submittedState: {
375 addressValue: {
376 name: 'Updated GmbH',
377 address: {
378 line1: '500 Market St',
379 line2: '',
380 city: 'Vienna',
381 state: 'CA',
382 postal_code: '1010',
383 country: 'AT',
384 },
385 },
386 taxIdValues: {
387 tax_id_name: 'AT VAT',
388 tax_id_type: 'eu_vat',
389 tax_id_value: '12345678',
390 },
391 },
392 })
393
394 expect(onCustomerDataChange).toHaveBeenCalledWith({
395 address: {
396 line1: '500 Market St',
397 line2: undefined,
398 city: 'Vienna',
399 state: 'CA',
400 postal_code: '1010',
401 country: 'AT',
402 },
403 billing_name: 'Updated GmbH',
404 tax_id: {
405 type: 'eu_vat',
406 value: 'ATU12345678',
407 country: 'AT',
408 },
409 })
410 })
411
412 it('submits an updated address without changing a missing tax ID', async () => {
413 const onCustomerDataChange = vi.fn()
414 const customerProfile = makeCustomerProfile()
415
416 const { result } = renderHook(
417 ({ customerProfile, onCustomerDataChange }: HookPropsWithoutTaxId) =>
418 useBillingCustomerDataForm({
419 customerProfile,
420 taxId: null,
421 onCustomerDataChange,
422 }),
423 {
424 initialProps: { customerProfile, onCustomerDataChange },
425 }
426 )
427
428 act(() => {
429 result.current.onAddressChange(
430 makeAddressChangeEvent({
431 name: 'Updated Company',
432 address: { city: 'Los Angeles', state: 'CA', postal_code: '90001' },
433 })
434 )
435 })
436
437 expect(result.current.isDirty).toBe(true)
438 const submitResult = await submitHook(result.current.handleSubmit)
439 expect(submitResult).toEqual({
440 status: 'success',
441 submittedState: {
442 addressValue: {
443 name: 'Updated Company',
444 address: {
445 line1: '500 Market St',
446 line2: '',
447 city: 'Los Angeles',
448 state: 'CA',
449 postal_code: '90001',
450 country: 'US',
451 },
452 },
453 taxIdValues: {
454 tax_id_name: '',
455 tax_id_type: '',
456 tax_id_value: '',
457 },
458 },
459 })
460
461 expect(onCustomerDataChange).toHaveBeenCalledWith({
462 address: {
463 line1: '500 Market St',
464 line2: undefined,
465 city: 'Los Angeles',
466 state: 'CA',
467 postal_code: '90001',
468 country: 'US',
469 },
470 billing_name: 'Updated Company',
471 tax_id: null,
472 })
473 })
474
475 it('resets dirty state and restores the initial address payload', async () => {
476 const onCustomerDataChange = vi.fn()
477 const customerProfile = makeCustomerProfile()
478
479 const { result } = renderHook(
480 ({ customerProfile, onCustomerDataChange }: HookPropsWithoutTaxId) =>
481 useBillingCustomerDataForm({
482 customerProfile,
483 taxId: null,
484 onCustomerDataChange,
485 }),
486 {
487 initialProps: { customerProfile, onCustomerDataChange },
488 }
489 )
490
491 act(() => {
492 result.current.onAddressChange(
493 makeAddressChangeEvent({
494 address: { city: 'Los Angeles', state: 'CA', postal_code: '90001' },
495 })
496 )
497 result.current.form.setValue('tax_id_name', 'US EIN', { shouldDirty: true })
498 result.current.form.setValue('tax_id_type', 'us_ein', { shouldDirty: true })
499 result.current.form.setValue('tax_id_value', '12-3456789', { shouldDirty: true })
500 })
501
502 await waitFor(() => expect(result.current.isDirty).toBe(true))
503
504 act(() => {
505 result.current.handleReset()
506 })
507
508 expect(result.current.isDirty).toBe(false)
509 expect(result.current.addressCountry).toBe('US')
510
511 act(() => {
512 result.current.form.setValue('tax_id_name', 'US EIN', { shouldDirty: true })
513 result.current.form.setValue('tax_id_type', 'us_ein', { shouldDirty: true })
514 result.current.form.setValue('tax_id_value', '12-3456789', { shouldDirty: true })
515 })
516
517 const submitResult = await submitHook(result.current.handleSubmit)
518 expect(submitResult).toEqual({
519 status: 'success',
520 submittedState: {
521 addressValue: {
522 name: 'Acme Inc',
523 address: customerProfile.address,
524 },
525 taxIdValues: {
526 tax_id_name: 'US EIN',
527 tax_id_type: 'us_ein',
528 tax_id_value: '12-3456789',
529 },
530 },
531 })
532
533 expect(onCustomerDataChange).toHaveBeenLastCalledWith({
534 address: customerProfile.address,
535 billing_name: 'Acme Inc',
536 tax_id: {
537 type: 'us_ein',
538 value: '12-3456789',
539 country: 'US',
540 },
541 })
542 })
543
544 it('syncs addressCountry when the customer profile loads after mount', async () => {
545 const onCustomerDataChange = vi.fn<CustomerChangeHandler>()
546 const initialProps: HookPropsHydrated = {
547 customerProfile: undefined,
548 onCustomerDataChange,
549 }
550
551 const { result, rerender } = renderHook(
552 ({ customerProfile, onCustomerDataChange }: HookPropsHydrated) =>
553 useBillingCustomerDataForm({
554 customerProfile,
555 taxId: null,
556 onCustomerDataChange,
557 }),
558 {
559 initialProps,
560 }
561 )
562
563 expect(result.current.addressCountry).toBeUndefined()
564
565 rerender({
566 onCustomerDataChange,
567 customerProfile: makeCustomerProfile({
568 address: {
569 line1: 'Schonhauser Allee 1',
570 line2: '',
571 city: 'Berlin',
572 state: 'BE',
573 postal_code: '10119',
574 country: 'DE',
575 },
576 }),
577 })
578
579 await waitFor(() => expect(result.current.addressCountry).toBe('DE'))
580 })
581})