Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .reports/embedded-react-sdk.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1892,6 +1892,8 @@ declare namespace ContractorManagement {
PaymentFlowProps,
CreatePaymentFlow,
CreatePaymentFlowProps,
HistoricalPaymentFlow,
HistoricalPaymentFlowProps,
ViewPaymentFlow,
ViewPaymentFlowProps,
PaymentsList,
Expand Down Expand Up @@ -3242,6 +3244,14 @@ export interface HeadingProps extends Pick<HTMLAttributes<HTMLHeadingElement>, '
// @public
export type HireDateFieldProps = HookFieldProps<DatePickerHookFieldProps<JobRequiredValidation>>;

// @alpha
function HistoricalPaymentFlow(props: HistoricalPaymentFlowProps): JSX;

// @alpha
interface HistoricalPaymentFlowProps extends BaseComponentInterface<never> {
companyId: string;
}

// @alpha
function HistoricalPaymentSummary(props: HistoricalPaymentSummaryProps): JSX;

Expand Down
6 changes: 6 additions & 0 deletions docs/guides/endpoint-inventory.json
Original file line number Diff line number Diff line change
Expand Up @@ -2493,6 +2493,12 @@
"ContractorManagement.Profile"
]
},
"ContractorManagement.HistoricalPaymentFlow": {
"blocks": [
"ContractorManagement.CreateHistoricalPayment",
"ContractorManagement.HistoricalPaymentSummary"
]
},
"ContractorManagement.PaymentFlow": {
"blocks": [
"ContractorManagement.CreatePaymentFlow",
Expand Down
1 change: 1 addition & 0 deletions docs/guides/endpoint-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/Translations/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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."` |
| <a id="property-contractorpaymentscreatehistoricalpaymentbreadcrumblabel"></a> `breadcrumbLabel` | `"Record a historical payment"` |
| <a id="property-contractorpaymentscreatehistoricalpaymentcontractortableheaders"></a> `contractorTableHeaders` | |
| `contractorTableHeaders.bonus` | `"Bonus"` |
| `contractorTableHeaders.contractor` | `"Contractor"` |
Expand Down Expand Up @@ -1978,6 +1979,7 @@ Translation keys for the `Contractor.Payments.HistoricalPaymentSummary` i18n nam
| Property | Default value |
| ------ | ------ |
| <a id="property-contractorpaymentshistoricalpaymentsummarybonus"></a> `bonus` | `"Bonus"` |
| <a id="property-contractorpaymentshistoricalpaymentsummarybreadcrumblabel"></a> `breadcrumbLabel` | `"Payment summary"` |
| <a id="property-contractorpaymentshistoricalpaymentsummarycontractor"></a> `contractor` | `"Contractor"` |
| <a id="property-contractorpaymentshistoricalpaymentsummarycontractorpaydate"></a> `contractorPayDate` | `"Contractor Pay Date"` |
| <a id="property-contractorpaymentshistoricalpaymentsummarycontractorpaymentstitle"></a> `contractorPaymentsTitle` | `"Contractor Payments"` |
Expand Down
Original file line number Diff line number Diff line change
@@ -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(<HistoricalPaymentFlow companyId={COMPANY_ID} onEvent={onEvent} />)
return { onEvent }
}

async function typeDate(
user: ReturnType<typeof userEvent.setup>,
{ 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<typeof userEvent.setup>) => {
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<typeof userEvent.setup>

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()

Check failure on line 142 in src/components/Contractor/Payments/HistoricalPaymentFlow/HistoricalPaymentFlow.test.tsx

View workflow job for this annotation

GitHub Actions / test

src/components/Contractor/Payments/HistoricalPaymentFlow/HistoricalPaymentFlow.test.tsx > HistoricalPaymentFlow > chains CreateHistoricalPayment into HistoricalPaymentSummary, carrying the created payment group id

TestingLibraryElementError: Unable to find an element with the text: Ada Lovelace. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible. Ignored nodes: comments, script, style <body> <div data-live-announcer="true" style="border: 0px; clip-path: inset(50%); height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap;" > <div aria-live="assertive" aria-relevant="additions" role="log" > <div> 10 </div> </div> <div aria-live="polite" aria-relevant="additions" role="log" /> </div> <div> <article class="GSDK" data-testid="GSDK" > <div lang="en-US" > <div class="_flexContainer_1b3eeb" > <div class="_flex_1b3eeb" style="--g-flex-direction-base: row; --g-justify-content-base: normal; --g-align-items-base: flex-start; --g-gap-base: 1.5rem;" > <div class="_flexContainer_1b3eeb" > <div class="_flex_1b3eeb" style="--g-flex-direction-base: column; --g-justify-content-base: normal; --g-align-items-base: flex-start; --g-gap-base: 2rem;" > <div class="_flexContainer_1b3eeb" > <div class="_flex_1b3eeb" style="--g-flex-direction-base: row; --g-justify-content-base: space-between; --g-align-items-base: center; --g-gap-base: 1.5rem;" > <div style="flex-grow: 1;" > <div> <div class="_flexContainer_1b3eeb" > <div class="_flex_1b3eeb" style="--g-flex-direction-base: column; --g-justify-content-base: normal; --g-align-items-base: flex-start; --g-gap-base: 1.5rem;" > <nav aria-label="Breadcrumbs" > <ol class="_list_1d2af3" > <li aria-current="step" class="_item_1d2af3" > <span> Payment summary </span> </li> </ol> </nav> </div> </div> </div> </div> <div /> </div> </div> <div class="_fade_8c1d2d " > <div class="_flexContainer_1b3eeb" > <div class="_flex_1b3eeb" style="--g-flex-direction-base: column; --g-justify-content-base: normal; --g-align-items-base: flex-start; --g-gap-base: 1.5rem;" > <div class="_root_4e1e63" > <div aria-labelledby="_r_65_" class="_alert_4e1e63" data-variant="success" role="alert" tabindex="-1" > <div class="_header_4e1e63" > <div class="_iconLabelContainer_4e1e63"
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,
)
})
})
Original file line number Diff line number Diff line change
@@ -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 (
* <ContractorManagement.HistoricalPaymentFlow
* companyId="a007e1ab-3595-43c2-ab4b-af7a5af2e365"
* onEvent={() => {}}
* />
* )
* }
* ```
*/
export function HistoricalPaymentFlow(props: HistoricalPaymentFlowProps) {
return <HistoricalPaymentInternalFlow {...props} />
}

/**
* 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,
}),
Comment on lines +113 to +121

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You might consider placing the machine creation in a useMemo here instead. Ex. if we got a new company id, we'd want to tear down the machine and create a new one

)
})

return <Flow machine={historicalPaymentFlow} onEvent={onEvent} />
}
Original file line number Diff line number Diff line change
@@ -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<never> {
/** The associated company identifier. */
companyId: string
}

/** @internal */
export interface HistoricalPaymentFlowContextInterface extends FlowContextInterface {
companyId: string
createdPaymentGroupId?: string
}

/** @internal */
export function CreateHistoricalPaymentContextual() {
const { companyId, onEvent } = useFlow<HistoricalPaymentFlowContextInterface>()
return <CreateHistoricalPayment onEvent={onEvent} companyId={ensureRequired(companyId)} />
}

/** @internal */
export function HistoricalPaymentSummaryContextual() {
const { createdPaymentGroupId, companyId, onEvent } =
useFlow<HistoricalPaymentFlowContextInterface>()

return (
<HistoricalPaymentSummary
onEvent={onEvent}
paymentGroupId={ensureRequired(createdPaymentGroupId)}
companyId={ensureRequired(companyId)}
/>
)
}
Loading
Loading