diff --git a/docs/reference/endpoint-inventory.json b/docs/reference/endpoint-inventory.json
index 748920980..b90aa124b 100644
--- a/docs/reference/endpoint-inventory.json
+++ b/docs/reference/endpoint-inventory.json
@@ -1180,6 +1180,10 @@
},
"Payroll.PayrollList": {
"endpoints": [
+ {
+ "method": "GET",
+ "path": "/v1/companies/:companyId/pay_periods"
+ },
{
"method": "GET",
"path": "/v1/companies/:companyId/pay_schedules"
diff --git a/docs/reference/endpoint-reference.md b/docs/reference/endpoint-reference.md
index d1974acc6..cf21a7e76 100644
--- a/docs/reference/endpoint-reference.md
+++ b/docs/reference/endpoint-reference.md
@@ -229,7 +229,8 @@ import inventory from '@gusto/embedded-react-sdk/endpoint-inventory.json'
| | GET | `/v1/companies/:companyUuid/payrolls/blockers` |
| | POST | `/v1/companies/:companyUuid/payrolls/skip` |
| | GET | `/v1/companies/:companyUuid/wire_in_requests` |
-| **Payroll.PayrollList** | GET | `/v1/companies/:companyId/pay_schedules` |
+| **Payroll.PayrollList** | GET | `/v1/companies/:companyId/pay_periods` |
+| | GET | `/v1/companies/:companyId/pay_schedules` |
| | GET | `/v1/companies/:companyId/payrolls` |
| | DELETE | `/v1/companies/:companyId/payrolls/:payrollId` |
| | GET | `/v1/companies/:companyUuid/payrolls/blockers` |
diff --git a/docs/workflows-overview/run-payroll.md b/docs/workflows-overview/run-payroll.md
index 097d0905a..b7a022c59 100644
--- a/docs/workflows-overview/run-payroll.md
+++ b/docs/workflows-overview/run-payroll.md
@@ -105,6 +105,8 @@ function MyComponent() {
Displays a list of available payrolls that can be run, including pay period dates and status information. Users can run payrolls, submit calculated payrolls, skip payrolls, and view any payroll blockers.
+> **Note:** When the company has unprocessed transition pay periods within the next 90 days, the Run Payroll action on Regular rows is disabled to prevent regular payrolls from being run before the transition is resolved. Off-cycle rows and the Run off-cycle action remain enabled. `Payroll.PayrollLanding` pairs this list with the alert that lets users run or skip the pending transition; when using `Payroll.PayrollList` directly, render an equivalent resolution surface alongside it.
+
```jsx
import { Payroll } from '@gusto/embedded-react-sdk'
diff --git a/src/components/Payroll/PayrollList/PayrollList.test.tsx b/src/components/Payroll/PayrollList/PayrollList.test.tsx
index f38f3cde1..9d61947af 100644
--- a/src/components/Payroll/PayrollList/PayrollList.test.tsx
+++ b/src/components/Payroll/PayrollList/PayrollList.test.tsx
@@ -18,6 +18,10 @@ const sharedHandlers = [
http.get(`${API_BASE_URL}/v1/companies/:company_uuid/wire_in_requests`, () => {
return HttpResponse.json([])
}),
+
+ http.get(`${API_BASE_URL}/v1/companies/:company_id/pay_periods`, () => {
+ return HttpResponse.json([])
+ }),
]
describe('PayrollList', () => {
@@ -87,6 +91,68 @@ describe('PayrollList', () => {
expect(capturedPayrollListUrl!.searchParams.get('per')).toBeTruthy()
})
+ describe('transition payroll blocker', () => {
+ const regularPayroll = {
+ payroll_uuid: 'payroll-regular-1',
+ processed: false,
+ off_cycle: false,
+ payroll_type: 'Regular',
+ check_date: '2025-01-15',
+ payroll_deadline: '2025-01-14T23:30:00Z',
+ pay_period: {
+ start_date: '2025-01-01',
+ end_date: '2025-01-15',
+ pay_schedule_uuid: 'schedule-1',
+ },
+ }
+
+ const transitionPayPeriod = {
+ start_date: '2025-01-16',
+ end_date: '2025-01-31',
+ pay_schedule_uuid: 'schedule-1',
+ payroll: { processed: false, payroll_type: 'transition' },
+ }
+
+ it('disables Run Payroll on regular rows when an unprocessed transition exists', async () => {
+ server.use(
+ http.get(`${API_BASE_URL}/v1/companies/:company_id/payrolls`, () =>
+ HttpResponse.json([regularPayroll]),
+ ),
+ http.get(`${API_BASE_URL}/v1/companies/:company_id/pay_schedules`, () =>
+ HttpResponse.json([]),
+ ),
+ http.get(`${API_BASE_URL}/v1/companies/:company_uuid/payrolls/blockers`, () =>
+ HttpResponse.json([]),
+ ),
+ http.get(`${API_BASE_URL}/v1/companies/:company_uuid/wire_in_requests`, () =>
+ HttpResponse.json([]),
+ ),
+ http.get(`${API_BASE_URL}/v1/companies/:company_id/pay_periods`, () =>
+ HttpResponse.json([transitionPayPeriod]),
+ ),
+ )
+
+ renderWithProviders()
+
+ const runPayrollButton = await screen.findByRole('button', { name: 'Run Payroll' })
+ expect(runPayrollButton).toBeDisabled()
+ })
+
+ it('does not disable Run Payroll when there are no unprocessed transitions', async () => {
+ server.use(
+ http.get(`${API_BASE_URL}/v1/companies/:company_id/payrolls`, () =>
+ HttpResponse.json([regularPayroll]),
+ ),
+ ...sharedHandlers,
+ )
+
+ renderWithProviders()
+
+ const runPayrollButton = await screen.findByRole('button', { name: 'Run Payroll' })
+ expect(runPayrollButton).not.toBeDisabled()
+ })
+ })
+
it('renders pagination controls when totalCount exceeds page size', async () => {
server.use(
http.get(`${API_BASE_URL}/v1/companies/:company_id/payrolls`, ({ request }) => {
diff --git a/src/components/Payroll/PayrollList/PayrollList.tsx b/src/components/Payroll/PayrollList/PayrollList.tsx
index 0e80c2682..38a139dfc 100644
--- a/src/components/Payroll/PayrollList/PayrollList.tsx
+++ b/src/components/Payroll/PayrollList/PayrollList.tsx
@@ -16,6 +16,7 @@ import {
} from '@gusto/embedded-api-v-2025-11-15/models/operations/getv1companiescompanyidpayrolls'
import type { Payroll } from '@gusto/embedded-api-v-2025-11-15/models/components/payroll'
import type { ApiPayrollBlocker } from '../PayrollBlocker/payrollHelpers'
+import { useUnprocessedTransitionPayPeriods } from '../useUnprocessedTransitionPayPeriods'
import { PayrollListPresentation } from './PayrollListPresentation'
import type { BaseComponentInterface } from '@/components/Base'
import { BaseComponent, useBase } from '@/components/Base'
@@ -27,6 +28,22 @@ interface PayrollListBlockProps extends BaseComponentInterface {
companyId: string
}
+/**
+ * Lists upcoming payrolls and lets users start running them.
+ *
+ * Disables the Run Payroll action on Regular rows when the company has any
+ * unprocessed transition pay periods within the next 90 days — running a
+ * regular payroll before resolving a transition causes the transition to be
+ * dropped on the backend, so this gate matches the behavior shipped in
+ * Gusto-hosted flows. Off-cycle rows and the Run off-cycle CTA are
+ * intentionally left enabled, since off-cycle is the path used to actually
+ * run a transition payroll.
+ *
+ * When composed via `Payroll.PayrollLanding`, the alert that explains the
+ * block (and lets the user resolve it via Run / Skip) is rendered
+ * automatically. When using `PayrollList` directly, render an equivalent
+ * resolution surface alongside it.
+ */
export function PayrollList(props: PayrollListBlockProps) {
return (
@@ -99,6 +116,8 @@ const Root = ({ companyId, onEvent }: PayrollListBlockProps) => {
const wireInRequests = wireInRequestsData.wireInRequestList ?? []
+ const { hasUnprocessedTransitions } = useUnprocessedTransitionPayPeriods(companyId)
+
const { mutateAsync: skipPayroll } = usePayrollsSkipMutation()
const { mutateAsync: deletePayrollMutation } = usePayrollsDeleteMutation()
@@ -182,6 +201,7 @@ const Root = ({ companyId, onEvent }: PayrollListBlockProps) => {
blockers={blockers}
wireInRequests={wireInRequests}
dateRangeFilter={dateRangeFilter}
+ hasUnprocessedTransitions={hasUnprocessedTransitions}
/>
)
}
diff --git a/src/components/Payroll/PayrollList/PayrollListPresentation.test.tsx b/src/components/Payroll/PayrollList/PayrollListPresentation.test.tsx
index 5da0008c9..9db912d39 100644
--- a/src/components/Payroll/PayrollList/PayrollListPresentation.test.tsx
+++ b/src/components/Payroll/PayrollList/PayrollListPresentation.test.tsx
@@ -394,6 +394,50 @@ describe('PayrollListPresentation', () => {
})
})
+ describe('transition payroll blocker', () => {
+ const offCyclePayroll: PresentationPayroll = {
+ ...mockUnprocessedPayroll,
+ payrollUuid: 'payroll-off-cycle',
+ offCycle: true,
+ offCycleReason: 'Bonus',
+ payrollType: 'Off-Cycle',
+ }
+
+ it('disables the run payroll button on regular rows when transitions are unprocessed', async () => {
+ renderWithProviders(
+ ,
+ )
+
+ await screen.findByRole('heading', { name: 'Upcoming payroll' })
+ expect(screen.getByRole('button', { name: 'Run Payroll' })).toBeDisabled()
+ })
+
+ it('does not disable the run payroll button on regular rows when no transitions exist', async () => {
+ renderWithProviders(
+ ,
+ )
+
+ await screen.findByRole('heading', { name: 'Upcoming payroll' })
+ expect(screen.getByRole('button', { name: 'Run Payroll' })).not.toBeDisabled()
+ })
+
+ it('leaves off-cycle rows enabled when transitions are unprocessed', async () => {
+ renderWithProviders(
+ ,
+ )
+
+ await screen.findByRole('heading', { name: 'Upcoming payroll' })
+ const runButtons = screen.getAllByRole('button', { name: 'Run Payroll' })
+ // First row is the Regular payroll (disabled); second is Off-Cycle (enabled).
+ expect(runButtons[0]).toBeDisabled()
+ expect(runButtons[1]).not.toBeDisabled()
+ })
+ })
+
describe('skip success alert', () => {
it('shows skip success alert when showSkipSuccessAlert is true', async () => {
renderWithProviders()
diff --git a/src/components/Payroll/PayrollList/PayrollListPresentation.tsx b/src/components/Payroll/PayrollList/PayrollListPresentation.tsx
index 0eb3b305c..a0506d153 100644
--- a/src/components/Payroll/PayrollList/PayrollListPresentation.tsx
+++ b/src/components/Payroll/PayrollList/PayrollListPresentation.tsx
@@ -71,6 +71,7 @@ interface PayrollListPresentationProps {
blockers: ApiPayrollBlocker[]
wireInRequests: WireInRequest[]
dateRangeFilter: UseDateRangeFilterResult
+ hasUnprocessedTransitions?: boolean
}
export const PayrollListPresentation = ({
@@ -91,6 +92,7 @@ export const PayrollListPresentation = ({
blockers,
wireInRequests,
dateRangeFilter,
+ hasUnprocessedTransitions = false,
}: PayrollListPresentationProps) => {
const { Box, Button, ButtonIcon, Dialog, Heading, Text, Alert } = useComponentContext()
useI18n('Payroll.PayrollList')
@@ -195,10 +197,13 @@ export const PayrollListPresentation = ({
}
const isProcessingSkipPayroll = skippingPayrollId === payrollUuid
+ const isRegular = !payroll.offCycle
+ const isDisabledByTransition = isRegular && hasUnprocessedTransitions
return calculatedAt ? (