diff --git a/src/components/Payroll/PayrollHistory/PayrollHistory.test.tsx b/src/components/Payroll/PayrollHistory/PayrollHistory.test.tsx index 9ae642341..23d4c16da 100644 --- a/src/components/Payroll/PayrollHistory/PayrollHistory.test.tsx +++ b/src/components/Payroll/PayrollHistory/PayrollHistory.test.tsx @@ -190,12 +190,17 @@ describe('PayrollHistory', () => { }) it('shows cancel option only for cancellable payrolls', async () => { - // Use fixture with unprocessed payroll data - const mockUnprocessedPayroll = await getFixture('payroll-history-unprocessed-test-data') + // Mock current time to be before 4 PM PT cutoff on deadline day + // Deadline is 2024-12-14T23:30:00Z (3:30 PM PST Dec 14) + // Cutoff is 4:00 PM PST Dec 14 (2024-12-15T00:00:00Z) + // Set current time to 2:00 PM PST Dec 14 (2024-12-14T22:00:00Z) + vi.setSystemTime(new Date('2024-12-14T22:00:00Z')) + + const mockProcessedPayroll = await getFixture('payroll-history-unprocessed-test-data') server.use( http.get(`${API_BASE_URL}/v1/companies/:company_id/payrolls`, () => { - return HttpResponse.json(mockUnprocessedPayroll) + return HttpResponse.json(mockProcessedPayroll) }), ) @@ -208,19 +213,23 @@ describe('PayrollHistory', () => { const menuButtons = screen.getAllByRole('button', { name: /open menu/i }) await user.click(menuButtons[0]!) - // Should show cancel option for unprocessed payroll + // Should show cancel option for processed payroll before cutoff await waitFor(() => { expect(screen.getByText('Cancel payroll')).toBeInTheDocument() }) + + vi.useRealTimers() }) it('handles payroll cancellation', async () => { - // Use fixture with unprocessed payroll data - const mockUnprocessedPayroll = await getFixture('payroll-history-unprocessed-test-data') + // Mock current time to be before 4 PM PT cutoff on deadline day + vi.setSystemTime(new Date('2024-12-14T22:00:00Z')) + + const mockProcessedPayroll = await getFixture('payroll-history-unprocessed-test-data') server.use( http.get(`${API_BASE_URL}/v1/companies/:company_id/payrolls`, () => { - return HttpResponse.json(mockUnprocessedPayroll) + return HttpResponse.json(mockProcessedPayroll) }), // Mock the cancel API http.put(`${API_BASE_URL}/v1/companies/:company_id/payrolls/:payroll_id/cancel`, () => { @@ -260,14 +269,19 @@ describe('PayrollHistory', () => { }), ) }) + + vi.useRealTimers() }) it('handles cancellation errors gracefully', async () => { - const mockUnprocessedPayroll = await getFixture('payroll-history-unprocessed-test-data') + // Mock current time to be before 4 PM PT cutoff on deadline day + vi.setSystemTime(new Date('2024-12-14T22:00:00Z')) + + const mockProcessedPayroll = await getFixture('payroll-history-unprocessed-test-data') server.use( http.get(`${API_BASE_URL}/v1/companies/:company_id/payrolls`, () => { - return HttpResponse.json(mockUnprocessedPayroll) + return HttpResponse.json(mockProcessedPayroll) }), // Mock API error http.put(`${API_BASE_URL}/v1/companies/:company_id/payrolls/:payroll_id/cancel`, () => { @@ -301,6 +315,8 @@ describe('PayrollHistory', () => { await waitFor(() => { expect(screen.getByText('There was a problem with your submission')).toBeInTheDocument() }) + + vi.useRealTimers() }) }) diff --git a/src/components/Payroll/PayrollHistory/PayrollHistoryPresentation.tsx b/src/components/Payroll/PayrollHistory/PayrollHistoryPresentation.tsx index e27fae82f..b0b564c6e 100644 --- a/src/components/Payroll/PayrollHistory/PayrollHistoryPresentation.tsx +++ b/src/components/Payroll/PayrollHistory/PayrollHistoryPresentation.tsx @@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next' import type { Payroll } from '@gusto/embedded-api/models/components/payroll' import type { WireInRequest } from '@gusto/embedded-api/models/components/wireinrequest' import { PayrollStatusBadges } from '../PayrollStatusBadges' -import { getPayrollType, calculateTotalPayroll } from '../helpers' +import { getPayrollType, calculateTotalPayroll, canCancelPayroll } from '../helpers' import type { TimeFilterOption } from './PayrollHistory' import styles from './PayrollHistoryPresentation.module.scss' import type { MenuItem } from '@/components/Common/UI/Menu/MenuTypes' @@ -54,48 +54,6 @@ export const PayrollHistoryPresentation = ({ { value: 'year', label: t('timeFilter.options.year') }, ] - const canCancelPayroll = (item: Payroll) => { - if (item.payrollStatusMeta?.cancellable === false) { - return false - } - - if (item.processed && item.payrollDeadline) { - const now = new Date() - const deadline = new Date(item.payrollDeadline) - - const ptOffset = getPacificTimeOffset(now) - const nowInPT = new Date(now.getTime() + ptOffset * 60 * 60 * 1000) - const deadlineInPT = new Date( - deadline.getTime() + getPacificTimeOffset(deadline) * 60 * 60 * 1000, - ) - - const isSameDay = nowInPT.toDateString() === deadlineInPT.toDateString() - if (isSameDay) { - const cutoffTime = new Date(deadlineInPT) - cutoffTime.setHours(15, 30, 0, 0) - - if (nowInPT > cutoffTime) { - return false - } - } - } - - return true - } - - const getPacificTimeOffset = (date: Date): number => { - const year = date.getFullYear() - - const secondSundayMarch = new Date(year, 2, 1) - secondSundayMarch.setDate(1 + (7 - secondSundayMarch.getDay()) + 7) - - const firstSundayNovember = new Date(year, 10, 1) - firstSundayNovember.setDate(1 + ((7 - firstSundayNovember.getDay()) % 7)) - - const isDST = date >= secondSundayMarch && date < firstSundayNovember - return isDST ? -7 : -8 - } - const formatDeadlineForDialog = (item: Payroll): string => { const deadline = item.payrollDeadline if (!deadline) return '' diff --git a/src/components/Payroll/PayrollOverview/PayrollOverview.tsx b/src/components/Payroll/PayrollOverview/PayrollOverview.tsx index 3386ef958..9a06c1f31 100644 --- a/src/components/Payroll/PayrollOverview/PayrollOverview.tsx +++ b/src/components/Payroll/PayrollOverview/PayrollOverview.tsx @@ -16,6 +16,7 @@ import { ConfirmWireDetails, type ConfirmWireDetailsComponentType, } from '../ConfirmWireDetails/ConfirmWireDetails' +import { canCancelPayroll } from '../helpers' import { PayrollOverviewPresentation } from './PayrollOverviewPresentation' import { componentEvents, @@ -331,6 +332,7 @@ export const Root = ({ isProcessed={ payrollData.processingRequest?.status === PAYROLL_PROCESSING_STATUS.submit_success } + canCancel={canCancelPayroll(payrollData)} payrollData={payrollData} bankAccount={bankAccount} employeeDetails={employeeData.showEmployees || []} diff --git a/src/components/Payroll/PayrollOverview/PayrollOverviewPresentation.tsx b/src/components/Payroll/PayrollOverview/PayrollOverviewPresentation.tsx index ac9c8a126..0fd0024e4 100644 --- a/src/components/Payroll/PayrollOverview/PayrollOverviewPresentation.tsx +++ b/src/components/Payroll/PayrollOverview/PayrollOverviewPresentation.tsx @@ -34,6 +34,7 @@ interface PayrollOverviewProps { taxes: Record isSubmitting?: boolean isProcessed: boolean + canCancel?: boolean alerts?: PayrollFlowAlert[] submissionBlockers?: PayrollSubmissionBlockersType[] selectedUnblockOptions?: Record @@ -69,6 +70,7 @@ export const PayrollOverviewPresentation = ({ taxes, isSubmitting = false, isProcessed, + canCancel = false, alerts = [], submissionBlockers = [], selectedUnblockOptions = {}, diff --git a/src/components/Payroll/helpers.test.ts b/src/components/Payroll/helpers.test.ts index 5a91c9f82..b2fc4cf1e 100644 --- a/src/components/Payroll/helpers.test.ts +++ b/src/components/Payroll/helpers.test.ts @@ -1,4 +1,4 @@ -import { expect, describe, it } from 'vitest' +import { expect, describe, it, vi } from 'vitest' import { formatEmployeePayRate, getRegularHours, @@ -10,11 +10,13 @@ import { getPayrollType, getAdditionalEarningsCompensations, getReimbursementCompensation, + canCancelPayroll, } from './helpers' import { type Employee, EmployeePaymentMethod1, } from '@gusto/embedded-api/models/components/employee' +import type { Payroll } from '@gusto/embedded-api/models/components/payroll' import type { EmployeeCompensations, PayrollShowPaidTimeOff, @@ -1235,4 +1237,151 @@ describe('Payroll helpers', () => { expect(result).toBeNull() }) }) + + describe('canCancelPayroll', () => { + const createMockPayroll = (overrides: Partial = {}): Payroll => ({ + payrollUuid: 'test-payroll-id', + processed: true, + payrollDeadline: new Date('2024-01-15T23:00:00Z'), + payrollStatusMeta: { + cancellable: true, + expectedCheckDate: '2024-01-20', + initialCheckDate: '2024-01-20', + expectedDebitTime: '2024-01-15T23:00:00Z', + payrollLate: false, + initialDebitCutoffTime: '2024-01-15T23:00:00Z', + }, + ...overrides, + }) + + it('returns false when payrollStatusMeta.cancellable is explicitly false', () => { + const payroll = createMockPayroll({ + payrollStatusMeta: { + cancellable: false, + expectedCheckDate: '2024-01-20', + initialCheckDate: '2024-01-20', + expectedDebitTime: '2024-01-15T23:00:00Z', + payrollLate: false, + initialDebitCutoffTime: '2024-01-15T23:00:00Z', + }, + }) + + expect(canCancelPayroll(payroll)).toBe(false) + }) + + it('returns false when payroll is not processed', () => { + const payroll = createMockPayroll({ processed: false }) + + expect(canCancelPayroll(payroll)).toBe(false) + }) + + it('returns false when payrollDeadline is missing', () => { + const payroll = createMockPayroll({ payrollDeadline: undefined }) + + expect(canCancelPayroll(payroll)).toBe(false) + }) + + it('returns true when processed and before 4 PM PT on deadline day', () => { + const deadlineDate = new Date('2024-01-15T23:00:00Z') + const beforeCutoff = new Date('2024-01-15T22:00:00Z') + + vi.setSystemTime(beforeCutoff) + + const payroll = createMockPayroll({ + payrollDeadline: deadlineDate, + }) + + expect(canCancelPayroll(payroll)).toBe(true) + + vi.useRealTimers() + }) + + it('returns false when after 4 PM PT on deadline day', () => { + const deadlineDate = new Date('2024-01-15T23:00:00Z') + const afterCutoff = new Date('2024-01-16T00:30:00Z') + + vi.setSystemTime(afterCutoff) + + const payroll = createMockPayroll({ + payrollDeadline: deadlineDate, + }) + + expect(canCancelPayroll(payroll)).toBe(false) + + vi.useRealTimers() + }) + + it('returns true when before deadline day', () => { + const deadlineDate = new Date('2024-01-15T23:00:00Z') + const beforeDeadlineDay = new Date('2024-01-14T20:00:00Z') + + vi.setSystemTime(beforeDeadlineDay) + + const payroll = createMockPayroll({ + payrollDeadline: deadlineDate, + }) + + expect(canCancelPayroll(payroll)).toBe(true) + + vi.useRealTimers() + }) + + it('handles DST transitions correctly - PDT (summer)', () => { + const summerDeadline = new Date('2024-07-15T23:00:00Z') + const beforeCutoffPDT = new Date('2024-07-15T22:00:00Z') + + vi.setSystemTime(beforeCutoffPDT) + + const payroll = createMockPayroll({ + payrollDeadline: summerDeadline, + }) + + expect(canCancelPayroll(payroll)).toBe(true) + + vi.useRealTimers() + }) + + it('handles DST transitions correctly - PST (winter)', () => { + const winterDeadline = new Date('2024-12-15T00:00:00Z') + const beforeCutoffPST = new Date('2024-12-14T23:00:00Z') + + vi.setSystemTime(beforeCutoffPST) + + const payroll = createMockPayroll({ + payrollDeadline: winterDeadline, + }) + + expect(canCancelPayroll(payroll)).toBe(true) + + vi.useRealTimers() + }) + + it('returns true when cancellable is undefined but other conditions met', () => { + const payroll = createMockPayroll({ + payrollStatusMeta: undefined, + }) + + const beforeDeadline = new Date('2024-01-14T20:00:00Z') + vi.setSystemTime(beforeDeadline) + + expect(canCancelPayroll(payroll)).toBe(true) + + vi.useRealTimers() + }) + + it('returns false exactly at 4:00 PM PT on deadline day', () => { + const deadlineDate = new Date('2024-01-15T23:00:00Z') + const exactlyCutoff = new Date('2024-01-16T00:00:00Z') + + vi.setSystemTime(exactlyCutoff) + + const payroll = createMockPayroll({ + payrollDeadline: deadlineDate, + }) + + expect(canCancelPayroll(payroll)).toBe(false) + + vi.useRealTimers() + }) + }) }) diff --git a/src/components/Payroll/helpers.ts b/src/components/Payroll/helpers.ts index e6b146ac3..dfad1b282 100644 --- a/src/components/Payroll/helpers.ts +++ b/src/components/Payroll/helpers.ts @@ -13,7 +13,7 @@ import type { PayrollType } from './PayrollList/types' import { formatPayRate } from '@/helpers/formattedStrings' import { useLocale } from '@/contexts/LocaleProvider/useLocale' import { COMPENSATION_NAME_REIMBURSEMENT, FlsaStatus } from '@/shared/constants' - +import { MS_PER_HOUR } from '@/helpers/dateFormatting' const REGULAR_HOURS_NAME = 'regular hours' // Utility to get the primary job from an employee @@ -586,3 +586,64 @@ export const calculateTotalPayroll = (payrollData: Payroll) => { return totalPayroll } + +/** + * Converts a Date to Pacific Time considering Daylight Saving Time. + * Returns the UTC offset in hours (e.g., -7 for PDT, -8 for PST). + * + * DST rules for Pacific Time: + * - Starts: Second Sunday in March at 2:00 AM + * - Ends: First Sunday in November at 2:00 AM + */ +const getPacificTimeOffset = (date: Date): number => { + const year = date.getFullYear() + + const secondSundayMarch = new Date(year, 2, 1) + secondSundayMarch.setDate(1 + (7 - secondSundayMarch.getDay()) + 7) + + const firstSundayNovember = new Date(year, 10, 1) + firstSundayNovember.setDate(1 + ((7 - firstSundayNovember.getDay()) % 7)) + + const isDST = date >= secondSundayMarch && date < firstSundayNovember + return isDST ? -7 : -8 +} + +/** + * Determines if a payroll can be cancelled based on business rules. + * + * A payroll can be cancelled if all of the following conditions are met: + * - The payroll has been processed (processed === true) + * - Current time is before 4:00 PM PT on the payroll deadline + * - The payrollStatusMeta.cancellable flag is not explicitly false + * + * This check enforces the business rule that payrolls can only be cancelled + * before the 4:00 PM PT cutoff time on their deadline date. + */ +export const canCancelPayroll = (payroll: Payroll): boolean => { + if (payroll.payrollStatusMeta?.cancellable === false) { + return false + } + + if (!payroll.processed) { + return false + } + + if (!payroll.payrollDeadline) { + return false + } + + const now = new Date() + const deadline = new Date(payroll.payrollDeadline) + + const nowInPT = new Date(now.getTime() + getPacificTimeOffset(now) * MS_PER_HOUR) + const deadlineInPT = new Date(deadline.getTime() + getPacificTimeOffset(deadline) * MS_PER_HOUR) + + const cutoffTime = new Date(deadlineInPT) + cutoffTime.setUTCHours(16, 0, 0, 0) + + if (nowInPT >= cutoffTime) { + return false + } + + return true +} diff --git a/src/helpers/dateFormatting.ts b/src/helpers/dateFormatting.ts index 12e52c20d..41629191a 100644 --- a/src/helpers/dateFormatting.ts +++ b/src/helpers/dateFormatting.ts @@ -200,7 +200,7 @@ export const normalizeDateToLocal = (date: Date | null): Date | null => { return new Date(year, month - 1, day) } -const MS_PER_HOUR = 1000 * 60 * 60 +export const MS_PER_HOUR = 1000 * 60 * 60 const MS_PER_DAY = MS_PER_HOUR * 24 export const getHoursUntil = (deadline?: Date | string | null): number | null => { diff --git a/src/test/mocks/fixtures/payroll-history-unprocessed-test-data.json b/src/test/mocks/fixtures/payroll-history-unprocessed-test-data.json index 90073015e..9729e7c2d 100644 --- a/src/test/mocks/fixtures/payroll-history-unprocessed-test-data.json +++ b/src/test/mocks/fixtures/payroll-history-unprocessed-test-data.json @@ -1,7 +1,7 @@ [ { "payroll_uuid": "payroll-1", - "processed": false, + "processed": true, "check_date": "2024-12-15", "external": false, "off_cycle": false,