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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/reference/endpoint-inventory.json
Original file line number Diff line number Diff line change
Expand Up @@ -1180,6 +1180,10 @@
},
"Payroll.PayrollList": {
"endpoints": [
{
"method": "GET",
"path": "/v1/companies/:companyId/pay_periods"
},
{
"method": "GET",
"path": "/v1/companies/:companyId/pay_schedules"
Expand Down
3 changes: 2 additions & 1 deletion docs/reference/endpoint-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
2 changes: 2 additions & 0 deletions docs/workflows-overview/run-payroll.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
66 changes: 66 additions & 0 deletions src/components/Payroll/PayrollList/PayrollList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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(<PayrollList {...defaultProps} />)

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(<PayrollList {...defaultProps} />)

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 }) => {
Expand Down
20 changes: 20 additions & 0 deletions src/components/Payroll/PayrollList/PayrollList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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 (
<BaseComponent {...props}>
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -182,6 +201,7 @@ const Root = ({ companyId, onEvent }: PayrollListBlockProps) => {
blockers={blockers}
wireInRequests={wireInRequests}
dateRangeFilter={dateRangeFilter}
hasUnprocessedTransitions={hasUnprocessedTransitions}
/>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<PayrollListPresentation {...defaultProps} hasUnprocessedTransitions={true} />,
)

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(
<PayrollListPresentation {...defaultProps} hasUnprocessedTransitions={false} />,
)

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(
<PayrollListPresentation
{...defaultProps}
payrolls={[mockUnprocessedPayroll, offCyclePayroll]}
hasUnprocessedTransitions={true}
/>,
)

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(<PayrollListPresentation {...defaultProps} showSkipSuccessAlert={true} />)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ interface PayrollListPresentationProps {
blockers: ApiPayrollBlocker[]
wireInRequests: WireInRequest[]
dateRangeFilter: UseDateRangeFilterResult
hasUnprocessedTransitions?: boolean
}

export const PayrollListPresentation = ({
Expand All @@ -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')
Expand Down Expand Up @@ -195,10 +197,13 @@ export const PayrollListPresentation = ({
}

const isProcessingSkipPayroll = skippingPayrollId === payrollUuid
const isRegular = !payroll.offCycle
const isDisabledByTransition = isRegular && hasUnprocessedTransitions

return calculatedAt ? (
<Button
isLoading={isProcessingSkipPayroll}
isDisabled={isDisabledByTransition}
onClick={() => {
onSubmitPayroll({ payrollUuid, payPeriod })
}}
Expand All @@ -210,6 +215,7 @@ export const PayrollListPresentation = ({
) : (
<Button
isLoading={isProcessingSkipPayroll}
isDisabled={isDisabledByTransition}
onClick={() => {
onRunPayroll({ payrollUuid, payPeriod })
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ describe('TransitionPayrollAlert', () => {

it('renders nothing and emits an ERROR event when a gate query fails', async () => {
server.use(
http.get(payPeriodsPath, () => new HttpResponse(null, { status: 500 })),
http.get(paySchedulesPath, () => HttpResponse.json(paySchedulesResponse)),
http.get(payPeriodsPath, () => HttpResponse.json([])),
http.get(paySchedulesPath, () => new HttpResponse(null, { status: 500 })),
)
const onEvent = vi.fn()

Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import { useMemo, useState, useCallback, useEffect } from 'react'
import { usePaySchedulesGetPayPeriods } from '@gusto/embedded-api-v-2025-11-15/react-query/paySchedulesGetPayPeriods'
import { usePaySchedulesGetAll } from '@gusto/embedded-api-v-2025-11-15/react-query/paySchedulesGetAll'
import { usePayrollsSkipMutation } from '@gusto/embedded-api-v-2025-11-15/react-query/payrollsSkip'
import { PayrollType } from '@gusto/embedded-api-v-2025-11-15/models/operations/postcompaniespayrollskipcompanyuuid'
import { PayrollTypes } from '@gusto/embedded-api-v-2025-11-15/models/operations/getv1companiescompanyidpayperiods'
import type { PayPeriod } from '@gusto/embedded-api-v-2025-11-15/models/components/payperiod'
import { RFCDate } from '@gusto/embedded-api-v-2025-11-15/types/rfcdate'
import { useUnprocessedTransitionPayPeriods } from '../useUnprocessedTransitionPayPeriods'
import {
TransitionPayrollAlertPresentation,
type TransitionPayPeriodGroup,
Expand All @@ -23,53 +21,41 @@ interface TransitionPayrollAlertProps {
onEvent: OnEventType<EventType, unknown>
}

interface RootProps {
companyId: string
groupedPayPeriods: TransitionPayPeriodGroup[]
}

const LOOK_AHEAD_DAYS = 90
const COMPONENT_NAME = 'Payroll.TransitionPayrollAlert'

export function TransitionPayrollAlert({ companyId, onEvent }: TransitionPayrollAlertProps) {
const { observability } = useObservability()
return (
<BaseComponent onEvent={onEvent}>
<Root companyId={companyId} />
</BaseComponent>
)
}

const lookAheadEndDate = useMemo(() => {
const date = new Date()
date.setDate(date.getDate() + LOOK_AHEAD_DAYS)
return new RFCDate(date)
}, [])
function Root({ companyId }: { companyId: string }) {
const { onEvent, baseSubmitHandler } = useBase()
const { observability } = useObservability()

const { data: payPeriodsData, error: payPeriodsError } = usePaySchedulesGetPayPeriods({
companyId,
payrollTypes: PayrollTypes.Transition,
endDate: lookAheadEndDate,
})
const { unprocessedPayPeriods } = useUnprocessedTransitionPayPeriods(companyId)

const { data: paySchedulesData, error: paySchedulesError } = usePaySchedulesGetAll({ companyId })

const gateError = payPeriodsError ?? paySchedulesError

useEffect(() => {
if (!gateError) return
onEvent(componentEvents.ERROR, gateError)
const sdkError = normalizeToSDKError(gateError)
if (!paySchedulesError) return
onEvent(componentEvents.ERROR, paySchedulesError)
const sdkError = normalizeToSDKError(paySchedulesError)
observability?.onError?.({
...sdkError,
timestamp: Date.now(),
componentName: COMPONENT_NAME,
})
}, [gateError, onEvent, observability])
}, [paySchedulesError, onEvent, observability])

const groupedPayPeriods = useMemo<TransitionPayPeriodGroup[]>(() => {
if (!payPeriodsData || !paySchedulesData) return []
if (!paySchedulesData) return []
const paySchedules = paySchedulesData.payScheduleShowResponse ?? []
const unprocessed = (payPeriodsData.payPeriods ?? []).filter(
(pp: PayPeriod) => !pp.payroll?.processed,
)

const groups = new Map<string, PayPeriod[]>()
for (const period of unprocessed) {
for (const period of unprocessedPayPeriods) {
const uuid = period.payScheduleUuid ?? 'unknown'
const existing = groups.get(uuid) ?? []
existing.push(period)
Expand All @@ -81,21 +67,7 @@ export function TransitionPayrollAlert({ companyId, onEvent }: TransitionPayroll
const payScheduleName = schedule?.customName || schedule?.name || 'Transition'
return { payScheduleUuid, payScheduleName, payPeriods }
})
}, [payPeriodsData, paySchedulesData])

if (!payPeriodsData || !paySchedulesData || groupedPayPeriods.length === 0) {
return null
}

return (
<BaseComponent onEvent={onEvent}>
<Root companyId={companyId} groupedPayPeriods={groupedPayPeriods} />
</BaseComponent>
)
}

function Root({ companyId, groupedPayPeriods }: RootProps) {
const { onEvent, baseSubmitHandler } = useBase()
}, [unprocessedPayPeriods, paySchedulesData])

const [showSkipSuccessAlert, setShowSkipSuccessAlert] = useState(false)
const [skippingPayPeriod, setSkippingPayPeriod] = useState<PayPeriod | null>(null)
Expand Down Expand Up @@ -144,6 +116,10 @@ function Root({ companyId, groupedPayPeriods }: RootProps) {
setShowSkipSuccessAlert(false)
}, [])

if (!paySchedulesData || groupedPayPeriods.length === 0) {
return null
}

return (
<TransitionPayrollAlertPresentation
groupedPayPeriods={groupedPayPeriods}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@ describe('TransitionPayrollAlertPresentation', () => {

expect(await screen.findByText(/Transition payroll - Dec 1/)).toBeInTheDocument()
expect(screen.getByText(/you changed your pay schedule/i)).toBeInTheDocument()
expect(
screen.getByText(
/Regular payroll functionality is blocked until you either run or skip transition payrolls/i,
),
).toBeInTheDocument()
})

it('shows run action directly without schedule name for single group', async () => {
Expand Down
Loading
Loading