diff --git a/.reports/embedded-react-sdk.api.md b/.reports/embedded-react-sdk.api.md index 42fd0b7abd..0a00df3b6a 100644 --- a/.reports/embedded-react-sdk.api.md +++ b/.reports/embedded-react-sdk.api.md @@ -1892,6 +1892,8 @@ declare namespace ContractorManagement { PaymentFlowProps, CreatePaymentFlow, CreatePaymentFlowProps, + HistoricalPaymentFlow, + HistoricalPaymentFlowProps, ViewPaymentFlow, ViewPaymentFlowProps, PaymentsList, @@ -3242,6 +3244,14 @@ export interface HeadingProps extends Pick, ' // @public export type HireDateFieldProps = HookFieldProps>; +// @alpha +function HistoricalPaymentFlow(props: HistoricalPaymentFlowProps): JSX; + +// @alpha +interface HistoricalPaymentFlowProps extends BaseComponentInterface { + companyId: string; +} + // @alpha function HistoricalPaymentSummary(props: HistoricalPaymentSummaryProps): JSX; diff --git a/docs/guides/endpoint-inventory.json b/docs/guides/endpoint-inventory.json index 03e0c79c1b..c32a75f049 100644 --- a/docs/guides/endpoint-inventory.json +++ b/docs/guides/endpoint-inventory.json @@ -2493,6 +2493,12 @@ "ContractorManagement.Profile" ] }, + "ContractorManagement.HistoricalPaymentFlow": { + "blocks": [ + "ContractorManagement.CreateHistoricalPayment", + "ContractorManagement.HistoricalPaymentSummary" + ] + }, "ContractorManagement.PaymentFlow": { "blocks": [ "ContractorManagement.CreatePaymentFlow", diff --git a/docs/guides/endpoint-reference.md b/docs/guides/endpoint-reference.md index 0f18838ff4..168817245a 100644 --- a/docs/guides/endpoint-reference.md +++ b/docs/guides/endpoint-reference.md @@ -365,6 +365,7 @@ import inventory from '@gusto/embedded-react-sdk/endpoint-inventory.json' | --- | --- | | **ContractorManagement.CreatePaymentFlow** | ContractorManagement.CreatePayment, ContractorManagement.PaymentSummary | | **ContractorManagement.DashboardFlow** | ContractorManagement.Address, ContractorManagement.Compensation, ContractorManagement.DocumentsCard, ContractorManagement.PaymentMethod, ContractorManagement.Profile | +| **ContractorManagement.HistoricalPaymentFlow** | ContractorManagement.CreateHistoricalPayment, ContractorManagement.HistoricalPaymentSummary | | **ContractorManagement.PaymentFlow** | ContractorManagement.CreatePaymentFlow, ContractorManagement.PaymentsList, ContractorManagement.ViewPaymentFlow, InformationRequests.InformationRequestsFlow | | **ContractorManagement.ViewPaymentFlow** | ContractorManagement.PaymentHistory, ContractorManagement.PaymentStatement | | **ContractorOnboarding.OnboardingFlow** | ContractorOnboarding.Address, ContractorOnboarding.ContractorList, ContractorOnboarding.ContractorProfile, ContractorOnboarding.ContractorSubmit, ContractorOnboarding.NewHireReport, ContractorOnboarding.PaymentMethod | diff --git a/docs/reference/Translations/index.md b/docs/reference/Translations/index.md index b34a962f51..8a7b437ca3 100644 --- a/docs/reference/Translations/index.md +++ b/docs/reference/Translations/index.md @@ -1761,6 +1761,7 @@ Translation keys for the `Contractor.Payments.CreateHistoricalPayment` i18n name | `amounts.continueButton` | `"Continue"` | | `amounts.heading` | `"Enter payment amounts"` | | `amounts.subtitle` | `"Enter the hours or wage paid to each contractor along with any bonuses and reimbursements."` | +| `breadcrumbLabel` | `"Record a historical payment"` | | `contractorTableHeaders` | | | `contractorTableHeaders.bonus` | `"Bonus"` | | `contractorTableHeaders.contractor` | `"Contractor"` | @@ -1978,6 +1979,7 @@ Translation keys for the `Contractor.Payments.HistoricalPaymentSummary` i18n nam | Property | Default value | | ------ | ------ | | `bonus` | `"Bonus"` | +| `breadcrumbLabel` | `"Payment summary"` | | `contractor` | `"Contractor"` | | `contractorPayDate` | `"Contractor Pay Date"` | | `contractorPaymentsTitle` | `"Contractor Payments"` | diff --git a/src/components/Contractor/Payments/HistoricalPaymentFlow/HistoricalPaymentFlow.test.tsx b/src/components/Contractor/Payments/HistoricalPaymentFlow/HistoricalPaymentFlow.test.tsx new file mode 100644 index 0000000000..6da1232b77 --- /dev/null +++ b/src/components/Contractor/Payments/HistoricalPaymentFlow/HistoricalPaymentFlow.test.tsx @@ -0,0 +1,160 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { HttpResponse } from 'msw' +import { HistoricalPaymentFlow } from './HistoricalPaymentFlow' +import { server } from '@/test/mocks/server' +import { renderWithProviders } from '@/test-utils/renderWithProviders' +import { handleGetContractorsList } from '@/test/mocks/apis/contractors' +import { + handleCreateContractorPaymentGroup, + handleGetContractorPaymentGroup, + handlePreviewContractorPaymentGroup, +} from '@/test/mocks/apis/contractor_payment_groups' +import { componentEvents } from '@/shared/constants' + +const COMPANY_ID = 'company-123' +const DATE_LABEL = 'Payment date' + +const hourlyContractor = { + uuid: 'contractor-1', + company_uuid: COMPANY_ID, + wage_type: 'Hourly', + type: 'Individual', + first_name: 'Ada', + last_name: 'Lovelace', + is_active: true, + onboarding_status: 'onboarding_completed', + hourly_rate: '50.00', + payment_method: 'Direct Deposit', +} + +const createdPaymentGroup = { + uuid: 'created-group-uuid', + company_uuid: COMPANY_ID, + check_date: '2026-07-15', + status: 'Funded', + totals: { amount: '500.00' }, + contractor_payments: [ + { + uuid: 'payment-1', + contractor_uuid: 'contractor-1', + payment_method: 'Historical Payment', + wage_type: 'Hourly', + hourly_rate: '50.00', + hours: '10', + bonus: '0', + reimbursement: '0', + wage_total: '500.00', + }, + ], +} + +const renderScreen = (onEvent = vi.fn()) => { + server.use( + handleGetContractorsList(() => + HttpResponse.json([hourlyContractor], { + headers: { 'x-total-pages': '1', 'x-total-count': '1' }, + }), + ), + handlePreviewContractorPaymentGroup(() => + HttpResponse.json({ + check_date: '2026-07-15', + creation_token: 'preview-token-123', + contractor_payments: [ + { + contractor_uuid: 'contractor-1', + uuid: 'preview-payment-1', + wage_type: 'Hourly', + hourly_rate: '50.00', + hours: '10', + wage_total: '500.00', + }, + ], + totals: { amount: '500.00' }, + }), + ), + handleCreateContractorPaymentGroup(() => + HttpResponse.json(createdPaymentGroup, { status: 201 }), + ), + handleGetContractorPaymentGroup(() => HttpResponse.json(createdPaymentGroup)), + ) + renderWithProviders() + return { onEvent } +} + +async function typeDate( + user: ReturnType, + { month, day, year }: { month: string; day: string; year: string }, +) { + const group = screen.getByRole('group', { name: new RegExp(DATE_LABEL, 'i') }) + await user.type(within(group).getByRole('spinbutton', { name: /^month/i }), month) + await user.type(within(group).getByRole('spinbutton', { name: /^day/i }), day) + await user.type(within(group).getByRole('spinbutton', { name: /^year/i }), year) +} + +const walkToSummary = async (user: ReturnType) => { + await waitFor(() => { + expect(screen.getByText('Ada Lovelace')).toBeInTheDocument() + }) + await typeDate(user, { month: '07', day: '15', year: '2026' }) + const checkboxes = screen.getAllByRole('checkbox') + await user.click(checkboxes[1] as Element) + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Continue' })).toBeEnabled() + }) + await user.click(screen.getByRole('button', { name: 'Continue' })) + + await screen.findByRole('heading', { name: 'Enter payment amounts' }) + await user.click(screen.getByRole('button', { name: 'Edit contractor payment' })) + await user.click(await screen.findByRole('menuitem', { name: 'Edit contractor payment' })) + await user.type(screen.getByLabelText('Hours'), '10') + await user.click(screen.getByRole('button', { name: 'Done' })) + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Continue' })).toBeEnabled() + }) + await user.click(screen.getByRole('button', { name: 'Continue' })) + + await screen.findByRole('heading', { name: 'Review and submit' }) + await user.click(screen.getByRole('button', { name: 'Submit historical payment' })) +} + +describe('HistoricalPaymentFlow', () => { + let user: ReturnType + + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + vi.setSystemTime(new Date('2026-07-27T12:00:00-07:00')) + user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('chains CreateHistoricalPayment into HistoricalPaymentSummary, carrying the created payment group id', async () => { + const { onEvent } = renderScreen() + + await walkToSummary(user) + + expect(await screen.findByRole('heading', { name: 'Payment summary' })).toBeInTheDocument() + expect(screen.getByText('Ada Lovelace')).toBeInTheDocument() + expect(onEvent).toHaveBeenCalledWith( + componentEvents.CONTRACTOR_HISTORICAL_PAYMENT_CREATED, + expect.objectContaining({ uuid: 'created-group-uuid' }), + ) + }) + + it('emits exit when Done is clicked on the summary', async () => { + const { onEvent } = renderScreen() + + await walkToSummary(user) + await user.click(await screen.findByRole('button', { name: 'Done' })) + + expect(onEvent).toHaveBeenCalledWith( + componentEvents.CONTRACTOR_HISTORICAL_PAYMENT_EXIT, + undefined, + ) + }) +}) diff --git a/src/components/Contractor/Payments/HistoricalPaymentFlow/HistoricalPaymentFlow.tsx b/src/components/Contractor/Payments/HistoricalPaymentFlow/HistoricalPaymentFlow.tsx new file mode 100644 index 0000000000..4f0817f261 --- /dev/null +++ b/src/components/Contractor/Payments/HistoricalPaymentFlow/HistoricalPaymentFlow.tsx @@ -0,0 +1,126 @@ +import { createMachine } from 'robot3' +import { useState } from 'react' +import { + historicalPaymentBreadcrumbsNodes, + historicalPaymentMachine, +} from './historicalPaymentMachine' +import { + CreateHistoricalPaymentContextual, + type HistoricalPaymentFlowContextInterface, + type HistoricalPaymentFlowProps, +} from './HistoricalPaymentFlowComponents' +import { Flow } from '@/components/Flow/Flow' +import type { FlowBreadcrumb } from '@/components/Common/FlowBreadcrumbs/FlowBreadcrumbsTypes' +import { buildBreadcrumbs, updateBreadcrumbs } from '@/helpers/breadcrumbHelpers' + +const EMPTY_BREADCRUMBS: FlowBreadcrumb[] = [] + +/** + * Props for the flow-internal {@link HistoricalPaymentInternalFlow}, which layers a parent flow's + * prefix breadcrumbs on top of the public {@link HistoricalPaymentFlowProps}. + * + * @internal + */ +export interface HistoricalPaymentInternalFlowProps extends HistoricalPaymentFlowProps { + /** + * Breadcrumbs prepended to the flow's own breadcrumb trail. Set by a parent flow (e.g. + * `PaymentFlow`) so the breadcrumb history remains coherent across the handoff. + */ + prefixBreadcrumbs?: FlowBreadcrumb[] +} + +/** + * Guided flow to record a historical contractor payment and review the resulting summary. + * + * @remarks + * This is the inner flow that powers the historical-payment spoke of `ContractorManagement.PaymentFlow`. + * Render it directly when you have built your own payments landing page and want to hand the user + * off to the standard historical-payment experience without re-implementing it. A historical payment + * already happened outside Gusto and does not move money, so unlike `CreatePaymentFlow` there is no + * Fast ACH blocker or wire-transfer step. + * + * @events + * | Event | Description | Data | + * | ----- | ----------- | ---- | + * | `contractor/historicalPayments/edit` | The edit modal was opened for a contractor | — | + * | `contractor/historicalPayments/update` | A contractor's payment values were updated locally | The updated form values (hours, wage, bonus, reimbursement, payment method, etc.) | + * | `contractor/historicalPayments/preview` | The preview API call succeeded | The contractor payment group preview response | + * | `contractor/historicalPayments/backToEdit` | The user returned from preview to continue editing | — | + * | `contractor/historicalPayments/created` | The payment group was successfully created | The created `ContractorPaymentGroup` | + * | `contractor/historicalPayments/exit` | User is done reviewing the summary | — | + * | `breadcrumb/navigate` | Fired when the user clicks a breadcrumb to navigate back | `{ key: string, onNavigate: (ctx) => ctx }` | + * + * @components + * - {@link CreateHistoricalPayment} + * - {@link HistoricalPaymentSummary} + * + * @param props - See {@link HistoricalPaymentFlowProps}. + * @returns The composed historical-payment flow. + * @alpha + * + * @example + * ```tsx title="App.tsx" + * import { ContractorManagement } from '@gusto/embedded-react-sdk' + * + * function MyApp() { + * return ( + * {}} + * /> + * ) + * } + * ``` + */ +export function HistoricalPaymentFlow(props: HistoricalPaymentFlowProps) { + return +} + +/** + * Flow-internal entry point for {@link HistoricalPaymentFlow} that additionally accepts + * flow-injected `prefixBreadcrumbs`. Partners use {@link HistoricalPaymentFlow}; `PaymentFlow` + * renders this directly to prepend its own breadcrumb trail. + * + * @internal + */ +export function HistoricalPaymentInternalFlow({ + companyId, + onEvent, + prefixBreadcrumbs = EMPTY_BREADCRUMBS, +}: HistoricalPaymentInternalFlowProps) { + // Built once via a lazy useState initializer, not useMemo: the machine's identity must survive + // re-renders no matter what, and useMemo is only a performance hint React may discard, not an + // identity guarantee. A useMemo keyed on `prefixBreadcrumbs` would recreate this machine (and + // reset in-flight state, losing entered amounts) whenever the parent app re-renders in response + // to a bubbled `onEvent` call, since an inline array literal upstream gets a new reference every + // render. + const [historicalPaymentFlow] = useState(() => { + const baseBreadcrumbs = buildBreadcrumbs(historicalPaymentBreadcrumbsNodes) + const breadcrumbs = Object.fromEntries( + Object.entries(baseBreadcrumbs).map(([stateKey, trail]) => [ + stateKey, + [...prefixBreadcrumbs, ...trail], + ]), + ) + + const initialBreadcrumbContext = updateBreadcrumbs('createHistoricalPayment', { + header: { + type: 'breadcrumbs' as const, + breadcrumbs, + }, + }) + + return createMachine( + 'createHistoricalPayment', + historicalPaymentMachine, + (initialContext: HistoricalPaymentFlowContextInterface) => ({ + ...initialContext, + ...initialBreadcrumbContext, + component: CreateHistoricalPaymentContextual, + companyId, + }), + ) + }) + + return +} diff --git a/src/components/Contractor/Payments/HistoricalPaymentFlow/HistoricalPaymentFlowComponents.tsx b/src/components/Contractor/Payments/HistoricalPaymentFlow/HistoricalPaymentFlowComponents.tsx new file mode 100644 index 0000000000..b1fcd36a8c --- /dev/null +++ b/src/components/Contractor/Payments/HistoricalPaymentFlow/HistoricalPaymentFlowComponents.tsx @@ -0,0 +1,41 @@ +import { CreateHistoricalPayment } from '../CreateHistoricalPayment/CreateHistoricalPayment' +import { HistoricalPaymentSummary } from '../HistoricalPaymentSummary/HistoricalPaymentSummary' +import { useFlow, type FlowContextInterface } from '@/components/Flow/useFlow' +import type { BaseComponentInterface } from '@/components/Base' +import { ensureRequired } from '@/helpers/ensureRequired' + +/** + * Props for {@link HistoricalPaymentFlow}. + * + * @alpha + */ +export interface HistoricalPaymentFlowProps extends BaseComponentInterface { + /** The associated company identifier. */ + companyId: string +} + +/** @internal */ +export interface HistoricalPaymentFlowContextInterface extends FlowContextInterface { + companyId: string + createdPaymentGroupId?: string +} + +/** @internal */ +export function CreateHistoricalPaymentContextual() { + const { companyId, onEvent } = useFlow() + return +} + +/** @internal */ +export function HistoricalPaymentSummaryContextual() { + const { createdPaymentGroupId, companyId, onEvent } = + useFlow() + + return ( + + ) +} diff --git a/src/components/Contractor/Payments/HistoricalPaymentFlow/historicalPaymentMachine.ts b/src/components/Contractor/Payments/HistoricalPaymentFlow/historicalPaymentMachine.ts new file mode 100644 index 0000000000..a0703291c7 --- /dev/null +++ b/src/components/Contractor/Payments/HistoricalPaymentFlow/historicalPaymentMachine.ts @@ -0,0 +1,73 @@ +import { reduce, state, transition } from 'robot3' +import type { ContractorPaymentGroup } from '@gusto/embedded-api/models/components/contractorpaymentgroup' +import { + HistoricalPaymentSummaryContextual, + type HistoricalPaymentFlowContextInterface, +} from './HistoricalPaymentFlowComponents' +import { componentEvents } from '@/shared/constants' +import type { MachineEventType, MachineTransition } from '@/types/Helpers' +import { updateBreadcrumbs } from '@/helpers/breadcrumbHelpers' +import type { BreadcrumbNodes } from '@/components/Common/FlowBreadcrumbs/FlowBreadcrumbsTypes' + +type EventPayloads = { + [componentEvents.CONTRACTOR_HISTORICAL_PAYMENT_CREATED]: ContractorPaymentGroup +} + +/** @internal */ +export const historicalPaymentBreadcrumbsNodes: BreadcrumbNodes = { + createHistoricalPayment: { + parent: null, + item: { + id: 'createHistoricalPayment', + label: 'breadcrumbLabel', + namespace: 'Contractor.Payments.CreateHistoricalPayment', + onNavigate: ((ctx: HistoricalPaymentFlowContextInterface) => ({ + ...updateBreadcrumbs('createHistoricalPayment', ctx), + })) as (context: unknown) => unknown, + }, + }, + historicalPaymentSummary: { + parent: null, + item: { + id: 'historicalPaymentSummary', + label: 'breadcrumbLabel', + namespace: 'Contractor.Payments.HistoricalPaymentSummary', + }, + }, +} + +/** + * Flow machine for {@link HistoricalPaymentFlow}. + * + * @remarks + * `historicalPaymentSummary` is a plain zero-transition state, not a robot3 `final()` state: + * `HistoricalPaymentFlow` can be mounted directly by a partner, so `contractor/historicalPayments/exit` + * bubbles up via `onEvent` without a local transition, keeping the summary screen interactive if the + * host doesn't unmount the component immediately (see SDK-1169). + * + * @internal + */ +export const historicalPaymentMachine = { + createHistoricalPayment: state( + transition( + componentEvents.CONTRACTOR_HISTORICAL_PAYMENT_CREATED, + 'historicalPaymentSummary', + reduce( + ( + ctx: HistoricalPaymentFlowContextInterface, + ev: MachineEventType< + EventPayloads, + typeof componentEvents.CONTRACTOR_HISTORICAL_PAYMENT_CREATED + >, + ): HistoricalPaymentFlowContextInterface => { + return { + ...updateBreadcrumbs('historicalPaymentSummary', ctx), + component: HistoricalPaymentSummaryContextual, + createdPaymentGroupId: ev.payload.uuid, + } + }, + ), + ), + ), + historicalPaymentSummary: state(), +} diff --git a/src/components/Contractor/Payments/HistoricalPaymentFlow/index.ts b/src/components/Contractor/Payments/HistoricalPaymentFlow/index.ts new file mode 100644 index 0000000000..7ebdc50cd8 --- /dev/null +++ b/src/components/Contractor/Payments/HistoricalPaymentFlow/index.ts @@ -0,0 +1,2 @@ +export { HistoricalPaymentFlow } from './HistoricalPaymentFlow' +export type { HistoricalPaymentFlowProps } from './HistoricalPaymentFlowComponents' diff --git a/src/components/Contractor/exports/contractorManagement.ts b/src/components/Contractor/exports/contractorManagement.ts index 8ff5a8dbc6..8e8cd8c02f 100644 --- a/src/components/Contractor/exports/contractorManagement.ts +++ b/src/components/Contractor/exports/contractorManagement.ts @@ -28,6 +28,10 @@ export type { export { DocumentsCard, type DocumentsCardProps } from '../Documents/management' export { PaymentFlow, type PaymentFlowProps } from '../Payments/PaymentFlow' export { CreatePaymentFlow, type CreatePaymentFlowProps } from '../Payments/CreatePaymentFlow' +export { + HistoricalPaymentFlow, + type HistoricalPaymentFlowProps, +} from '../Payments/HistoricalPaymentFlow' export { ViewPaymentFlow, type ViewPaymentFlowProps } from '../Payments/ViewPaymentFlow' export { PaymentsList, type PaymentsListProps } from '../Payments/PaymentsList/PaymentsList' export { CreatePayment, type CreatePaymentProps } from '../Payments/CreatePayment/CreatePayment' diff --git a/src/i18n/en/Contractor.Payments.CreateHistoricalPayment.json b/src/i18n/en/Contractor.Payments.CreateHistoricalPayment.json index 03956ccb6c..69ce71fb86 100644 --- a/src/i18n/en/Contractor.Payments.CreateHistoricalPayment.json +++ b/src/i18n/en/Contractor.Payments.CreateHistoricalPayment.json @@ -1,4 +1,5 @@ { + "breadcrumbLabel": "Record a historical payment", "select": { "heading": "Record a historical payment", "subtitle": "Log a contractor payment that already happened outside Gusto. Pick a paid date and the contractors you paid.", diff --git a/src/i18n/en/Contractor.Payments.HistoricalPaymentSummary.json b/src/i18n/en/Contractor.Payments.HistoricalPaymentSummary.json index 7c124ad54b..fd447b055e 100644 --- a/src/i18n/en/Contractor.Payments.HistoricalPaymentSummary.json +++ b/src/i18n/en/Contractor.Payments.HistoricalPaymentSummary.json @@ -1,4 +1,5 @@ { + "breadcrumbLabel": "Payment summary", "successTitle": "Historical payment recorded successfully", "successMessage_one": "{{count}} contractor payment has been recorded.", "successMessage_other": "{{count}} contractor payments have been recorded.", diff --git a/src/i18n/types.d.ts b/src/i18n/types.d.ts index 26670498f0..e639f3be4a 100644 --- a/src/i18n/types.d.ts +++ b/src/i18n/types.d.ts @@ -2414,6 +2414,8 @@ export namespace Translations { } /** Translation keys for the `Contractor.Payments.CreateHistoricalPayment` i18n namespace. */ export interface ContractorPaymentsCreateHistoricalPayment { + /** @defaultValue `"Record a historical payment"` */ + breadcrumbLabel: string select: { /** @defaultValue `"Record a historical payment"` */ heading: string @@ -2812,6 +2814,8 @@ export namespace Translations { } /** Translation keys for the `Contractor.Payments.HistoricalPaymentSummary` i18n namespace. */ export interface ContractorPaymentsHistoricalPaymentSummary { + /** @defaultValue `"Payment summary"` */ + breadcrumbLabel: string /** @defaultValue `"Historical payment recorded successfully"` */ successTitle: string /** @defaultValue `"{{count}} contractor payment has been recorded."` */