diff --git a/.reports/embedded-react-sdk.api.md b/.reports/embedded-react-sdk.api.md
index b0314598ff..83ebe65b44 100644
--- a/.reports/embedded-react-sdk.api.md
+++ b/.reports/embedded-react-sdk.api.md
@@ -129,6 +129,7 @@ import { FunctionComponent } from 'react';
import { Garnishment } from '@gusto/embedded-api/models/components/garnishment';
import { GarnishmentChildSupport } from '@gusto/embedded-api/models/components/garnishmentchildsupport';
import { GarnishmentType } from '@gusto/embedded-api/models/components/garnishment';
+import { GeneratedDocumentStatus } from '@gusto/embedded-api/models/components/generateddocument';
import { HolidayPayPolicy } from '@gusto/embedded-api/models/components/holidaypaypolicy';
import { HolidayPayPolicyEmployees } from '@gusto/embedded-api/models/components/holidaypaypolicy';
import { HourlyCompensations } from '@gusto/embedded-api/models/components/payrollemployeecompensationstype';
@@ -247,6 +248,8 @@ import { PayScheduleShow } from '@gusto/embedded-api/models/components/payschedu
import { PlaidStatus } from '@gusto/embedded-api/models/components/companybankaccount';
import { PolicyType } from '@gusto/embedded-api/models/components/timeoffpolicy';
import { PresidentsDay } from '@gusto/embedded-api/models/components/holidaypaypolicy';
+import { PrintablePayrollChecksBody } from '@gusto/embedded-api/models/components/printablepayrollchecksbody';
+import { PrintingFormat } from '@gusto/embedded-api/models/components/printablepayrollchecksbody';
import { QueryClient } from '@tanstack/react-query';
import { Questions } from '@gusto/embedded-api/models/components/employeestatetaxesrequest';
import { RateType } from '@gusto/embedded-api/models/components/taxrequirementmetadata';
@@ -537,6 +540,7 @@ declare namespace APIModels {
Garnishment,
GarnishmentChildSupport,
PaymentPeriod,
+ GeneratedDocumentStatus,
ChristmasDay,
ColumbusDay,
HolidayPayPolicyEmployees,
@@ -656,6 +660,8 @@ declare namespace APIModels {
PayScheduleFrequency_2 as PayScheduleFrequency,
PaySchedulePreviewPayPeriod,
PayScheduleShow,
+ PrintablePayrollChecksBody,
+ PrintingFormat,
RecoveryCase,
RecoveryCaseStatus,
IdentityVerificationStatus,
@@ -5007,6 +5013,8 @@ export interface Resources {
// (undocumented)
'Payroll.PrintChecksFailure': Translations.PayrollPrintChecksFailure
// (undocumented)
+ 'Payroll.PrintChecksForm': Translations.PayrollPrintChecksForm
+ // (undocumented)
'Payroll.PrintChecksSummary': Translations.PayrollPrintChecksSummary
// (undocumented)
'Payroll.RecoveryCasesList': Translations.PayrollRecoveryCasesList
diff --git a/docs/reference/APIModels/index.md b/docs/reference/APIModels/index.md
index fcead82f1a..eba04f4f4c 100644
--- a/docs/reference/APIModels/index.md
+++ b/docs/reference/APIModels/index.md
@@ -3036,6 +3036,39 @@ Defined in: [gusto\_embedded\_v\_2026\_06\_15/src/models/components/garnishment.
***
+
+
+## GeneratedDocumentStatus
+
+> `const` **GeneratedDocumentStatus**: `object`
+
+Defined in: [gusto\_embedded\_v\_2026\_06\_15/src/models/components/generateddocument.ts:15](https://github.com/Gusto/gusto-typescript-client/blob/gusto_embedded_v_2026_06_15/v0.1.0/gusto_embedded_v_2026_06_15/src/models/components/generateddocument.ts#L15)
+
+Current status of the Generated Document
+
+### Type Declaration
+
+| Name | Type |
+| ------ | ------ |
+| `Failed` | `"failed"` |
+| `Pending` | `"pending"` |
+| `Started` | `"started"` |
+| `Succeeded` | `"succeeded"` |
+
+***
+
+
+
+## GeneratedDocumentStatus
+
+> **GeneratedDocumentStatus** = `ClosedEnum`\<*typeof* [`GeneratedDocumentStatus`](#generateddocumentstatus)\>
+
+Defined in: [gusto\_embedded\_v\_2026\_06\_15/src/models/components/generateddocument.ts:15](https://github.com/Gusto/gusto-typescript-client/blob/gusto_embedded_v_2026_06_15/v0.1.0/gusto_embedded_v_2026_06_15/src/models/components/generateddocument.ts#L15)
+
+Current status of the Generated Document
+
+***
+
## HolidayPayPolicy
@@ -6028,6 +6061,56 @@ Defined in: [gusto\_embedded\_v\_2026\_06\_15/src/models/components/holidaypaypo
***
+
+
+## PrintablePayrollChecksBody
+
+> **PrintablePayrollChecksBody** = `object`
+
+Defined in: [gusto\_embedded\_v\_2026\_06\_15/src/models/components/printablepayrollchecksbody.ts:24](https://github.com/Gusto/gusto-typescript-client/blob/gusto_embedded_v_2026_06_15/v0.1.0/gusto_embedded_v_2026_06_15/src/models/components/printablepayrollchecksbody.ts#L24)
+
+Request body for generating printable payroll checks.
+
+### Properties
+
+| Property | Type | Description |
+| ------ | ------ | ------ |
+| `printingFormat` | [`PrintingFormat`](#printingformat-1) | The type of check stock being printed. Check the "Types of check stock" section in this [link](https://support.gusto.com/article/999877761000000/Pay-your-team-by-check) for more info on check types |
+| `startingCheckNumber?` | `number` | The starting check number we will start generating checks from. Use to override the sequence that will be used to generate check numbers. |
+
+***
+
+
+
+## PrintingFormat
+
+> `const` **PrintingFormat**: `object`
+
+Defined in: [gusto\_embedded\_v\_2026\_06\_15/src/models/components/printablepayrollchecksbody.ts:12](https://github.com/Gusto/gusto-typescript-client/blob/gusto_embedded_v_2026_06_15/v0.1.0/gusto_embedded_v_2026_06_15/src/models/components/printablepayrollchecksbody.ts#L12)
+
+The type of check stock being printed. Check the "Types of check stock" section in this [link](https://support.gusto.com/article/999877761000000/Pay-your-team-by-check) for more info on check types
+
+### Type Declaration
+
+| Name | Type |
+| ------ | ------ |
+| `Bottom` | `"bottom"` |
+| `Top` | `"top"` |
+
+***
+
+
+
+## PrintingFormat
+
+> **PrintingFormat** = `ClosedEnum`\<*typeof* [`PrintingFormat`](#printingformat)\>
+
+Defined in: [gusto\_embedded\_v\_2026\_06\_15/src/models/components/printablepayrollchecksbody.ts:12](https://github.com/Gusto/gusto-typescript-client/blob/gusto_embedded_v_2026_06_15/v0.1.0/gusto_embedded_v_2026_06_15/src/models/components/printablepayrollchecksbody.ts#L12)
+
+The type of check stock being printed. Check the "Types of check stock" section in this [link](https://support.gusto.com/article/999877761000000/Pay-your-team-by-check) for more info on check types
+
+***
+
## Questions
diff --git a/docs/reference/Translations/index.md b/docs/reference/Translations/index.md
index dd22b08ea3..f5a9122b68 100644
--- a/docs/reference/Translations/index.md
+++ b/docs/reference/Translations/index.md
@@ -5271,6 +5271,31 @@ Translation keys for the `Payroll.PrintChecksFailure` i18n namespace.
***
+
+
+### PayrollPrintChecksForm
+
+Translation keys for the `Payroll.PrintChecksForm` i18n namespace.
+
+#### Properties
+
+| Property | Default value |
+| ------ | ------ |
+| `blankStockDescription` | `"Use this check stock if you have blank check stock and need us to populate your company and bank information. The physical check will always be on the bottom of the check PDF."` |
+| `blankStockLabel` | `"Blank check stock"` |
+| `cancelCta` | `"Cancel"` |
+| `customStockDescription` | `"Use this check stock if you have check stock that is pre-printed with your company and bank information. The physical check will appear on the top of the check PDF. Check numbers should already be pre-printed on the check stock you purchased."` |
+| `customStockLabel` | `"Custom check stock"` |
+| `modalTitle` | `"Choose check stock"` |
+| `startingCheckNumberDescription` | `"This will be the first check number, all other checks will follow sequentially."` |
+| `startingCheckNumberLabel` | `"Check number starts with"` |
+| `submitCta` | `"View checks"` |
+| `submitCtaLoading` | `"Generating..."` |
+| `validations` | |
+| `validations.startingCheckNumber` | `"Enter a valid check number"` |
+
+***
+
### PayrollPrintChecksSummary
@@ -5662,6 +5687,7 @@ yields that namespace's keys. Backs i18next `t()` typing and `ResourceDictionary
| `Payroll.PayrollReceipts` | [`PayrollPayrollReceipts`](#payrollpayrollreceipts) |
| `Payroll.PrintChecksBanner` | [`PayrollPrintChecksBanner`](#payrollprintchecksbanner) |
| `Payroll.PrintChecksFailure` | [`PayrollPrintChecksFailure`](#payrollprintchecksfailure) |
+| `Payroll.PrintChecksForm` | [`PayrollPrintChecksForm`](#payrollprintchecksform) |
| `Payroll.PrintChecksSummary` | [`PayrollPrintChecksSummary`](#payrollprintcheckssummary) |
| `Payroll.RecoveryCasesList` | [`PayrollRecoveryCasesList`](#payrollrecoverycaseslist) |
| `Payroll.RecoveryCasesResubmit` | [`PayrollRecoveryCasesResubmit`](#payrollrecoverycasesresubmit) |
diff --git a/src/components/Payroll/PrintChecks/PrintChecksForm/PrintChecksForm.stories.tsx b/src/components/Payroll/PrintChecks/PrintChecksForm/PrintChecksForm.stories.tsx
new file mode 100644
index 0000000000..d4fc3c8316
--- /dev/null
+++ b/src/components/Payroll/PrintChecks/PrintChecksForm/PrintChecksForm.stories.tsx
@@ -0,0 +1,33 @@
+import { Suspense } from 'react'
+import { fn } from 'storybook/test'
+import { PrintChecksForm } from './PrintChecksForm'
+import { GustoTestProvider } from '@/test/GustoTestApiProvider'
+import { FlowContext } from '@/components/Flow/useFlow'
+import { useI18n } from '@/i18n'
+
+function I18nLoader({ children }: { children: React.ReactNode }) {
+ useI18n('Payroll.PrintChecksForm')
+ return <>{children}>
+}
+
+export default {
+ title: 'Domain/Payroll/PrintChecksForm',
+ decorators: [
+ (Story: React.ComponentType) => (
+ Loading translations...}>
+
+
+
+
+
+
+ ),
+ ],
+}
+
+export const Default = () => (
+
+
+
+
+)
diff --git a/src/components/Payroll/PrintChecks/PrintChecksForm/PrintChecksForm.test.tsx b/src/components/Payroll/PrintChecks/PrintChecksForm/PrintChecksForm.test.tsx
new file mode 100644
index 0000000000..2fde39bdcd
--- /dev/null
+++ b/src/components/Payroll/PrintChecks/PrintChecksForm/PrintChecksForm.test.tsx
@@ -0,0 +1,280 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { HttpResponse, type HttpResponseResolver } from 'msw'
+import { PrintChecksForm } from './PrintChecksForm'
+import {
+ handlePayrollsGeneratePrintableChecks,
+ handleGeneratedDocumentsGet,
+ createPayrollCheck,
+ createGeneratedDocument,
+} from '@/test/mocks/apis/printable_payroll_checks'
+import { server } from '@/test/mocks/server'
+import { renderWithProviders } from '@/test-utils/renderWithProviders'
+import { printChecksEvents } from '@/shared/constants'
+import { FlowContext } from '@/components/Flow/useFlow'
+
+describe('PrintChecksForm', () => {
+ const onEvent = vi.fn()
+ const user = userEvent.setup()
+ let anchorClickSpy: ReturnType
+
+ const flowContextValue = {
+ component: null,
+ onEvent,
+ }
+
+ const renderForm = () =>
+ renderWithProviders(
+
+
+
+ ,
+ )
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ // The download is triggered via a synthetic anchor click rather than a real navigation —
+ // jsdom throws "Not implemented: navigation" if this isn't stubbed.
+ anchorClickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
+ })
+
+ it('omits starting_check_number from the request body for custom check stock', async () => {
+ let capturedBody: Record | null = null
+ const generateResolver = vi.fn(async ({ request }) => {
+ capturedBody = (await request.json()) as Record
+ return HttpResponse.json(createPayrollCheck(), { status: 200 })
+ })
+ server.use(handlePayrollsGeneratePrintableChecks(generateResolver))
+ server.use(
+ handleGeneratedDocumentsGet(() =>
+ HttpResponse.json(createGeneratedDocument({ status: 'pending' })),
+ ),
+ )
+
+ renderForm()
+
+ await user.click(await screen.findByRole('button', { name: 'View checks' }))
+
+ await waitFor(() => {
+ expect(generateResolver).toHaveBeenCalledTimes(1)
+ })
+ expect(capturedBody).toEqual({ printing_format: 'top' })
+ })
+
+ it('includes starting_check_number in the request body for blank check stock', async () => {
+ let capturedBody: Record | null = null
+ const generateResolver = vi.fn(async ({ request }) => {
+ capturedBody = (await request.json()) as Record
+ return HttpResponse.json(createPayrollCheck(), { status: 200 })
+ })
+ server.use(handlePayrollsGeneratePrintableChecks(generateResolver))
+ server.use(
+ handleGeneratedDocumentsGet(() =>
+ HttpResponse.json(createGeneratedDocument({ status: 'pending' })),
+ ),
+ )
+
+ renderForm()
+
+ await user.click(await screen.findByRole('radio', { name: 'Blank check stock' }))
+ const input = screen.getByLabelText(/Check number starts with/)
+ await user.clear(input)
+ await user.type(input, '1001')
+ await user.click(screen.getByRole('button', { name: 'View checks' }))
+
+ await waitFor(() => {
+ expect(generateResolver).toHaveBeenCalledTimes(1)
+ })
+ expect(capturedBody).toEqual({ printing_format: 'bottom', starting_check_number: 1001 })
+ })
+
+ it('includes a starting_check_number of 0 in the request body for blank check stock', async () => {
+ let capturedBody: Record | null = null
+ const generateResolver = vi.fn(async ({ request }) => {
+ capturedBody = (await request.json()) as Record
+ return HttpResponse.json(createPayrollCheck(), { status: 200 })
+ })
+ server.use(handlePayrollsGeneratePrintableChecks(generateResolver))
+ server.use(
+ handleGeneratedDocumentsGet(() =>
+ HttpResponse.json(createGeneratedDocument({ status: 'pending' })),
+ ),
+ )
+
+ renderForm()
+
+ await user.click(await screen.findByRole('radio', { name: 'Blank check stock' }))
+ await user.click(screen.getByRole('button', { name: 'View checks' }))
+
+ await waitFor(() => {
+ expect(generateResolver).toHaveBeenCalledTimes(1)
+ })
+ expect(capturedBody).toEqual({ printing_format: 'bottom', starting_check_number: 0 })
+ })
+
+ it('calls the generate endpoint before the poll endpoint', async () => {
+ const generateResolver = vi.fn(() =>
+ HttpResponse.json(createPayrollCheck(), { status: 200 }),
+ )
+ const getDocumentResolver = vi.fn(() =>
+ HttpResponse.json(createGeneratedDocument({ status: 'succeeded' })),
+ )
+ server.use(handlePayrollsGeneratePrintableChecks(generateResolver))
+ server.use(handleGeneratedDocumentsGet(getDocumentResolver))
+
+ renderForm()
+
+ await user.click(await screen.findByRole('button', { name: 'View checks' }))
+
+ await waitFor(() => {
+ expect(getDocumentResolver).toHaveBeenCalled()
+ })
+ expect(generateResolver.mock.invocationCallOrder[0]!).toBeLessThan(
+ getDocumentResolver.mock.invocationCallOrder[0]!,
+ )
+ })
+
+ it('fires PRINT_CHECKS_GENERATE_START on submit', async () => {
+ server.use(
+ handlePayrollsGeneratePrintableChecks(() =>
+ HttpResponse.json(createPayrollCheck(), { status: 200 }),
+ ),
+ )
+ server.use(
+ handleGeneratedDocumentsGet(() =>
+ HttpResponse.json(createGeneratedDocument({ status: 'pending' })),
+ ),
+ )
+
+ renderForm()
+
+ await user.click(await screen.findByRole('button', { name: 'View checks' }))
+
+ expect(onEvent).toHaveBeenCalledWith(printChecksEvents.PRINT_CHECKS_GENERATE_START)
+ })
+
+ it('fires PRINT_CHECKS_GENERATE_SUCCEEDED with the document URL once the poll succeeds', async () => {
+ server.use(
+ handlePayrollsGeneratePrintableChecks(() =>
+ HttpResponse.json(createPayrollCheck(), { status: 200 }),
+ ),
+ )
+ server.use(
+ handleGeneratedDocumentsGet(() =>
+ HttpResponse.json(
+ createGeneratedDocument({
+ status: 'succeeded',
+ document_urls: ['https://example.com/checks.pdf'],
+ }),
+ ),
+ ),
+ )
+
+ renderForm()
+
+ await user.click(await screen.findByRole('button', { name: 'View checks' }))
+
+ await waitFor(() => {
+ expect(onEvent).toHaveBeenCalledWith(printChecksEvents.PRINT_CHECKS_GENERATE_SUCCEEDED, {
+ documentUrl: 'https://example.com/checks.pdf',
+ })
+ })
+ await waitFor(() => {
+ expect(anchorClickSpy).toHaveBeenCalledTimes(1)
+ })
+ expect(anchorClickSpy.mock.contexts[0]).toHaveProperty('href', 'https://example.com/checks.pdf')
+ })
+
+ it('does not open any window or navigate anywhere when generation succeeds', async () => {
+ const openSpy = vi.spyOn(window, 'open')
+ server.use(
+ handlePayrollsGeneratePrintableChecks(() =>
+ HttpResponse.json(createPayrollCheck(), { status: 200 }),
+ ),
+ )
+ server.use(
+ handleGeneratedDocumentsGet(() =>
+ HttpResponse.json(
+ createGeneratedDocument({
+ status: 'succeeded',
+ document_urls: ['https://example.com/checks.pdf'],
+ }),
+ ),
+ ),
+ )
+
+ renderForm()
+
+ await user.click(await screen.findByRole('button', { name: 'View checks' }))
+
+ await waitFor(() => {
+ expect(anchorClickSpy).toHaveBeenCalledTimes(1)
+ })
+ expect(openSpy).not.toHaveBeenCalled()
+ })
+
+ it('fires PRINT_CHECKS_GENERATE_FAILED when the poll reports failure', async () => {
+ server.use(
+ handlePayrollsGeneratePrintableChecks(() =>
+ HttpResponse.json(createPayrollCheck(), { status: 200 }),
+ ),
+ )
+ server.use(
+ handleGeneratedDocumentsGet(() =>
+ HttpResponse.json(createGeneratedDocument({ status: 'failed', document_urls: [] })),
+ ),
+ )
+
+ renderForm()
+
+ await user.click(await screen.findByRole('button', { name: 'View checks' }))
+
+ await waitFor(() => {
+ expect(onEvent).toHaveBeenCalledWith(printChecksEvents.PRINT_CHECKS_GENERATE_FAILED, {
+ errorMessage: null,
+ })
+ })
+ })
+
+ it('fires PRINT_CHECKS_GENERATE_FAILED with the server error message when the mutation itself is rejected', async () => {
+ server.use(
+ handlePayrollsGeneratePrintableChecks(() =>
+ HttpResponse.json(
+ {
+ errors: [
+ {
+ error_key: 'invalid_action',
+ category: 'invalid_operation',
+ message: 'Cannot generate checks on an unprocessed payroll',
+ },
+ ],
+ },
+ { status: 422 },
+ ),
+ ),
+ )
+
+ renderForm()
+
+ await user.click(await screen.findByRole('button', { name: 'View checks' }))
+
+ await waitFor(() => {
+ expect(onEvent).toHaveBeenCalledWith(printChecksEvents.PRINT_CHECKS_GENERATE_FAILED, {
+ errorMessage: 'Cannot generate checks on an unprocessed payroll',
+ })
+ })
+ expect(onEvent).not.toHaveBeenCalledWith(
+ printChecksEvents.PRINT_CHECKS_GENERATE_SUCCEEDED,
+ expect.anything(),
+ )
+ })
+
+ it('fires PRINT_CHECKS_CANCEL when the Footer cancel button is clicked', async () => {
+ renderForm()
+
+ await user.click(await screen.findByRole('button', { name: 'Cancel' }))
+
+ expect(onEvent).toHaveBeenCalledWith(printChecksEvents.PRINT_CHECKS_CANCEL)
+ })
+})
diff --git a/src/components/Payroll/PrintChecks/PrintChecksForm/PrintChecksForm.tsx b/src/components/Payroll/PrintChecks/PrintChecksForm/PrintChecksForm.tsx
new file mode 100644
index 0000000000..e4b24eec0f
--- /dev/null
+++ b/src/components/Payroll/PrintChecks/PrintChecksForm/PrintChecksForm.tsx
@@ -0,0 +1,238 @@
+import { useEffect, useState } from 'react'
+import { FormProvider, useForm, useWatch } from 'react-hook-form'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { useTranslation } from 'react-i18next'
+import { z } from 'zod'
+import { usePayrollsGeneratePrintableChecksMutation } from '@gusto/embedded-api/react-query/payrollsGeneratePrintableChecks'
+import { useGeneratedDocumentsGet } from '@gusto/embedded-api/react-query/generatedDocumentsGet'
+import {
+ PrintingFormat,
+ type PrintablePayrollChecksBody,
+} from '@gusto/embedded-api/models/components/printablepayrollchecksbody'
+import { DocumentType } from '@gusto/embedded-api/models/operations/getv1generateddocumentsdocumenttyperequestuuid'
+import { GeneratedDocumentStatus } from '@gusto/embedded-api/models/components/generateddocument'
+import { BaseComponent, useBase, type BaseComponentInterface } from '@/components/Base'
+import type { OnEventType } from '@/components/Base/useBase'
+import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext'
+import { useComponentDictionary, useI18n } from '@/i18n'
+import { useFlow, type FlowContextInterface } from '@/components/Flow/useFlow'
+import { ActionsLayout, Flex, NumberInputField, RadioGroupField } from '@/components/Common'
+import { Form } from '@/components/Common/Form'
+import { printChecksEvents, type EventType } from '@/shared/constants'
+import type { RadioGroupOption } from '@/index'
+
+interface PrintChecksFormProps extends BaseComponentInterface<'Payroll.PrintChecksForm'> {
+ payrollId: string
+ /** Whether a generate-and-poll cycle is in flight; disables the fields while true. */
+ isGenerating?: boolean
+}
+
+// The Footer is rendered by the top-level orchestrator as a sibling of this component (not a
+// child), so it can only read shared state — like whether a generate-and-poll cycle is in
+// flight — via the machine's FlowContext rather than as a prop. This narrow interface describes
+// only the field Footer needs, rather than depending on the orchestrator's full context type.
+interface PrintChecksFormFlowContext extends FlowContextInterface {
+ isGenerating?: boolean
+}
+
+const PrintChecksFormSchema = z.object({
+ printingFormat: z.nativeEnum(PrintingFormat),
+ startingCheckNumber: z
+ .number({ message: 'invalidStartingCheckNumber' })
+ .int({ message: 'invalidStartingCheckNumber' })
+ .nonnegative({ message: 'invalidStartingCheckNumber' })
+ .lt(10_000_000_000, { message: 'invalidStartingCheckNumber' })
+ .optional(),
+})
+
+type PrintChecksFormValues = z.infer
+
+const PRINT_CHECKS_FORM_ID = 'gusto-sdk-print-checks-form'
+
+const isErrorList = (val: unknown): val is { message?: string }[] =>
+ Array.isArray(val) && val.every(entry => typeof entry === 'object' && entry !== null)
+
+const extractErrorMessage = (err: unknown): string | null => {
+ if (err && typeof err === 'object' && 'errors' in err && isErrorList(err.errors)) {
+ const [firstError] = err.errors
+ if (firstError?.message) return firstError.message
+ }
+ return err instanceof Error ? err.message : null
+}
+
+const buildRequestBody = (data: PrintChecksFormValues): PrintablePayrollChecksBody => ({
+ printingFormat: data.printingFormat,
+ ...(data.printingFormat === PrintingFormat.Bottom && data.startingCheckNumber !== undefined
+ ? { startingCheckNumber: data.startingCheckNumber }
+ : {}),
+})
+
+// Navigates directly to `url` via a synthetic anchor click rather than `fetch`-ing it into a blob:
+// the generated-document URL is a signed, cross-origin S3 URL with `response-content-disposition:
+// attachment` baked into its query string, so a direct browser-level request downloads it via that
+// response header — but a `fetch()` from JS is subject to CORS, which the bucket doesn't allow, and
+// fails outright. A plain navigation isn't subject to CORS and never opens a new tab or navigates
+// the host page, since the browser intercepts the download instead of rendering a response.
+const downloadGeneratedChecks = (url: string) => {
+ const link = document.createElement('a')
+ link.href = url
+ link.rel = 'noopener noreferrer'
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+}
+
+/** @internal */
+export function PrintChecksForm(props: PrintChecksFormProps) {
+ return (
+
+ {props.children}
+
+ )
+}
+
+const Root = ({ dictionary, payrollId, isGenerating }: PrintChecksFormProps) => {
+ useComponentDictionary('Payroll.PrintChecksForm', dictionary)
+ useI18n('Payroll.PrintChecksForm')
+ const { t } = useTranslation('Payroll.PrintChecksForm')
+ const { onEvent, baseSubmitHandler } = useBase()
+ const [requestUuid, setRequestUuid] = useState(null)
+ const [isPolling, setIsPolling] = useState(false)
+
+ const formMethods = useForm({
+ resolver: zodResolver(PrintChecksFormSchema),
+ defaultValues: { printingFormat: PrintingFormat.Top, startingCheckNumber: 0 },
+ })
+
+ const printingFormat = useWatch({
+ name: 'printingFormat',
+ control: formMethods.control,
+ })
+
+ const { mutateAsync } = usePayrollsGeneratePrintableChecksMutation()
+
+ const { data } = useGeneratedDocumentsGet(
+ {
+ documentType: DocumentType.PrintablePayrollChecks,
+ requestUuid: requestUuid || '',
+ },
+ {
+ enabled: !!requestUuid,
+ refetchInterval: isPolling ? 5_000 : false,
+ },
+ )
+
+ useEffect(() => {
+ const status = data?.generatedDocument?.status
+ if (!isPolling || !status) return
+
+ if (status === GeneratedDocumentStatus.Succeeded) {
+ setIsPolling(false)
+ const url = data.generatedDocument?.documentUrls?.[0] ?? null
+ onEvent(printChecksEvents.PRINT_CHECKS_GENERATE_SUCCEEDED, { documentUrl: url })
+ if (url) {
+ downloadGeneratedChecks(url)
+ }
+ } else if (status === GeneratedDocumentStatus.Failed) {
+ setIsPolling(false)
+ onEvent(printChecksEvents.PRINT_CHECKS_GENERATE_FAILED, { errorMessage: null })
+ }
+ }, [data, isPolling, onEvent])
+
+ const onSubmit = async (formData: PrintChecksFormValues) => {
+ onEvent(printChecksEvents.PRINT_CHECKS_GENERATE_START)
+
+ await baseSubmitHandler(formData, async submittedData => {
+ try {
+ const result = await mutateAsync({
+ request: {
+ payrollUuid: payrollId,
+ printablePayrollChecksBody: buildRequestBody(submittedData),
+ },
+ })
+
+ const nextRequestUuid = result.payrollCheck?.requestUuid
+ if (!nextRequestUuid) {
+ throw new Error('Missing requestUuid in generate-printable-checks response')
+ }
+
+ setRequestUuid(nextRequestUuid)
+ setIsPolling(true)
+ } catch (err) {
+ onEvent(printChecksEvents.PRINT_CHECKS_GENERATE_FAILED, {
+ errorMessage: extractErrorMessage(err),
+ })
+ throw err
+ }
+ })
+ }
+
+ const startingCheckNumberErrorCode = formMethods.formState.errors.startingCheckNumber?.message
+ const startingCheckNumberErrorMessage = startingCheckNumberErrorCode
+ ? t('validations.startingCheckNumber')
+ : undefined
+
+ const printingFormatOptions: RadioGroupOption[] = [
+ {
+ value: PrintingFormat.Top,
+ label: t('customStockLabel'),
+ description: t('customStockDescription'),
+ },
+ {
+ value: PrintingFormat.Bottom,
+ label: t('blankStockLabel'),
+ description: t('blankStockDescription'),
+ },
+ ]
+
+ return (
+
+
+
+ )
+}
+
+const Footer = ({ onEvent }: { onEvent: OnEventType }) => {
+ useI18n('Payroll.PrintChecksForm')
+ const { t } = useTranslation('Payroll.PrintChecksForm')
+ const { Button } = useComponentContext()
+ const { isGenerating } = useFlow()
+
+ return (
+
+
+
+
+ )
+}
+PrintChecksForm.Footer = Footer
diff --git a/src/components/Payroll/PrintChecks/PrintChecksForm/index.ts b/src/components/Payroll/PrintChecks/PrintChecksForm/index.ts
new file mode 100644
index 0000000000..8cc0912dd6
--- /dev/null
+++ b/src/components/Payroll/PrintChecks/PrintChecksForm/index.ts
@@ -0,0 +1 @@
+export { PrintChecksForm } from './PrintChecksForm'
diff --git a/src/i18n/en/Payroll.PrintChecksForm.json b/src/i18n/en/Payroll.PrintChecksForm.json
new file mode 100644
index 0000000000..b894ba348f
--- /dev/null
+++ b/src/i18n/en/Payroll.PrintChecksForm.json
@@ -0,0 +1,15 @@
+{
+ "modalTitle": "Choose check stock",
+ "customStockLabel": "Custom check stock",
+ "customStockDescription": "Use this check stock if you have check stock that is pre-printed with your company and bank information. The physical check will appear on the top of the check PDF. Check numbers should already be pre-printed on the check stock you purchased.",
+ "blankStockLabel": "Blank check stock",
+ "blankStockDescription": "Use this check stock if you have blank check stock and need us to populate your company and bank information. The physical check will always be on the bottom of the check PDF.",
+ "startingCheckNumberLabel": "Check number starts with",
+ "startingCheckNumberDescription": "This will be the first check number, all other checks will follow sequentially.",
+ "cancelCta": "Cancel",
+ "submitCta": "View checks",
+ "submitCtaLoading": "Generating...",
+ "validations": {
+ "startingCheckNumber": "Enter a valid check number"
+ }
+}
diff --git a/src/i18n/types.d.ts b/src/i18n/types.d.ts
index c915629e36..aeeeef90c6 100644
--- a/src/i18n/types.d.ts
+++ b/src/i18n/types.d.ts
@@ -129,6 +129,7 @@ export interface Resources {
'Payroll.PayrollReceipts': Translations.PayrollPayrollReceipts
'Payroll.PrintChecksBanner': Translations.PayrollPrintChecksBanner
'Payroll.PrintChecksFailure': Translations.PayrollPrintChecksFailure
+ 'Payroll.PrintChecksForm': Translations.PayrollPrintChecksForm
'Payroll.PrintChecksSummary': Translations.PayrollPrintChecksSummary
'Payroll.RecoveryCasesList': Translations.PayrollRecoveryCasesList
'Payroll.RecoveryCasesResubmit': Translations.PayrollRecoveryCasesResubmit
@@ -7696,6 +7697,33 @@ export namespace Translations {
/** @defaultValue `"Close"` */
closeCta: string
}
+ /** Translation keys for the `Payroll.PrintChecksForm` i18n namespace. */
+ export interface PayrollPrintChecksForm {
+ /** @defaultValue `"Choose check stock"` */
+ modalTitle: string
+ /** @defaultValue `"Custom check stock"` */
+ customStockLabel: string
+ /** @defaultValue `"Use this check stock if you have check stock that is pre-printed with your company and bank information. The physical check will appear on the top of the check PDF. Check numbers should already be pre-printed on the check stock you purchased."` */
+ customStockDescription: string
+ /** @defaultValue `"Blank check stock"` */
+ blankStockLabel: string
+ /** @defaultValue `"Use this check stock if you have blank check stock and need us to populate your company and bank information. The physical check will always be on the bottom of the check PDF."` */
+ blankStockDescription: string
+ /** @defaultValue `"Check number starts with"` */
+ startingCheckNumberLabel: string
+ /** @defaultValue `"This will be the first check number, all other checks will follow sequentially."` */
+ startingCheckNumberDescription: string
+ /** @defaultValue `"Cancel"` */
+ cancelCta: string
+ /** @defaultValue `"View checks"` */
+ submitCta: string
+ /** @defaultValue `"Generating..."` */
+ submitCtaLoading: string
+ validations: {
+ /** @defaultValue `"Enter a valid check number"` */
+ startingCheckNumber: string
+ }
+ }
/** Translation keys for the `Payroll.PrintChecksSummary` i18n namespace. */
export interface PayrollPrintChecksSummary {
/** @defaultValue `"Your checks are ready"` */
diff --git a/src/models/external.ts b/src/models/external.ts
index 58a5ac8729..65dca838a7 100644
--- a/src/models/external.ts
+++ b/src/models/external.ts
@@ -189,6 +189,7 @@ export { GarnishmentType } from '@gusto/embedded-api/models/components/garnishme
export type { Garnishment } from '@gusto/embedded-api/models/components/garnishment'
export type { GarnishmentChildSupport } from '@gusto/embedded-api/models/components/garnishmentchildsupport'
export { PaymentPeriod } from '@gusto/embedded-api/models/components/garnishmentchildsupport'
+export { GeneratedDocumentStatus } from '@gusto/embedded-api/models/components/generateddocument'
/** `ChristmasDay` entity from the Gusto Embedded API. */
export type { ChristmasDay } from '@gusto/embedded-api/models/components/holidaypaypolicy'
/** `ColumbusDay` entity from the Gusto Embedded API. */
@@ -383,6 +384,8 @@ export type {
export { PayScheduleFrequency } from '@gusto/embedded-api/models/components/payschedulefrequency'
export type { PaySchedulePreviewPayPeriod } from '@gusto/embedded-api/models/components/payschedulepreviewpayperiod'
export type { PayScheduleShow } from '@gusto/embedded-api/models/components/payscheduleshow'
+export type { PrintablePayrollChecksBody } from '@gusto/embedded-api/models/components/printablepayrollchecksbody'
+export { PrintingFormat } from '@gusto/embedded-api/models/components/printablepayrollchecksbody'
export type { RecoveryCase } from '@gusto/embedded-api/models/components/recoverycase'
export { RecoveryCaseStatus } from '@gusto/embedded-api/models/components/recoverycase'
/** `IdentityVerificationStatus` entity from the Gusto Embedded API. */
diff --git a/src/test/mocks/apis/printable_payroll_checks.ts b/src/test/mocks/apis/printable_payroll_checks.ts
new file mode 100644
index 0000000000..cf3ae3b057
--- /dev/null
+++ b/src/test/mocks/apis/printable_payroll_checks.ts
@@ -0,0 +1,50 @@
+import type { HttpResponseResolver, PathParams } from 'msw'
+import { http, HttpResponse } from 'msw'
+import type { PostV1PayrollsPayrollUuidGeneratedDocumentsPrintablePayrollChecksRequest } from '@gusto/embedded-api/models/operations/postv1payrollspayrolluuidgenerateddocumentsprintablepayrollchecks'
+import type { GetV1GeneratedDocumentsDocumentTypeRequestUuidRequest } from '@gusto/embedded-api/models/operations/getv1generateddocumentsdocumenttyperequestuuid'
+import { API_BASE_URL } from '@/test/constants'
+
+export const createPayrollCheck = (overrides: Record = {}) => ({
+ payroll_uuid: 'payroll-1',
+ printing_format: 'top',
+ starting_check_number: null,
+ request_uuid: 'print-checks-request-1',
+ status: 'pending',
+ employee_check_number_mapping: [{ employee_uuid: 'employee-1', check_number: 1001 }],
+ ...overrides,
+})
+
+export const createGeneratedDocument = (overrides: Record = {}) => ({
+ request_uuid: 'print-checks-request-1',
+ status: 'succeeded',
+ document_urls: ['https://example.com/checks.pdf'],
+ ...overrides,
+})
+
+export function handlePayrollsGeneratePrintableChecks(
+ resolver: HttpResponseResolver<
+ PathParams,
+ PostV1PayrollsPayrollUuidGeneratedDocumentsPrintablePayrollChecksRequest
+ >,
+) {
+ return http.post(
+ `${API_BASE_URL}/v1/payrolls/:payroll_uuid/generated_documents/printable_payroll_checks`,
+ resolver,
+ )
+}
+
+export function handleGeneratedDocumentsGet(
+ resolver: HttpResponseResolver,
+) {
+ return http.get(`${API_BASE_URL}/v1/generated_documents/:document_type/:request_uuid`, resolver)
+}
+
+export const generatePrintableChecks = handlePayrollsGeneratePrintableChecks(() =>
+ HttpResponse.json(createPayrollCheck(), { status: 200 }),
+)
+
+export const getGeneratedDocument = handleGeneratedDocumentsGet(() =>
+ HttpResponse.json(createGeneratedDocument()),
+)
+
+export default [generatePrintableChecks, getGeneratedDocument]
diff --git a/src/test/mocks/handlers.ts b/src/test/mocks/handlers.ts
index b61b3c6fc2..47351757db 100644
--- a/src/test/mocks/handlers.ts
+++ b/src/test/mocks/handlers.ts
@@ -23,6 +23,7 @@ import ContractorHandlers from './apis/contractors'
import ContractorDocumentsHandlers from './apis/contractor_documents'
import ContractorPaymentGroupsHandlers from './apis/contractor_payment_groups'
import WireInRequestsHandlers from './apis/wire_in_requests'
+import PrintablePayrollChecksHandlers from './apis/printable_payroll_checks'
import InformationRequestsHandlers from './apis/information_requests'
import I9AuthorizationHandlers from './apis/i9_authorization'
import EmployeeFormHandlers from './apis/employee_forms'
@@ -74,6 +75,7 @@ export const handlers = [
...ContractorDocumentsHandlers,
...ContractorPaymentGroupsHandlers,
...WireInRequestsHandlers,
+ ...PrintablePayrollChecksHandlers,
...InformationRequestsHandlers,
...I9AuthorizationHandlers,
...EmployeeFormHandlers,