diff --git a/.reports/embedded-react-sdk.api.md b/.reports/embedded-react-sdk.api.md index 66bc38594..f8dec361e 100644 --- a/.reports/embedded-react-sdk.api.md +++ b/.reports/embedded-react-sdk.api.md @@ -1773,6 +1773,10 @@ declare namespace ContractorManagement { ContractorTab, PaymentFlow, PaymentFlowProps, + CreatePaymentFlow, + CreatePaymentFlowProps, + ViewHistoryFlow, + ViewHistoryFlowProps, PaymentsList, PaymentsListProps, CreatePayment, @@ -2072,6 +2076,14 @@ type CreatableTimeOffPolicyType = Extract; // @public function CreatePayment(props: CreatePaymentProps): JSX; +// @alpha +function CreatePaymentFlow(props: CreatePaymentFlowProps): JSX; + +// @alpha +interface CreatePaymentFlowProps extends BaseComponentInterface { + companyId: string; +} + // @public interface CreatePaymentProps extends BaseComponentInterface<'Contractor.Payments.CreatePayment'> { companyId: string; @@ -6204,6 +6216,14 @@ export type UseWorkAddressFormResult = HookLoadingResult | UseWorkAddressFormRea // @public export type ValidationMessages = Record & Partial>; +// @alpha +function ViewHistoryFlow(props: ViewHistoryFlowProps): JSX; + +// @alpha +interface ViewHistoryFlowProps extends BaseComponentInterface { + paymentId: string; +} + // @public function ViewHolidayEmployees(props: ViewHolidayEmployeesProps): JSX; diff --git a/docs/guides/endpoint-inventory.json b/docs/guides/endpoint-inventory.json index 5fb23dd18..702d638e4 100644 --- a/docs/guides/endpoint-inventory.json +++ b/docs/guides/endpoint-inventory.json @@ -2300,16 +2300,27 @@ "EmployeeOnboarding.OnboardingFlow" ] }, - "ContractorManagement.PaymentFlow": { + "ContractorManagement.CreatePaymentFlow": { "blocks": [ "ContractorManagement.CreatePayment", - "ContractorManagement.PaymentHistory", - "ContractorManagement.PaymentStatement", - "ContractorManagement.PaymentSummary", + "ContractorManagement.PaymentSummary" + ] + }, + "ContractorManagement.PaymentFlow": { + "blocks": [ + "ContractorManagement.CreatePaymentFlow", "ContractorManagement.PaymentsList", + "ContractorManagement.ViewHistoryFlow", "InformationRequests.InformationRequestsFlow" ] }, + "ContractorManagement.ViewHistoryFlow": { + "blocks": [ + "ContractorManagement.CreatePayment", + "ContractorManagement.PaymentHistory", + "ContractorManagement.PaymentStatement" + ] + }, "ContractorOnboarding.OnboardingFlow": { "blocks": [ "ContractorOnboarding.Address", diff --git a/docs/guides/endpoint-reference.md b/docs/guides/endpoint-reference.md index 6e2571cb5..c313d9394 100644 --- a/docs/guides/endpoint-reference.md +++ b/docs/guides/endpoint-reference.md @@ -341,7 +341,9 @@ import inventory from '@gusto/embedded-react-sdk/endpoint-inventory.json' | Flow | Blocks included | | --- | --- | -| **ContractorManagement.PaymentFlow** | ContractorManagement.CreatePayment, ContractorManagement.PaymentHistory, ContractorManagement.PaymentStatement, ContractorManagement.PaymentSummary, ContractorManagement.PaymentsList, InformationRequests.InformationRequestsFlow | +| **ContractorManagement.CreatePaymentFlow** | ContractorManagement.CreatePayment, ContractorManagement.PaymentSummary | +| **ContractorManagement.PaymentFlow** | ContractorManagement.CreatePaymentFlow, ContractorManagement.PaymentsList, ContractorManagement.ViewHistoryFlow, InformationRequests.InformationRequestsFlow | +| **ContractorManagement.ViewHistoryFlow** | ContractorManagement.CreatePayment, ContractorManagement.PaymentHistory, ContractorManagement.PaymentStatement | | **ContractorOnboarding.OnboardingFlow** | ContractorOnboarding.Address, ContractorOnboarding.ContractorList, ContractorOnboarding.ContractorProfile, ContractorOnboarding.ContractorSubmit, ContractorOnboarding.NewHireReport, ContractorOnboarding.PaymentMethod | | **ContractorOnboarding.SelfOnboardingFlow** | ContractorOnboarding.Address, ContractorOnboarding.ContractorProfile, ContractorOnboarding.DocumentSigner, ContractorOnboarding.Landing, ContractorOnboarding.OnboardingSummary, ContractorOnboarding.PaymentMethod | diff --git a/sdk-app/scripts/analyze-component-props.ts b/sdk-app/scripts/analyze-component-props.ts index 57c93fa9f..5367138ba 100644 --- a/sdk-app/scripts/analyze-component-props.ts +++ b/sdk-app/scripts/analyze-component-props.ts @@ -22,7 +22,7 @@ import { Project, SyntaxKind, type Type } from 'ts-morph' const ROOT = resolve(import.meta.dirname, '../..') const OUTPUT = resolve(import.meta.dirname, '../src/generated-registry-data.ts') -const ENTITY_ID_PATTERN = /^(company|employee|contractor|payroll|request)Id$/ +const ENTITY_ID_PATTERN = /^(company|employee|contractor|payroll|request|payment)Id$/ const NAMESPACES: Record = { CompanyOnboarding: ['src/components/Company/exports/companyOnboarding.ts'], diff --git a/sdk-app/scripts/demo-provisioner.ts b/sdk-app/scripts/demo-provisioner.ts index 6c322d5d3..50a334258 100644 --- a/sdk-app/scripts/demo-provisioner.ts +++ b/sdk-app/scripts/demo-provisioner.ts @@ -58,6 +58,51 @@ export interface DemoResult { demoType: string } +/** + * Records a past-dated "Historical Payment" for the given contractor so freshly + * provisioned demos have at least one contractor payment group to inspect + * (e.g. via ContractorManagement.ViewHistoryFlow). Historical payments are + * record-only -- no funding or ACH lead time -- so they post synchronously. + */ +async function seedHistoricalContractorPayment( + proxyBase: string, + companyId: string, + contractorId: string, +): Promise { + const contractorRes = await fetch(`${proxyBase}/v1/contractors/${contractorId}`, { + signal: AbortSignal.timeout(10000), + }) + if (!contractorRes.ok) return '' + const contractor = (await contractorRes.json()) as { wage_type?: string } + + const checkDate = new Date() + checkDate.setDate(checkDate.getDate() - 14) + + const paymentRes = await fetch( + `${proxyBase}/v1/companies/${companyId}/contractor_payment_groups`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + signal: AbortSignal.timeout(10000), + body: JSON.stringify({ + check_date: checkDate.toISOString().slice(0, 10), + creation_token: `sdk-app-seed-${companyId}-${contractorId}`, + contractor_payments: [ + { + contractor_uuid: contractorId, + payment_method: 'Historical Payment', + ...(contractor.wage_type === 'Hourly' ? { hours: '10' } : { wage: '500.00' }), + }, + ], + }), + }, + ) + if (!paymentRes.ok) return '' + + const created = (await paymentRes.json()) as { contractor_payment_group?: { uuid?: string } } + return created.contractor_payment_group?.uuid || '' +} + export async function createDemoAndProvision( gwsFlowsHost: string, demoType: string, @@ -150,6 +195,20 @@ export async function createDemoAndProvision( if (companyId) { onProgress?.('Fetching entity IDs...') entities = await fetchEntityIds(proxyBase, companyId) + + if (entities.contractorId && !entities.paymentId) { + onProgress?.('Seeding a historical contractor payment...') + try { + const paymentId = await seedHistoricalContractorPayment( + proxyBase, + companyId, + entities.contractorId, + ) + if (paymentId) entities.paymentId = paymentId + } catch { + // Best-effort seed; demo creation should not fail if this errors. + } + } } return { flowToken, companyId, entities, demoType } diff --git a/sdk-app/src/App.tsx b/sdk-app/src/App.tsx index 08df2149a..fc4541e4a 100644 --- a/sdk-app/src/App.tsx +++ b/sdk-app/src/App.tsx @@ -65,6 +65,7 @@ function entitiesFromManualConfig(config: ManualConfig): EntityIds { payrollId: config.payrollId, formId: config.formId, requestId: config.requestId, + paymentId: config.paymentId, } } @@ -87,6 +88,7 @@ export function App() { manual.config.payrollId, manual.config.formId, manual.config.requestId, + manual.config.paymentId, ], ) const entityCatalog = useEntityCatalog(isManual ? '' : entities.companyId) @@ -212,6 +214,7 @@ export function App() { payrollId: result.entities.payrollId || '', formId: result.entities.formId || '', requestId: '', + paymentId: result.entities.paymentId || '', }) window.location.reload() } diff --git a/sdk-app/src/DemoSettingsPanel.tsx b/sdk-app/src/DemoSettingsPanel.tsx index 07c4c6cb5..796a704fa 100644 --- a/sdk-app/src/DemoSettingsPanel.tsx +++ b/sdk-app/src/DemoSettingsPanel.tsx @@ -48,6 +48,7 @@ const MANUAL_FIELDS: { key: keyof ManualConfig; label: string; required?: boolea { key: 'payrollId', label: 'Payroll ID' }, { key: 'formId', label: 'Form ID' }, { key: 'requestId', label: 'Request ID' }, + { key: 'paymentId', label: 'Payment ID' }, ] interface EntityComboboxProps { @@ -391,7 +392,8 @@ export function DemoSettingsPanel({ entities.contractorId !== confirmedSnapshot.current.contractorId || entities.payrollId !== confirmedSnapshot.current.payrollId || entities.requestId !== confirmedSnapshot.current.requestId || - entities.formId !== confirmedSnapshot.current.formId + entities.formId !== confirmedSnapshot.current.formId || + entities.paymentId !== confirmedSnapshot.current.paymentId const displayEnv = env === 'localzp' ? 'local' : env @@ -775,6 +777,31 @@ export function DemoSettingsPanel({ } /> + { + onUpdateEntity('paymentId', value) + }} + trailing={ + <> + + + + } + /> + = { 'CompanyOnboarding.StateTaxes': ['companyId'], 'CompanyOnboarding.StateTaxesForm': ['companyId'], 'CompanyOnboarding.StateTaxesList': ['companyId'], + 'ContractorManagement.ContractorList': ['companyId'], 'ContractorManagement.CreatePayment': ['companyId'], + 'ContractorManagement.CreatePaymentFlow': ['companyId'], 'ContractorManagement.PaymentFlow': ['companyId'], - 'ContractorManagement.PaymentHistory': ['companyId'], + 'ContractorManagement.PaymentHistory': ['paymentId'], 'ContractorManagement.PaymentStatement': ['companyId'], 'ContractorManagement.PaymentSummary': ['companyId'], 'ContractorManagement.PaymentsList': ['companyId'], + 'ContractorManagement.ViewHistoryFlow': ['paymentId'], 'ContractorOnboarding.Address': ['contractorId'], 'ContractorOnboarding.ContractorList': ['companyId'], 'ContractorOnboarding.ContractorProfile': ['companyId'], @@ -139,7 +142,6 @@ export const ENTITY_REQUIREMENTS: Record = { export const ADDITIONAL_REQUIRED_PROPS: Record = { 'CompanyOnboarding.SignatureForm': ['formId'], 'CompanyOnboarding.StateTaxesForm': ['state'], - 'ContractorManagement.PaymentHistory': ['paymentId'], 'ContractorManagement.PaymentStatement': ['paymentGroupId', 'contractorUuid'], 'ContractorManagement.PaymentSummary': ['paymentGroupId'], 'ContractorOnboarding.SignatureForm': ['documentUuid'], diff --git a/sdk-app/src/useEntities.ts b/sdk-app/src/useEntities.ts index 2232839b7..863311795 100644 --- a/sdk-app/src/useEntities.ts +++ b/sdk-app/src/useEntities.ts @@ -14,6 +14,7 @@ function getEnvDefaults(): EntityIds { payrollId: import.meta.env.VITE_PAYROLL_ID || '', requestId: import.meta.env.VITE_REQUEST_ID || '', formId: import.meta.env.VITE_FORM_ID || '', + paymentId: import.meta.env.VITE_PAYMENT_ID || '', } } @@ -46,7 +47,7 @@ function saveToStorage(ids: EntityIds) { } function hasMissingEntities(ids: EntityIds): boolean { - return !ids.employeeId || !ids.contractorId || !ids.payrollId || !ids.formId + return !ids.employeeId || !ids.contractorId || !ids.payrollId || !ids.formId || !ids.paymentId } export function useEntities() { @@ -60,6 +61,7 @@ export function useEntities() { payrollId: stored.payrollId || defaults.payrollId, requestId: stored.requestId || defaults.requestId, formId: stored.formId || defaults.formId, + paymentId: stored.paymentId || defaults.paymentId, } }) @@ -98,6 +100,9 @@ export function useEntities() { ? data.payrollId || prev.payrollId : prev.payrollId || data.payrollId || '', formId: overwrite ? data.formId || prev.formId : prev.formId || data.formId || '', + paymentId: overwrite + ? data.paymentId || prev.paymentId + : prev.paymentId || data.paymentId || '', })) } } catch { diff --git a/sdk-app/src/useEntityCatalog.ts b/sdk-app/src/useEntityCatalog.ts index 11e5271f7..37fc79512 100644 --- a/sdk-app/src/useEntityCatalog.ts +++ b/sdk-app/src/useEntityCatalog.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { type EntityOption, type RawContractor, + type RawContractorPaymentGroup, type RawEmployee, type RawForm, type RawInformationRequest, @@ -10,6 +11,7 @@ import { formatEmployee, formatForm, formatInformationRequestType, + formatPaymentGroup, formatPayPeriod, } from './entityFormatters' @@ -17,6 +19,7 @@ export interface EntityCatalog { employees: EntityOption[] contractors: EntityOption[] payrolls: EntityOption[] + payments: EntityOption[] informationRequests: EntityOption[] forms: EntityOption[] isLoading: boolean @@ -26,6 +29,7 @@ const EMPTY_CATALOG: EntityCatalog = { employees: [], contractors: [], payrolls: [], + payments: [], informationRequests: [], forms: [], isLoading: false, @@ -71,13 +75,23 @@ export function useEntityCatalog(companyId: string): EntityCatalog { end_date: toIso(endDate), per: '100', }) - const [employees, contractors, payrolls, informationRequests, forms] = await Promise.all([ - fetchList(`${base}/employees`, controller.signal), - fetchList(`${base}/contractors`, controller.signal), - fetchList(`${base}/payrolls?${payrollsQuery.toString()}`, controller.signal), - fetchList(`${base}/information_requests`, controller.signal), - fetchList(`${base}/forms`, controller.signal), - ]) + const paymentsQuery = new URLSearchParams({ + start_date: toIso(startDate), + end_date: toIso(endDate), + per: '100', + }) + const [employees, contractors, payrolls, payments, informationRequests, forms] = + await Promise.all([ + fetchList(`${base}/employees`, controller.signal), + fetchList(`${base}/contractors`, controller.signal), + fetchList(`${base}/payrolls?${payrollsQuery.toString()}`, controller.signal), + fetchList( + `${base}/contractor_payment_groups?${paymentsQuery.toString()}`, + controller.signal, + ), + fetchList(`${base}/information_requests`, controller.signal), + fetchList(`${base}/forms`, controller.signal), + ]) if (controller.signal.aborted) return @@ -111,6 +125,20 @@ export function useEntityCatalog(companyId: string): EntityCatalog { }, } }), + payments: payments + .filter(p => !!p.uuid) + .map(p => { + const isFunded = p.status === 'Funded' + return { + value: p.uuid as string, + primary: formatPaymentGroup(p), + secondary: p.uuid as string, + badge: { + label: p.status || 'Unknown', + tone: isFunded ? ('processed' as const) : ('unprocessed' as const), + }, + } + }), informationRequests: informationRequests .filter(r => !!r.uuid) .map(r => ({ diff --git a/sdk-app/src/useManualConfig.ts b/sdk-app/src/useManualConfig.ts index e5746f7f0..83d671062 100644 --- a/sdk-app/src/useManualConfig.ts +++ b/sdk-app/src/useManualConfig.ts @@ -10,6 +10,7 @@ export interface ManualConfig { payrollId: string formId: string requestId: string + paymentId: string } interface PersistedShape { @@ -30,6 +31,7 @@ const EMPTY_CONFIG: ManualConfig = { payrollId: '', formId: '', requestId: '', + paymentId: '', } function readPersisted(): PersistedShape { diff --git a/src/components/Contractor/Payments/CreatePaymentFlow/CreatePaymentFlow.tsx b/src/components/Contractor/Payments/CreatePaymentFlow/CreatePaymentFlow.tsx new file mode 100644 index 000000000..4a98d4a53 --- /dev/null +++ b/src/components/Contractor/Payments/CreatePaymentFlow/CreatePaymentFlow.tsx @@ -0,0 +1,114 @@ +import { createMachine } from 'robot3' +import { useMemo } from 'react' +import { createPaymentBreadcrumbsNodes, createPaymentMachine } from './createPaymentMachine' +import { + CreatePaymentContextual, + type CreatePaymentFlowContextInterface, + type CreatePaymentFlowProps, +} from './CreatePaymentFlowComponents' +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 CreatePaymentInternalFlow}, which layers a parent flow's + * prefix breadcrumbs on top of the public {@link CreatePaymentFlowProps}. + * + * @internal + */ +export interface CreatePaymentInternalFlowProps extends CreatePaymentFlowProps { + /** + * 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 create a contractor payment and review the resulting summary. + * + * @remarks + * This is the inner flow that powers the create-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 create-payment experience without re-implementing it. The flow ships with + * breadcrumb navigation and handles Fast ACH blockers and wire transfer requirements inline. + * + * @events + * | Event | Description | Data | + * | ----- | ----------- | ---- | + * | `contractor/payments/created` | Fired when a payment group is successfully created | The created `ContractorPaymentGroup` | + * | `contractor/payments/exit` | Fired when the user completes the payment flow | `{ uuid?: string \| null }` | + * | `payroll/wire/form/done` | Fired when wire transfer details are submitted | `{ wireInRequest: WireInRequest, confirmationAlert: { title: string, content?: string } }` | + * | `breadcrumb/navigate` | Fired when the user clicks a breadcrumb to navigate back | `{ key: string, onNavigate: (ctx) => ctx }` | + * + * @components + * - {@link CreatePayment} + * - {@link PaymentSummary} + * + * @param props - See {@link CreatePaymentFlowProps}. + * @returns The composed create-payment flow. + * @alpha + * + * @example + * ```tsx title="App.tsx" + * import { ContractorManagement } from '@gusto/embedded-react-sdk' + * + * function MyApp() { + * return ( + * {}} + * /> + * ) + * } + * ``` + */ +export function CreatePaymentFlow(props: CreatePaymentFlowProps) { + return +} + +/** + * Flow-internal entry point for {@link CreatePaymentFlow} that additionally accepts + * flow-injected `prefixBreadcrumbs`. Partners use {@link CreatePaymentFlow}; `PaymentFlow` renders + * this directly to prepend its own breadcrumb trail. + * + * @internal + */ +export function CreatePaymentInternalFlow({ + companyId, + onEvent, + prefixBreadcrumbs = EMPTY_BREADCRUMBS, +}: CreatePaymentInternalFlowProps) { + const createPaymentFlow = useMemo(() => { + const baseBreadcrumbs = buildBreadcrumbs(createPaymentBreadcrumbsNodes) + const displayOnlyPrefixes = prefixBreadcrumbs.map(({ onNavigate, ...rest }) => rest) + const breadcrumbs = Object.fromEntries( + Object.entries(baseBreadcrumbs).map(([stateKey, trail]) => [ + stateKey, + [...displayOnlyPrefixes, ...trail], + ]), + ) + + const initialBreadcrumbContext = updateBreadcrumbs('createPayment', { + header: { + type: 'breadcrumbs' as const, + breadcrumbs, + }, + }) + + return createMachine( + 'createPayment', + createPaymentMachine, + (initialContext: CreatePaymentFlowContextInterface) => ({ + ...initialContext, + ...initialBreadcrumbContext, + component: CreatePaymentContextual, + companyId, + }), + ) + }, [companyId, prefixBreadcrumbs]) + + return +} diff --git a/src/components/Contractor/Payments/CreatePaymentFlow/CreatePaymentFlowComponents.tsx b/src/components/Contractor/Payments/CreatePaymentFlow/CreatePaymentFlowComponents.tsx new file mode 100644 index 000000000..bc2c1301c --- /dev/null +++ b/src/components/Contractor/Payments/CreatePaymentFlow/CreatePaymentFlowComponents.tsx @@ -0,0 +1,44 @@ +import { CreatePayment } from '../CreatePayment/CreatePayment' +import { PaymentSummaryInternal } from '../PaymentSummary/PaymentSummary' +import type { InternalAlert } from '../types' +import { useFlow, type FlowContextInterface } from '@/components/Flow/useFlow' +import type { BaseComponentInterface } from '@/components/Base' +import { ensureRequired } from '@/helpers/ensureRequired' + +/** + * Props for {@link CreatePaymentFlow}. + * + * @alpha + */ +export interface CreatePaymentFlowProps extends BaseComponentInterface { + /** The associated company identifier. */ + companyId: string +} + +/** @internal */ +export interface CreatePaymentFlowContextInterface extends FlowContextInterface { + companyId: string + createdPaymentGroupId?: string + alerts?: InternalAlert[] +} + +/** @internal */ +export function CreatePaymentContextual() { + const { companyId, onEvent } = useFlow() + return +} + +/** @internal */ +export function PaymentSummaryContextual() { + const { createdPaymentGroupId, companyId, onEvent, alerts } = + useFlow() + + return ( + + ) +} diff --git a/src/components/Contractor/Payments/CreatePaymentFlow/GUIDE.md b/src/components/Contractor/Payments/CreatePaymentFlow/GUIDE.md new file mode 100644 index 000000000..0d073ec80 --- /dev/null +++ b/src/components/Contractor/Payments/CreatePaymentFlow/GUIDE.md @@ -0,0 +1,16 @@ + + +# CreatePaymentFlow + +## Step flow + +`CreatePaymentFlow` has no hub of its own — it's a straight line from creating a payment to reviewing the result. `CreatePayment` handles selecting a date, editing per-contractor amounts, and submitting; Fast ACH blockers and wire transfer requirements are handled inline. On success (`contractor/payments/created`) the flow hands off to `PaymentSummary`, which shows the created group, debit details, and wire instructions when required. + +```mermaid +flowchart TD + start@{ shape: sm-circ } --> CreatePayment + CreatePayment -->|"contractor/payments/created"| PaymentSummary + PaymentSummary -->|"contractor/payments/exit"| done@{ shape: fr-circ, label: " " } +``` + +The breadcrumb header (`breadcrumb/navigate`) returns to the payments list; submitting wire-transfer details (`payroll/wire/form/done`) surfaces a success alert on `PaymentSummary` without leaving the step. diff --git a/src/components/Contractor/Payments/CreatePaymentFlow/createPaymentMachine.test.ts b/src/components/Contractor/Payments/CreatePaymentFlow/createPaymentMachine.test.ts new file mode 100644 index 000000000..165a6aef6 --- /dev/null +++ b/src/components/Contractor/Payments/CreatePaymentFlow/createPaymentMachine.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest' +import { createMachine, interpret, type SendFunction } from 'robot3' +import { createPaymentMachine, createPaymentBreadcrumbsNodes } from './createPaymentMachine' +import type { CreatePaymentFlowContextInterface } from './CreatePaymentFlowComponents' +import { componentEvents, payrollWireEvents } from '@/shared/constants' +import { buildBreadcrumbs } from '@/helpers/breadcrumbHelpers' + +function createTestMachine() { + return createMachine( + 'createPayment', + createPaymentMachine, + (initialContext: CreatePaymentFlowContextInterface): CreatePaymentFlowContextInterface => ({ + ...initialContext, + component: () => null, + companyId: 'test-company', + header: { + type: 'breadcrumbs', + breadcrumbs: buildBreadcrumbs(createPaymentBreadcrumbsNodes), + currentBreadcrumbId: 'createPayment', + }, + }), + ) +} + +function createService() { + const machine = createTestMachine() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const service = interpret(machine, () => {}, {} as any) + return service +} + +function send(service: ReturnType, type: string, payload?: unknown) { + ;(service.send as SendFunction)({ type, payload }) +} + +function currentBreadcrumbId(service: ReturnType) { + return service.context.header?.type === 'breadcrumbs' + ? service.context.header.currentBreadcrumbId + : undefined +} + +describe('createPaymentMachine', () => { + describe('createPayment state', () => { + it('transitions to paymentSummary on CONTRACTOR_PAYMENT_CREATED with the created group id', () => { + const service = createService() + expect(service.machine.current).toBe('createPayment') + + send(service, componentEvents.CONTRACTOR_PAYMENT_CREATED, { uuid: 'group-456' }) + + expect(service.machine.current).toBe('paymentSummary') + expect(service.context.createdPaymentGroupId).toBe('group-456') + expect(currentBreadcrumbId(service)).toBe('paymentSummary') + expect(service.context.alerts).toBeUndefined() + }) + }) + + describe('paymentSummary state', () => { + function toPaymentSummary(service: ReturnType) { + send(service, componentEvents.CONTRACTOR_PAYMENT_CREATED, { uuid: 'group-456' }) + expect(service.machine.current).toBe('paymentSummary') + } + + it('sets a success alert on PAYROLL_WIRE_FORM_DONE and stays on paymentSummary', () => { + const service = createService() + toPaymentSummary(service) + + send(service, payrollWireEvents.PAYROLL_WIRE_FORM_DONE, { + wireInRequest: {}, + confirmationAlert: { title: 'wireDetailsSubmitted', content: 'Wire submitted' }, + }) + + expect(service.machine.current).toBe('paymentSummary') + expect(service.context.alerts).toEqual([ + { type: 'success', title: 'wireDetailsSubmitted', content: 'Wire submitted' }, + ]) + }) + }) +}) diff --git a/src/components/Contractor/Payments/CreatePaymentFlow/createPaymentMachine.ts b/src/components/Contractor/Payments/CreatePaymentFlow/createPaymentMachine.ts new file mode 100644 index 000000000..e7c3a6a1c --- /dev/null +++ b/src/components/Contractor/Payments/CreatePaymentFlow/createPaymentMachine.ts @@ -0,0 +1,91 @@ +import { reduce, state, transition } from 'robot3' +import type { ContractorPaymentGroup } from '@gusto/embedded-api/models/components/contractorpaymentgroup' +import type { WireInRequest } from '@gusto/embedded-api/models/components/wireinrequest' +import { + PaymentSummaryContextual, + type CreatePaymentFlowContextInterface, +} from './CreatePaymentFlowComponents' +import { payrollWireEvents, 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_PAYMENT_CREATED]: ContractorPaymentGroup + [payrollWireEvents.PAYROLL_WIRE_FORM_DONE]: { + wireInRequest: WireInRequest + confirmationAlert: { + title: string + content?: string + } + } +} + +/** @internal */ +export const createPaymentBreadcrumbsNodes: BreadcrumbNodes = { + createPayment: { + parent: null, + item: { + id: 'createPayment', + label: 'breadcrumbLabel', + namespace: 'Contractor.Payments.CreatePayment', + onNavigate: ((ctx: CreatePaymentFlowContextInterface) => ({ + ...updateBreadcrumbs('createPayment', ctx), + })) as (context: unknown) => unknown, + }, + }, + paymentSummary: { + parent: null, + item: { + id: 'paymentSummary', + label: 'breadcrumbLabel', + namespace: 'Contractor.Payments.PaymentSummary', + }, + }, +} + +/** @internal */ +export const createPaymentMachine = { + createPayment: state( + transition( + componentEvents.CONTRACTOR_PAYMENT_CREATED, + 'paymentSummary', + reduce( + ( + ctx: CreatePaymentFlowContextInterface, + ev: MachineEventType, + ): CreatePaymentFlowContextInterface => { + return { + ...updateBreadcrumbs('paymentSummary', ctx), + component: PaymentSummaryContextual, + createdPaymentGroupId: ev.payload.uuid, + alerts: undefined, + } + }, + ), + ), + ), + paymentSummary: state( + transition( + payrollWireEvents.PAYROLL_WIRE_FORM_DONE, + 'paymentSummary', + reduce( + ( + ctx: CreatePaymentFlowContextInterface, + ev: MachineEventType, + ): CreatePaymentFlowContextInterface => { + return { + ...ctx, + alerts: [ + { + type: 'success', + title: 'wireDetailsSubmitted', + content: ev.payload.confirmationAlert.content, + }, + ], + } + }, + ), + ), + ), +} diff --git a/src/components/Contractor/Payments/CreatePaymentFlow/index.ts b/src/components/Contractor/Payments/CreatePaymentFlow/index.ts new file mode 100644 index 000000000..d1853f13d --- /dev/null +++ b/src/components/Contractor/Payments/CreatePaymentFlow/index.ts @@ -0,0 +1,2 @@ +export { CreatePaymentFlow } from './CreatePaymentFlow' +export type { CreatePaymentFlowProps } from './CreatePaymentFlowComponents' diff --git a/src/components/Contractor/Payments/PaymentFlow/PaymentFlowComponents.tsx b/src/components/Contractor/Payments/PaymentFlow/PaymentFlowComponents.tsx index b1d192e1f..858be30bc 100644 --- a/src/components/Contractor/Payments/PaymentFlow/PaymentFlowComponents.tsx +++ b/src/components/Contractor/Payments/PaymentFlow/PaymentFlowComponents.tsx @@ -1,13 +1,10 @@ import { PaymentsListInternal } from '../PaymentsList/PaymentsList' -import { CreatePayment } from '../CreatePayment/CreatePayment' -import { PaymentHistory } from '../PaymentHistory/PaymentHistory' -import { PaymentStatement } from '../PaymentStatement/PaymentStatement' -import { PaymentSummaryInternal } from '../PaymentSummary/PaymentSummary' +import { CreatePaymentInternalFlow } from '../CreatePaymentFlow/CreatePaymentFlow' +import { ViewHistoryInternalFlow } from '../ViewHistoryFlow/ViewHistoryFlow' import type { InternalAlert } from '../types' import { InformationRequestsFlow } from '@/components/InformationRequests' import { useFlow, type FlowContextInterface } from '@/components/Flow/useFlow' import type { BaseComponentInterface } from '@/components/Base' -import type { BreadcrumbTrail } from '@/components/Common/FlowBreadcrumbs/FlowBreadcrumbsTypes' import { ensureRequired } from '@/helpers/ensureRequired' /** @@ -23,10 +20,7 @@ export interface PaymentFlowProps extends BaseComponentInterface { /** @internal */ export interface PaymentFlowContextInterface extends FlowContextInterface { companyId: string - breadcrumbs?: BreadcrumbTrail currentPaymentId?: string - currentContractorUuid?: string - createdPaymentGroupId?: string alerts?: InternalAlert[] } @@ -38,42 +32,37 @@ export function PaymentListContextual() { ) } -/** @internal */ -export function CreatePaymentContextual() { - const { companyId, onEvent } = useFlow() - return +function useLandingPrefixBreadcrumbs() { + const { header } = useFlow() + const landingBreadcrumb = + header?.type === 'breadcrumbs' ? header.breadcrumbs?.landing?.[0] : undefined + return landingBreadcrumb ? [landingBreadcrumb] : undefined } /** @internal */ -export function PaymentHistoryContextual() { - const { currentPaymentId, onEvent } = useFlow() - return -} +export function CreatePaymentFlowContextual() { + const { companyId, onEvent } = useFlow() + const prefixBreadcrumbs = useLandingPrefixBreadcrumbs() -/** @internal */ -export function PaymentStatementContextual() { - const { currentPaymentId, currentContractorUuid, onEvent } = - useFlow() return ( - ) } /** @internal */ -export function PaymentSummaryContextual() { - const { createdPaymentGroupId, companyId, onEvent, alerts } = - useFlow() +export function ViewHistoryFlowContextual() { + const { currentPaymentId, onEvent } = useFlow() + const prefixBreadcrumbs = useLandingPrefixBreadcrumbs() return ( - ) } diff --git a/src/components/Contractor/Payments/PaymentFlow/paymentStateMachine.test.ts b/src/components/Contractor/Payments/PaymentFlow/paymentStateMachine.test.ts new file mode 100644 index 000000000..51912663e --- /dev/null +++ b/src/components/Contractor/Payments/PaymentFlow/paymentStateMachine.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from 'vitest' +import { createMachine, interpret, type SendFunction } from 'robot3' +import { paymentMachine, paymentFlowBreadcrumbsNodes } from './paymentStateMachine' +import type { PaymentFlowContextInterface } from './PaymentFlowComponents' +import { componentEvents, informationRequestEvents, payrollWireEvents } from '@/shared/constants' +import { buildBreadcrumbs } from '@/helpers/breadcrumbHelpers' +import { ensureRequired } from '@/helpers/ensureRequired' + +function createTestMachine() { + return createMachine( + 'landing', + paymentMachine, + (initialContext: PaymentFlowContextInterface): PaymentFlowContextInterface => ({ + ...initialContext, + component: () => null, + companyId: 'test-company', + header: { + type: 'breadcrumbs', + breadcrumbs: buildBreadcrumbs(paymentFlowBreadcrumbsNodes), + }, + }), + ) +} + +function createService() { + const machine = createTestMachine() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const service = interpret(machine, () => {}, {} as any) + return service +} + +function send(service: ReturnType, type: string, payload?: unknown) { + ;(service.send as SendFunction)({ type, payload }) +} + +function navigateToLanding(service: ReturnType) { + send(service, componentEvents.BREADCRUMB_NAVIGATE, { + key: 'landing', + onNavigate: ensureRequired(paymentFlowBreadcrumbsNodes.landing).item.onNavigate, + }) +} + +describe('paymentMachine', () => { + describe('landing state', () => { + it('transitions to createPayment on CONTRACTOR_PAYMENT_CREATE', () => { + const service = createService() + expect(service.machine.current).toBe('landing') + + send(service, componentEvents.CONTRACTOR_PAYMENT_CREATE) + + expect(service.machine.current).toBe('createPayment') + expect(service.context.alerts).toBeUndefined() + }) + + it('transitions to history on CONTRACTOR_PAYMENT_VIEW with paymentId from event', () => { + const service = createService() + + send(service, componentEvents.CONTRACTOR_PAYMENT_VIEW, { paymentId: 'payment-123' }) + + expect(service.machine.current).toBe('history') + expect(service.context.currentPaymentId).toBe('payment-123') + expect(service.context.alerts).toBeUndefined() + }) + + it('transitions to informationRequests on CONTRACTOR_PAYMENT_RFI_RESPOND', () => { + const service = createService() + + send(service, componentEvents.CONTRACTOR_PAYMENT_RFI_RESPOND) + + expect(service.machine.current).toBe('informationRequests') + }) + + it('sets a success alert on PAYROLL_WIRE_FORM_DONE and stays on landing', () => { + const service = createService() + + send(service, payrollWireEvents.PAYROLL_WIRE_FORM_DONE, { + wireInRequest: {}, + confirmationAlert: { title: 'wireDetailsSubmitted', content: 'Wire submitted' }, + }) + + expect(service.machine.current).toBe('landing') + expect(service.context.alerts).toEqual([ + { type: 'success', title: 'wireDetailsSubmitted', content: 'Wire submitted' }, + ]) + }) + }) + + describe('createPayment state', () => { + function toCreatePayment(service: ReturnType) { + send(service, componentEvents.CONTRACTOR_PAYMENT_CREATE) + expect(service.machine.current).toBe('createPayment') + } + + it('stays active for the full lifetime of the CreatePaymentFlow spoke (no internal transitions)', () => { + const service = createService() + toCreatePayment(service) + + expect(service.context.component).toBeDefined() + }) + + it('transitions to landing on CONTRACTOR_PAYMENT_EXIT bubbled from the spoke', () => { + const service = createService() + toCreatePayment(service) + + send(service, componentEvents.CONTRACTOR_PAYMENT_EXIT, { uuid: 'group-456' }) + + expect(service.machine.current).toBe('landing') + expect(service.context.alerts).toBeUndefined() + }) + + it('transitions to landing on the bubbled landing BREADCRUMB_NAVIGATE', () => { + const service = createService() + toCreatePayment(service) + + navigateToLanding(service) + + expect(service.machine.current).toBe('landing') + }) + }) + + describe('history state', () => { + function toHistory(service: ReturnType) { + send(service, componentEvents.CONTRACTOR_PAYMENT_VIEW, { paymentId: 'payment-123' }) + expect(service.machine.current).toBe('history') + } + + it('transitions to landing with a success alert on CONTRACTOR_PAYMENT_CANCEL bubbled from the spoke', () => { + const service = createService() + toHistory(service) + + send(service, componentEvents.CONTRACTOR_PAYMENT_CANCEL, { paymentId: 'payment-123' }) + + expect(service.machine.current).toBe('landing') + expect(service.context.alerts).toEqual([ + { type: 'success', title: 'paymentCancelledSuccessfully' }, + ]) + }) + + it('transitions to landing on the bubbled landing BREADCRUMB_NAVIGATE', () => { + const service = createService() + toHistory(service) + + navigateToLanding(service) + + expect(service.machine.current).toBe('landing') + }) + }) + + describe('informationRequests state', () => { + function toInformationRequests(service: ReturnType) { + send(service, componentEvents.CONTRACTOR_PAYMENT_RFI_RESPOND) + expect(service.machine.current).toBe('informationRequests') + } + + it('transitions to landing on INFORMATION_REQUEST_FORM_DONE', () => { + const service = createService() + toInformationRequests(service) + + send(service, informationRequestEvents.INFORMATION_REQUEST_FORM_DONE) + + expect(service.machine.current).toBe('landing') + }) + + it('transitions to landing on INFORMATION_REQUEST_FORM_CANCEL', () => { + const service = createService() + toInformationRequests(service) + + send(service, informationRequestEvents.INFORMATION_REQUEST_FORM_CANCEL) + + expect(service.machine.current).toBe('landing') + }) + }) +}) diff --git a/src/components/Contractor/Payments/PaymentFlow/paymentStateMachine.ts b/src/components/Contractor/Payments/PaymentFlow/paymentStateMachine.ts index 7494cc4c4..7ff55bde4 100644 --- a/src/components/Contractor/Payments/PaymentFlow/paymentStateMachine.ts +++ b/src/components/Contractor/Payments/PaymentFlow/paymentStateMachine.ts @@ -1,32 +1,22 @@ import { reduce, state, transition } from 'robot3' -import type { ContractorPaymentGroup } from '@gusto/embedded-api/models/components/contractorpaymentgroup' -import type { Contractor } from '@gusto/embedded-api/models/components/contractor' import type { WireInRequest } from '@gusto/embedded-api/models/components/wireinrequest' -import { getContractorDisplayName } from '../CreatePayment/helpers' import { - CreatePaymentContextual, - type PaymentFlowContextInterface, - PaymentHistoryContextual, - PaymentListContextual, - PaymentStatementContextual, - PaymentSummaryContextual, + CreatePaymentFlowContextual, InformationRequestsContextual, + PaymentListContextual, + type PaymentFlowContextInterface, + ViewHistoryFlowContextual, } from './PaymentFlowComponents' import { componentEvents, informationRequestEvents, payrollWireEvents } from '@/shared/constants' import type { MachineEventType, MachineTransition } from '@/types/Helpers' -import { patchBreadcrumbsHeader, updateBreadcrumbs } from '@/helpers/breadcrumbHelpers' +import { patchBreadcrumbsHeader } from '@/helpers/breadcrumbHelpers' import type { BreadcrumbNodes } from '@/components/Common/FlowBreadcrumbs/FlowBreadcrumbsTypes' import { createBreadcrumbNavigateTransition } from '@/components/Common/FlowBreadcrumbs/breadcrumbTransitionHelpers' type EventPayloads = { [componentEvents.CONTRACTOR_PAYMENT_CREATE]: undefined - [componentEvents.CONTRACTOR_PAYMENT_CREATED]: ContractorPaymentGroup [componentEvents.CONTRACTOR_PAYMENT_EXIT]: { uuid?: string | null } [componentEvents.CONTRACTOR_PAYMENT_VIEW]: { paymentId: string } - [componentEvents.CONTRACTOR_PAYMENT_VIEW_DETAILS]: { - contractor: Contractor - paymentGroupId: string - } [componentEvents.CONTRACTOR_PAYMENT_CANCEL]: { paymentId: string } [componentEvents.CONTRACTOR_PAYMENT_RFI_RESPOND]: undefined [componentEvents.BREADCRUMB_NAVIGATE]: { @@ -44,7 +34,16 @@ type EventPayloads = { } } -/** @internal */ +/** + * Hub-level breadcrumb nodes for {@link PaymentFlow}. + * + * @remarks + * Only `landing` is defined here — the `createPayment` and `history` spokes own and render their + * own breadcrumb trails (via `CreatePaymentFlow`/`ViewHistoryFlow`), prefixed with this `landing` + * item so the trail reads continuously across the hub/spoke boundary. + * + * @internal + */ export const paymentFlowBreadcrumbsNodes: BreadcrumbNodes = { landing: { parent: null, @@ -58,54 +57,23 @@ export const paymentFlowBreadcrumbsNodes: BreadcrumbNodes = { })) as (context: unknown) => unknown, }, }, - createPayment: { - parent: 'landing', - item: { - id: 'createPayment', - label: 'breadcrumbLabel', - namespace: 'Contractor.Payments.CreatePayment', - onNavigate: ((ctx: PaymentFlowContextInterface) => ({ - ...updateBreadcrumbs('createPayment', ctx), - })) as (context: unknown) => unknown, - }, - }, - paymentSummary: { - parent: 'landing', - item: { - id: 'paymentSummary', - label: 'breadcrumbLabel', - namespace: 'Contractor.Payments.PaymentSummary', - onNavigate: ((ctx: PaymentFlowContextInterface) => ({ - ...updateBreadcrumbs('paymentSummary', ctx), - })) as (context: unknown) => unknown, - }, - }, - history: { - parent: 'landing', - item: { - id: 'history', - label: 'breadcrumbLabel', - namespace: 'Contractor.Payments.PaymentHistory', - onNavigate: ((ctx: PaymentFlowContextInterface) => ({ - ...updateBreadcrumbs('history', ctx), - component: PaymentHistoryContextual, - })) as (context: unknown) => unknown, - }, - }, - statement: { - parent: 'history', - item: { - id: 'statement', - label: 'breadcrumbLabel', - namespace: 'Contractor.Payments.PaymentStatement', - }, - }, -} as const +} const breadcrumbNavigateTransition = createBreadcrumbNavigateTransition() -/** @internal */ +/** + * Hub machine for {@link PaymentFlow}. + * + * @remarks + * `createPayment` and `history` each stay active for the full lifetime of their respective spoke + * (`CreatePaymentFlow`, `ViewHistoryFlow`) rather than tracking the spoke's internal screen. The + * spoke's own machine drives its internal steps and breadcrumb trail; events it can't handle + * locally (e.g. the `landing` breadcrumb, or a terminal exit/cancel event) bubble up here to + * transition the hub back to `landing`. + * + * @internal + */ export const paymentMachine = { landing: state( transition( @@ -113,8 +81,8 @@ export const paymentMachine = { 'createPayment', reduce((ctx: PaymentFlowContextInterface): PaymentFlowContextInterface => { return { - ...updateBreadcrumbs('createPayment', ctx), - component: CreatePaymentContextual, + ...ctx, + component: CreatePaymentFlowContextual, alerts: undefined, } }), @@ -128,8 +96,8 @@ export const paymentMachine = { ev: MachineEventType, ): PaymentFlowContextInterface => { return { - ...updateBreadcrumbs('history', ctx), - component: PaymentHistoryContextual, + ...ctx, + component: ViewHistoryFlowContextual, currentPaymentId: ev.payload.paymentId, alerts: undefined, } @@ -169,26 +137,6 @@ export const paymentMachine = { ), ), createPayment: state( - transition( - componentEvents.CONTRACTOR_PAYMENT_CREATED, - 'paymentSummary', - reduce( - ( - ctx: PaymentFlowContextInterface, - ev: MachineEventType, - ): PaymentFlowContextInterface => { - return { - ...updateBreadcrumbs('paymentSummary', ctx), - component: PaymentSummaryContextual, - createdPaymentGroupId: ev.payload.uuid, - alerts: undefined, - } - }, - ), - ), - breadcrumbNavigateTransition('landing'), - ), - paymentSummary: state( transition( componentEvents.CONTRACTOR_PAYMENT_EXIT, 'landing', @@ -196,58 +144,13 @@ export const paymentMachine = { return { ...patchBreadcrumbsHeader(ctx, { currentBreadcrumbId: undefined }), component: PaymentListContextual, - createdPaymentGroupId: undefined, alerts: undefined, } }), ), - transition( - payrollWireEvents.PAYROLL_WIRE_FORM_DONE, - 'paymentSummary', - reduce( - ( - ctx: PaymentFlowContextInterface, - ev: MachineEventType, - ): PaymentFlowContextInterface => { - return { - ...ctx, - alerts: [ - { - type: 'success', - title: 'wireDetailsSubmitted', - content: ev.payload.confirmationAlert.content, - }, - ], - } - }, - ), - ), breadcrumbNavigateTransition('landing'), ), history: state( - transition( - componentEvents.CONTRACTOR_PAYMENT_VIEW_DETAILS, - 'statement', - reduce( - ( - ctx: PaymentFlowContextInterface, - ev: MachineEventType< - EventPayloads, - typeof componentEvents.CONTRACTOR_PAYMENT_VIEW_DETAILS - >, - ): PaymentFlowContextInterface => { - return { - ...updateBreadcrumbs('statement', ctx, { - contractorName: getContractorDisplayName(ev.payload.contractor), - }), - component: PaymentStatementContextual, - currentContractorUuid: ev.payload.contractor.uuid, - currentPaymentId: ev.payload.paymentGroupId, - alerts: undefined, - } - }, - ), - ), transition( componentEvents.CONTRACTOR_PAYMENT_CANCEL, 'landing', @@ -266,10 +169,6 @@ export const paymentMachine = { ), breadcrumbNavigateTransition('landing'), ), - statement: state( - breadcrumbNavigateTransition('landing'), - breadcrumbNavigateTransition('history'), - ), informationRequests: state( transition( informationRequestEvents.INFORMATION_REQUEST_FORM_DONE, diff --git a/src/components/Contractor/Payments/ViewHistoryFlow/GUIDE.md b/src/components/Contractor/Payments/ViewHistoryFlow/GUIDE.md new file mode 100644 index 000000000..ef7f1527e --- /dev/null +++ b/src/components/Contractor/Payments/ViewHistoryFlow/GUIDE.md @@ -0,0 +1,16 @@ + + +# ViewHistoryFlow + +## Step flow + +`ViewHistoryFlow` centers on `PaymentHistory` as its hub: it shows a payment group's details, lets you cancel an individual contractor's payment in place (`contractor/payments/cancel`), and can drill into an individual contractor's statement (`contractor/payments/view/details` → `PaymentStatement`). There's no exit from this flow — cancelling refreshes the group's details without leaving the step. + +```mermaid +flowchart LR + start@{ shape: sm-circ } --> PaymentHistory + PaymentHistory -->|"contractor/payments/view/details"| PaymentStatement + PaymentStatement -->|"breadcrumb/navigate"| PaymentHistory +``` + +Cancelling a contractor's payment (`contractor/payments/cancel`, fired from `PaymentHistory`) removes it from the group and refreshes the view without leaving the step. diff --git a/src/components/Contractor/Payments/ViewHistoryFlow/ViewHistoryFlow.tsx b/src/components/Contractor/Payments/ViewHistoryFlow/ViewHistoryFlow.tsx new file mode 100644 index 000000000..e0f5f9963 --- /dev/null +++ b/src/components/Contractor/Payments/ViewHistoryFlow/ViewHistoryFlow.tsx @@ -0,0 +1,114 @@ +import { createMachine } from 'robot3' +import { useMemo } from 'react' +import { viewHistoryBreadcrumbsNodes, viewHistoryMachine } from './viewHistoryMachine' +import { + PaymentHistoryContextual, + type ViewHistoryFlowContextInterface, + type ViewHistoryFlowProps, +} from './ViewHistoryFlowComponents' +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 ViewHistoryInternalFlow}, which layers a parent flow's + * prefix breadcrumbs on top of the public {@link ViewHistoryFlowProps}. + * + * @internal + */ +export interface ViewHistoryInternalFlowProps extends ViewHistoryFlowProps { + /** + * 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 inspect a contractor payment group's history and drill into an individual + * contractor's payment statement. + * + * @remarks + * This is the inner flow that powers the view-history 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 history-viewing experience without re-implementing it. The flow ships with + * breadcrumb navigation and lets the user cancel an individual payment from the history screen. + * + * @events + * | Event | Description | Data | + * | ----- | ----------- | ---- | + * | `contractor/payments/view/details` | Fired when the user views a specific contractor payment | `{ contractor: Contractor, paymentGroupId: string }` | + * | `contractor/payments/cancel` | Fired when a payment is cancelled | `{ paymentId: string }` | + * | `breadcrumb/navigate` | Fired when the user clicks a breadcrumb to navigate back | `{ key: string, onNavigate: (ctx) => ctx }` | + * + * @components + * - {@link PaymentHistory} + * - {@link PaymentStatement} + * + * @param props - See {@link ViewHistoryFlowProps}. + * @returns The composed view-history flow. + * @alpha + * + * @example + * ```tsx title="App.tsx" + * import { ContractorManagement } from '@gusto/embedded-react-sdk' + * + * function MyApp() { + * return ( + * {}} + * /> + * ) + * } + * ``` + */ +export function ViewHistoryFlow(props: ViewHistoryFlowProps) { + return +} + +/** + * Flow-internal entry point for {@link ViewHistoryFlow} that additionally accepts + * flow-injected `prefixBreadcrumbs`. Partners use {@link ViewHistoryFlow}; `PaymentFlow` renders + * this directly to prepend its own breadcrumb trail. + * + * @internal + */ +export function ViewHistoryInternalFlow({ + paymentId, + onEvent, + prefixBreadcrumbs = EMPTY_BREADCRUMBS, +}: ViewHistoryInternalFlowProps) { + const viewHistoryFlow = useMemo(() => { + const baseBreadcrumbs = buildBreadcrumbs(viewHistoryBreadcrumbsNodes) + const displayOnlyPrefixes = prefixBreadcrumbs.map(({ onNavigate, ...rest }) => rest) + const breadcrumbs = Object.fromEntries( + Object.entries(baseBreadcrumbs).map(([stateKey, trail]) => [ + stateKey, + [...displayOnlyPrefixes, ...trail], + ]), + ) + + const initialBreadcrumbContext = updateBreadcrumbs('history', { + header: { + type: 'breadcrumbs' as const, + breadcrumbs, + }, + }) + + return createMachine( + 'history', + viewHistoryMachine, + (initialContext: ViewHistoryFlowContextInterface) => ({ + ...initialContext, + ...initialBreadcrumbContext, + component: PaymentHistoryContextual, + currentPaymentId: paymentId, + }), + ) + }, [paymentId, prefixBreadcrumbs]) + + return +} diff --git a/src/components/Contractor/Payments/ViewHistoryFlow/ViewHistoryFlowComponents.tsx b/src/components/Contractor/Payments/ViewHistoryFlow/ViewHistoryFlowComponents.tsx new file mode 100644 index 000000000..fe52cade1 --- /dev/null +++ b/src/components/Contractor/Payments/ViewHistoryFlow/ViewHistoryFlowComponents.tsx @@ -0,0 +1,40 @@ +import { PaymentHistory } from '../PaymentHistory/PaymentHistory' +import { PaymentStatement } from '../PaymentStatement/PaymentStatement' +import { useFlow, type FlowContextInterface } from '@/components/Flow/useFlow' +import type { BaseComponentInterface } from '@/components/Base' +import { ensureRequired } from '@/helpers/ensureRequired' + +/** + * Props for {@link ViewHistoryFlow}. + * + * @alpha + */ +export interface ViewHistoryFlowProps extends BaseComponentInterface { + /** Identifier of the payment group to inspect. */ + paymentId: string +} + +/** @internal */ +export interface ViewHistoryFlowContextInterface extends FlowContextInterface { + currentPaymentId?: string + currentContractorUuid?: string +} + +/** @internal */ +export function PaymentHistoryContextual() { + const { currentPaymentId, onEvent } = useFlow() + return +} + +/** @internal */ +export function PaymentStatementContextual() { + const { currentPaymentId, currentContractorUuid, onEvent } = + useFlow() + return ( + + ) +} diff --git a/src/components/Contractor/Payments/ViewHistoryFlow/index.ts b/src/components/Contractor/Payments/ViewHistoryFlow/index.ts new file mode 100644 index 000000000..9701b077d --- /dev/null +++ b/src/components/Contractor/Payments/ViewHistoryFlow/index.ts @@ -0,0 +1,2 @@ +export { ViewHistoryFlow } from './ViewHistoryFlow' +export type { ViewHistoryFlowProps } from './ViewHistoryFlowComponents' diff --git a/src/components/Contractor/Payments/ViewHistoryFlow/viewHistoryMachine.test.ts b/src/components/Contractor/Payments/ViewHistoryFlow/viewHistoryMachine.test.ts new file mode 100644 index 000000000..84ac514c1 --- /dev/null +++ b/src/components/Contractor/Payments/ViewHistoryFlow/viewHistoryMachine.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import { createMachine, interpret, type SendFunction } from 'robot3' +import { viewHistoryMachine, viewHistoryBreadcrumbsNodes } from './viewHistoryMachine' +import type { ViewHistoryFlowContextInterface } from './ViewHistoryFlowComponents' +import { componentEvents } from '@/shared/constants' +import { buildBreadcrumbs } from '@/helpers/breadcrumbHelpers' +import { ensureRequired } from '@/helpers/ensureRequired' + +function createTestMachine() { + return createMachine( + 'history', + viewHistoryMachine, + (initialContext: ViewHistoryFlowContextInterface): ViewHistoryFlowContextInterface => ({ + ...initialContext, + component: () => null, + currentPaymentId: 'payment-123', + header: { + type: 'breadcrumbs', + breadcrumbs: buildBreadcrumbs(viewHistoryBreadcrumbsNodes), + currentBreadcrumbId: 'history', + }, + }), + ) +} + +function createService() { + const machine = createTestMachine() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const service = interpret(machine, () => {}, {} as any) + return service +} + +function send(service: ReturnType, type: string, payload?: unknown) { + ;(service.send as SendFunction)({ type, payload }) +} + +function currentBreadcrumbId(service: ReturnType) { + return service.context.header?.type === 'breadcrumbs' + ? service.context.header.currentBreadcrumbId + : undefined +} + +describe('viewHistoryMachine', () => { + describe('history state', () => { + it('transitions to statement on CONTRACTOR_PAYMENT_VIEW_DETAILS with contractor and payment ids', () => { + const service = createService() + const contractor = { uuid: 'contractor-789', firstName: 'Ada', lastName: 'Lovelace' } + + send(service, componentEvents.CONTRACTOR_PAYMENT_VIEW_DETAILS, { + contractor, + paymentGroupId: 'group-456', + }) + + expect(service.machine.current).toBe('statement') + expect(service.context.currentContractorUuid).toBe('contractor-789') + expect(service.context.currentPaymentId).toBe('group-456') + expect(currentBreadcrumbId(service)).toBe('statement') + }) + }) + + describe('statement state', () => { + function toStatement(service: ReturnType) { + send(service, componentEvents.CONTRACTOR_PAYMENT_VIEW_DETAILS, { + contractor: { uuid: 'contractor-789', firstName: 'Ada', lastName: 'Lovelace' }, + paymentGroupId: 'group-456', + }) + expect(service.machine.current).toBe('statement') + } + + it('transitions to history on BREADCRUMB_NAVIGATE with history key', () => { + const service = createService() + toStatement(service) + + send(service, componentEvents.BREADCRUMB_NAVIGATE, { + key: 'history', + onNavigate: ensureRequired(viewHistoryBreadcrumbsNodes.history).item.onNavigate, + }) + + expect(service.machine.current).toBe('history') + }) + + it('ignores BREADCRUMB_NAVIGATE with an unrelated key', () => { + const service = createService() + toStatement(service) + + send(service, componentEvents.BREADCRUMB_NAVIGATE, { key: 'landing' }) + + expect(service.machine.current).toBe('statement') + }) + }) +}) diff --git a/src/components/Contractor/Payments/ViewHistoryFlow/viewHistoryMachine.ts b/src/components/Contractor/Payments/ViewHistoryFlow/viewHistoryMachine.ts new file mode 100644 index 000000000..45a74bfe9 --- /dev/null +++ b/src/components/Contractor/Payments/ViewHistoryFlow/viewHistoryMachine.ts @@ -0,0 +1,76 @@ +import { reduce, state, transition } from 'robot3' +import type { Contractor } from '@gusto/embedded-api/models/components/contractor' +import { getContractorDisplayName } from '../CreatePayment/helpers' +import { + PaymentHistoryContextual, + PaymentStatementContextual, + type ViewHistoryFlowContextInterface, +} from './ViewHistoryFlowComponents' +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' +import { createBreadcrumbNavigateTransition } from '@/components/Common/FlowBreadcrumbs/breadcrumbTransitionHelpers' + +type EventPayloads = { + [componentEvents.CONTRACTOR_PAYMENT_VIEW_DETAILS]: { + contractor: Contractor + paymentGroupId: string + } +} + +const breadcrumbNavigateTransition = + createBreadcrumbNavigateTransition() + +/** @internal */ +export const viewHistoryBreadcrumbsNodes: BreadcrumbNodes = { + history: { + parent: null, + item: { + id: 'history', + label: 'breadcrumbLabel', + namespace: 'Contractor.Payments.PaymentHistory', + onNavigate: ((ctx: ViewHistoryFlowContextInterface) => ({ + ...updateBreadcrumbs('history', ctx), + component: PaymentHistoryContextual, + })) as (context: unknown) => unknown, + }, + }, + statement: { + parent: 'history', + item: { + id: 'statement', + label: 'breadcrumbLabel', + namespace: 'Contractor.Payments.PaymentStatement', + }, + }, +} + +/** @internal */ +export const viewHistoryMachine = { + history: state( + transition( + componentEvents.CONTRACTOR_PAYMENT_VIEW_DETAILS, + 'statement', + reduce( + ( + ctx: ViewHistoryFlowContextInterface, + ev: MachineEventType< + EventPayloads, + typeof componentEvents.CONTRACTOR_PAYMENT_VIEW_DETAILS + >, + ): ViewHistoryFlowContextInterface => { + return { + ...updateBreadcrumbs('statement', ctx, { + contractorName: getContractorDisplayName(ev.payload.contractor), + }), + component: PaymentStatementContextual, + currentContractorUuid: ev.payload.contractor.uuid, + currentPaymentId: ev.payload.paymentGroupId, + } + }, + ), + ), + ), + statement: state(breadcrumbNavigateTransition('history')), +} diff --git a/src/components/Contractor/exports/contractorManagement.ts b/src/components/Contractor/exports/contractorManagement.ts index 06611a9ab..bdca363e8 100644 --- a/src/components/Contractor/exports/contractorManagement.ts +++ b/src/components/Contractor/exports/contractorManagement.ts @@ -4,6 +4,8 @@ export { type ContractorTab, } from '../ContractorList/management/ManagementContractorList' export { PaymentFlow, type PaymentFlowProps } from '../Payments/PaymentFlow' +export { CreatePaymentFlow, type CreatePaymentFlowProps } from '../Payments/CreatePaymentFlow' +export { ViewHistoryFlow, type ViewHistoryFlowProps } from '../Payments/ViewHistoryFlow' export { PaymentsList, type PaymentsListProps } from '../Payments/PaymentsList/PaymentsList' export { CreatePayment, type CreatePaymentProps } from '../Payments/CreatePayment/CreatePayment' export { PaymentHistory, type PaymentHistoryProps } from '../Payments/PaymentHistory/PaymentHistory'