diff --git a/.reports/embedded-react-sdk.api.md b/.reports/embedded-react-sdk.api.md index 5f43a119ff..9e862ec383 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, @@ -1293,6 +1299,13 @@ export const componentEvents: { readonly CONTRACTOR_PAYMENT_CANCEL: "contractor/payments/cancel"; readonly CONTRACTOR_PAYMENT_EXIT: "contractor/payments/exit"; readonly CONTRACTOR_PAYMENT_RFI_RESPOND: "contractor/payments/rfi/respond"; + readonly PRINT_CHECKS_START: "payroll/printChecks/start"; + readonly PRINT_CHECKS_GENERATE_START: "payroll/printChecks/generate/start"; + readonly PRINT_CHECKS_GENERATE_SUCCEEDED: "payroll/printChecks/generate/succeeded"; + readonly PRINT_CHECKS_GENERATE_FAILED: "payroll/printChecks/generate/failed"; + readonly PRINT_CHECKS_RETRY: "payroll/printChecks/retry"; + readonly PRINT_CHECKS_CANCEL: "payroll/printChecks/cancel"; + readonly PRINT_CHECKS_CLOSE: "payroll/printChecks/close"; readonly RECOVERY_CASE_RESOLVE: "recoveryCase/resolve"; readonly RECOVERY_CASE_RESUBMIT: "recoveryCase/resubmit"; readonly RECOVERY_CASE_RESUBMIT_CANCEL: "recoveryCase/resubmit/cancel"; @@ -4212,6 +4225,8 @@ declare namespace Payroll { PayrollBlockerListProps, RecoveryCases, RecoveryCasesProps, + PrintChecks, + PrintChecksProps, OffCyclePayPeriodDateFormData, OffCyclePayrollDateType, OffCycleCreation, @@ -4563,6 +4578,16 @@ export type PreparerSelectFieldProps = HookFieldProps>; +// @public +function PrintChecks(input: PrintChecksProps): JSX; + +// @public +interface PrintChecksProps extends Omit, 'onEvent'> { + companyId: string; + onEvent?: BaseComponentInterface['onEvent']; + payrollId: string; +} + // @public function Profile(input: ProfileProps): JSX; @@ -4983,6 +5008,14 @@ export interface Resources { // (undocumented) 'Payroll.PayrollReceipts': Translations.PayrollPayrollReceipts // (undocumented) + 'Payroll.PrintChecksBanner': Translations.PayrollPrintChecksBanner + // (undocumented) + 'Payroll.PrintChecksFailure': Translations.PayrollPrintChecksFailure + // (undocumented) + 'Payroll.PrintChecksForm': Translations.PayrollPrintChecksForm + // (undocumented) + 'Payroll.PrintChecksSummary': Translations.PayrollPrintChecksSummary + // (undocumented) 'Payroll.RecoveryCasesList': Translations.PayrollRecoveryCasesList // (undocumented) 'Payroll.RecoveryCasesResubmit': Translations.PayrollRecoveryCasesResubmit diff --git a/docs/guides/endpoint-inventory.json b/docs/guides/endpoint-inventory.json index 03e0c79c1b..c60af2922d 100644 --- a/docs/guides/endpoint-inventory.json +++ b/docs/guides/endpoint-inventory.json @@ -384,6 +384,32 @@ "recoveryCaseUuid" ] }, + "Payroll.PrintChecks": { + "endpoints": [ + { + "method": "GET", + "path": "/v1/companies/:companyId/payrolls/:payrollId", + "docsUrl": "https://docs.gusto.com/embedded-payroll/v2026-06-15/reference/get-v1-companies-company_id-payrolls-payroll_id" + }, + { + "method": "GET", + "path": "/v1/generated_documents/:documentType/:requestUuid", + "docsUrl": "https://docs.gusto.com/embedded-payroll/v2026-06-15/reference/get-v1-generated_documents-document_type-request_uuid" + }, + { + "method": "POST", + "path": "/v1/payrolls/:payrollUuid/generated_documents/printable_payroll_checks", + "docsUrl": "https://docs.gusto.com/embedded-payroll/v2026-06-15/reference/post-v1-payrolls-payroll_uuid-generated_documents-printable_payroll_checks" + } + ], + "variables": [ + "companyId", + "documentType", + "payrollId", + "payrollUuid", + "requestUuid" + ] + }, "Payroll.OffCycleCreation": { "endpoints": [ { diff --git a/docs/guides/endpoint-reference.md b/docs/guides/endpoint-reference.md index 0f18838ff4..c522c97999 100644 --- a/docs/guides/endpoint-reference.md +++ b/docs/guides/endpoint-reference.md @@ -446,6 +446,9 @@ import inventory from '@gusto/embedded-react-sdk/endpoint-inventory.json' | | GET | [`/v1/companies/:companyUuid/recovery_cases`](https://docs.gusto.com/embedded-payroll/v2026-06-15/reference/get-recovery-cases) | | **Payroll.RecoveryCases** | GET | [`/v1/companies/:companyUuid/recovery_cases`](https://docs.gusto.com/embedded-payroll/v2026-06-15/reference/get-recovery-cases) | | | PUT | [`/v1/recovery_cases/:recoveryCaseUuid/redebit`](https://docs.gusto.com/embedded-payroll/v2026-06-15/reference/redebit-recovery-case) | +| **Payroll.PrintChecks** | GET | [`/v1/companies/:companyId/payrolls/:payrollId`](https://docs.gusto.com/embedded-payroll/v2026-06-15/reference/get-v1-companies-company_id-payrolls-payroll_id) | +| | GET | [`/v1/generated_documents/:documentType/:requestUuid`](https://docs.gusto.com/embedded-payroll/v2026-06-15/reference/get-v1-generated_documents-document_type-request_uuid) | +| | POST | [`/v1/payrolls/:payrollUuid/generated_documents/printable_payroll_checks`](https://docs.gusto.com/embedded-payroll/v2026-06-15/reference/post-v1-payrolls-payroll_uuid-generated_documents-printable_payroll_checks) | | **Payroll.OffCycleCreation** | GET | [`/v1/companies/:companyId/employees`](https://docs.gusto.com/embedded-payroll/v2026-06-15/reference/get-v1-companies-company_id-employees) | | | POST | [`/v1/companies/:companyId/payrolls`](https://docs.gusto.com/embedded-payroll/v2026-06-15/reference/post-v1-companies-company_id-payrolls) | | | GET | [`/v1/companies/:companyUuid/payment_configs`](https://docs.gusto.com/embedded-payroll/v2026-06-15/reference/get-v1-company-payment-configs) | 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 b34a962f51..b386a5f44c 100644 --- a/docs/reference/Translations/index.md +++ b/docs/reference/Translations/index.md @@ -5069,9 +5069,6 @@ Translation keys for the `Payroll.PayrollOverview` i18n namespace. | Property | Default value | | ------ | ------ | | `alerts` | | -| `alerts.checkPaymentWarning_one` | `"You noted {{count}} employee who should be paid by check."` | -| `alerts.checkPaymentWarning_other` | `"You noted {{count}} employees who should be paid by check."` | -| `alerts.checkPaymentWarningDescription` | `"Employees with this payment method will need their checks delivered to them."` | | `alerts.directDepositDeadline` | `"To pay your employees with direct deposit by {{payDate}}, you'll need to run payroll by {{time}} on {{date}}"` | | `alerts.payrollNotCalculated` | `"Payroll is not calculated"` | | `alerts.payrollProcessedMessage` | `"{{amount}} will be debited on {{date}}. Make sure you have these funds available."` | @@ -5234,6 +5231,81 @@ Translation keys for the `Payroll.PayrollReceipts` i18n namespace. *** + + +### PayrollPrintChecksBanner + +Translation keys for the `Payroll.PrintChecksBanner` i18n namespace. + +#### Properties + +| Property | Default value | +| ------ | ------ | +| `cta` | `"View and print checks"` | +| `description` | `"Employees with this payment method will need their checks delivered to them. If you aren't using your own checks, you can view and print checks."` | +| `title_one` | `"You noted {{count}} employee that should be paid by check."` | +| `title_other` | `"You noted {{count}} employees that should be paid by check."` | + +*** + + + +### PayrollPrintChecksFailure + +Translation keys for the `Payroll.PrintChecksFailure` i18n namespace. + +#### Properties + +| Property | Default value | +| ------ | ------ | +| `closeCta` | `"Close"` | +| `failedTitle` | `"We couldn't generate your checks"` | +| `retryCta` | `"Try again"` | + +*** + + + +### 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 + +Translation keys for the `Payroll.PrintChecksSummary` i18n namespace. + +#### Properties + +| Property | Default value | +| ------ | ------ | +| `closeCta` | `"Close"` | +| `succeededDescription` | `"The download should have started automatically. If not, use the link below."` | +| `succeededTitle` | `"Your checks are ready"` | +| `viewChecksCta` | `"View checks"` | + +*** + ### PayrollRecoveryCasesList @@ -5606,6 +5678,10 @@ yields that namespace's keys. Backs i18next `t()` typing and `ResourceDictionary | `Payroll.PayrollList` | [`PayrollPayrollList`](#payrollpayrolllist) | | `Payroll.PayrollOverview` | [`PayrollPayrollOverview`](#payrollpayrolloverview) | | `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) | | `Payroll.Transition` | [`PayrollTransition`](#payrolltransition) | diff --git a/docs/reference/blocks.md b/docs/reference/blocks.md index ef3b6e8fd5..08b1079605 100644 --- a/docs/reference/blocks.md +++ b/docs/reference/blocks.md @@ -132,6 +132,7 @@ Individual form and UI components with SDK logic built in — use these for cust | [Payroll.PayrollList](payroll/blocks#payrolllist) | Lists upcoming payrolls and lets users start running them. | | [Payroll.PayrollOverview](payroll/blocks#payrolloverview) | Final review screen for a calculated payroll before submission, with submit, cancel, and edit controls. After submission, tracks processing status and surfaces the receipt and per-employee paystub downloads once complete. | | [Payroll.PayrollReceipts](payroll/blocks#payrollreceipts) | Displays a detailed receipt for a completed payroll, including the debited total, per-category breakdown, tax breakdown, and a per-employee summary of payment method, garnishments, reimbursements, taxes, and net pay. | +| [Payroll.PrintChecks](payroll/blocks#printchecks) | Displays a banner prompting the user to print checks for employees paid by check on a processed payroll, and walks them through choosing check stock and generating the check PDF. | | [Payroll.RecoveryCases](payroll/blocks#recoverycases) | Displays open recovery cases for a company and provides an in-modal resubmit workflow for resolving them. | | [Payroll.TransitionCreation](payroll/blocks#transitioncreation) | Creation form for transition payrolls covering the gap between an old and new pay schedule. | | [TimeOff.AddEmployeesHoliday](time-off/blocks#addemployeesholiday) | Employee selection screen for assigning employees to a company's holiday pay policy. | diff --git a/docs/reference/events.md b/docs/reference/events.md index 78f4aa1921..f2179f2396 100644 --- a/docs/reference/events.md +++ b/docs/reference/events.md @@ -304,6 +304,13 @@ import { componentEvents, EmployeeOnboarding } from '@gusto/embedded-react-sdk' | `PAYROLL_WIRE_INSTRUCTIONS_DONE` | `"payroll/wire/instructions/done"` | | `PAYROLL_WIRE_INSTRUCTIONS_SELECT` | `"payroll/wire/instructions/select"` | | `PAYROLL_WIRE_START_TRANSFER` | `"payroll/wire/startTransfer"` | +| `PRINT_CHECKS_CANCEL` | `"payroll/printChecks/cancel"` | +| `PRINT_CHECKS_CLOSE` | `"payroll/printChecks/close"` | +| `PRINT_CHECKS_GENERATE_FAILED` | `"payroll/printChecks/generate/failed"` | +| `PRINT_CHECKS_GENERATE_START` | `"payroll/printChecks/generate/start"` | +| `PRINT_CHECKS_GENERATE_SUCCEEDED` | `"payroll/printChecks/generate/succeeded"` | +| `PRINT_CHECKS_RETRY` | `"payroll/printChecks/retry"` | +| `PRINT_CHECKS_START` | `"payroll/printChecks/start"` | | `RECOVERY_CASE_RESOLVE` | `"recoveryCase/resolve"` | | `RECOVERY_CASE_RESUBMIT` | `"recoveryCase/resubmit"` | | `RECOVERY_CASE_RESUBMIT_CANCEL` | `"recoveryCase/resubmit/cancel"` | diff --git a/docs/reference/index.mdx b/docs/reference/index.mdx index 4ade1effe2..1403c7dfb0 100644 --- a/docs/reference/index.mdx +++ b/docs/reference/index.mdx @@ -12,7 +12,7 @@ custom_edit_url: null ## Browse by domain - + --- diff --git a/docs/reference/payroll/blocks.md b/docs/reference/payroll/blocks.md index a41f8a2d66..af1cd7936d 100644 --- a/docs/reference/payroll/blocks.md +++ b/docs/reference/payroll/blocks.md @@ -665,6 +665,13 @@ _Inherits `children`, `className`, `defaultValues`, `FallbackComponent`, `Loader | `runPayroll/receipt/get` | User requested the payroll receipt | `{ payrollId }` | | `runPayroll/pdfPaystub/viewed` | User opened an employee's paystub PDF | `{ employeeId }` | | `payroll/wire/form/done` | Wire-in details were confirmed via the embedded wire form | Submit wire-in response | +| `payroll/printChecks/start` | User opened the print-checks modal from the embedded print-checks banner | — | +| `payroll/printChecks/generate/start` | User submitted the print-checks form | — | +| `payroll/printChecks/generate/succeeded` | Printable checks finished generating | `{ documentUrl }` | +| `payroll/printChecks/generate/failed` | The print-checks request was rejected or generation failed | `{ errorMessage }` | +| `payroll/printChecks/retry` | User retried after a failed check generation | — | +| `payroll/printChecks/cancel` | User cancelled the print-checks form | — | +| `payroll/printChecks/close` | User closed the print-checks failure or summary screen | — |
@@ -729,6 +736,55 @@ _Inherits `children`, `className`, `defaultValues`, `FallbackComponent`, `Loader *** + + +## PrintChecks + +Displays a banner prompting the user to print checks for employees paid by check on a +processed payroll, and walks them through choosing check stock and generating the check PDF. + +
+ +### PrintChecksProps + + + +Props for [PrintChecks](#printchecks). + +| Property | Type | Description | +| ------ | ------ | ------ | +| `companyId` | `string` | Identifier of the company that owns the payroll. | +| `payrollId` | `string` | Identifier of the payroll to generate printable checks for. | +| `onEvent?` | [`OnEventType`](../events.md#oneventtype)\<[`EventType`](../events.md#eventtype), `unknown`\> | Callback invoked each time the component emits an event. | + +_Inherits `children`, `className`, `defaultValues`, `dictionary`, `FallbackComponent`, `LoaderComponent` from Omit._ + +
+ +### Events + +| Event | Description | Data | +| ----- | ----------- | ---- | +| `payroll/printChecks/start` | User opened the print-checks modal from the banner | — | +| `payroll/printChecks/generate/start` | User submitted the print-checks form | — | +| `payroll/printChecks/generate/succeeded` | Printable checks finished generating | `{ documentUrl }` | +| `payroll/printChecks/generate/failed` | The print-checks request was rejected or generation failed | `{ errorMessage }` | +| `payroll/printChecks/retry` | User retried after a failed generation | — | +| `payroll/printChecks/cancel` | User cancelled the print-checks form | — | +| `payroll/printChecks/close` | User closed the failure or summary screen | — | + +
+ +### Endpoints + +| Method | Path | +| --- | --- | +| GET | [`/v1/companies/:companyId/payrolls/:payrollId`](https://docs.gusto.com/embedded-payroll/v2026-06-15/reference/get-v1-companies-company_id-payrolls-payroll_id) | +| GET | [`/v1/generated_documents/:documentType/:requestUuid`](https://docs.gusto.com/embedded-payroll/v2026-06-15/reference/get-v1-generated_documents-document_type-request_uuid) | +| POST | [`/v1/payrolls/:payrollUuid/generated_documents/printable_payroll_checks`](https://docs.gusto.com/embedded-payroll/v2026-06-15/reference/post-v1-payrolls-payroll_uuid-generated_documents-printable_payroll_checks) | + +*** + ## RecoveryCases diff --git a/docs/reference/payroll/index.mdx b/docs/reference/payroll/index.mdx index 8a97b14134..595d2b00e3 100644 --- a/docs/reference/payroll/index.mdx +++ b/docs/reference/payroll/index.mdx @@ -23,4 +23,4 @@ Flows and blocks for running and managing payroll across a company's pay schedul import { Payroll } from '@gusto/embedded-react-sdk' ``` - + diff --git a/docs/reference/payroll/namespace.md b/docs/reference/payroll/namespace.md index d115a11db1..c3b44936fc 100644 --- a/docs/reference/payroll/namespace.md +++ b/docs/reference/payroll/namespace.md @@ -45,5 +45,6 @@ import { Payroll } from '@gusto/embedded-react-sdk' | [PayrollList](blocks.md#payrolllist) | Lists upcoming payrolls and lets users start running them. | | [PayrollOverview](blocks.md#payrolloverview) | Final review screen for a calculated payroll before submission, with submit, cancel, and edit controls. After submission, tracks processing status and surfaces the receipt and per-employee paystub downloads once complete. | | [PayrollReceipts](blocks.md#payrollreceipts) | Displays a detailed receipt for a completed payroll, including the debited total, per-category breakdown, tax breakdown, and a per-employee summary of payment method, garnishments, reimbursements, taxes, and net pay. | +| [PrintChecks](blocks.md#printchecks) | Displays a banner prompting the user to print checks for employees paid by check on a processed payroll, and walks them through choosing check stock and generating the check PDF. | | [RecoveryCases](blocks.md#recoverycases) | Displays open recovery cases for a company and provides an in-modal resubmit workflow for resolving them. | | [TransitionCreation](blocks.md#transitioncreation) | Creation form for transition payrolls covering the gap between an old and new pay schedule. | diff --git a/sdk-app/src/generated-registry-data.ts b/sdk-app/src/generated-registry-data.ts index 5eb27cbc77..7fa5251903 100644 --- a/sdk-app/src/generated-registry-data.ts +++ b/sdk-app/src/generated-registry-data.ts @@ -138,6 +138,7 @@ export const ENTITY_REQUIREMENTS: Record = { 'Payroll.PayrollList': ['companyId'], 'Payroll.PayrollOverview': ['companyId', 'payrollId'], 'Payroll.PayrollReceipts': ['payrollId'], + 'Payroll.PrintChecks': ['companyId', 'payrollId'], 'Payroll.RecoveryCases': ['companyId'], 'Payroll.TransitionCreation': ['companyId'], 'Payroll.TransitionFlow': ['companyId'], diff --git a/src/components/Payroll/PayrollOverview/PayrollOverview.stories.tsx b/src/components/Payroll/PayrollOverview/PayrollOverview.stories.tsx index 20834d568a..6391e6eea6 100644 --- a/src/components/Payroll/PayrollOverview/PayrollOverview.stories.tsx +++ b/src/components/Payroll/PayrollOverview/PayrollOverview.stories.tsx @@ -1357,3 +1357,112 @@ export const WithSkippedEmployee = () => { /> ) } + +const checkPaymentPayrollData = { + payrollDeadline: new Date('2025-09-24T23:00:00.000Z'), + checkDate: '2025-09-26', + processedDate: null, + calculatedAt: new Date('2025-09-15T16:25:07.000Z'), + uuid: 'payroll-uuid', + payrollUuid: 'payroll-uuid', + companyUuid: 'company-uuid', + offCycle: false, + external: false, + payPeriod: { + startDate: '2025-09-12', + endDate: '2025-09-18', + payScheduleUuid: 'schedule-uuid', + }, + totals: { + companyDebit: '5000.00', + netPayDebit: '4000.00', + taxDebit: '1000.00', + reimbursementDebit: '0.00', + childSupportDebit: '0.00', + reimbursements: '0.00', + netPay: '4000.00', + grossPay: '5000.00', + employeeBonuses: '0.00', + employeeCommissions: '0.00', + employeeCashTips: '0.00', + employeePaycheckTips: '0.00', + additionalEarnings: '0.00', + ownersDraw: '0.00', + checkAmount: '1000.00', + employerTaxes: '500.00', + employeeTaxes: '500.00', + benefits: '0.00', + employeeBenefitsDeductions: '0.00', + imputedPay: '0.00', + deferredPayrollTaxes: '0.00', + otherDeductions: '0.00', + }, + companyTaxes: [], + createdAt: new Date('2025-09-15T16:19:04.000Z'), + submissionBlockers: [], + partnerOwnedDisbursement: false, + employeeCompensations: [ + { + employeeUuid: 'emp-check-1', + firstName: 'Isaiah', + lastName: 'Berlin', + excluded: false, + version: 'v1', + grossPay: '4000', + netPay: '3200', + checkAmount: '3200', + paymentMethod: 'Check' as const, + memo: null, + fixedCompensations: [], + hourlyCompensations: [ + { + name: 'Regular Hours', + hours: '40.000', + amount: '4000.0', + jobUuid: 'job-1', + compensationMultiplier: 1, + flsaStatus: 'Nonexempt', + }, + ], + paidTimeOff: [], + taxes: [ + { name: 'Federal Income Tax', employer: false, amount: '80' }, + { name: 'Federal Income Tax', employer: true, amount: '160' }, + ], + benefits: [], + deductions: [], + }, + ], +} + +export const WithCheckPaymentEmployeeUnprocessed = () => { + return ( + + ) +} + +export const WithCheckPaymentEmployeeProcessed = () => { + return ( + + ) +} diff --git a/src/components/Payroll/PayrollOverview/PayrollOverview.test.tsx b/src/components/Payroll/PayrollOverview/PayrollOverview.test.tsx index 361fd9c7a5..872b9a52ad 100644 --- a/src/components/Payroll/PayrollOverview/PayrollOverview.test.tsx +++ b/src/components/Payroll/PayrollOverview/PayrollOverview.test.tsx @@ -311,3 +311,61 @@ describe('PayrollOverview calculatedAt guard', () => { expect(screen.queryByText(/Review payroll/i)).toBeNull() }) }) + +describe('PayrollOverview print checks modal', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPayrollData = { + ...basePayrollData, + employeeCompensations: [ + { + employeeUuid: 'emp-check-1', + firstName: 'Isaiah', + lastName: 'Berlin', + excluded: false, + version: 'v1', + grossPay: '4000', + netPay: '3200', + checkAmount: '3200', + paymentMethod: 'Check', + memo: null, + fixedCompensations: [], + hourlyCompensations: [], + paidTimeOff: [], + taxes: [], + benefits: [], + deductions: [], + }, + ], + } + mockIsFetching = false + }) + + it('hides the View and print checks button until the payroll is processed', async () => { + mockPayrollData = { ...mockPayrollData, processed: false } + + renderWithProviders( + , + ) + + expect(await screen.findByText(/noted 1 employee/i)).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'View and print checks' })).toBeNull() + }) + + it('opens the print checks modal from the alert once the payroll is processed', async () => { + const user = userEvent.setup() + mockPayrollData = { + ...mockPayrollData, + processed: true, + processingRequest: { status: 'submit_success', errors: [] }, + } + + renderWithProviders( + , + ) + + await user.click(await screen.findByRole('button', { name: 'View and print checks' })) + + expect(await screen.findByText('Choose check stock')).toBeInTheDocument() + }) +}) diff --git a/src/components/Payroll/PayrollOverview/PayrollOverview.tsx b/src/components/Payroll/PayrollOverview/PayrollOverview.tsx index 2d679f12da..1bca84233f 100644 --- a/src/components/Payroll/PayrollOverview/PayrollOverview.tsx +++ b/src/components/Payroll/PayrollOverview/PayrollOverview.tsx @@ -20,6 +20,7 @@ import { type ConfirmWireDetailsComponentType, } from '../ConfirmWireDetails/ConfirmWireDetails' import { canCancelPayroll } from '../helpers' +import { PrintChecks } from '../PrintChecks/PrintChecks' import { PayrollOverviewPresentation } from './PayrollOverviewPresentation' import { PayrollOverviewStatus } from './PayrollOverviewTypes' import { useCompanyPaymentSpeed } from '@/hooks/useCompanyPaymentSpeed' @@ -109,6 +110,13 @@ const findWireInRequestUuid = ( * | `runPayroll/receipt/get` | User requested the payroll receipt | `{ payrollId }` | * | `runPayroll/pdfPaystub/viewed` | User opened an employee's paystub PDF | `{ employeeId }` | * | `payroll/wire/form/done` | Wire-in details were confirmed via the embedded wire form | Submit wire-in response | + * | `payroll/printChecks/start` | User opened the print-checks modal from the embedded print-checks banner | — | + * | `payroll/printChecks/generate/start` | User submitted the print-checks form | — | + * | `payroll/printChecks/generate/succeeded` | Printable checks finished generating | `{ documentUrl }` | + * | `payroll/printChecks/generate/failed` | The print-checks request was rejected or generation failed | `{ errorMessage }` | + * | `payroll/printChecks/retry` | User retried after a failed check generation | — | + * | `payroll/printChecks/cancel` | User cancelled the print-checks form | — | + * | `payroll/printChecks/close` | User closed the print-checks failure or summary screen | — | * * @param props - See {@link PayrollOverviewProps}. * @returns The payroll overview surface. @@ -195,6 +203,10 @@ const Root = ({ /> ) + const printChecksBanner = ( + + ) + useEffect(() => { if (wireInRequest?.status === 'pending_review' && !showWireDetailsConfirmation) { setShowWireDetailsConfirmation(true) @@ -435,6 +447,7 @@ const Root = ({ setSelectedUnblockOptions(prev => ({ ...prev, [blockerType]: value })) }} wireInConfirmationRequest={wireInConfirmationRequest} + printChecksBanner={printChecksBanner} withReimbursements={withReimbursements} paymentSpeed={paymentSpeed} pagination={pagination} diff --git a/src/components/Payroll/PayrollOverview/PayrollOverviewPresentation.tsx b/src/components/Payroll/PayrollOverview/PayrollOverviewPresentation.tsx index 2420743bfa..29dfa7c428 100644 --- a/src/components/Payroll/PayrollOverview/PayrollOverviewPresentation.tsx +++ b/src/components/Payroll/PayrollOverview/PayrollOverviewPresentation.tsx @@ -32,7 +32,6 @@ import { compensationTypeLabels, FlsaStatus, PAYROLL_RESOLVABLE_SUBMISSION_BLOCKER_TYPES, - PAYMENT_METHODS, } from '@/shared/constants' import type { PaginationControlProps } from '@/components/Common/PaginationControl/PaginationControlTypes' import DownloadIcon from '@/assets/icons/download-cloud.svg?react' @@ -48,6 +47,7 @@ interface PayrollOverviewProps { submissionBlockers?: PayrollSubmissionBlockerType[] selectedUnblockOptions?: Record wireInConfirmationRequest?: React.ReactNode + printChecksBanner?: React.ReactNode pagination?: PaginationControlProps onEdit: () => void onSubmit: () => void @@ -87,6 +87,7 @@ export const PayrollOverviewPresentation = ({ selectedUnblockOptions = {}, onUnblockOptionChange, wireInConfirmationRequest, + printChecksBanner, withReimbursements = true, paymentSpeed, pagination, @@ -212,12 +213,6 @@ export const PayrollOverviewPresentation = ({ ) } - const checkPaymentsCount = - payrollData.employeeCompensations?.reduce( - (acc, comp) => - !comp.excluded && comp.paymentMethod === PAYMENT_METHODS.check ? acc + 1 : acc, - 0, - ) ?? 0 const companyPaysColumns: Array<{ key: string title: string @@ -780,14 +775,7 @@ export const PayrollOverviewPresentation = ({ data={[{}]} /> )} - {checkPaymentsCount > 0 && ( - - {t('alerts.checkPaymentWarningDescription')} - - )} + {printChecksBanner} ({ + usePayrollsGet: () => ({ + data: { + payrollShow: { + processed: true, + employeeCompensations: [checkCompensation], + }, + }, + }), +})) + +describe('PrintChecks', () => { + const onEvent = vi.fn() + const user = userEvent.setup() + + const defaultProps = { + companyId: 'company-1', + payrollId: 'payroll-1', + onEvent, + } + + 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. + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + }) + + it('opens the form from the banner CTA and cancels back to the banner', async () => { + renderWithProviders() + + await user.click(await screen.findByRole('button', { name: 'View and print checks' })) + + expect(await screen.findByText('Choose check stock')).toBeInTheDocument() + expect(onEvent).toHaveBeenCalledWith(printChecksEvents.PRINT_CHECKS_START, undefined) + + await user.click(screen.getByRole('button', { name: 'Cancel' })) + + await waitFor(() => { + expect(screen.queryByText('Choose check stock')).toBeNull() + }) + }) + + it('walks through a successful generate flow to the summary screen and back', async () => { + server.use( + handlePayrollsGeneratePrintableChecks(() => + HttpResponse.json(createPayrollCheck(), { status: 200 }), + ), + ) + server.use( + handleGeneratedDocumentsGet(() => + HttpResponse.json( + createGeneratedDocument({ + status: 'succeeded', + document_urls: ['https://example.com/checks.pdf'], + }), + ), + ), + ) + + renderWithProviders() + + await user.click(await screen.findByRole('button', { name: 'View and print checks' })) + await user.click(screen.getByRole('button', { name: 'View checks' })) + + expect(await screen.findByText('Your checks are ready')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'View checks' })).toHaveAttribute( + 'href', + 'https://example.com/checks.pdf', + ) + + await user.click(screen.getByRole('button', { name: 'Close' })) + + await waitFor(() => { + expect(screen.queryByText('Your checks are ready')).toBeNull() + }) + }) + + it('walks through a failed generate flow and allows retrying', async () => { + server.use( + handlePayrollsGeneratePrintableChecks(() => + HttpResponse.json(createPayrollCheck(), { status: 200 }), + ), + ) + server.use( + handleGeneratedDocumentsGet(() => + HttpResponse.json(createGeneratedDocument({ status: 'failed', document_urls: [] })), + ), + ) + + renderWithProviders() + + await user.click(await screen.findByRole('button', { name: 'View and print checks' })) + await user.click(screen.getByRole('button', { name: 'View checks' })) + + expect(await screen.findByText("We couldn't generate your checks")).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Try again' })) + + expect(await screen.findByText('Choose check stock')).toBeInTheDocument() + }) +}) diff --git a/src/components/Payroll/PrintChecks/PrintChecks.tsx b/src/components/Payroll/PrintChecks/PrintChecks.tsx new file mode 100644 index 0000000000..ac39353d11 --- /dev/null +++ b/src/components/Payroll/PrintChecks/PrintChecks.tsx @@ -0,0 +1,106 @@ +import { createMachine } from 'robot3' +import { useMachine } from 'react-robot' +import { useMemo, useState } from 'react' +import { PrintChecksBanner } from './PrintChecksBanner' +import { printChecksMachine } from './printChecksStateMachine' +import { type PrintChecksContextInterface } from './PrintChecksComponents' +import type { PrintChecksProps } from './types' +import { BaseComponent } from '@/components/Base' +import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' +import { FlowContext } from '@/components/Flow/useFlow' +import { printChecksEvents, type EventType } from '@/shared/constants' + +/** + * Displays a banner prompting the user to print checks for employees paid by check on a + * processed payroll, and walks them through choosing check stock and generating the check PDF. + * + * @events + * | Event | Description | Data | + * | ----- | ----------- | ---- | + * | `payroll/printChecks/start` | User opened the print-checks modal from the banner | — | + * | `payroll/printChecks/generate/start` | User submitted the print-checks form | — | + * | `payroll/printChecks/generate/succeeded` | Printable checks finished generating | `{ documentUrl }` | + * | `payroll/printChecks/generate/failed` | The print-checks request was rejected or generation failed | `{ errorMessage }` | + * | `payroll/printChecks/retry` | User retried after a failed generation | — | + * | `payroll/printChecks/cancel` | User cancelled the print-checks form | — | + * | `payroll/printChecks/close` | User closed the failure or summary screen | — | + * + * @param props - {@link PrintChecksProps} + * @returns The print-checks banner and modal flow. + * @public + */ +export function PrintChecks({ onEvent = () => {}, ...props }: PrintChecksProps) { + return ( + + + + ) +} + +function Root({ companyId, payrollId, onEvent = () => {} }: PrintChecksProps) { + const { Modal } = useComponentContext() + const [isModalOpen, setIsModalOpen] = useState(false) + + const printChecksMachineInstance = useMemo( + () => + createMachine('banner', printChecksMachine, (): PrintChecksContextInterface => ({ + component: null, + companyId, + payrollId, + onEvent: handleEvent, + })), + [companyId, payrollId], + ) + const [current, send] = useMachine(printChecksMachineInstance) + + function handleEvent(type: EventType, data?: unknown): void { + send({ type, payload: data }) + + if (type === printChecksEvents.PRINT_CHECKS_START) { + setIsModalOpen(true) + } + + if ( + type === printChecksEvents.PRINT_CHECKS_CANCEL || + type === printChecksEvents.PRINT_CHECKS_CLOSE + ) { + setIsModalOpen(false) + } + + onEvent(type, data) + } + + const handleStartPrintChecks = () => { + handleEvent(printChecksEvents.PRINT_CHECKS_START) + } + + const handleCloseModal = () => { + setIsModalOpen(false) + } + + const CurrentComponent = current.context.component + const Footer = CurrentComponent?.Footer || undefined + + return ( + + + } + > + {CurrentComponent && } + + + ) +} diff --git a/src/components/Payroll/PrintChecks/PrintChecksBanner/PrintChecksBanner.test.tsx b/src/components/Payroll/PrintChecks/PrintChecksBanner/PrintChecksBanner.test.tsx new file mode 100644 index 0000000000..77aca09abb --- /dev/null +++ b/src/components/Payroll/PrintChecks/PrintChecksBanner/PrintChecksBanner.test.tsx @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { PrintChecksBanner } from './PrintChecksBanner' +import { renderWithProviders } from '@/test-utils/renderWithProviders' + +const checkCompensation = { + employeeUuid: 'emp-check-1', + excluded: false, + paymentMethod: 'Check', +} + +let mockEmployeeCompensations: Record[] = [] +let mockProcessed = false +let mockProcessingStatus: string | undefined + +vi.mock('@gusto/embedded-api/react-query/payrollsGet', () => ({ + usePayrollsGet: () => ({ + data: { + payrollShow: { + processed: mockProcessed, + processingRequest: mockProcessingStatus ? { status: mockProcessingStatus } : undefined, + employeeCompensations: mockEmployeeCompensations, + }, + }, + }), +})) + +describe('PrintChecksBanner', () => { + const onEvent = vi.fn() + const onStartPrintChecks = vi.fn() + const user = userEvent.setup() + + const defaultProps = { + companyId: 'company-1', + payrollId: 'payroll-1', + onStartPrintChecks, + onEvent, + } + + beforeEach(() => { + vi.clearAllMocks() + mockEmployeeCompensations = [] + mockProcessed = false + mockProcessingStatus = undefined + }) + + it('renders nothing when no employees are paid by check', () => { + renderWithProviders() + + expect(screen.queryByRole('alert')).toBeNull() + }) + + it('shows the alert without a CTA when the payroll has not been processed', async () => { + mockEmployeeCompensations = [checkCompensation] + + renderWithProviders() + + expect(await screen.findByText(/noted 1 employee/i)).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'View and print checks' })).toBeNull() + }) + + it('shows the CTA once the payroll is processed, and fires onStartPrintChecks when clicked', async () => { + mockEmployeeCompensations = [checkCompensation] + mockProcessed = true + + renderWithProviders() + + const cta = await screen.findByRole('button', { name: 'View and print checks' }) + await user.click(cta) + + expect(onStartPrintChecks).toHaveBeenCalledTimes(1) + }) + + it('shows the CTA when the processing request succeeded even if processed is not yet true', async () => { + mockEmployeeCompensations = [checkCompensation] + mockProcessingStatus = 'submit_success' + + renderWithProviders() + + expect(await screen.findByRole('button', { name: 'View and print checks' })).toBeInTheDocument() + }) + + it('excludes compensations marked as excluded from the count', () => { + mockEmployeeCompensations = [{ ...checkCompensation, excluded: true }] + + renderWithProviders() + + expect(screen.queryByRole('alert')).toBeNull() + }) +}) diff --git a/src/components/Payroll/PrintChecks/PrintChecksBanner/PrintChecksBanner.tsx b/src/components/Payroll/PrintChecks/PrintChecksBanner/PrintChecksBanner.tsx new file mode 100644 index 0000000000..9ae13f1c5c --- /dev/null +++ b/src/components/Payroll/PrintChecks/PrintChecksBanner/PrintChecksBanner.tsx @@ -0,0 +1,65 @@ +import { useTranslation } from 'react-i18next' +import { usePayrollsGet } from '@gusto/embedded-api/react-query/payrollsGet' +import { BaseComponent, type BaseComponentInterface } from '@/components/Base' +import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' +import { useComponentDictionary, useI18n } from '@/i18n' +import { PAYMENT_METHODS, PAYROLL_PROCESSING_STATUS } from '@/shared/constants' + +interface PrintChecksBannerProps extends BaseComponentInterface<'Payroll.PrintChecksBanner'> { + companyId: string + payrollId: string + onStartPrintChecks: () => void +} + +/** @internal */ +export function PrintChecksBanner(props: PrintChecksBannerProps) { + return ( + + {props.children} + + ) +} + +const Root = ({ companyId, payrollId, dictionary, onStartPrintChecks }: PrintChecksBannerProps) => { + useComponentDictionary('Payroll.PrintChecksBanner', dictionary) + useI18n('Payroll.PrintChecksBanner') + const { t } = useTranslation('Payroll.PrintChecksBanner') + const { Alert, Button } = useComponentContext() + + const { data } = usePayrollsGet({ + companyId, + payrollId, + }) + const payrollData = data?.payrollShow + + const checkPaymentsCount = + payrollData?.employeeCompensations?.reduce( + (acc, comp) => + !comp.excluded && comp.paymentMethod === PAYMENT_METHODS.check ? acc + 1 : acc, + 0, + ) ?? 0 + + const isProcessed = + payrollData?.processed === true || + payrollData?.processingRequest?.status === PAYROLL_PROCESSING_STATUS.submit_success + + if (checkPaymentsCount === 0) { + return null + } + + return ( + + {t('cta')} + + ) + } + > + {t('description')} + + ) +} diff --git a/src/components/Payroll/PrintChecks/PrintChecksBanner/index.ts b/src/components/Payroll/PrintChecks/PrintChecksBanner/index.ts new file mode 100644 index 0000000000..fb2fa9e311 --- /dev/null +++ b/src/components/Payroll/PrintChecks/PrintChecksBanner/index.ts @@ -0,0 +1 @@ +export { PrintChecksBanner } from './PrintChecksBanner' diff --git a/src/components/Payroll/PrintChecks/PrintChecksComponents.tsx b/src/components/Payroll/PrintChecks/PrintChecksComponents.tsx new file mode 100644 index 0000000000..e3c4e2b748 --- /dev/null +++ b/src/components/Payroll/PrintChecks/PrintChecksComponents.tsx @@ -0,0 +1,61 @@ +import { PrintChecksForm } from './PrintChecksForm' +import { PrintChecksFailure } from './PrintChecksFailure' +import { PrintChecksSummary } from './PrintChecksSummary' +import { useFlow } from '@/components/Flow/useFlow' +import type { FlowContextInterface } from '@/components/Flow/useFlow' +import type { CommonComponentInterface } from '@/components/Base' +import type { EventType } from '@/types/Helpers' +import type { OnEventType } from '@/components/Base/useBase' + +/** + * Flow-machine context shared across the print-checks states. + * + * @internal + */ +export interface PrintChecksContextInterface extends FlowContextInterface { + /** Company identifier. */ + companyId: string + /** Payroll being printed. */ + payrollId: string + /** URL of the generated checks PDF, once generation succeeds. */ + documentUrl?: string + /** Error message surfaced on the failure screen. */ + errorMessage?: string + /** Whether a generate-and-poll cycle is in flight, shared between the form body and its footer. */ + isGenerating?: boolean + /** Component to render inside the modal for the current state, with an optional footer slot. */ + component: + | (React.ComponentType & { + Footer?: React.ComponentType<{ + onEvent: OnEventType + }> + }) + | null +} + +/** @internal */ +export function PrintChecksFormContextual() { + const { payrollId, isGenerating, onEvent } = useFlow() + + return +} + +PrintChecksFormContextual.Footer = PrintChecksForm.Footer + +/** @internal */ +export function PrintChecksFailureContextual() { + const { errorMessage, onEvent } = useFlow() + + return +} + +PrintChecksFailureContextual.Footer = PrintChecksFailure.Footer + +/** @internal */ +export function PrintChecksSummaryContextual() { + const { documentUrl, onEvent } = useFlow() + + return +} + +PrintChecksSummaryContextual.Footer = PrintChecksSummary.Footer diff --git a/src/components/Payroll/PrintChecks/PrintChecksFailure/PrintChecksFailure.stories.tsx b/src/components/Payroll/PrintChecks/PrintChecksFailure/PrintChecksFailure.stories.tsx new file mode 100644 index 0000000000..4cfc4c80e9 --- /dev/null +++ b/src/components/Payroll/PrintChecks/PrintChecksFailure/PrintChecksFailure.stories.tsx @@ -0,0 +1,35 @@ +import { Suspense } from 'react' +import { fn } from 'storybook/test' +import { MockBaseProvider } from '../../../../../.storybook/helpers/MockBaseProvider' +import { PrintChecksFailure } from './PrintChecksFailure' +import { useI18n } from '@/i18n' + +function I18nLoader({ children }: { children: React.ReactNode }) { + useI18n('Payroll.PrintChecksFailure') + return <>{children} +} + +export default { + title: 'Domain/Payroll/PrintChecksFailure', + decorators: [ + (Story: React.ComponentType) => ( + Loading translations...}> + + + + + + + ), + ], +} + +export const Default = () => ( + <> + + + +) diff --git a/src/components/Payroll/PrintChecks/PrintChecksFailure/PrintChecksFailure.test.tsx b/src/components/Payroll/PrintChecks/PrintChecksFailure/PrintChecksFailure.test.tsx new file mode 100644 index 0000000000..4b969ea512 --- /dev/null +++ b/src/components/Payroll/PrintChecks/PrintChecksFailure/PrintChecksFailure.test.tsx @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from 'vitest' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { PrintChecksFailure } from './PrintChecksFailure' +import { renderWithProviders } from '@/test-utils/renderWithProviders' +import { printChecksEvents } from '@/shared/constants' + +describe('PrintChecksFailure', () => { + const user = userEvent.setup() + + it('renders the error message', async () => { + renderWithProviders( + , + ) + + expect(await screen.findByText("We couldn't generate your checks")).toBeInTheDocument() + expect(screen.getByText('Cannot generate checks on an unprocessed payroll')).toBeInTheDocument() + }) + + it('fires PRINT_CHECKS_RETRY when the retry button is clicked', async () => { + const onEvent = vi.fn() + renderWithProviders() + + await user.click(await screen.findByRole('button', { name: 'Try again' })) + + expect(onEvent).toHaveBeenCalledWith(printChecksEvents.PRINT_CHECKS_RETRY) + }) + + it('fires PRINT_CHECKS_CLOSE when the Footer close button is clicked', async () => { + const onEvent = vi.fn() + renderWithProviders() + + await user.click(await screen.findByRole('button', { name: 'Close' })) + + expect(onEvent).toHaveBeenCalledWith(printChecksEvents.PRINT_CHECKS_CLOSE) + }) +}) diff --git a/src/components/Payroll/PrintChecks/PrintChecksFailure/PrintChecksFailure.tsx b/src/components/Payroll/PrintChecks/PrintChecksFailure/PrintChecksFailure.tsx new file mode 100644 index 0000000000..db6e3a8f61 --- /dev/null +++ b/src/components/Payroll/PrintChecks/PrintChecksFailure/PrintChecksFailure.tsx @@ -0,0 +1,63 @@ +import { useTranslation } from 'react-i18next' +import { BaseComponent, type BaseComponentInterface } from '@/components/Base' +import type { OnEventType } from '@/components/Base/useBase' +import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' +import { useComponentDictionary, useI18n } from '@/i18n' +import { ActionsLayout, Flex } from '@/components/Common' +import { printChecksEvents, type EventType } from '@/shared/constants' + +interface PrintChecksFailureProps extends BaseComponentInterface<'Payroll.PrintChecksFailure'> { + errorMessage?: string +} + +/** @internal */ +export function PrintChecksFailure(props: PrintChecksFailureProps) { + return ( + + {props.children} + + ) +} + +const Root = ({ dictionary, errorMessage, onEvent }: PrintChecksFailureProps) => { + useComponentDictionary('Payroll.PrintChecksFailure', dictionary) + useI18n('Payroll.PrintChecksFailure') + const { t } = useTranslation('Payroll.PrintChecksFailure') + const { Alert, Button } = useComponentContext() + + return ( + + + {errorMessage} + + + + ) +} + +const Footer = ({ onEvent }: { onEvent: OnEventType }) => { + useI18n('Payroll.PrintChecksFailure') + const { t } = useTranslation('Payroll.PrintChecksFailure') + const { Button } = useComponentContext() + + return ( + + + + ) +} +PrintChecksFailure.Footer = Footer diff --git a/src/components/Payroll/PrintChecks/PrintChecksFailure/index.ts b/src/components/Payroll/PrintChecks/PrintChecksFailure/index.ts new file mode 100644 index 0000000000..49e02db11f --- /dev/null +++ b/src/components/Payroll/PrintChecks/PrintChecksFailure/index.ts @@ -0,0 +1 @@ +export { PrintChecksFailure } from './PrintChecksFailure' 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..d15ead2993 --- /dev/null +++ b/src/components/Payroll/PrintChecks/PrintChecksForm/PrintChecksForm.tsx @@ -0,0 +1,231 @@ +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 type { PrintChecksContextInterface } from '../PrintChecksComponents' +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 } 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 +} + +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 ( + +
+ + + {printingFormat === PrintingFormat.Bottom && ( + + )} + +
+
+ ) +} + +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/components/Payroll/PrintChecks/PrintChecksSummary/PrintChecksSummary.stories.tsx b/src/components/Payroll/PrintChecks/PrintChecksSummary/PrintChecksSummary.stories.tsx new file mode 100644 index 0000000000..5b10df6866 --- /dev/null +++ b/src/components/Payroll/PrintChecks/PrintChecksSummary/PrintChecksSummary.stories.tsx @@ -0,0 +1,35 @@ +import { Suspense } from 'react' +import { fn } from 'storybook/test' +import { MockBaseProvider } from '../../../../../.storybook/helpers/MockBaseProvider' +import { PrintChecksSummary } from './PrintChecksSummary' +import { useI18n } from '@/i18n' + +function I18nLoader({ children }: { children: React.ReactNode }) { + useI18n('Payroll.PrintChecksSummary') + return <>{children} +} + +export default { + title: 'Domain/Payroll/PrintChecksSummary', + decorators: [ + (Story: React.ComponentType) => ( + Loading translations...}> + + + + + + + ), + ], +} + +export const Default = () => ( + <> + + + +) diff --git a/src/components/Payroll/PrintChecks/PrintChecksSummary/PrintChecksSummary.test.tsx b/src/components/Payroll/PrintChecks/PrintChecksSummary/PrintChecksSummary.test.tsx new file mode 100644 index 0000000000..1acef761ca --- /dev/null +++ b/src/components/Payroll/PrintChecks/PrintChecksSummary/PrintChecksSummary.test.tsx @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { PrintChecksSummary } from './PrintChecksSummary' +import { renderWithProviders } from '@/test-utils/renderWithProviders' +import { printChecksEvents } from '@/shared/constants' + +describe('PrintChecksSummary', () => { + const user = userEvent.setup() + + it('renders a link to the document when a documentUrl is provided', async () => { + renderWithProviders( + , + ) + + expect(await screen.findByText('Your checks are ready')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'View checks' })).toHaveAttribute( + 'href', + 'https://example.com/checks.pdf', + ) + }) + + it('omits the link when no documentUrl is provided', async () => { + renderWithProviders() + + expect(await screen.findByText('Your checks are ready')).toBeInTheDocument() + expect(screen.queryByRole('link', { name: 'View checks' })).toBeNull() + }) + + it('fires PRINT_CHECKS_CLOSE when the Footer close button is clicked', async () => { + const onEvent = vi.fn() + renderWithProviders() + + await user.click(await screen.findByRole('button', { name: 'Close' })) + + expect(onEvent).toHaveBeenCalledWith(printChecksEvents.PRINT_CHECKS_CLOSE) + }) +}) diff --git a/src/components/Payroll/PrintChecks/PrintChecksSummary/PrintChecksSummary.tsx b/src/components/Payroll/PrintChecks/PrintChecksSummary/PrintChecksSummary.tsx new file mode 100644 index 0000000000..fb99ebf717 --- /dev/null +++ b/src/components/Payroll/PrintChecks/PrintChecksSummary/PrintChecksSummary.tsx @@ -0,0 +1,59 @@ +import { useTranslation } from 'react-i18next' +import { BaseComponent, type BaseComponentInterface } from '@/components/Base' +import type { OnEventType } from '@/components/Base/useBase' +import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' +import { useComponentDictionary, useI18n } from '@/i18n' +import { ActionsLayout, Flex } from '@/components/Common' +import { printChecksEvents, type EventType } from '@/shared/constants' + +interface PrintChecksSummaryProps extends BaseComponentInterface<'Payroll.PrintChecksSummary'> { + documentUrl?: string +} + +/** @internal */ +export function PrintChecksSummary(props: PrintChecksSummaryProps) { + return ( + + {props.children} + + ) +} + +const Root = ({ dictionary, documentUrl }: PrintChecksSummaryProps) => { + useComponentDictionary('Payroll.PrintChecksSummary', dictionary) + useI18n('Payroll.PrintChecksSummary') + const { t } = useTranslation('Payroll.PrintChecksSummary') + const { Heading, Text, Link } = useComponentContext() + + return ( + + {t('succeededTitle')} + {t('succeededDescription')} + {documentUrl && ( + + {t('viewChecksCta')} + + )} + + ) +} + +const Footer = ({ onEvent }: { onEvent: OnEventType }) => { + useI18n('Payroll.PrintChecksSummary') + const { t } = useTranslation('Payroll.PrintChecksSummary') + const { Button } = useComponentContext() + + return ( + + + + ) +} +PrintChecksSummary.Footer = Footer diff --git a/src/components/Payroll/PrintChecks/PrintChecksSummary/index.ts b/src/components/Payroll/PrintChecks/PrintChecksSummary/index.ts new file mode 100644 index 0000000000..22904e6151 --- /dev/null +++ b/src/components/Payroll/PrintChecks/PrintChecksSummary/index.ts @@ -0,0 +1 @@ +export { PrintChecksSummary } from './PrintChecksSummary' diff --git a/src/components/Payroll/PrintChecks/index.ts b/src/components/Payroll/PrintChecks/index.ts new file mode 100644 index 0000000000..4bc8060988 --- /dev/null +++ b/src/components/Payroll/PrintChecks/index.ts @@ -0,0 +1,2 @@ +export { PrintChecks } from './PrintChecks' +export type { PrintChecksProps } from './types' diff --git a/src/components/Payroll/PrintChecks/printChecksStateMachine.tsx b/src/components/Payroll/PrintChecks/printChecksStateMachine.tsx new file mode 100644 index 0000000000..6124518352 --- /dev/null +++ b/src/components/Payroll/PrintChecks/printChecksStateMachine.tsx @@ -0,0 +1,135 @@ +import { state, transition, reduce } from 'robot3' +import type { PrintChecksContextInterface } from './PrintChecksComponents' +import { + PrintChecksFormContextual, + PrintChecksFailureContextual, + PrintChecksSummaryContextual, +} from './PrintChecksComponents' +import { printChecksEvents } from '@/shared/constants' +import type { MachineEventType, MachineTransition } from '@/types/Helpers' + +/** + * Payload shapes for each print-checks state machine event. + * + * @internal + */ +export type EventPayloads = { + /** Banner CTA pressed to begin the print-checks flow. */ + [printChecksEvents.PRINT_CHECKS_START]: undefined + /** Form submitted; a generate-and-poll cycle has started. */ + [printChecksEvents.PRINT_CHECKS_GENERATE_START]: undefined + /** Generation succeeded; carries the generated checks document URL. */ + [printChecksEvents.PRINT_CHECKS_GENERATE_SUCCEEDED]: { + documentUrl: string | null + } + /** Generation failed; carries the error message to surface. */ + [printChecksEvents.PRINT_CHECKS_GENERATE_FAILED]: { + errorMessage: string | null + } + /** User retried after a failure. */ + [printChecksEvents.PRINT_CHECKS_RETRY]: undefined + /** Form cancelled. */ + [printChecksEvents.PRINT_CHECKS_CANCEL]: undefined + /** Failure or summary screen closed. */ + [printChecksEvents.PRINT_CHECKS_CLOSE]: undefined +} + +/** @internal */ +export const printChecksMachine = { + banner: state( + transition( + printChecksEvents.PRINT_CHECKS_START, + 'form', + reduce((ctx: PrintChecksContextInterface): PrintChecksContextInterface => ({ + ...ctx, + component: PrintChecksFormContextual, + })), + ), + ), + form: state( + transition( + printChecksEvents.PRINT_CHECKS_GENERATE_START, + 'form', + reduce((ctx: PrintChecksContextInterface): PrintChecksContextInterface => ({ + ...ctx, + isGenerating: true, + })), + ), + transition( + printChecksEvents.PRINT_CHECKS_GENERATE_SUCCEEDED, + 'summary', + reduce( + ( + ctx: PrintChecksContextInterface, + ev: MachineEventType< + EventPayloads, + typeof printChecksEvents.PRINT_CHECKS_GENERATE_SUCCEEDED + >, + ): PrintChecksContextInterface => ({ + ...ctx, + component: PrintChecksSummaryContextual, + documentUrl: ev.payload.documentUrl ?? undefined, + isGenerating: false, + }), + ), + ), + transition( + printChecksEvents.PRINT_CHECKS_GENERATE_FAILED, + 'failure', + reduce( + ( + ctx: PrintChecksContextInterface, + ev: MachineEventType< + EventPayloads, + typeof printChecksEvents.PRINT_CHECKS_GENERATE_FAILED + >, + ): PrintChecksContextInterface => ({ + ...ctx, + component: PrintChecksFailureContextual, + errorMessage: ev.payload.errorMessage ?? undefined, + isGenerating: false, + }), + ), + ), + transition( + printChecksEvents.PRINT_CHECKS_CANCEL, + 'banner', + reduce((ctx: PrintChecksContextInterface): PrintChecksContextInterface => ({ + ...ctx, + component: null, + isGenerating: false, + })), + ), + ), + failure: state( + transition( + printChecksEvents.PRINT_CHECKS_RETRY, + 'form', + reduce((ctx: PrintChecksContextInterface): PrintChecksContextInterface => ({ + ...ctx, + component: PrintChecksFormContextual, + errorMessage: undefined, + })), + ), + transition( + printChecksEvents.PRINT_CHECKS_CLOSE, + 'banner', + reduce((ctx: PrintChecksContextInterface): PrintChecksContextInterface => ({ + ...ctx, + component: null, + errorMessage: undefined, + })), + ), + ), + summary: state( + transition( + printChecksEvents.PRINT_CHECKS_CLOSE, + 'banner', + reduce((ctx: PrintChecksContextInterface): PrintChecksContextInterface => ({ + ...ctx, + component: null, + documentUrl: undefined, + })), + ), + ), +} diff --git a/src/components/Payroll/PrintChecks/types.ts b/src/components/Payroll/PrintChecks/types.ts new file mode 100644 index 0000000000..9bc1cacbc0 --- /dev/null +++ b/src/components/Payroll/PrintChecks/types.ts @@ -0,0 +1,15 @@ +import type { BaseComponentInterface } from '@/components/Base' + +/** + * Props for {@link PrintChecks}. + * + * @public + */ +export interface PrintChecksProps extends Omit, 'onEvent'> { + /** Identifier of the company that owns the payroll. */ + companyId: string + /** Identifier of the payroll to generate printable checks for. */ + payrollId: string + /** Callback invoked each time the component emits an event. */ + onEvent?: BaseComponentInterface['onEvent'] +} diff --git a/src/components/Payroll/index.ts b/src/components/Payroll/index.ts index 37d306da1f..06e79deec5 100644 --- a/src/components/Payroll/index.ts +++ b/src/components/Payroll/index.ts @@ -38,6 +38,8 @@ export { PayrollBlockerList, type ApiPayrollBlocker } from './PayrollBlocker' export type { PayrollBlockerListProps } from './PayrollBlocker/components/PayrollBlockerList' export { RecoveryCases } from './RecoveryCases/RecoveryCases' export type { RecoveryCasesProps } from './RecoveryCases/RecoveryCases' +export { PrintChecks } from './PrintChecks/PrintChecks' +export type { PrintChecksProps } from './PrintChecks' export type { OffCyclePayPeriodDateFormData, OffCyclePayrollDateType, diff --git a/src/i18n/en/Payroll.PayrollOverview.json b/src/i18n/en/Payroll.PayrollOverview.json index 3268835cd3..9cded1fc93 100644 --- a/src/i18n/en/Payroll.PayrollOverview.json +++ b/src/i18n/en/Payroll.PayrollOverview.json @@ -28,9 +28,6 @@ "payrollNotCalculated": "Payroll is not calculated", "directDepositDeadline": "To pay your employees with direct deposit by {{payDate}}, you'll need to run payroll by {{time}} on {{date}}", "paystubPdfError": "There was an issue generating the paystub PDF. Please try again later.", - "checkPaymentWarning_one": "You noted {{count}} employee who should be paid by check.", - "checkPaymentWarning_other": "You noted {{count}} employees who should be paid by check.", - "checkPaymentWarningDescription": "Employees with this payment method will need their checks delivered to them.", "payrollProcessedTitle": "Payroll submitted", "payrollProcessedMessage": "{{amount}} will be debited on {{date}}. Make sure you have these funds available.", "payrollProcessingFailedTitle": "There was an error submitting payroll", diff --git a/src/i18n/en/Payroll.PrintChecksBanner.json b/src/i18n/en/Payroll.PrintChecksBanner.json new file mode 100644 index 0000000000..d75b58b223 --- /dev/null +++ b/src/i18n/en/Payroll.PrintChecksBanner.json @@ -0,0 +1,6 @@ +{ + "title_one": "You noted {{count}} employee that should be paid by check.", + "title_other": "You noted {{count}} employees that should be paid by check.", + "description": "Employees with this payment method will need their checks delivered to them. If you aren't using your own checks, you can view and print checks.", + "cta": "View and print checks" +} diff --git a/src/i18n/en/Payroll.PrintChecksFailure.json b/src/i18n/en/Payroll.PrintChecksFailure.json new file mode 100644 index 0000000000..f941210656 --- /dev/null +++ b/src/i18n/en/Payroll.PrintChecksFailure.json @@ -0,0 +1,5 @@ +{ + "failedTitle": "We couldn't generate your checks", + "retryCta": "Try again", + "closeCta": "Close" +} 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/en/Payroll.PrintChecksSummary.json b/src/i18n/en/Payroll.PrintChecksSummary.json new file mode 100644 index 0000000000..95e736c6ec --- /dev/null +++ b/src/i18n/en/Payroll.PrintChecksSummary.json @@ -0,0 +1,6 @@ +{ + "succeededTitle": "Your checks are ready", + "succeededDescription": "The download should have started automatically. If not, use the link below.", + "viewChecksCta": "View checks", + "closeCta": "Close" +} diff --git a/src/i18n/types.d.ts b/src/i18n/types.d.ts index 26670498f0..353f8edfe5 100644 --- a/src/i18n/types.d.ts +++ b/src/i18n/types.d.ts @@ -127,6 +127,10 @@ export interface Resources { 'Payroll.PayrollList': Translations.PayrollPayrollList 'Payroll.PayrollOverview': Translations.PayrollPayrollOverview '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 'Payroll.Transition': Translations.PayrollTransition @@ -7416,12 +7420,6 @@ export namespace Translations { directDepositDeadline: string /** @defaultValue `"There was an issue generating the paystub PDF. Please try again later."` */ paystubPdfError: string - /** @defaultValue `"You noted {{count}} employee who should be paid by check."` */ - checkPaymentWarning_one: string - /** @defaultValue `"You noted {{count}} employees who should be paid by check."` */ - checkPaymentWarning_other: string - /** @defaultValue `"Employees with this payment method will need their checks delivered to them."` */ - checkPaymentWarningDescription: string /** @defaultValue `"Payroll submitted"` */ payrollProcessedTitle: string /** @defaultValue `"{{amount}} will be debited on {{date}}. Make sure you have these funds available."` */ @@ -7665,6 +7663,64 @@ export namespace Translations { totalEmployees_other: string } } + /** Translation keys for the `Payroll.PrintChecksBanner` i18n namespace. */ + export interface PayrollPrintChecksBanner { + /** @defaultValue `"You noted {{count}} employee that should be paid by check."` */ + title_one: string + /** @defaultValue `"You noted {{count}} employees that should be paid by check."` */ + title_other: string + /** @defaultValue `"Employees with this payment method will need their checks delivered to them. If you aren't using your own checks, you can view and print checks."` */ + description: string + /** @defaultValue `"View and print checks"` */ + cta: string + } + /** Translation keys for the `Payroll.PrintChecksFailure` i18n namespace. */ + export interface PayrollPrintChecksFailure { + /** @defaultValue `"We couldn't generate your checks"` */ + failedTitle: string + /** @defaultValue `"Try again"` */ + retryCta: string + /** @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"` */ + succeededTitle: string + /** @defaultValue `"The download should have started automatically. If not, use the link below."` */ + succeededDescription: string + /** @defaultValue `"View checks"` */ + viewChecksCta: string + /** @defaultValue `"Close"` */ + closeCta: string + } /** Translation keys for the `Payroll.RecoveryCasesList` i18n namespace. */ export interface PayrollRecoveryCasesList { /** @defaultValue `"Recovery cases"` */ 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/shared/constants.ts b/src/shared/constants.ts index 153799c8b7..c57f771324 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -466,6 +466,26 @@ export const recoveryCasesEvents = { RECOVERY_CASE_RESUBMIT_DONE: 'recoveryCase/resubmit/done', } as const +/** + * Event keys emitted by the print-checks component. + * + * @remarks + * These keys are merged into {@link componentEvents}. Consume them through the + * `onEvent` handler of the print-checks component and compare against the + * value of an entry on this object. + * + * @public + */ +export const printChecksEvents = { + PRINT_CHECKS_START: 'payroll/printChecks/start', + PRINT_CHECKS_GENERATE_START: 'payroll/printChecks/generate/start', + PRINT_CHECKS_GENERATE_SUCCEEDED: 'payroll/printChecks/generate/succeeded', + PRINT_CHECKS_GENERATE_FAILED: 'payroll/printChecks/generate/failed', + PRINT_CHECKS_RETRY: 'payroll/printChecks/retry', + PRINT_CHECKS_CANCEL: 'payroll/printChecks/cancel', + PRINT_CHECKS_CLOSE: 'payroll/printChecks/close', +} as const + /** * Event keys emitted by off-cycle payroll and transition components. * @@ -567,6 +587,7 @@ export const componentEvents = { ...payrollWireEvents, ...informationRequestEvents, ...recoveryCasesEvents, + ...printChecksEvents, ...contractorPaymentEvents, ...contractorHistoricalPaymentEvents, ...offCycleEvents, 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,