From 264ac074ea3c711251b7faf36b9909b87b291323 Mon Sep 17 00:00:00 2001 From: Steve Jensen Date: Fri, 26 Jun 2026 15:19:12 -0600 Subject: [PATCH 1/2] feat(contractor): migrate PaymentMethod to useContractorPaymentMethodForm hook Migrate the Contractor PaymentMethod component from its monolithic inline-form pattern to the hook-based architecture. - Add useContractorPaymentMethodForm: a single headless hook for the combined payment-type + bank-account form, with value-aware excludeFields (Check hides and de-requires the bank fields) per the useContractorDetailsForm precedent. - Rewrite PaymentMethod.tsx to BaseBoundaries + thin Root + SDKFormProvider, preserving the event surface (CREATED / UPDATED / DONE) and masked-account behavior. - Harden pre-migration test coverage and add hook unit tests. - Export the hook, schema, error codes, and fields from src/index.ts. - Remove the now-unused PaymentTypeForm/BankAccountForm and inline schema. Co-authored-by: Cursor --- .../PaymentMethod/BankAccountForm.tsx | 44 -- .../PaymentMethod/PaymentMethod.test.tsx | 160 ++++++- .../PaymentMethod/PaymentMethod.tsx | 252 +++++------ .../PaymentMethod/PaymentTypeForm.tsx | 27 -- .../Contractor/PaymentMethod/shared/index.ts | 1 + .../contractorPaymentMethodSchema.ts | 208 +++++++++ .../useContractorPaymentMethodForm/fields.tsx | 159 +++++++ .../useContractorPaymentMethodForm/index.ts | 39 ++ .../useContractorPaymentMethodForm.test.tsx | 314 ++++++++++++++ .../useContractorPaymentMethodForm.tsx | 395 ++++++++++++++++++ .../Contractor/PaymentMethod/types.ts | 6 - src/index.ts | 34 ++ .../mocks/apis/contractor_payment_method.ts | 12 + 13 files changed, 1424 insertions(+), 227 deletions(-) delete mode 100644 src/components/Contractor/PaymentMethod/BankAccountForm.tsx delete mode 100644 src/components/Contractor/PaymentMethod/PaymentTypeForm.tsx create mode 100644 src/components/Contractor/PaymentMethod/shared/index.ts create mode 100644 src/components/Contractor/PaymentMethod/shared/useContractorPaymentMethodForm/contractorPaymentMethodSchema.ts create mode 100644 src/components/Contractor/PaymentMethod/shared/useContractorPaymentMethodForm/fields.tsx create mode 100644 src/components/Contractor/PaymentMethod/shared/useContractorPaymentMethodForm/index.ts create mode 100644 src/components/Contractor/PaymentMethod/shared/useContractorPaymentMethodForm/useContractorPaymentMethodForm.test.tsx create mode 100644 src/components/Contractor/PaymentMethod/shared/useContractorPaymentMethodForm/useContractorPaymentMethodForm.tsx diff --git a/src/components/Contractor/PaymentMethod/BankAccountForm.tsx b/src/components/Contractor/PaymentMethod/BankAccountForm.tsx deleted file mode 100644 index 4f1e3a42c..000000000 --- a/src/components/Contractor/PaymentMethod/BankAccountForm.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { useTranslation } from 'react-i18next' -import type { BankAccountFormProps } from './types' -import { Flex, RadioGroupField, TextInputField } from '@/components/Common' - -/** @internal */ -export function BankAccountForm({ bankAccount }: BankAccountFormProps) { - const { t } = useTranslation('Contractor.PaymentMethod', { keyPrefix: 'bankAccountForm' }) - - return ( - - - - - - - - - - ) -} diff --git a/src/components/Contractor/PaymentMethod/PaymentMethod.test.tsx b/src/components/Contractor/PaymentMethod/PaymentMethod.test.tsx index c01a12ad2..a158b92b0 100644 --- a/src/components/Contractor/PaymentMethod/PaymentMethod.test.tsx +++ b/src/components/Contractor/PaymentMethod/PaymentMethod.test.tsx @@ -1,11 +1,33 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { HttpResponse, type HttpResponseResolver } from 'msw' import { PaymentMethod } from './PaymentMethod' import { setupApiTestMocks } from '@/test/mocks/apiServer' +import { server } from '@/test/mocks/server' +import { + handleCreateContractorBankAccount, + handleUpdateContractorPaymentMethod, +} from '@/test/mocks/apis/contractor_payment_method' import { renderWithProviders } from '@/test-utils/renderWithProviders' import { componentEvents } from '@/shared/constants' +const createdBankAccount = { + uuid: 'new-bank-uuid', + contractor_uuid: 'contractor-123', + name: 'New Bank', + routing_number: '266905059', + hidden_account_number: 'XXXX3123', + account_type: 'Checking', +} + +const updatedPaymentMethod = { + version: 'updated-version', + type: 'Direct Deposit', + split_by: 'Percentage', + splits: [], +} + describe('Contractor PaymentMethod', () => { const onEvent = vi.fn() const user = userEvent.setup() @@ -23,9 +45,28 @@ describe('Contractor PaymentMethod', () => { expect(nameField).toHaveValue('BoA Checking Account') }) + it('shows bank account fields when Direct Deposit is selected', async () => { + expect(await screen.findByLabelText('Account nickname')).toBeInTheDocument() + expect(screen.getByLabelText('Routing number')).toBeInTheDocument() + expect(screen.getByLabelText('Account number')).toBeInTheDocument() + expect(screen.getByLabelText('Checking')).toBeInTheDocument() + expect(screen.getByLabelText('Savings')).toBeInTheDocument() + }) + + it('hides bank account fields when Check is selected', async () => { + const checkRadio = await screen.findByLabelText('Check') + await user.click(checkRadio) + + await waitFor(() => { + expect(screen.queryByLabelText('Account nickname')).not.toBeInTheDocument() + }) + expect(screen.queryByLabelText('Routing number')).not.toBeInTheDocument() + expect(screen.queryByLabelText('Account number')).not.toBeInTheDocument() + }) + it('fails to submit with touched bank information and incorrect account number', async () => { - const nameField = await screen.findByLabelText('Routing number') - await user.type(nameField, '123456789') + const routingField = await screen.findByLabelText('Routing number') + await user.type(routingField, '123456789') const submitButton = screen.getByRole('button', { name: 'Continue' }) await user.click(submitButton) @@ -33,6 +74,29 @@ describe('Contractor PaymentMethod', () => { expect(onEvent).not.toHaveBeenCalled() }) + it('blocks submit when the account nickname is cleared', async () => { + const nameField = await screen.findByLabelText('Account nickname') + await user.clear(nameField) + + const submitButton = screen.getByRole('button', { name: 'Continue' }) + await user.click(submitButton) + + await screen.findByText('Account nickname is required') + expect(onEvent).not.toHaveBeenCalled() + }) + + it('blocks submit when the routing number is invalid', async () => { + const routingField = await screen.findByLabelText('Routing number') + await user.clear(routingField) + await user.type(routingField, '123') + + const submitButton = screen.getByRole('button', { name: 'Continue' }) + await user.click(submitButton) + + await screen.findByText('Routing number is required (9-digits)') + expect(onEvent).not.toHaveBeenCalled() + }) + it('submits with correct bank account information', async () => { const field = await screen.findByLabelText('Account number') await user.clear(field) @@ -53,4 +117,96 @@ describe('Contractor PaymentMethod', () => { expect(onEvent).toHaveBeenCalledWith(componentEvents.CONTRACTOR_PAYMENT_METHOD_DONE) }) }) + + it('submits a Direct Deposit unchanged without re-validating the masked account number', async () => { + let createBody: Record | null = null + const createResolver = vi.fn(async ({ request }) => { + createBody = (await request.json()) as Record + return HttpResponse.json(createdBankAccount, { status: 201 }) + }) + const updateResolver = vi.fn(() => + HttpResponse.json(updatedPaymentMethod), + ) + server.use( + handleCreateContractorBankAccount(createResolver), + handleUpdateContractorPaymentMethod(updateResolver), + ) + + await screen.findByLabelText('Account nickname') + const submitButton = screen.getByRole('button', { name: 'Continue' }) + await user.click(submitButton) + + await waitFor(() => { + expect(onEvent).toHaveBeenCalledWith(componentEvents.CONTRACTOR_PAYMENT_METHOD_DONE) + }) + // The unchanged masked account number passes through without a format error + // because the dirty-check skips re-validating it. + expect(createResolver).toHaveBeenCalledTimes(1) + expect(updateResolver).toHaveBeenCalledTimes(1) + expect(createBody).toMatchObject({ name: 'BoA Checking Account' }) + expect(onEvent).toHaveBeenCalledWith( + componentEvents.CONTRACTOR_BANK_ACCOUNT_CREATED, + expect.any(Object), + ) + }) + + it('submits Check without creating a bank account', async () => { + let updateBody: Record | null = null + const createResolver = vi.fn(() => + HttpResponse.json(createdBankAccount, { status: 201 }), + ) + const updateResolver = vi.fn(async ({ request }) => { + updateBody = (await request.json()) as Record + return HttpResponse.json({ ...updatedPaymentMethod, type: 'Check' }) + }) + server.use( + handleCreateContractorBankAccount(createResolver), + handleUpdateContractorPaymentMethod(updateResolver), + ) + + const checkRadio = await screen.findByLabelText('Check') + await user.click(checkRadio) + + const submitButton = screen.getByRole('button', { name: 'Continue' }) + await user.click(submitButton) + + await waitFor(() => { + expect(onEvent).toHaveBeenCalledWith(componentEvents.CONTRACTOR_PAYMENT_METHOD_DONE) + }) + expect(createResolver).not.toHaveBeenCalled() + expect(updateResolver).toHaveBeenCalledTimes(1) + expect(updateBody).toMatchObject({ type: 'Check' }) + expect(onEvent).not.toHaveBeenCalledWith( + componentEvents.CONTRACTOR_BANK_ACCOUNT_CREATED, + expect.anything(), + ) + }) + + it('creates the bank account before updating the payment method', async () => { + const createResolver = vi.fn(() => + HttpResponse.json(createdBankAccount, { status: 201 }), + ) + const updateResolver = vi.fn(() => + HttpResponse.json(updatedPaymentMethod), + ) + server.use( + handleCreateContractorBankAccount(createResolver), + handleUpdateContractorPaymentMethod(updateResolver), + ) + + const field = await screen.findByLabelText('Account number') + await user.clear(field) + await user.type(field, '123123123') + + const submitButton = screen.getByRole('button', { name: 'Continue' }) + await user.click(submitButton) + + await waitFor(() => { + expect(updateResolver).toHaveBeenCalledTimes(1) + }) + expect(createResolver).toHaveBeenCalledTimes(1) + expect(createResolver.mock.invocationCallOrder[0]!).toBeLessThan( + updateResolver.mock.invocationCallOrder[0]!, + ) + }) }) diff --git a/src/components/Contractor/PaymentMethod/PaymentMethod.tsx b/src/components/Contractor/PaymentMethod/PaymentMethod.tsx index c3587992d..056c39e49 100644 --- a/src/components/Contractor/PaymentMethod/PaymentMethod.tsx +++ b/src/components/Contractor/PaymentMethod/PaymentMethod.tsx @@ -1,43 +1,19 @@ import { useTranslation } from 'react-i18next' -import type { SubmitHandler } from 'react-hook-form' -import { FormProvider, useForm, useWatch } from 'react-hook-form' -import { useContractorPaymentMethodGetSuspense } from '@gusto/embedded-api-v-2025-11-15/react-query/contractorPaymentMethodGet' -import { useContractorPaymentMethodGetBankAccountsSuspense } from '@gusto/embedded-api-v-2025-11-15/react-query/contractorPaymentMethodGetBankAccounts' -import { useContractorPaymentMethodsCreateBankAccountMutation } from '@gusto/embedded-api-v-2025-11-15/react-query/contractorPaymentMethodsCreateBankAccount' -import { useMemo, useState } from 'react' -import z from 'zod' -import { zodResolver } from '@hookform/resolvers/zod' -import { useContractorPaymentMethodUpdateMutation } from '@gusto/embedded-api-v-2025-11-15/react-query/contractorPaymentMethodUpdate' -import { useQueryClient } from '@tanstack/react-query' -import { buildContractorPaymentMethodGetQuery } from '@gusto/embedded-api-v-2025-11-15/react-query/contractorPaymentMethodGet' -import { useGustoEmbeddedContext } from '@gusto/embedded-api-v-2025-11-15/react-query/_context' import type { PaymentMethodProps } from './types' -import { BankAccountForm } from './BankAccountForm' -import { PaymentTypeForm } from './PaymentTypeForm' +import { + useContractorPaymentMethodForm, + type ContractorPaymentMethodFormType, +} from './shared/useContractorPaymentMethodForm' import { useI18n } from '@/i18n' -import { BaseComponent, useBase } from '@/components/Base' +import { BaseBoundaries, BaseLayout } from '@/components/Base' +import { SDKFormProvider } from '@/partner-hook-utils/form/SDKFormProvider' import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' import { Form } from '@/components/Common/Form' import { useComponentDictionary } from '@/i18n/I18n' import { componentEvents, PAYMENT_METHODS } from '@/shared/constants' import { ActionsLayout } from '@/components/Common/ActionsLayout' import { Flex } from '@/components/Common' -import { accountNumberValidation, routingNumberValidation } from '@/helpers/validations' - -const PaymentMethodSchema = z.discriminatedUnion('type', [ - z.object({ - type: z.literal('Direct Deposit'), - name: z.string().min(1), - routingNumber: routingNumberValidation, - accountNumber: z.any(), //Explicitely setting account number as most permissive - accountType: z.enum(['Checking', 'Savings']), - }), - z.object({ - type: z.literal('Check'), - }), -]) - -type PaymentMethodSchemaInputs = z.input +import type { RadioGroupProps } from '@/components/Common/UI/RadioGroup/RadioGroupTypes' /** * Manages a contractor's payment method, capturing a bank account for direct deposit or recording check as the payment method. @@ -72,142 +48,122 @@ type PaymentMethodSchemaInputs = z.input * } * ``` */ -export function PaymentMethod(props: PaymentMethodProps) { +export function PaymentMethod({ dictionary, ...props }: PaymentMethodProps) { + useComponentDictionary('Contractor.PaymentMethod', dictionary) return ( - - {props.children} - + + + ) } -function Root({ contractorId, className, dictionary }: PaymentMethodProps) { - useComponentDictionary('Contractor.PaymentMethod', dictionary) +function Root({ contractorId, className, onEvent }: Omit) { useI18n('Contractor.PaymentMethod') const { t } = useTranslation('Contractor.PaymentMethod') - const { onEvent, baseSubmitHandler } = useBase() const Components = useComponentContext() - const queryClient = useQueryClient() - const gustoClient = useGustoEmbeddedContext() - const [isPaymentMethodPending, setIsPaymentMethodPending] = useState(false) - - const contractorPaymentMethod = useContractorPaymentMethodGetSuspense({ - contractorUuid: contractorId, - }) - const getPaymentMethodQuery = buildContractorPaymentMethodGetQuery(gustoClient, { - contractorUuid: contractorId, - }) + const paymentMethod = useContractorPaymentMethodForm({ contractorId }) - const paymentMethod = contractorPaymentMethod.data.contractorPaymentMethod! - - const { - data: { contractorBankAccountList }, - } = useContractorPaymentMethodGetBankAccountsSuspense({ - contractorUuid: contractorId, - }) - const bankAccount = contractorBankAccountList?.[0] || undefined - - const { mutateAsync: updatePaymentMethod, isPending: paymentMethodPending } = - useContractorPaymentMethodUpdateMutation() - const { mutateAsync: createBankAccount, isPending: bankAccountPending } = - useContractorPaymentMethodsCreateBankAccountMutation() - - const defaultValues = useMemo( - () => ({ - type: paymentMethod.type || PAYMENT_METHODS.check, - name: bankAccount?.name || '', - routingNumber: bankAccount?.routingNumber || '', - accountNumber: bankAccount?.hiddenAccountNumber || '', - accountType: bankAccount?.accountType || 'Checking', - }), - [paymentMethod, bankAccount], - ) - - const formMethods = useForm({ - resolver: zodResolver(PaymentMethodSchema), - defaultValues: defaultValues, - }) + if (paymentMethod.isLoading) { + return + } - const watchedType = useWatch({ control: formMethods.control, name: 'type' }) - const onSubmit: SubmitHandler = async data => { - await baseSubmitHandler(data, async payload => { - let updatedPaymentMethodVersion: string | undefined - if (payload.type === PAYMENT_METHODS.directDeposit) { - /** Custom validation logic for accountNumber - because masked account value is used as default value, it is only validated when any of the bank-related fields are modified*/ - const { name, accountNumber, routingNumber, accountType } = payload - if ( - name !== bankAccount?.name || - routingNumber !== bankAccount.routingNumber || - accountType !== bankAccount.accountType || - accountNumber !== bankAccount.hiddenAccountNumber - ) { - const res = accountNumberValidation.safeParse(payload.accountNumber) - if (!res.success) { - formMethods.setError('accountNumber', { type: 'validate' }) - return - } - } + const { Fields } = paymentMethod.form - const bankAccountResponse = await createBankAccount({ - request: { - contractorUuid: contractorId, - contractorBankAccountCreateRequestBody: { - name, - routingNumber, - accountNumber, - accountType, - }, - }, + const handleSubmit = async () => { + const result = await paymentMethod.actions.onSubmit({ + onBankAccountCreated: bankAccount => { + onEvent(componentEvents.CONTRACTOR_BANK_ACCOUNT_CREATED, { + contractorBankAccount: bankAccount, }) - - onEvent(componentEvents.CONTRACTOR_BANK_ACCOUNT_CREATED, bankAccountResponse) - - // We have to fetch the updated payment method imperatively here because updating the bank - // account will cause the payment method version to update. This ensures we have the latest version. - setIsPaymentMethodPending(true) - const updatedPaymentMethodResponse = await queryClient.fetchQuery(getPaymentMethodQuery) - const updatedPaymentMethod = updatedPaymentMethodResponse.contractorPaymentMethod! - setIsPaymentMethodPending(false) - - updatedPaymentMethodVersion = updatedPaymentMethod.version as string - } - // For check payment method, no bank account creation needed - const paymentMethodResponse = await updatePaymentMethod({ - request: { - contractorUuid: contractorId, - requestBody: { - type: payload.type, - version: updatedPaymentMethodVersion || (paymentMethod.version as string), - }, - }, + }, + }) + if (result) { + onEvent(componentEvents.CONTRACTOR_PAYMENT_METHOD_UPDATED, { + contractorPaymentMethod: result.data, }) - onEvent(componentEvents.CONTRACTOR_PAYMENT_METHOD_UPDATED, paymentMethodResponse) onEvent(componentEvents.CONTRACTOR_PAYMENT_METHOD_DONE) - }) + } } - const showBankAccountForm = watchedType === PAYMENT_METHODS.directDeposit + const TypeFieldComponent = (radioProps: RadioGroupProps) => ( + ({ + ...option, + description: + option.value === PAYMENT_METHODS.directDeposit + ? t('directDepositDescription') + : t('checkDescription'), + }))} + /> + ) return (
- -
- - {t('title')} - - {showBankAccountForm && } - - - {t('continueCta')} - - - -
-
+ + +
void handleSubmit()}> + + {t('title')} + + value === PAYMENT_METHODS.directDeposit + ? t('directDepositLabel') + : t('checkLabel') + } + FieldComponent={TypeFieldComponent} + /> + {Fields.Name && ( + + )} + {Fields.RoutingNumber && ( + + )} + {Fields.AccountNumber && ( + + )} + {Fields.AccountType && ( + + value === 'Checking' + ? t('bankAccountForm.accountTypeChecking') + : t('bankAccountForm.accountTypeSavings') + } + /> + )} + + + {t('continueCta')} + + + +
+
+
) } diff --git a/src/components/Contractor/PaymentMethod/PaymentTypeForm.tsx b/src/components/Contractor/PaymentMethod/PaymentTypeForm.tsx deleted file mode 100644 index 8d858c1f0..000000000 --- a/src/components/Contractor/PaymentMethod/PaymentTypeForm.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { useTranslation } from 'react-i18next' -import { RadioGroupField } from '@/components/Common' -import { PAYMENT_METHODS } from '@/shared/constants' - -/** @internal */ -export function PaymentTypeForm() { - const { t } = useTranslation('Contractor.PaymentMethod') - return ( - - ) -} diff --git a/src/components/Contractor/PaymentMethod/shared/index.ts b/src/components/Contractor/PaymentMethod/shared/index.ts new file mode 100644 index 000000000..43fc13c46 --- /dev/null +++ b/src/components/Contractor/PaymentMethod/shared/index.ts @@ -0,0 +1 @@ +export * from './useContractorPaymentMethodForm' diff --git a/src/components/Contractor/PaymentMethod/shared/useContractorPaymentMethodForm/contractorPaymentMethodSchema.ts b/src/components/Contractor/PaymentMethod/shared/useContractorPaymentMethodForm/contractorPaymentMethodSchema.ts new file mode 100644 index 000000000..92040f4bf --- /dev/null +++ b/src/components/Contractor/PaymentMethod/shared/useContractorPaymentMethodForm/contractorPaymentMethodSchema.ts @@ -0,0 +1,208 @@ +import { z } from 'zod' +import { + buildFormSchema, + type OptionalFieldsToRequire, + type RequiredFieldConfig, +} from '@/partner-hook-utils/form/buildFormSchema' +import { PAYMENT_METHODS } from '@/shared/constants' + +/** + * Validation error codes emitted by the contractor payment method form schema. + * Map these codes to localized copy in `validationMessages` when composing the + * hook. + * + * @public + */ +export const ContractorPaymentMethodErrorCodes = { + REQUIRED: 'REQUIRED', + INVALID_ROUTING_NUMBER: 'INVALID_ROUTING_NUMBER', + INVALID_ACCOUNT_NUMBER: 'INVALID_ACCOUNT_NUMBER', +} as const + +/** + * Union of validation error code strings emitted by the contractor payment + * method form schema. + * + * @public + */ +export type ContractorPaymentMethodErrorCode = + (typeof ContractorPaymentMethodErrorCodes)[keyof typeof ContractorPaymentMethodErrorCodes] + +const ROUTING_NUMBER_REGEX = /^[0-9]{9}$/ +const ACCOUNT_NUMBER_REGEX = /^[0-9]{1,17}$/ + +/** + * Supported payment method type values: direct deposit and check. + * + * @public + */ +export const PAYMENT_METHOD_TYPES = [PAYMENT_METHODS.directDeposit, PAYMENT_METHODS.check] as const + +/** + * Union of payment method type values that the form accepts. + * + * @public + */ +export type ContractorPaymentMethodFormType = (typeof PAYMENT_METHOD_TYPES)[number] + +/** + * Supported bank account type values: checking and savings. + * + * @public + */ +export const ACCOUNT_TYPES = ['Checking', 'Savings'] as const + +/** + * Union of bank account type values that the form accepts. + * + * @public + */ +export type ContractorAccountType = (typeof ACCOUNT_TYPES)[number] + +// Bank-account format validation lives in `superRefine` gated on `type` rather +// than on the per-field validators. The masked `hiddenAccountNumber` is used as +// the default `accountNumber` value, so the bank fields must stay permissive at +// the field level: format checks only run for Direct Deposit, and the account +// number is only re-validated when a bank field actually changed. +const fieldValidators = { + type: z.enum(PAYMENT_METHOD_TYPES), + name: z.string(), + routingNumber: z.string(), + accountNumber: z.string(), + accountType: z.enum(ACCOUNT_TYPES), +} + +/** + * Field names accepted by the contractor payment method form. + * + * @public + */ +export type ContractorPaymentMethodFormField = keyof typeof fieldValidators + +/** + * Shape of the values managed by the contractor payment method form. + * + * @public + */ +export type ContractorPaymentMethodFormData = { + [K in keyof typeof fieldValidators]: z.infer<(typeof fieldValidators)[K]> +} + +/** + * Shape of the validated values produced by the contractor payment method form + * on submit. + * + * @public + */ +export type ContractorPaymentMethodFormOutputs = ContractorPaymentMethodFormData + +// Bank fields are required whenever they apply (i.e. whenever Direct Deposit is +// selected). Applicability is handled by the value-aware `excludeFields` +// function below, which keeps every field in the schema — and therefore +// promotable via `optionalFieldsToRequire` — while skipping its required check +// when the payment method is Check. +const requiredFieldsConfig = { + name: 'always', + routingNumber: 'always', + accountNumber: 'always', + accountType: 'always', +} satisfies RequiredFieldConfig + +/** + * Keys of optional contractor payment method fields that can be promoted to + * required via the hook's `optionalFieldsToRequire` option. + * + * @public + */ +export type ContractorPaymentMethodOptionalFieldsToRequire = OptionalFieldsToRequire< + typeof requiredFieldsConfig +> + +/** + * The masked bank account the form was seeded with, used to decide whether the + * account number needs to be re-validated. When a bank field differs from these + * values the account number is treated as freshly entered and format-checked. + * + * @internal + */ +export interface ExistingBankAccountComparison { + name?: string + routingNumber?: string + accountType?: string + hiddenAccountNumber?: string +} + +/** + * Bank fields that don't apply to the current selection. They're excluded — and + * never rendered or required — when the payment method is Check. + * + * @internal + */ +export function getExcludedPaymentMethodFields( + values: Pick, +): Array { + if (values.type === PAYMENT_METHODS.check) { + return ['name', 'routingNumber', 'accountNumber', 'accountType'] + } + return [] +} + +function bankFieldsChanged( + data: ContractorPaymentMethodFormData, + existing?: ExistingBankAccountComparison, +): boolean { + if (!existing) return true + return ( + data.name !== existing.name || + data.routingNumber !== existing.routingNumber || + data.accountType !== existing.accountType || + data.accountNumber !== existing.hiddenAccountNumber + ) +} + +/** @internal */ +interface ContractorPaymentMethodSchemaOptions { + optionalFieldsToRequire?: ContractorPaymentMethodOptionalFieldsToRequire + existingBankAccount?: ExistingBankAccountComparison +} + +/** @internal */ +export function createContractorPaymentMethodSchema( + options: ContractorPaymentMethodSchemaOptions = {}, +) { + const { optionalFieldsToRequire, existingBankAccount } = options + + return buildFormSchema(fieldValidators, { + requiredFieldsConfig, + requiredErrorCode: ContractorPaymentMethodErrorCodes.REQUIRED, + mode: 'update', + optionalFieldsToRequire, + excludeFields: getExcludedPaymentMethodFields, + superRefine: (data, ctx) => { + if (data.type !== PAYMENT_METHODS.directDeposit) return + + if (data.routingNumber && !ROUTING_NUMBER_REGEX.test(data.routingNumber)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['routingNumber'], + message: ContractorPaymentMethodErrorCodes.INVALID_ROUTING_NUMBER, + }) + } + + // The masked account number is only re-validated once a bank field + // changes, mirroring the original component: an untouched Direct Deposit + // submit reuses the masked value without a format error. + if ( + bankFieldsChanged(data, existingBankAccount) && + data.accountNumber && + !ACCOUNT_NUMBER_REGEX.test(data.accountNumber) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['accountNumber'], + message: ContractorPaymentMethodErrorCodes.INVALID_ACCOUNT_NUMBER, + }) + } + }, + }) +} diff --git a/src/components/Contractor/PaymentMethod/shared/useContractorPaymentMethodForm/fields.tsx b/src/components/Contractor/PaymentMethod/shared/useContractorPaymentMethodForm/fields.tsx new file mode 100644 index 000000000..69193e3b5 --- /dev/null +++ b/src/components/Contractor/PaymentMethod/shared/useContractorPaymentMethodForm/fields.tsx @@ -0,0 +1,159 @@ +import type { + ContractorAccountType, + ContractorPaymentMethodErrorCodes, + ContractorPaymentMethodFormType, +} from './contractorPaymentMethodSchema' +import type { TextInputHookFieldProps } from '@/partner-hook-utils/form/fields/TextInputHookField' +import type { RadioGroupHookFieldProps } from '@/partner-hook-utils/form/fields/RadioGroupHookField' +import { TextInputHookField, RadioGroupHookField } from '@/partner-hook-utils/form/fields' +import type { HookFieldProps } from '@/partner-hook-utils/types' + +/** + * Validation error code emitted by {@link useContractorPaymentMethodForm} fields + * that only emit `REQUIRED`. + * + * @public + */ +export type RequiredValidation = typeof ContractorPaymentMethodErrorCodes.REQUIRED + +/** + * Validation error codes emitted by the `routingNumber` field of + * {@link useContractorPaymentMethodForm}. + * + * @public + */ +export type RoutingNumberValidation = (typeof ContractorPaymentMethodErrorCodes)[keyof Pick< + typeof ContractorPaymentMethodErrorCodes, + 'REQUIRED' | 'INVALID_ROUTING_NUMBER' +>] + +/** + * Validation error codes emitted by the `accountNumber` field of + * {@link useContractorPaymentMethodForm}. + * + * @public + */ +export type AccountNumberValidation = (typeof ContractorPaymentMethodErrorCodes)[keyof Pick< + typeof ContractorPaymentMethodErrorCodes, + 'REQUIRED' | 'INVALID_ACCOUNT_NUMBER' +>] + +/** + * Props accepted by {@link useContractorPaymentMethodForm}'s `Fields.Type` component. + * + * @public + */ +export type TypeFieldProps = HookFieldProps< + RadioGroupHookFieldProps +> + +/** + * Radio group bound to the `type` field of {@link useContractorPaymentMethodForm}. + * + * @remarks + * Selects whether the contractor is paid by Direct Deposit or Check. Provide + * `getOptionLabel` to localize the option labels. + * + * @param props - See {@link TypeFieldProps}. + * @returns The rendered radio group bound to `type`. + * @public + */ +export function TypeField(props: TypeFieldProps) { + return +} + +/** + * Props accepted by {@link useContractorPaymentMethodForm}'s `Fields.Name` component. + * + * @public + */ +export type NameFieldProps = HookFieldProps> + +/** + * Text input bound to the `name` field of {@link useContractorPaymentMethodForm}. + * + * @remarks + * Available on the hook result as `form.Fields.Name`; `undefined` when the + * payment method is Check. Captures the bank account nickname. + * + * @param props - See {@link NameFieldProps}. + * @returns The rendered text input bound to `name`. + * @public + */ +export function NameField(props: NameFieldProps) { + return +} + +/** + * Props accepted by {@link useContractorPaymentMethodForm}'s `Fields.RoutingNumber` component. + * + * @public + */ +export type RoutingNumberFieldProps = HookFieldProps< + TextInputHookFieldProps +> + +/** + * Text input bound to the `routingNumber` field of {@link useContractorPaymentMethodForm}. + * + * @remarks + * Available on the hook result as `form.Fields.RoutingNumber`; `undefined` when + * the payment method is Check. Validated against a 9-digit numeric pattern. + * + * @param props - See {@link RoutingNumberFieldProps}. + * @returns The rendered text input bound to `routingNumber`. + * @public + */ +export function RoutingNumberField(props: RoutingNumberFieldProps) { + return +} + +/** + * Props accepted by {@link useContractorPaymentMethodForm}'s `Fields.AccountNumber` component. + * + * @public + */ +export type AccountNumberFieldProps = HookFieldProps< + TextInputHookFieldProps +> + +/** + * Text input bound to the `accountNumber` field of {@link useContractorPaymentMethodForm}. + * + * @remarks + * Available on the hook result as `form.Fields.AccountNumber`; `undefined` when + * the payment method is Check. Pre-filled with the masked account number; only + * re-validated against the 1–17 digit numeric pattern once a bank field changes. + * + * @param props - See {@link AccountNumberFieldProps}. + * @returns The rendered text input bound to `accountNumber`. + * @public + */ +export function AccountNumberField(props: AccountNumberFieldProps) { + return +} + +/** + * Props accepted by {@link useContractorPaymentMethodForm}'s `Fields.AccountType` component. + * + * @public + */ +export type AccountTypeFieldProps = HookFieldProps< + RadioGroupHookFieldProps +> + +/** + * Radio group bound to the `accountType` field of {@link useContractorPaymentMethodForm}. + * + * @remarks + * Available on the hook result as `form.Fields.AccountType`; `undefined` when + * the payment method is Check. Options are `Checking` and `Savings`; defaults to + * `Checking`. Supply `getOptionLabel` to translate the option labels. + * + * @param props - See {@link AccountTypeFieldProps}. + * @returns The rendered radio group bound to `accountType`. + * @public + */ +export function AccountTypeField(props: AccountTypeFieldProps) { + return +} diff --git a/src/components/Contractor/PaymentMethod/shared/useContractorPaymentMethodForm/index.ts b/src/components/Contractor/PaymentMethod/shared/useContractorPaymentMethodForm/index.ts new file mode 100644 index 000000000..b2b6822ed --- /dev/null +++ b/src/components/Contractor/PaymentMethod/shared/useContractorPaymentMethodForm/index.ts @@ -0,0 +1,39 @@ +export { useContractorPaymentMethodForm } from './useContractorPaymentMethodForm' +export type { + ContractorPaymentMethodSubmitOptions, + UseContractorPaymentMethodFormProps, + UseContractorPaymentMethodFormResult, + UseContractorPaymentMethodFormReady, + ContractorPaymentMethodFormFields, + ContractorPaymentMethodFieldsMetadata, +} from './useContractorPaymentMethodForm' +export { + ContractorPaymentMethodErrorCodes, + PAYMENT_METHOD_TYPES, + ACCOUNT_TYPES, + createContractorPaymentMethodSchema, + type ContractorPaymentMethodErrorCode, + type ContractorPaymentMethodFormType, + type ContractorAccountType, + type ContractorPaymentMethodFormData, + type ContractorPaymentMethodFormOutputs, + type ContractorPaymentMethodFormField, + type ContractorPaymentMethodOptionalFieldsToRequire, +} from './contractorPaymentMethodSchema' +export { + TypeField, + NameField, + RoutingNumberField, + AccountNumberField, + AccountTypeField, +} from './fields' +export type { + RequiredValidation as ContractorPaymentMethodRequiredValidation, + RoutingNumberValidation as ContractorPaymentMethodRoutingNumberValidation, + AccountNumberValidation as ContractorPaymentMethodAccountNumberValidation, + TypeFieldProps, + NameFieldProps, + RoutingNumberFieldProps, + AccountNumberFieldProps, + AccountTypeFieldProps, +} from './fields' diff --git a/src/components/Contractor/PaymentMethod/shared/useContractorPaymentMethodForm/useContractorPaymentMethodForm.test.tsx b/src/components/Contractor/PaymentMethod/shared/useContractorPaymentMethodForm/useContractorPaymentMethodForm.test.tsx new file mode 100644 index 000000000..de52bbad4 --- /dev/null +++ b/src/components/Contractor/PaymentMethod/shared/useContractorPaymentMethodForm/useContractorPaymentMethodForm.test.tsx @@ -0,0 +1,314 @@ +import { renderHook, act, waitFor } from '@testing-library/react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { HttpResponse, type HttpResponseResolver } from 'msw' +import { + useContractorPaymentMethodForm, + type UseContractorPaymentMethodFormResult, +} from './useContractorPaymentMethodForm' +import { + ContractorPaymentMethodErrorCodes, + createContractorPaymentMethodSchema, + getExcludedPaymentMethodFields, +} from './contractorPaymentMethodSchema' +import { server } from '@/test/mocks/server' +import { setupApiTestMocks } from '@/test/mocks/apiServer' +import { + handleCreateContractorBankAccount, + handleUpdateContractorPaymentMethod, +} from '@/test/mocks/apis/contractor_payment_method' +import { GustoTestProvider } from '@/test/GustoTestApiProvider' +import { PAYMENT_METHODS } from '@/shared/constants' + +type ReadyResult = Extract + +function assertReady( + hookResult: UseContractorPaymentMethodFormResult, +): asserts hookResult is ReadyResult { + if (hookResult.isLoading) { + throw new Error('Expected hook to be ready but it is still loading') + } +} + +const createdBankAccount = { + uuid: 'new-bank-uuid', + contractor_uuid: 'contractor-123', + name: 'New Bank', + routing_number: '266905059', + hidden_account_number: 'XXXX3123', + account_type: 'Checking', +} + +const updatedPaymentMethod = { + version: 'updated-version', + type: 'Direct Deposit', + split_by: 'Percentage', + splits: [], +} + +describe('createContractorPaymentMethodSchema', () => { + it('requires bank fields when Direct Deposit is selected', () => { + const [schema] = createContractorPaymentMethodSchema() + const result = schema.safeParse({ + type: PAYMENT_METHODS.directDeposit, + name: '', + routingNumber: '', + accountNumber: '', + accountType: 'Checking', + }) + expect(result.success).toBe(false) + if (result.success) return + const fields = new Set(result.error.issues.map(issue => String(issue.path[0]))) + expect(fields).toContain('name') + expect(fields).toContain('routingNumber') + expect(fields).toContain('accountNumber') + }) + + it('excludes bank fields from validation when Check is selected', () => { + const [schema] = createContractorPaymentMethodSchema() + const result = schema.safeParse({ + type: PAYMENT_METHODS.check, + name: '', + routingNumber: '', + accountNumber: '', + accountType: 'Checking', + }) + expect(result.success).toBe(true) + }) + + it('rejects a routing number that is not 9 digits with INVALID_ROUTING_NUMBER', () => { + const [schema] = createContractorPaymentMethodSchema() + const result = schema.safeParse({ + type: PAYMENT_METHODS.directDeposit, + name: 'My Bank', + routingNumber: '123', + accountNumber: '123456789', + accountType: 'Checking', + }) + expect(result.success).toBe(false) + if (result.success) return + const routingIssue = result.error.issues.find( + issue => String(issue.path[0]) === 'routingNumber', + ) + expect(routingIssue?.message).toBe(ContractorPaymentMethodErrorCodes.INVALID_ROUTING_NUMBER) + }) + + it('validates the account number format when a bank field changes', () => { + const [schema] = createContractorPaymentMethodSchema({ + existingBankAccount: { + name: 'BoA', + routingNumber: '266905059', + accountType: 'Checking', + hiddenAccountNumber: 'XXXX1207', + }, + }) + const result = schema.safeParse({ + type: PAYMENT_METHODS.directDeposit, + name: 'BoA', + routingNumber: '266905059', + accountNumber: 'not-a-number', + accountType: 'Checking', + }) + expect(result.success).toBe(false) + if (result.success) return + const accountIssue = result.error.issues.find( + issue => String(issue.path[0]) === 'accountNumber', + ) + expect(accountIssue?.message).toBe(ContractorPaymentMethodErrorCodes.INVALID_ACCOUNT_NUMBER) + }) + + it('does not re-validate the masked account number when nothing changed', () => { + const [schema] = createContractorPaymentMethodSchema({ + existingBankAccount: { + name: 'BoA', + routingNumber: '266905059', + accountType: 'Checking', + hiddenAccountNumber: 'XXXX1207', + }, + }) + const result = schema.safeParse({ + type: PAYMENT_METHODS.directDeposit, + name: 'BoA', + routingNumber: '266905059', + accountNumber: 'XXXX1207', + accountType: 'Checking', + }) + expect(result.success).toBe(true) + }) + + it('excludes the bank fields for Check and none for Direct Deposit', () => { + expect(getExcludedPaymentMethodFields({ type: PAYMENT_METHODS.check })).toEqual([ + 'name', + 'routingNumber', + 'accountNumber', + 'accountType', + ]) + expect(getExcludedPaymentMethodFields({ type: PAYMENT_METHODS.directDeposit })).toEqual([]) + }) +}) + +describe('useContractorPaymentMethodForm', () => { + beforeEach(() => { + setupApiTestMocks() + }) + + it('loads the payment method and bank account in update mode', async () => { + const { result } = renderHook( + () => useContractorPaymentMethodForm({ contractorId: 'contractor-123' }), + { wrapper: GustoTestProvider }, + ) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + + assertReady(result.current) + expect(result.current.status.mode).toBe('update') + expect(result.current.data.paymentMethod.type).toBe(PAYMENT_METHODS.directDeposit) + expect(result.current.data.bankAccount?.name).toBe('BoA Checking Account') + expect(result.current.form.Fields.Name).toBeDefined() + expect(result.current.form.Fields.RoutingNumber).toBeDefined() + expect(result.current.form.Fields.AccountNumber).toBeDefined() + expect(result.current.form.Fields.AccountType).toBeDefined() + }) + + it('hides the bank fields when Check is selected', async () => { + const { result } = renderHook( + () => useContractorPaymentMethodForm({ contractorId: 'contractor-123' }), + { wrapper: GustoTestProvider }, + ) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + + act(() => { + assertReady(result.current) + result.current.form.hookFormInternals.formMethods.setValue('type', PAYMENT_METHODS.check) + }) + + await waitFor(() => { + assertReady(result.current) + expect(result.current.form.Fields.Name).toBeUndefined() + }) + assertReady(result.current) + expect(result.current.form.Fields.RoutingNumber).toBeUndefined() + expect(result.current.form.Fields.AccountNumber).toBeUndefined() + expect(result.current.form.Fields.AccountType).toBeUndefined() + }) + + it('creates the bank account before updating the payment method on Direct Deposit', async () => { + const onBankAccountCreated = vi.fn() + const createResolver = vi.fn(() => + HttpResponse.json(createdBankAccount, { status: 201 }), + ) + const updateResolver = vi.fn(() => + HttpResponse.json(updatedPaymentMethod), + ) + server.use( + handleCreateContractorBankAccount(createResolver), + handleUpdateContractorPaymentMethod(updateResolver), + ) + + const { result } = renderHook( + () => useContractorPaymentMethodForm({ contractorId: 'contractor-123' }), + { wrapper: GustoTestProvider }, + ) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + + let submitResult + await act(async () => { + assertReady(result.current) + submitResult = await result.current.actions.onSubmit({ onBankAccountCreated }) + }) + + expect(createResolver).toHaveBeenCalledTimes(1) + expect(updateResolver).toHaveBeenCalledTimes(1) + expect(createResolver.mock.invocationCallOrder[0]!).toBeLessThan( + updateResolver.mock.invocationCallOrder[0]!, + ) + expect(onBankAccountCreated).toHaveBeenCalledWith( + expect.objectContaining({ uuid: 'new-bank-uuid' }), + ) + expect(submitResult).toMatchObject({ mode: 'update' }) + }) + + it('updates the payment method without creating a bank account on Check', async () => { + let updateBody: Record | null = null + const onBankAccountCreated = vi.fn() + const createResolver = vi.fn(() => + HttpResponse.json(createdBankAccount, { status: 201 }), + ) + const updateResolver = vi.fn(async ({ request }) => { + updateBody = (await request.json()) as Record + return HttpResponse.json({ ...updatedPaymentMethod, type: 'Check' }) + }) + server.use( + handleCreateContractorBankAccount(createResolver), + handleUpdateContractorPaymentMethod(updateResolver), + ) + + const { result } = renderHook( + () => useContractorPaymentMethodForm({ contractorId: 'contractor-123' }), + { wrapper: GustoTestProvider }, + ) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + + act(() => { + assertReady(result.current) + result.current.form.hookFormInternals.formMethods.setValue('type', PAYMENT_METHODS.check) + }) + + await act(async () => { + assertReady(result.current) + await result.current.actions.onSubmit({ onBankAccountCreated }) + }) + + expect(createResolver).not.toHaveBeenCalled() + expect(onBankAccountCreated).not.toHaveBeenCalled() + expect(updateResolver).toHaveBeenCalledTimes(1) + expect(updateBody).toMatchObject({ type: 'Check' }) + }) + + it('does not call any mutation when validation fails', async () => { + const createResolver = vi.fn(() => + HttpResponse.json(createdBankAccount, { status: 201 }), + ) + const updateResolver = vi.fn(() => + HttpResponse.json(updatedPaymentMethod), + ) + server.use( + handleCreateContractorBankAccount(createResolver), + handleUpdateContractorPaymentMethod(updateResolver), + ) + + const { result } = renderHook( + () => useContractorPaymentMethodForm({ contractorId: 'contractor-123' }), + { wrapper: GustoTestProvider }, + ) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + + act(() => { + assertReady(result.current) + result.current.form.hookFormInternals.formMethods.setValue('routingNumber', '123') + }) + + let submitResult + await act(async () => { + assertReady(result.current) + submitResult = await result.current.actions.onSubmit() + }) + + expect(submitResult).toBeUndefined() + expect(createResolver).not.toHaveBeenCalled() + expect(updateResolver).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/Contractor/PaymentMethod/shared/useContractorPaymentMethodForm/useContractorPaymentMethodForm.tsx b/src/components/Contractor/PaymentMethod/shared/useContractorPaymentMethodForm/useContractorPaymentMethodForm.tsx new file mode 100644 index 000000000..f9c4cd467 --- /dev/null +++ b/src/components/Contractor/PaymentMethod/shared/useContractorPaymentMethodForm/useContractorPaymentMethodForm.tsx @@ -0,0 +1,395 @@ +import { useMemo, useState } from 'react' +import { useForm, useWatch } from 'react-hook-form' +import type { UseFormProps } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { useQueryClient } from '@tanstack/react-query' +import type { ContractorPaymentMethod } from '@gusto/embedded-api-v-2025-11-15/models/components/contractorpaymentmethod' +import type { ContractorBankAccount } from '@gusto/embedded-api-v-2025-11-15/models/components/contractorbankaccount' +import { useContractorPaymentMethodGet } from '@gusto/embedded-api-v-2025-11-15/react-query/contractorPaymentMethodGet' +import { buildContractorPaymentMethodGetQuery } from '@gusto/embedded-api-v-2025-11-15/react-query/contractorPaymentMethodGet' +import { useContractorPaymentMethodGetBankAccounts } from '@gusto/embedded-api-v-2025-11-15/react-query/contractorPaymentMethodGetBankAccounts' +import { useContractorPaymentMethodsCreateBankAccountMutation } from '@gusto/embedded-api-v-2025-11-15/react-query/contractorPaymentMethodsCreateBankAccount' +import { useContractorPaymentMethodUpdateMutation } from '@gusto/embedded-api-v-2025-11-15/react-query/contractorPaymentMethodUpdate' +import { useGustoEmbeddedContext } from '@gusto/embedded-api-v-2025-11-15/react-query/_context' +import { + ACCOUNT_TYPES, + createContractorPaymentMethodSchema, + type ContractorAccountType, + type ContractorPaymentMethodFormData, + type ContractorPaymentMethodFormOutputs, + type ContractorPaymentMethodFormType, + type ContractorPaymentMethodOptionalFieldsToRequire, + PAYMENT_METHOD_TYPES, +} from './contractorPaymentMethodSchema' +import { + AccountNumberField, + AccountTypeField, + NameField, + RoutingNumberField, + TypeField, +} from './fields' +import { useDeriveFieldsMetadata } from '@/partner-hook-utils/form/useDeriveFieldsMetadata' +import { useHookFormInternals } from '@/partner-hook-utils/form/useHookFormInternals' +import { createGetFormSubmissionValues } from '@/partner-hook-utils/form/getFormSubmissionValues' +import { withOptions } from '@/partner-hook-utils/form/withOptions' +import { composeErrorHandler } from '@/partner-hook-utils/composeErrorHandler' +import type { + BaseFormHookReady, + FieldsMetadata, + HookLoadingResult, + HookSubmitResult, +} from '@/partner-hook-utils/types' +import { useBaseSubmit } from '@/components/Base/useBaseSubmit' +import { SDKInternalError } from '@/types/sdkError' +import { PAYMENT_METHODS } from '@/shared/constants' + +/** + * Optional submit-time callbacks for {@link useContractorPaymentMethodForm}'s `onSubmit`. + * + * @public + */ +export interface ContractorPaymentMethodSubmitOptions { + /** + * Called after a bank account is successfully created (Direct Deposit only), + * before the payment method update completes. Use it to surface the + * intermediate "bank account created" event. + */ + onBankAccountCreated?: (bankAccount: ContractorBankAccount) => void +} + +/** + * Props for {@link useContractorPaymentMethodForm}. + * + * @public + */ +export interface UseContractorPaymentMethodFormProps { + /** Contractor whose payment method is being edited. */ + contractorId: string + /** Override optional fields to be required. */ + optionalFieldsToRequire?: ContractorPaymentMethodOptionalFieldsToRequire + /** Pre-fill form values. Server data (the current payment method and bank account) is used when no override is supplied. */ + defaultValues?: Partial + /** When validation runs. Passed through to react-hook-form. Defaults to `'onSubmit'`. */ + validationMode?: UseFormProps['mode'] + /** Auto-focus the first invalid field on submit. Set to `false` when using `composeSubmitHandler`. Defaults to `true`. */ + shouldFocusError?: boolean +} + +/** + * Field components exposed by {@link useContractorPaymentMethodForm} on `form.Fields`. + * + * @remarks + * The bank-account fields are `undefined` when the payment method is Check. + * Always null-check before rendering. + * + * @public + */ +export interface ContractorPaymentMethodFormFields { + /** Radio group bound to `type`. Always available. */ + Type: typeof TypeField + /** Text input bound to `name`; available only for Direct Deposit. */ + Name: typeof NameField | undefined + /** Text input bound to `routingNumber`; available only for Direct Deposit. */ + RoutingNumber: typeof RoutingNumberField | undefined + /** Text input bound to `accountNumber`; available only for Direct Deposit. */ + AccountNumber: typeof AccountNumberField | undefined + /** Radio group bound to `accountType`; available only for Direct Deposit. */ + AccountType: typeof AccountTypeField | undefined +} + +/** + * Ready-state return value of {@link useContractorPaymentMethodForm}. + * + * @public + */ +export interface UseContractorPaymentMethodFormReady extends BaseFormHookReady< + FieldsMetadata, + ContractorPaymentMethodFormData, + ContractorPaymentMethodFormFields +> { + /** The contractor's current payment method and bank account, loaded from the API. */ + data: { + paymentMethod: ContractorPaymentMethod + bankAccount: ContractorBankAccount | undefined + } + /** `isPending` reflects the in-flight submit sequence; `mode` is always `'update'`. */ + status: { isPending: boolean; mode: 'update' } + /** Submit the form. Returns the updated payment method on success or `undefined` on validation/mutation failure. */ + actions: { + onSubmit: ( + options?: ContractorPaymentMethodSubmitOptions, + ) => Promise | undefined> + } +} + +/** + * Headless React Hook Form hook for managing a contractor's payment method. + * + * @remarks + * Switches between Direct Deposit and Check on a single form. Choosing Direct + * Deposit reveals the bank account fields (nickname, routing number, account + * number, account type) and, on submit, creates the bank account, refetches the + * payment method to pick up the bumped optimistic-locking version, then updates + * the payment method. Choosing Check hides the bank fields and only updates the + * payment method type. Always operates in update mode — every contractor has a + * payment method, defaulting to Check. + * + * Bank-field visibility and requiredness are driven by the selected `type`: the + * fields are `undefined` on `form.Fields` and skip their required checks when + * Check is selected. The account number is pre-filled with the masked value and + * only re-validated once a bank field changes. + * + * @param props - See {@link UseContractorPaymentMethodFormProps}. + * @returns A loading-state result while data loads, or a {@link UseContractorPaymentMethodFormReady} once ready. + * @public + * + * @example + * ```tsx + * import { + * useContractorPaymentMethodForm, + * SDKFormProvider, + * PAYMENT_METHODS, + * } from '@gusto/embedded-react-sdk' + * + * function PaymentMethodScreen({ contractorId }: { contractorId: string }) { + * const paymentMethod = useContractorPaymentMethodForm({ contractorId }) + * + * if (paymentMethod.isLoading) return null + * const { Fields } = paymentMethod.form + * + * return ( + * + *
{ + * e.preventDefault() + * void paymentMethod.actions.onSubmit() + * }} + * > + * + * {Fields.Name && } + * {Fields.RoutingNumber && } + * {Fields.AccountNumber && } + * {Fields.AccountType && } + * + * + *
+ * ) + * } + * ``` + */ +export function useContractorPaymentMethodForm({ + contractorId, + optionalFieldsToRequire, + defaultValues: partnerDefaults, + validationMode = 'onSubmit', + shouldFocusError = true, +}: UseContractorPaymentMethodFormProps): HookLoadingResult | UseContractorPaymentMethodFormReady { + const queryClient = useQueryClient() + const gustoClient = useGustoEmbeddedContext() + + const paymentMethodQuery = useContractorPaymentMethodGet({ contractorUuid: contractorId }) + const paymentMethod = paymentMethodQuery.data?.contractorPaymentMethod + + const bankAccountsQuery = useContractorPaymentMethodGetBankAccounts({ + contractorUuid: contractorId, + }) + const bankAccount = bankAccountsQuery.data?.contractorBankAccountList?.[0] ?? undefined + + const [schema, metadataConfig] = useMemo( + () => + createContractorPaymentMethodSchema({ + optionalFieldsToRequire, + existingBankAccount: bankAccount + ? { + name: bankAccount.name, + routingNumber: bankAccount.routingNumber, + accountType: bankAccount.accountType, + hiddenAccountNumber: bankAccount.hiddenAccountNumber, + } + : undefined, + }), + [optionalFieldsToRequire, bankAccount], + ) + + const resolvedDefaults: ContractorPaymentMethodFormData = useMemo( + () => ({ + type: (partnerDefaults?.type ?? + paymentMethod?.type ?? + PAYMENT_METHODS.check) as ContractorPaymentMethodFormType, + name: partnerDefaults?.name ?? bankAccount?.name ?? '', + routingNumber: partnerDefaults?.routingNumber ?? bankAccount?.routingNumber ?? '', + accountNumber: partnerDefaults?.accountNumber ?? bankAccount?.hiddenAccountNumber ?? '', + accountType: (partnerDefaults?.accountType ?? + bankAccount?.accountType ?? + 'Checking') as ContractorAccountType, + }), + [partnerDefaults, paymentMethod, bankAccount], + ) + + const formMethods = useForm< + ContractorPaymentMethodFormData, + unknown, + ContractorPaymentMethodFormOutputs + >({ + resolver: zodResolver(schema), + mode: validationMode, + shouldFocusError, + defaultValues: resolvedDefaults, + values: resolvedDefaults, + resetOptions: { keepDirtyValues: true }, + }) + + // Render-gating: the bank fields apply only to Direct Deposit. The schema + // mirrors this via `getExcludedPaymentMethodFields` so hidden fields never + // trip a phantom required error. + const watchedType = useWatch({ control: formMethods.control, name: 'type' }) + const showBankFields = watchedType === PAYMENT_METHODS.directDeposit + + const createBankAccountMutation = useContractorPaymentMethodsCreateBankAccountMutation() + const updatePaymentMethodMutation = useContractorPaymentMethodUpdateMutation() + const [isRefetchingVersion, setIsRefetchingVersion] = useState(false) + + const isPending = + createBankAccountMutation.isPending || + updatePaymentMethodMutation.isPending || + isRefetchingVersion + + const { + baseSubmitHandler, + error: submitError, + setError: setSubmitError, + } = useBaseSubmit('ContractorPaymentMethodForm') + + const errorHandling = composeErrorHandler([paymentMethodQuery, bankAccountsQuery], { + submitError, + setSubmitError, + }) + + const typeOptions = PAYMENT_METHOD_TYPES.map(value => ({ value, label: value })) + const accountTypeOptions = ACCOUNT_TYPES.map(value => ({ value, label: value })) + + const baseMetadata = useDeriveFieldsMetadata(metadataConfig, formMethods.control) + const fieldsMetadata = { + ...baseMetadata, + type: withOptions(baseMetadata.type, typeOptions, [ + ...PAYMENT_METHOD_TYPES, + ]), + accountType: withOptions(baseMetadata.accountType, accountTypeOptions, [ + ...ACCOUNT_TYPES, + ]), + } + + const onSubmit = async ( + options?: ContractorPaymentMethodSubmitOptions, + ): Promise | undefined> => { + if (!paymentMethod) { + throw new SDKInternalError('Cannot submit payment method form before data is loaded') + } + const currentPaymentMethod = paymentMethod + let submitResult: HookSubmitResult | undefined + + await new Promise(resolve => { + void formMethods.handleSubmit( + async (data: ContractorPaymentMethodFormOutputs) => { + await baseSubmitHandler(data, async payload => { + let version = currentPaymentMethod.version as string + + if (payload.type === PAYMENT_METHODS.directDeposit) { + const createResponse = await createBankAccountMutation.mutateAsync({ + request: { + contractorUuid: contractorId, + contractorBankAccountCreateRequestBody: { + name: payload.name, + routingNumber: payload.routingNumber, + accountNumber: payload.accountNumber, + accountType: payload.accountType, + }, + }, + }) + + if (createResponse.contractorBankAccount) { + options?.onBankAccountCreated?.(createResponse.contractorBankAccount) + } + + // Creating the bank account bumps the payment method version, so + // refetch it imperatively to update with the latest version. + setIsRefetchingVersion(true) + try { + const refetched = await queryClient.fetchQuery( + buildContractorPaymentMethodGetQuery(gustoClient, { + contractorUuid: contractorId, + }), + ) + version = refetched.contractorPaymentMethod?.version ?? version + } finally { + setIsRefetchingVersion(false) + } + } + + const updateResponse = await updatePaymentMethodMutation.mutateAsync({ + request: { + contractorUuid: contractorId, + requestBody: { type: payload.type, version }, + }, + }) + + if (!updateResponse.contractorPaymentMethod) { + throw new SDKInternalError('Payment method update failed') + } + + submitResult = { mode: 'update' as const, data: updateResponse.contractorPaymentMethod } + }) + resolve() + }, + () => { + resolve() + }, + )() + }) + + return submitResult + } + + const hookFormInternals = useHookFormInternals(formMethods) + + if (paymentMethodQuery.isLoading || bankAccountsQuery.isLoading || !paymentMethod) { + return { isLoading: true as const, errorHandling } + } + + return { + isLoading: false as const, + data: { paymentMethod, bankAccount }, + status: { isPending, mode: 'update' as const }, + actions: { onSubmit }, + errorHandling, + form: { + Fields: { + Type: TypeField, + Name: showBankFields ? NameField : undefined, + RoutingNumber: showBankFields ? RoutingNumberField : undefined, + AccountNumber: showBankFields ? AccountNumberField : undefined, + AccountType: showBankFields ? AccountTypeField : undefined, + }, + fieldsMetadata, + hookFormInternals, + getFormSubmissionValues: createGetFormSubmissionValues(formMethods, schema), + }, + } +} + +/** + * Return type of {@link useContractorPaymentMethodForm} — a discriminated union on `isLoading`. + * + * @public + */ +export type UseContractorPaymentMethodFormResult = + | HookLoadingResult + | UseContractorPaymentMethodFormReady + +/** + * Per-field metadata exposed on `form.fieldsMetadata` for {@link useContractorPaymentMethodForm}. + * + * @public + */ +export type ContractorPaymentMethodFieldsMetadata = + UseContractorPaymentMethodFormReady['form']['fieldsMetadata'] diff --git a/src/components/Contractor/PaymentMethod/types.ts b/src/components/Contractor/PaymentMethod/types.ts index 8ddad3b5e..d2e488574 100644 --- a/src/components/Contractor/PaymentMethod/types.ts +++ b/src/components/Contractor/PaymentMethod/types.ts @@ -1,4 +1,3 @@ -import type { ContractorBankAccount } from '@gusto/embedded-api-v-2025-11-15/models/components/contractorbankaccount' import type { BaseComponentInterface } from '@/components/Base' /** @@ -10,8 +9,3 @@ export interface PaymentMethodProps extends BaseComponentInterface<'Contractor.P /** Identifier of the contractor whose payment method is being managed. */ contractorId: string } - -/** @internal */ -export interface BankAccountFormProps { - bankAccount?: ContractorBankAccount -} diff --git a/src/index.ts b/src/index.ts index 32c67468e..641a4904f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -337,6 +337,40 @@ export type { WorkStateFieldProps as ContractorWorkStateFieldProps, } from '@/components/Contractor/Profile/shared/useContractorDetailsForm' +export { + useContractorPaymentMethodForm, + ContractorPaymentMethodErrorCodes, + PAYMENT_METHOD_TYPES as ContractorPaymentMethodTypes, + ACCOUNT_TYPES as ContractorAccountTypes, + TypeField as ContractorPaymentMethodTypeField, + NameField as ContractorPaymentMethodNameField, + RoutingNumberField as ContractorPaymentMethodRoutingNumberField, + AccountNumberField as ContractorPaymentMethodAccountNumberField, + AccountTypeField as ContractorPaymentMethodAccountTypeField, +} from '@/components/Contractor/PaymentMethod/shared/useContractorPaymentMethodForm' +export type { + ContractorPaymentMethodSubmitOptions, + UseContractorPaymentMethodFormProps, + UseContractorPaymentMethodFormResult, + UseContractorPaymentMethodFormReady, + ContractorPaymentMethodFormFields, + ContractorPaymentMethodFieldsMetadata, + ContractorPaymentMethodErrorCode, + ContractorPaymentMethodFormType, + ContractorAccountType, + ContractorPaymentMethodFormData, + ContractorPaymentMethodFormOutputs, + ContractorPaymentMethodOptionalFieldsToRequire, + ContractorPaymentMethodRequiredValidation, + ContractorPaymentMethodRoutingNumberValidation, + ContractorPaymentMethodAccountNumberValidation, + TypeFieldProps as ContractorPaymentMethodTypeFieldProps, + NameFieldProps as ContractorPaymentMethodNameFieldProps, + RoutingNumberFieldProps as ContractorPaymentMethodRoutingNumberFieldProps, + AccountNumberFieldProps as ContractorPaymentMethodAccountNumberFieldProps, + AccountTypeFieldProps as ContractorPaymentMethodAccountTypeFieldProps, +} from '@/components/Contractor/PaymentMethod/shared/useContractorPaymentMethodForm' + export { useWorkAddressForm, useCurrentWorkAddressForm, diff --git a/src/test/mocks/apis/contractor_payment_method.ts b/src/test/mocks/apis/contractor_payment_method.ts index d29008a7a..5972b8df8 100644 --- a/src/test/mocks/apis/contractor_payment_method.ts +++ b/src/test/mocks/apis/contractor_payment_method.ts @@ -28,6 +28,12 @@ export const updateContractorPaymentMethod = http.put< return HttpResponse.json(responseFixture) }) +export function handleUpdateContractorPaymentMethod( + resolver: HttpResponseResolver, +) { + return http.put(`${API_BASE_URL}/v1/contractors/:contractor_id/payment_method`, resolver) +} + export function handleGetContractorBankAccounts( resolver: HttpResponseResolver, ) { @@ -49,6 +55,12 @@ export const createContractorBankAccount = http.post< return HttpResponse.json(responseFixture[0], { status: 201 }) }) +export function handleCreateContractorBankAccount( + resolver: HttpResponseResolver, +) { + return http.post(`${API_BASE_URL}/v1/contractors/:contractor_id/bank_accounts`, resolver) +} + export default [ getContractorPaymentMethod, getContractorBankAccounts, From 05419485d4c7967bc162327962399ea6ddc22fd1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 26 Jun 2026 21:22:08 +0000 Subject: [PATCH 2/2] chore: update derived files --- .reports/embedded-react-sdk.api.md | 121 ++++- docs/reference/contractor/hooks/index.mdx | 2 +- .../use-contractor-payment-method-form.md | 474 ++++++++++++++++++ docs/reference/contractor/index.mdx | 2 +- docs/reference/utilities.md | 1 + 5 files changed, 597 insertions(+), 3 deletions(-) create mode 100644 docs/reference/contractor/hooks/use-contractor-payment-method-form.md diff --git a/.reports/embedded-react-sdk.api.md b/.reports/embedded-react-sdk.api.md index 7416a90e3..726a41749 100644 --- a/.reports/embedded-react-sdk.api.md +++ b/.reports/embedded-react-sdk.api.md @@ -20,7 +20,9 @@ import { Compensation } from '@gusto/embedded-api-v-2025-11-15/models/components import { ComponentType } from 'react'; import { Contractor } from '@gusto/embedded-api-v-2025-11-15/models/components/contractor'; import { ContractorAddress } from '@gusto/embedded-api-v-2025-11-15/models/components/contractoraddress'; +import { ContractorBankAccount } from '@gusto/embedded-api-v-2025-11-15/models/components/contractorbankaccount'; import { ContractorOnboardingStatus1 } from '@gusto/embedded-api-v-2025-11-15/models/components/contractor'; +import { ContractorPaymentMethod } from '@gusto/embedded-api-v-2025-11-15/models/components/contractorpaymentmethod'; import { Control } from 'react-hook-form'; import { CustomTypeOptions } from 'i18next'; import { default as default_2 } from 'react'; @@ -1123,6 +1125,12 @@ interface ConfirmWireDetailsProps extends BaseComponentInterface<'Payroll.Confir wireInId?: string; } +// @public +export type ContractorAccountType = (typeof ContractorAccountTypes)[number]; + +// @public +export const ContractorAccountTypes: readonly ["Checking", "Savings"]; + // @public export function ContractorBusinessNameField(props: ContractorBusinessNameFieldProps): JSX; @@ -1301,6 +1309,87 @@ export const ContractorOnboardingStatus: { readonly ONBOARDING_COMPLETED: "onboarding_completed"; }; +// @public +export function ContractorPaymentMethodAccountNumberField(props: ContractorPaymentMethodAccountNumberFieldProps): JSX; + +// @public +export type ContractorPaymentMethodAccountNumberFieldProps = HookFieldProps>; + +// @public +export type ContractorPaymentMethodAccountNumberValidation = (typeof ContractorPaymentMethodErrorCodes)[keyof Pick]; + +// @public +export function ContractorPaymentMethodAccountTypeField(props: ContractorPaymentMethodAccountTypeFieldProps): JSX; + +// @public +export type ContractorPaymentMethodAccountTypeFieldProps = HookFieldProps>; + +// @public +export type ContractorPaymentMethodErrorCode = (typeof ContractorPaymentMethodErrorCodes)[keyof typeof ContractorPaymentMethodErrorCodes]; + +// @public +export const ContractorPaymentMethodErrorCodes: { + readonly REQUIRED: "REQUIRED"; + readonly INVALID_ROUTING_NUMBER: "INVALID_ROUTING_NUMBER"; + readonly INVALID_ACCOUNT_NUMBER: "INVALID_ACCOUNT_NUMBER"; +}; + +// @public +export type ContractorPaymentMethodFieldsMetadata = UseContractorPaymentMethodFormReady['form']['fieldsMetadata']; + +// @public +export type ContractorPaymentMethodFormData = { type: "Check" | "Direct Deposit"; name: string; routingNumber: string; accountNumber: string; accountType: "Checking" | "Savings"; }; + +// @public +export interface ContractorPaymentMethodFormFields { + AccountNumber: typeof ContractorPaymentMethodAccountNumberField | undefined; + AccountType: typeof ContractorPaymentMethodAccountTypeField | undefined; + Name: typeof ContractorPaymentMethodNameField | undefined; + RoutingNumber: typeof ContractorPaymentMethodRoutingNumberField | undefined; + Type: typeof ContractorPaymentMethodTypeField; +} + +// @public +export type ContractorPaymentMethodFormOutputs = ContractorPaymentMethodFormData; + +// @public +export type ContractorPaymentMethodFormType = (typeof ContractorPaymentMethodTypes)[number]; + +// @public +export function ContractorPaymentMethodNameField(props: ContractorPaymentMethodNameFieldProps): JSX; + +// @public +export type ContractorPaymentMethodNameFieldProps = HookFieldProps>; + +// @public +export type ContractorPaymentMethodOptionalFieldsToRequire = { create?: never[] | undefined; update?: never[] | undefined; }; + +// @public +export type ContractorPaymentMethodRequiredValidation = typeof ContractorPaymentMethodErrorCodes.REQUIRED; + +// @public +export function ContractorPaymentMethodRoutingNumberField(props: ContractorPaymentMethodRoutingNumberFieldProps): JSX; + +// @public +export type ContractorPaymentMethodRoutingNumberFieldProps = HookFieldProps>; + +// @public +export type ContractorPaymentMethodRoutingNumberValidation = (typeof ContractorPaymentMethodErrorCodes)[keyof Pick]; + +// @public +export interface ContractorPaymentMethodSubmitOptions { + onBankAccountCreated?: (bankAccount: ContractorBankAccount) => void; +} + +// @public +export function ContractorPaymentMethodTypeField(props: ContractorPaymentMethodTypeFieldProps): JSX; + +// @public +export type ContractorPaymentMethodTypeFieldProps = HookFieldProps>; + +// @public +export const ContractorPaymentMethodTypes: readonly ["Direct Deposit", "Check"]; + // @public function ContractorProfile(props: ContractorProfileProps): JSX; @@ -3509,7 +3598,7 @@ function PaymentMethod(input: PaymentMethodProps): JSX; function PaymentMethod_2(input: PaymentMethodProps_2): JSX; // @public -function PaymentMethod_3(props: PaymentMethodProps_3): JSX; +function PaymentMethod_3(input: PaymentMethodProps_3): JSX; // @public function PaymentMethodBankForm(input: PaymentMethodBankFormProps): JSX; @@ -5154,6 +5243,36 @@ export type UseContractorDetailsFormSharedProps = { shouldFocusError?: boolean; }; +// @public +export function useContractorPaymentMethodForm(input: UseContractorPaymentMethodFormProps): HookLoadingResult | UseContractorPaymentMethodFormReady; + +// @public +export interface UseContractorPaymentMethodFormProps { + contractorId: string; + defaultValues?: Partial; + optionalFieldsToRequire?: ContractorPaymentMethodOptionalFieldsToRequire; + shouldFocusError?: boolean; + validationMode?: UseFormProps['mode']; +} + +// @public +export interface UseContractorPaymentMethodFormReady extends BaseFormHookReady { + actions: { + onSubmit: (options?: ContractorPaymentMethodSubmitOptions) => Promise | undefined>; + }; + data: { + paymentMethod: ContractorPaymentMethod; + bankAccount: ContractorBankAccount | undefined; + }; + status: { + isPending: boolean; + mode: 'update'; + }; +} + +// @public +export type UseContractorPaymentMethodFormResult = HookLoadingResult | UseContractorPaymentMethodFormReady; + // @public export function useCurrentHomeAddressForm(props: UseCurrentHomeAddressFormProps): UseHomeAddressFormResult; diff --git a/docs/reference/contractor/hooks/index.mdx b/docs/reference/contractor/hooks/index.mdx index fdd682e95..308db1e16 100644 --- a/docs/reference/contractor/hooks/index.mdx +++ b/docs/reference/contractor/hooks/index.mdx @@ -12,4 +12,4 @@ custom_edit_url: null # Hooks - + diff --git a/docs/reference/contractor/hooks/use-contractor-payment-method-form.md b/docs/reference/contractor/hooks/use-contractor-payment-method-form.md new file mode 100644 index 000000000..891be935f --- /dev/null +++ b/docs/reference/contractor/hooks/use-contractor-payment-method-form.md @@ -0,0 +1,474 @@ +--- +# Autogenerated by TypeDoc from TSDoc comments in the source code. +# To update content: edit TSDoc comments in src/. +# To update structure: edit docs-site/typedoc.config.ts or docs-site/plugins/typedoc-custom/. +# Then run `npm run docs:api:generate` to regenerate. +title: useContractorPaymentMethodForm +description: useContractorPaymentMethodForm reference. +generated_by: typedoc +custom_edit_url: null +--- + +# useContractorPaymentMethodForm + +## Components + + + +### ContractorPaymentMethodAccountNumberField + +Text input bound to the `accountNumber` field of [useContractorPaymentMethodForm](#usecontractorpaymentmethodform). + +#### Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `props` | [`ContractorPaymentMethodAccountNumberFieldProps`](#contractorpaymentmethodaccountnumberfieldprops) | See [AccountNumberFieldProps](#contractorpaymentmethodaccountnumberfieldprops). | + +#### Remarks + +Available on the hook result as `form.Fields.AccountNumber`; `undefined` when +the payment method is Check. Pre-filled with the masked account number; only +re-validated against the 1–17 digit numeric pattern once a bank field changes. + +*** + + + +### ContractorPaymentMethodAccountTypeField + +Radio group bound to the `accountType` field of [useContractorPaymentMethodForm](#usecontractorpaymentmethodform). + +#### Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `props` | [`ContractorPaymentMethodAccountTypeFieldProps`](#contractorpaymentmethodaccounttypefieldprops) | See [AccountTypeFieldProps](#contractorpaymentmethodaccounttypefieldprops). | + +#### Remarks + +Available on the hook result as `form.Fields.AccountType`; `undefined` when +the payment method is Check. Options are `Checking` and `Savings`; defaults to +`Checking`. Supply `getOptionLabel` to translate the option labels. + +*** + + + +### ContractorPaymentMethodNameField + +Text input bound to the `name` field of [useContractorPaymentMethodForm](#usecontractorpaymentmethodform). + +#### Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `props` | [`ContractorPaymentMethodNameFieldProps`](#contractorpaymentmethodnamefieldprops) | See [NameFieldProps](#contractorpaymentmethodnamefieldprops). | + +#### Remarks + +Available on the hook result as `form.Fields.Name`; `undefined` when the +payment method is Check. Captures the bank account nickname. + +*** + + + +### ContractorPaymentMethodRoutingNumberField + +Text input bound to the `routingNumber` field of [useContractorPaymentMethodForm](#usecontractorpaymentmethodform). + +#### Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `props` | [`ContractorPaymentMethodRoutingNumberFieldProps`](#contractorpaymentmethodroutingnumberfieldprops) | See [RoutingNumberFieldProps](#contractorpaymentmethodroutingnumberfieldprops). | + +#### Remarks + +Available on the hook result as `form.Fields.RoutingNumber`; `undefined` when +the payment method is Check. Validated against a 9-digit numeric pattern. + +*** + + + +### ContractorPaymentMethodTypeField + +Radio group bound to the `type` field of [useContractorPaymentMethodForm](#usecontractorpaymentmethodform). + +#### Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `props` | [`ContractorPaymentMethodTypeFieldProps`](#contractorpaymentmethodtypefieldprops) | See [TypeFieldProps](#contractorpaymentmethodtypefieldprops). | + +#### Remarks + +Selects whether the contractor is paid by Direct Deposit or Check. Provide +`getOptionLabel` to localize the option labels. + +## Form Hooks + + + +### useContractorPaymentMethodForm() + +> **useContractorPaymentMethodForm**(`props`): [`HookLoadingResult`](../../utilities.md#hookloadingresult) \| [`UseContractorPaymentMethodFormReady`](#usecontractorpaymentmethodformready) + +Headless React Hook Form hook for managing a contractor's payment method. + +#### Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `props` | [`UseContractorPaymentMethodFormProps`](#usecontractorpaymentmethodformprops) | See [UseContractorPaymentMethodFormProps](#usecontractorpaymentmethodformprops). | + +#### Returns + +[`HookLoadingResult`](../../utilities.md#hookloadingresult) \| [`UseContractorPaymentMethodFormReady`](#usecontractorpaymentmethodformready) + +A loading-state result while data loads, or a [UseContractorPaymentMethodFormReady](#usecontractorpaymentmethodformready) once ready. + +#### Remarks + +Switches between Direct Deposit and Check on a single form. Choosing Direct +Deposit reveals the bank account fields (nickname, routing number, account +number, account type) and, on submit, creates the bank account, refetches the +payment method to pick up the bumped optimistic-locking version, then updates +the payment method. Choosing Check hides the bank fields and only updates the +payment method type. Always operates in update mode — every contractor has a +payment method, defaulting to Check. + +Bank-field visibility and requiredness are driven by the selected `type`: the +fields are `undefined` on `form.Fields` and skip their required checks when +Check is selected. The account number is pre-filled with the masked value and +only re-validated once a bank field changes. + +#### Example + +```tsx +import { + useContractorPaymentMethodForm, + SDKFormProvider, + PAYMENT_METHODS, +} from '@gusto/embedded-react-sdk' + +function PaymentMethodScreen({ contractorId }: { contractorId: string }) { + const paymentMethod = useContractorPaymentMethodForm({ contractorId }) + + if (paymentMethod.isLoading) return null + const { Fields } = paymentMethod.form + + return ( + +
{ + e.preventDefault() + void paymentMethod.actions.onSubmit() + }} + > + + {Fields.Name && } + {Fields.RoutingNumber && } + {Fields.AccountNumber && } + {Fields.AccountType && } + + +
+ ) +} +``` + +## Variables + + + +### ContractorAccountTypes + +> `const` **ContractorAccountTypes**: readonly \[`"Checking"`, `"Savings"`\] + +Supported bank account type values: checking and savings. + +*** + + + +### ContractorPaymentMethodErrorCodes + +> `const` **ContractorPaymentMethodErrorCodes**: `object` + +Validation error codes emitted by the contractor payment method form schema. +Map these codes to localized copy in `validationMessages` when composing the +hook. + +#### Type Declaration + +| Name | Type | Default value | +| ------ | ------ | ------ | +| `INVALID_ACCOUNT_NUMBER` | `"INVALID_ACCOUNT_NUMBER"` | `'INVALID_ACCOUNT_NUMBER'` | +| `INVALID_ROUTING_NUMBER` | `"INVALID_ROUTING_NUMBER"` | `'INVALID_ROUTING_NUMBER'` | +| `REQUIRED` | `"REQUIRED"` | `'REQUIRED'` | + +*** + + + +### ContractorPaymentMethodTypes + +> `const` **ContractorPaymentMethodTypes**: readonly \[`"Direct Deposit"`, `"Check"`\] + +Supported payment method type values: direct deposit and check. + +## Interfaces + + + +### ContractorPaymentMethodFormFields + +Field components exposed by [useContractorPaymentMethodForm](#usecontractorpaymentmethodform) on `form.Fields`. + +#### Remarks + +The bank-account fields are `undefined` when the payment method is Check. +Always null-check before rendering. + +#### Properties + +| Property | Type | Description | +| ------ | ------ | ------ | +| `AccountNumber` | ((`props`) => `Element`) \| `undefined` | Text input bound to `accountNumber`; available only for Direct Deposit. | +| `AccountType` | ((`props`) => `Element`) \| `undefined` | Radio group bound to `accountType`; available only for Direct Deposit. | +| `Name` | ((`props`) => `Element`) \| `undefined` | Text input bound to `name`; available only for Direct Deposit. | +| `RoutingNumber` | ((`props`) => `Element`) \| `undefined` | Text input bound to `routingNumber`; available only for Direct Deposit. | +| `Type` | (`props`) => `Element` | Radio group bound to `type`. Always available. | + +*** + + + +### ContractorPaymentMethodSubmitOptions + +Optional submit-time callbacks for [useContractorPaymentMethodForm](#usecontractorpaymentmethodform)'s `onSubmit`. + +#### Properties + +| Property | Type | Description | +| ------ | ------ | ------ | +| `onBankAccountCreated?` | (`bankAccount`) => `void` | Called after a bank account is successfully created (Direct Deposit only), before the payment method update completes. Use it to surface the intermediate "bank account created" event. | + +*** + + + +### UseContractorPaymentMethodFormProps + +Props for [useContractorPaymentMethodForm](#usecontractorpaymentmethodform). + +#### Properties + +| Property | Type | Description | +| ------ | ------ | ------ | +| `contractorId` | `string` | Contractor whose payment method is being edited. | +| `defaultValues?` | `Partial`\<[`ContractorPaymentMethodFormData`](#contractorpaymentmethodformdata)\> | Pre-fill form values. Server data (the current payment method and bank account) is used when no override is supplied. | +| `optionalFieldsToRequire?` | [`ContractorPaymentMethodOptionalFieldsToRequire`](#contractorpaymentmethodoptionalfieldstorequire) | Override optional fields to be required. | +| `shouldFocusError?` | `boolean` | Auto-focus the first invalid field on submit. Set to `false` when using `composeSubmitHandler`. Defaults to `true`. | +| `validationMode?` | `"onChange"` \| `"onBlur"` \| `"onSubmit"` \| `"onTouched"` \| `"all"` | When validation runs. Passed through to react-hook-form. Defaults to `'onSubmit'`. | + +*** + + + +### UseContractorPaymentMethodFormReady + +Ready-state return value of [useContractorPaymentMethodForm](#usecontractorpaymentmethodform). + +#### Extends + +- [`BaseFormHookReady`](../../utilities.md#baseformhookready)\<[`FieldsMetadata`](../../utilities.md#fieldsmetadata), [`ContractorPaymentMethodFormData`](#contractorpaymentmethodformdata), [`ContractorPaymentMethodFormFields`](#contractorpaymentmethodformfields)\> + +#### Properties + +| Property | Type | Description | +| ------ | ------ | ------ | +| `actions` | `object` | Submit the form. Returns the updated payment method on success or `undefined` on validation/mutation failure. | +| `actions.onSubmit` | (`options?`) => `Promise`\<[`HookSubmitResult`](../../utilities.md#hooksubmitresult)\<`ContractorPaymentMethod`\> \| `undefined`\> | - | +| `data` | `object` | The contractor's current payment method and bank account, loaded from the API. | +| `data.bankAccount` | `ContractorBankAccount` \| `undefined` | - | +| `data.paymentMethod` | `ContractorPaymentMethod` | - | +| `errorHandling` | [`HookErrorHandling`](../../utilities.md#hookerrorhandling) | Error state and recovery actions. | +| `form` | `object` | Form bindings: pre-bound field components, per-field metadata, submission values, and react-hook-form internals. | +| `form.Fields` | [`ContractorPaymentMethodFormFields`](#contractorpaymentmethodformfields) | - | +| `form.fieldsMetadata` | [`FieldsMetadata`](../../utilities.md#fieldsmetadata) | - | +| `form.getFormSubmissionValues` | () => `Record`\<`string`, `unknown`\> \| `undefined` | - | +| `form.hookFormInternals` | [`HookFormInternals`](../../utilities.md#hookforminternals)\<[`ContractorPaymentMethodFormData`](#contractorpaymentmethodformdata)\> | - | +| `isLoading` | `false` | Always `false` in this branch; discriminates from [HookLoadingResult](../../utilities.md#hookloadingresult). | +| `status` | `object` | `isPending` reflects the in-flight submit sequence; `mode` is always `'update'`. | +| `status.isPending` | `boolean` | - | +| `status.mode` | `"update"` | - | + +## Type Aliases + + + +### ContractorAccountType + +> **ContractorAccountType** = *typeof* [`ContractorAccountTypes`](#contractoraccounttypes)\[`number`\] + +Union of bank account type values that the form accepts. + +*** + + + +### ContractorPaymentMethodAccountNumberFieldProps + +> **ContractorPaymentMethodAccountNumberFieldProps** = [`HookFieldProps`](../../utilities.md#hookfieldprops)\<[`TextInputHookFieldProps`](../../utilities.md#textinputhookfieldprops)\<[`ContractorPaymentMethodAccountNumberValidation`](#contractorpaymentmethodaccountnumbervalidation)\>\> + +Props accepted by [useContractorPaymentMethodForm](#usecontractorpaymentmethodform)'s `Fields.AccountNumber` component. + +*** + + + +### ContractorPaymentMethodAccountNumberValidation + +> **ContractorPaymentMethodAccountNumberValidation** = *typeof* [`ContractorPaymentMethodErrorCodes`](#contractorpaymentmethoderrorcodes)\[keyof `Pick`\<*typeof* [`ContractorPaymentMethodErrorCodes`](#contractorpaymentmethoderrorcodes), `"REQUIRED"` \| `"INVALID_ACCOUNT_NUMBER"`\>\] + +Validation error codes emitted by the `accountNumber` field of +[useContractorPaymentMethodForm](#usecontractorpaymentmethodform). + +*** + + + +### ContractorPaymentMethodAccountTypeFieldProps + +> **ContractorPaymentMethodAccountTypeFieldProps** = [`HookFieldProps`](../../utilities.md#hookfieldprops)\<[`RadioGroupHookFieldProps`](../../utilities.md#radiogrouphookfieldprops)\<[`ContractorPaymentMethodRequiredValidation`](#contractorpaymentmethodrequiredvalidation), [`ContractorAccountType`](#contractoraccounttype)\>\> + +Props accepted by [useContractorPaymentMethodForm](#usecontractorpaymentmethodform)'s `Fields.AccountType` component. + +*** + + + +### ContractorPaymentMethodErrorCode + +> **ContractorPaymentMethodErrorCode** = *typeof* [`ContractorPaymentMethodErrorCodes`](#contractorpaymentmethoderrorcodes)\[keyof *typeof* [`ContractorPaymentMethodErrorCodes`](#contractorpaymentmethoderrorcodes)\] + +Union of validation error code strings emitted by the contractor payment +method form schema. + +*** + + + +### ContractorPaymentMethodFieldsMetadata + +> **ContractorPaymentMethodFieldsMetadata** = [`UseContractorPaymentMethodFormReady`](#usecontractorpaymentmethodformready)\[`"form"`\]\[`"fieldsMetadata"`\] + +Per-field metadata exposed on `form.fieldsMetadata` for [useContractorPaymentMethodForm](#usecontractorpaymentmethodform). + +*** + + + +### ContractorPaymentMethodFormData + +> **ContractorPaymentMethodFormData** = `{ [K in keyof typeof fieldValidators]: z.infer }` + +Shape of the values managed by the contractor payment method form. + +*** + + + +### ContractorPaymentMethodFormOutputs + +> **ContractorPaymentMethodFormOutputs** = [`ContractorPaymentMethodFormData`](#contractorpaymentmethodformdata) + +Shape of the validated values produced by the contractor payment method form +on submit. + +*** + + + +### ContractorPaymentMethodFormType + +> **ContractorPaymentMethodFormType** = *typeof* [`ContractorPaymentMethodTypes`](#contractorpaymentmethodtypes)\[`number`\] + +Union of payment method type values that the form accepts. + +*** + + + +### ContractorPaymentMethodNameFieldProps + +> **ContractorPaymentMethodNameFieldProps** = [`HookFieldProps`](../../utilities.md#hookfieldprops)\<[`TextInputHookFieldProps`](../../utilities.md#textinputhookfieldprops)\<[`ContractorPaymentMethodRequiredValidation`](#contractorpaymentmethodrequiredvalidation)\>\> + +Props accepted by [useContractorPaymentMethodForm](#usecontractorpaymentmethodform)'s `Fields.Name` component. + +*** + + + +### ContractorPaymentMethodOptionalFieldsToRequire + +> **ContractorPaymentMethodOptionalFieldsToRequire** = `OptionalFieldsToRequire`\<*typeof* `requiredFieldsConfig`\> + +Keys of optional contractor payment method fields that can be promoted to +required via the hook's `optionalFieldsToRequire` option. + +*** + + + +### ContractorPaymentMethodRequiredValidation + +> **ContractorPaymentMethodRequiredValidation** = *typeof* `ContractorPaymentMethodErrorCodes.REQUIRED` + +Validation error code emitted by [useContractorPaymentMethodForm](#usecontractorpaymentmethodform) fields +that only emit `REQUIRED`. + +*** + + + +### ContractorPaymentMethodRoutingNumberFieldProps + +> **ContractorPaymentMethodRoutingNumberFieldProps** = [`HookFieldProps`](../../utilities.md#hookfieldprops)\<[`TextInputHookFieldProps`](../../utilities.md#textinputhookfieldprops)\<[`ContractorPaymentMethodRoutingNumberValidation`](#contractorpaymentmethodroutingnumbervalidation)\>\> + +Props accepted by [useContractorPaymentMethodForm](#usecontractorpaymentmethodform)'s `Fields.RoutingNumber` component. + +*** + + + +### ContractorPaymentMethodRoutingNumberValidation + +> **ContractorPaymentMethodRoutingNumberValidation** = *typeof* [`ContractorPaymentMethodErrorCodes`](#contractorpaymentmethoderrorcodes)\[keyof `Pick`\<*typeof* [`ContractorPaymentMethodErrorCodes`](#contractorpaymentmethoderrorcodes), `"REQUIRED"` \| `"INVALID_ROUTING_NUMBER"`\>\] + +Validation error codes emitted by the `routingNumber` field of +[useContractorPaymentMethodForm](#usecontractorpaymentmethodform). + +*** + + + +### ContractorPaymentMethodTypeFieldProps + +> **ContractorPaymentMethodTypeFieldProps** = [`HookFieldProps`](../../utilities.md#hookfieldprops)\<[`RadioGroupHookFieldProps`](../../utilities.md#radiogrouphookfieldprops)\<`never`, [`ContractorPaymentMethodFormType`](#contractorpaymentmethodformtype)\>\> + +Props accepted by [useContractorPaymentMethodForm](#usecontractorpaymentmethodform)'s `Fields.Type` component. + +*** + + + +### UseContractorPaymentMethodFormResult + +> **UseContractorPaymentMethodFormResult** = [`HookLoadingResult`](../../utilities.md#hookloadingresult) \| [`UseContractorPaymentMethodFormReady`](#usecontractorpaymentmethodformready) + +Return type of [useContractorPaymentMethodForm](#usecontractorpaymentmethodform) — a discriminated union on `isLoading`. diff --git a/docs/reference/contractor/index.mdx b/docs/reference/contractor/index.mdx index f334ae285..8d709d1b2 100644 --- a/docs/reference/contractor/index.mdx +++ b/docs/reference/contractor/index.mdx @@ -22,4 +22,4 @@ custom_edit_url: null ## 🪝 Hooks - + diff --git a/docs/reference/utilities.md b/docs/reference/utilities.md index 55ccc1895..2a5f9e0a4 100644 --- a/docs/reference/utilities.md +++ b/docs/reference/utilities.md @@ -238,6 +238,7 @@ parsed values (or `undefined` if invalid). - [`UseJobFormReady`](employee/hooks/use-job-form.md#usejobformready) - [`UseEmployeeDetailsFormReady`](employee/hooks/use-employee-details-form.md#useemployeedetailsformready) - [`UseContractorDetailsFormReady`](contractor/hooks/use-contractor-details-form.md#usecontractordetailsformready) +- [`UseContractorPaymentMethodFormReady`](contractor/hooks/use-contractor-payment-method-form.md#usecontractorpaymentmethodformready) - [`UseWorkAddressFormReady`](employee/hooks/use-work-address-form.md#useworkaddressformready) - [`UseHomeAddressFormReady`](employee/hooks/use-home-address-form.md#usehomeaddressformready) - [`UseBankFormReady`](employee/hooks/use-bank-form.md#usebankformready)