useBillingCustomerDataForm.test.tsx 16 KB

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