Skip to content

Commit 8edadbe

Browse files
committed
feat: cancel payroll - WIP
1 parent c7b0085 commit 8edadbe

File tree

7 files changed

+255
-63
lines changed

7 files changed

+255
-63
lines changed

src/components/Payroll/PayrollHistory/PayrollHistory.test.tsx

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -186,12 +186,17 @@ describe('PayrollHistory', () => {
186186
})
187187

188188
it('shows cancel option only for cancellable payrolls', async () => {
189-
// Use fixture with unprocessed payroll data
190-
const mockUnprocessedPayroll = await getFixture('payroll-history-unprocessed-test-data')
189+
// Mock current time to be before 4 PM PT cutoff on deadline day
190+
// Deadline is 2024-12-14T23:30:00Z (3:30 PM PST Dec 14)
191+
// Cutoff is 4:00 PM PST Dec 14 (2024-12-15T00:00:00Z)
192+
// Set current time to 2:00 PM PST Dec 14 (2024-12-14T22:00:00Z)
193+
vi.setSystemTime(new Date('2024-12-14T22:00:00Z'))
194+
195+
const mockProcessedPayroll = await getFixture('payroll-history-unprocessed-test-data')
191196

192197
server.use(
193198
http.get(`${API_BASE_URL}/v1/companies/:company_id/payrolls`, () => {
194-
return HttpResponse.json(mockUnprocessedPayroll)
199+
return HttpResponse.json(mockProcessedPayroll)
195200
}),
196201
)
197202

@@ -204,19 +209,23 @@ describe('PayrollHistory', () => {
204209
const menuButtons = screen.getAllByRole('button', { name: /open menu/i })
205210
await user.click(menuButtons[0]!)
206211

207-
// Should show cancel option for unprocessed payroll
212+
// Should show cancel option for processed payroll before cutoff
208213
await waitFor(() => {
209214
expect(screen.getByText('Cancel payroll')).toBeInTheDocument()
210215
})
216+
217+
vi.useRealTimers()
211218
})
212219

213220
it('handles payroll cancellation', async () => {
214-
// Use fixture with unprocessed payroll data
215-
const mockUnprocessedPayroll = await getFixture('payroll-history-unprocessed-test-data')
221+
// Mock current time to be before 4 PM PT cutoff on deadline day
222+
vi.setSystemTime(new Date('2024-12-14T22:00:00Z'))
223+
224+
const mockProcessedPayroll = await getFixture('payroll-history-unprocessed-test-data')
216225

217226
server.use(
218227
http.get(`${API_BASE_URL}/v1/companies/:company_id/payrolls`, () => {
219-
return HttpResponse.json(mockUnprocessedPayroll)
228+
return HttpResponse.json(mockProcessedPayroll)
220229
}),
221230
// Mock the cancel API
222231
http.put(`${API_BASE_URL}/v1/companies/:company_id/payrolls/:payroll_id/cancel`, () => {
@@ -256,14 +265,19 @@ describe('PayrollHistory', () => {
256265
}),
257266
)
258267
})
268+
269+
vi.useRealTimers()
259270
})
260271

261272
it('handles cancellation errors gracefully', async () => {
262-
const mockUnprocessedPayroll = await getFixture('payroll-history-unprocessed-test-data')
273+
// Mock current time to be before 4 PM PT cutoff on deadline day
274+
vi.setSystemTime(new Date('2024-12-14T22:00:00Z'))
275+
276+
const mockProcessedPayroll = await getFixture('payroll-history-unprocessed-test-data')
263277

264278
server.use(
265279
http.get(`${API_BASE_URL}/v1/companies/:company_id/payrolls`, () => {
266-
return HttpResponse.json(mockUnprocessedPayroll)
280+
return HttpResponse.json(mockProcessedPayroll)
267281
}),
268282
// Mock API error
269283
http.put(`${API_BASE_URL}/v1/companies/:company_id/payrolls/:payroll_id/cancel`, () => {
@@ -297,6 +311,8 @@ describe('PayrollHistory', () => {
297311
await waitFor(() => {
298312
expect(screen.getByText('There was a problem with your submission')).toBeInTheDocument()
299313
})
314+
315+
vi.useRealTimers()
300316
})
301317
})
302318

src/components/Payroll/PayrollHistory/PayrollHistoryPresentation.tsx

Lines changed: 1 addition & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'
22
import type { Payroll } from '@gusto/embedded-api/models/components/payroll'
33
import type { WireInRequest } from '@gusto/embedded-api/models/components/wireinrequest'
44
import { PayrollStatusBadges } from '../PayrollStatusBadges'
5-
import { getPayrollType, calculateTotalPayroll } from '../helpers'
5+
import { getPayrollType, calculateTotalPayroll, canCancelPayroll } from '../helpers'
66
import type { TimeFilterOption } from './PayrollHistory'
77
import styles from './PayrollHistoryPresentation.module.scss'
88
import type { MenuItem } from '@/components/Common/UI/Menu/MenuTypes'
@@ -54,48 +54,6 @@ export const PayrollHistoryPresentation = ({
5454
{ value: 'year', label: t('timeFilter.options.year') },
5555
]
5656

57-
const canCancelPayroll = (item: Payroll) => {
58-
if (item.payrollStatusMeta?.cancellable === false) {
59-
return false
60-
}
61-
62-
if (item.processed && item.payrollDeadline) {
63-
const now = new Date()
64-
const deadline = new Date(item.payrollDeadline)
65-
66-
const ptOffset = getPacificTimeOffset(now)
67-
const nowInPT = new Date(now.getTime() + ptOffset * 60 * 60 * 1000)
68-
const deadlineInPT = new Date(
69-
deadline.getTime() + getPacificTimeOffset(deadline) * 60 * 60 * 1000,
70-
)
71-
72-
const isSameDay = nowInPT.toDateString() === deadlineInPT.toDateString()
73-
if (isSameDay) {
74-
const cutoffTime = new Date(deadlineInPT)
75-
cutoffTime.setHours(15, 30, 0, 0)
76-
77-
if (nowInPT > cutoffTime) {
78-
return false
79-
}
80-
}
81-
}
82-
83-
return true
84-
}
85-
86-
const getPacificTimeOffset = (date: Date): number => {
87-
const year = date.getFullYear()
88-
89-
const secondSundayMarch = new Date(year, 2, 1)
90-
secondSundayMarch.setDate(1 + (7 - secondSundayMarch.getDay()) + 7)
91-
92-
const firstSundayNovember = new Date(year, 10, 1)
93-
firstSundayNovember.setDate(1 + ((7 - firstSundayNovember.getDay()) % 7))
94-
95-
const isDST = date >= secondSundayMarch && date < firstSundayNovember
96-
return isDST ? -7 : -8
97-
}
98-
9957
const formatDeadlineForDialog = (item: Payroll): string => {
10058
const deadline = item.payrollDeadline
10159
if (!deadline) return ''

src/components/Payroll/PayrollOverview/PayrollOverview.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
ConfirmWireDetails,
1717
type ConfirmWireDetailsComponentType,
1818
} from '../ConfirmWireDetails/ConfirmWireDetails'
19+
import { canCancelPayroll } from '../helpers'
1920
import { PayrollOverviewPresentation } from './PayrollOverviewPresentation'
2021
import {
2122
componentEvents,
@@ -331,6 +332,7 @@ export const Root = ({
331332
isProcessed={
332333
payrollData.processingRequest?.status === PAYROLL_PROCESSING_STATUS.submit_success
333334
}
335+
canCancel={canCancelPayroll(payrollData)}
334336
payrollData={payrollData}
335337
bankAccount={bankAccount}
336338
employeeDetails={employeeData.showEmployees || []}

src/components/Payroll/PayrollOverview/PayrollOverviewPresentation.tsx

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ interface PayrollOverviewProps {
3434
taxes: Record<string, { employee: number; employer: number }>
3535
isSubmitting?: boolean
3636
isProcessed: boolean
37+
canCancel?: boolean
3738
alerts?: PayrollFlowAlert[]
3839
submissionBlockers?: PayrollSubmissionBlockersType[]
3940
selectedUnblockOptions?: Record<string, string>
@@ -69,6 +70,7 @@ export const PayrollOverviewPresentation = ({
6970
taxes,
7071
isSubmitting = false,
7172
isProcessed,
73+
canCancel = false,
7274
alerts = [],
7375
submissionBlockers = [],
7476
selectedUnblockOptions = {},
@@ -570,15 +572,17 @@ export const PayrollOverviewPresentation = ({
570572
<Button onClick={onPayrollReceipt} variant="secondary" isDisabled={isSubmitting}>
571573
{t('payrollReceiptCta')}
572574
</Button>
573-
<Button
574-
onClick={() => {
575-
setIsCancelDialogOpen(true)
576-
}}
577-
variant="error"
578-
isDisabled={isSubmitting}
579-
>
580-
{t('cancelCta')}
581-
</Button>
575+
{canCancel && (
576+
<Button
577+
onClick={() => {
578+
setIsCancelDialogOpen(true)
579+
}}
580+
variant="error"
581+
isDisabled={isSubmitting}
582+
>
583+
{t('cancelCta')}
584+
</Button>
585+
)}
582586
</>
583587
) : (
584588
<>

src/components/Payroll/helpers.test.ts

Lines changed: 150 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { expect, describe, it } from 'vitest'
1+
import { expect, describe, it, vi } from 'vitest'
22
import {
33
formatEmployeePayRate,
44
getRegularHours,
@@ -10,8 +10,10 @@ import {
1010
getPayrollType,
1111
getAdditionalEarningsCompensations,
1212
getReimbursementCompensation,
13+
canCancelPayroll,
1314
} from './helpers'
1415
import type { Employee } from '@gusto/embedded-api/models/components/employee'
16+
import type { Payroll } from '@gusto/embedded-api/models/components/payroll'
1517
import type {
1618
EmployeeCompensations,
1719
PayrollShowPaidTimeOff,
@@ -1212,4 +1214,151 @@ describe('Payroll helpers', () => {
12121214
expect(result).toBeNull()
12131215
})
12141216
})
1217+
1218+
describe('canCancelPayroll', () => {
1219+
const createMockPayroll = (overrides: Partial<Payroll> = {}): Payroll => ({
1220+
payrollUuid: 'test-payroll-id',
1221+
processed: true,
1222+
payrollDeadline: new Date('2024-01-15T23:00:00Z'),
1223+
payrollStatusMeta: {
1224+
cancellable: true,
1225+
expectedCheckDate: '2024-01-20',
1226+
initialCheckDate: '2024-01-20',
1227+
expectedDebitTime: '2024-01-15T23:00:00Z',
1228+
payrollLate: false,
1229+
initialDebitCutoffTime: '2024-01-15T23:00:00Z',
1230+
},
1231+
...overrides,
1232+
})
1233+
1234+
it('returns false when payrollStatusMeta.cancellable is explicitly false', () => {
1235+
const payroll = createMockPayroll({
1236+
payrollStatusMeta: {
1237+
cancellable: false,
1238+
expectedCheckDate: '2024-01-20',
1239+
initialCheckDate: '2024-01-20',
1240+
expectedDebitTime: '2024-01-15T23:00:00Z',
1241+
payrollLate: false,
1242+
initialDebitCutoffTime: '2024-01-15T23:00:00Z',
1243+
},
1244+
})
1245+
1246+
expect(canCancelPayroll(payroll)).toBe(false)
1247+
})
1248+
1249+
it('returns false when payroll is not processed', () => {
1250+
const payroll = createMockPayroll({ processed: false })
1251+
1252+
expect(canCancelPayroll(payroll)).toBe(false)
1253+
})
1254+
1255+
it('returns false when payrollDeadline is missing', () => {
1256+
const payroll = createMockPayroll({ payrollDeadline: undefined })
1257+
1258+
expect(canCancelPayroll(payroll)).toBe(false)
1259+
})
1260+
1261+
it('returns true when processed and before 4 PM PT on deadline day', () => {
1262+
const deadlineDate = new Date('2024-01-15T23:00:00Z')
1263+
const beforeCutoff = new Date('2024-01-15T22:00:00Z')
1264+
1265+
vi.setSystemTime(beforeCutoff)
1266+
1267+
const payroll = createMockPayroll({
1268+
payrollDeadline: deadlineDate,
1269+
})
1270+
1271+
expect(canCancelPayroll(payroll)).toBe(true)
1272+
1273+
vi.useRealTimers()
1274+
})
1275+
1276+
it('returns false when after 4 PM PT on deadline day', () => {
1277+
const deadlineDate = new Date('2024-01-15T23:00:00Z')
1278+
const afterCutoff = new Date('2024-01-16T00:30:00Z')
1279+
1280+
vi.setSystemTime(afterCutoff)
1281+
1282+
const payroll = createMockPayroll({
1283+
payrollDeadline: deadlineDate,
1284+
})
1285+
1286+
expect(canCancelPayroll(payroll)).toBe(false)
1287+
1288+
vi.useRealTimers()
1289+
})
1290+
1291+
it('returns true when before deadline day', () => {
1292+
const deadlineDate = new Date('2024-01-15T23:00:00Z')
1293+
const beforeDeadlineDay = new Date('2024-01-14T20:00:00Z')
1294+
1295+
vi.setSystemTime(beforeDeadlineDay)
1296+
1297+
const payroll = createMockPayroll({
1298+
payrollDeadline: deadlineDate,
1299+
})
1300+
1301+
expect(canCancelPayroll(payroll)).toBe(true)
1302+
1303+
vi.useRealTimers()
1304+
})
1305+
1306+
it('handles DST transitions correctly - PDT (summer)', () => {
1307+
const summerDeadline = new Date('2024-07-15T23:00:00Z')
1308+
const beforeCutoffPDT = new Date('2024-07-15T22:00:00Z')
1309+
1310+
vi.setSystemTime(beforeCutoffPDT)
1311+
1312+
const payroll = createMockPayroll({
1313+
payrollDeadline: summerDeadline,
1314+
})
1315+
1316+
expect(canCancelPayroll(payroll)).toBe(true)
1317+
1318+
vi.useRealTimers()
1319+
})
1320+
1321+
it('handles DST transitions correctly - PST (winter)', () => {
1322+
const winterDeadline = new Date('2024-12-15T00:00:00Z')
1323+
const beforeCutoffPST = new Date('2024-12-14T23:00:00Z')
1324+
1325+
vi.setSystemTime(beforeCutoffPST)
1326+
1327+
const payroll = createMockPayroll({
1328+
payrollDeadline: winterDeadline,
1329+
})
1330+
1331+
expect(canCancelPayroll(payroll)).toBe(true)
1332+
1333+
vi.useRealTimers()
1334+
})
1335+
1336+
it('returns true when cancellable is undefined but other conditions met', () => {
1337+
const payroll = createMockPayroll({
1338+
payrollStatusMeta: undefined,
1339+
})
1340+
1341+
const beforeDeadline = new Date('2024-01-14T20:00:00Z')
1342+
vi.setSystemTime(beforeDeadline)
1343+
1344+
expect(canCancelPayroll(payroll)).toBe(true)
1345+
1346+
vi.useRealTimers()
1347+
})
1348+
1349+
it('returns false exactly at 4:00 PM PT on deadline day', () => {
1350+
const deadlineDate = new Date('2024-01-15T23:00:00Z')
1351+
const exactlyCutoff = new Date('2024-01-16T00:00:00Z')
1352+
1353+
vi.setSystemTime(exactlyCutoff)
1354+
1355+
const payroll = createMockPayroll({
1356+
payrollDeadline: deadlineDate,
1357+
})
1358+
1359+
expect(canCancelPayroll(payroll)).toBe(false)
1360+
1361+
vi.useRealTimers()
1362+
})
1363+
})
12151364
})

0 commit comments

Comments
 (0)