`;
const btn=document.createElement("button"); btn.className="btn warn"; btn.textContent="Got it";
btn.style.cssText="padding:6px 14px;font-size:13px;white-space:nowrap";
btn.onclick=()=>ackOvertime(g.id);
@@ -1232,10 +1279,50 @@ async function loadOvertime(){
});
}
async function ackOvertime(id){
+ // Guard against an accidental tap: dismissing only hides the reminder, but
+ // make the user confirm and tell them where to find the OT afterwards.
+ const ok=await confirmDialog({title:"Dismiss overtime reminder?",
+ message:"This only hides the reminder. Your overtime is still scheduled and you'll still be paid for it — you can always see it under “My schedule”.",
+ confirmText:"Dismiss",cancelText:"Keep it",kind:"warn"});
+ if(!ok) return;
try{ await api(`/me/overtime/${id}/ack`,{method:"POST"}); }catch(e){ toast(e.message,"err"); return; }
loadOvertime();
}
+// ── My schedule (read-only): upcoming shifts + granted overtime ──
+async function loadMySchedule(){
+ const box=document.getElementById("mysched-list");
+ if(!box) return;
+ box.innerHTML='
Your upcoming shifts. Any overtime you've been granted is highlighted here — and it stays here even after you dismiss the reminder.
+
+
+
+
@@ -165,20 +174,24 @@
Build schedule
-
-
-
-
-
-
-
-
+
+
Overtime
+
Grant overtime for a specific date — managed separately from shifts. Pick the person, the exact date, a start time, and the number of hours.
+
+
+
+
+
+
+
Classified automatically from the date & time — OT, NDOT (night 22:00–06:00), RDOT (rest day), or RDNDOT (rest day + night). Mark the date as a rest day in the schedule for RD rates.
+
+
Upcoming overtime
+
+
+
Copy a week
Duplicate a week's schedule — shifts, rest days, and overtime — to another week.
diff --git a/src/common/overtime.ts b/src/common/overtime.ts
new file mode 100644
index 0000000..92a6214
--- /dev/null
+++ b/src/common/overtime.ts
@@ -0,0 +1,69 @@
+// Overtime classification.
+//
+// A granted OT window is split into the four labor categories from two
+// independent dimensions of the scheduled date/time:
+// • rest day? → rest-day premium
+// • night hours 22:00–06:00 → night differential
+// yielding OT, NDOT (night), RDOT (rest day), and RDNDOT (rest day + night).
+//
+// The night window (22:00–06:00) is evaluated in PHILIPPINE time regardless of
+// the server's timezone — OT times are Manila wall-clock, so night must be too,
+// or the same OT window would classify differently on a UTC vs Manila server.
+
+const HOUR = 3_600_000;
+const DAY = 86_400_000;
+const MANILA_OFFSET = 8 * HOUR; // PH is UTC+8, no DST
+const NIGHT_START = 22; // 10:00 PM
+const NIGHT_END = 6; // 6:00 AM
+
+/** Hours of [a, b) that fall inside the nightly 22:00–06:00 window (Manila). */
+export function nightOverlapHours(a: Date, b: Date): number {
+ // Shift instants into a Manila-local frame, then apply fixed daily windows.
+ const aM = a.getTime() + MANILA_OFFSET;
+ const bM = b.getTime() + MANILA_OFFSET;
+ if (bM <= aM) return 0;
+ let total = 0;
+ let day = Math.floor(aM / DAY) * DAY - DAY; // include the prior evening's window
+ let guard = 0;
+ while (day < bM && guard++ < 400) {
+ const winStart = day + NIGHT_START * HOUR;
+ const winEnd = day + DAY + NIGHT_END * HOUR; // wraps to next-day 06:00
+ const ov = Math.min(bM, winEnd) - Math.max(aM, winStart);
+ if (ov > 0) total += ov / HOUR;
+ day += DAY;
+ }
+ return total;
+}
+
+export interface OtBreakdown {
+ ot: number; // ordinary overtime (working day, daytime)
+ ndot: number; // night-differential OT (working day, night)
+ rdot: number; // rest-day OT (rest day, daytime)
+ rdndot: number; // rest-day night-differential OT (rest day, night)
+}
+
+export const EMPTY_OT: OtBreakdown = { ot: 0, ndot: 0, rdot: 0, rdndot: 0 };
+
+/** Split a granted OT window into the four categories, in hours. */
+export function classifyOvertime(start: Date | null, end: Date | null, isRestDay: boolean): OtBreakdown {
+ if (!start || !end || end.getTime() <= start.getTime()) return { ...EMPTY_OT };
+ const total = (end.getTime() - start.getTime()) / HOUR;
+ const night = Math.min(total, nightOverlapHours(start, end));
+ const day = Math.max(0, total - night);
+ return isRestDay
+ ? { ...EMPTY_OT, rdot: day, rdndot: night }
+ : { ...EMPTY_OT, ot: day, ndot: night };
+}
+
+const CODES: [keyof OtBreakdown, string][] = [
+ ['ot', 'OT'], ['ndot', 'NDOT'], ['rdot', 'RDOT'], ['rdndot', 'RDNDOT'],
+];
+const r1 = (n: number) => Math.round(n * 10) / 10;
+
+/** Compact human label: single category → "RDOT"; mixed → "OT 2h · NDOT 1h". */
+export function otClassLabel(b: OtBreakdown): string {
+ const parts = CODES.filter(([k]) => b[k] > 0.0001);
+ if (!parts.length) return '';
+ if (parts.length === 1) return parts[0][1];
+ return parts.map(([k, code]) => `${code} ${r1(b[k])}h`).join(' · ');
+}
diff --git a/src/payroll/payroll.controller.ts b/src/payroll/payroll.controller.ts
index 296de46..265db1e 100644
--- a/src/payroll/payroll.controller.ts
+++ b/src/payroll/payroll.controller.ts
@@ -25,13 +25,12 @@ import {
PayslipStatus,
PayslipLineCategory,
} from '@prisma/client';
+import { classifyOvertime, nightOverlapHours } from '../common/overtime';
interface AuthedReq {
user: { userId: string; employeeId: string; roles: Role[] };
}
-const NIGHT_START = 22; // 10:00 PM
-const NIGHT_END = 6; // 6:00 AM
const HOUR = 3_600_000;
interface EarnRow {
@@ -41,11 +40,19 @@ interface EarnRow {
teamId: string | null;
rate: number;
regularHours: number;
- overtimeHours: number;
- nightHours: number;
+ overtimeHours: number; // OT + NDOT (non-rest overtime)
+ otHours: number;
+ ndotHours: number;
+ rdOvertimeHours: number; // RDOT + RDNDOT (rest-day overtime)
+ rdotHours: number;
+ rdndotHours: number;
+ nightHours: number; // all worked night hours (regular + OT)
regularPay: number;
- overtimePay: number;
- nightPay: number;
+ otPay: number;
+ ndotPay: number;
+ rdotPay: number;
+ rdndotPay: number;
+ nightPay: number; // differential on REGULAR (non-OT) night hours only
gross: number;
}
@@ -127,6 +134,7 @@ export class PayrollController {
): Promise {
const policy = await this.prisma.shiftPolicy.findFirst({ where: { orgId }, orderBy: { createdAt: 'asc' } });
const otMult = policy?.otMultiplier ?? 1.5;
+ const rdOtMult = policy?.rdOtMultiplier ?? 1.69;
const nightPct = policy?.nightDiffPercent ?? 10;
const employees = await this.prisma.employee.findMany({
@@ -152,13 +160,18 @@ export class PayrollController {
otStart: { not: null, lt: periodEndExclusive },
otEnd: { gt: periodStart },
},
- select: { employeeId: true, otStart: true, otEnd: true },
+ select: { employeeId: true, otStart: true, otEnd: true, isRestDay: true },
});
+ // Two window lists per employee: OT on a rest day (paid at the RD-OT
+ // premium) vs OT on a working day (regular OT multiplier). A day is either
+ // a rest day or a working day, so the two lists never overlap.
const otByEmp = new Map();
+ const rdOtByEmp = new Map();
for (const w of otRows) {
if (!w.otStart || !w.otEnd) continue;
- if (!otByEmp.has(w.employeeId)) otByEmp.set(w.employeeId, []);
- otByEmp.get(w.employeeId)!.push({ start: w.otStart.getTime(), end: w.otEnd.getTime() });
+ const bucket = w.isRestDay ? rdOtByEmp : otByEmp;
+ if (!bucket.has(w.employeeId)) bucket.set(w.employeeId, []);
+ bucket.get(w.employeeId)!.push({ start: w.otStart.getTime(), end: w.otEnd.getTime() });
}
const now = new Date();
@@ -178,29 +191,51 @@ export class PayrollController {
byEmp.get(eid)!.push({ startedAt: segStart, endedAt: segEnd });
}
+ // Intersect a segment with a list of OT windows → the overlapping
+ // sub-intervals, so each can be classified into day/night hours.
+ const intersect = (seg: { startedAt: Date; endedAt: Date }, wins: { start: number; end: number }[]) => {
+ const out: { start: number; end: number }[] = [];
+ for (const w of wins) {
+ const start = Math.max(seg.startedAt.getTime(), w.start);
+ const end = Math.min(seg.endedAt.getTime(), w.end);
+ if (end > start) out.push({ start, end });
+ }
+ return out;
+ };
+
return employees.map((e) => {
const segs = byEmp.get(e.id) ?? [];
const wins = otByEmp.get(e.id) ?? [];
- let regular = 0;
- let overtime = 0;
- let nightHours = 0;
+ const rdWins = rdOtByEmp.get(e.id) ?? [];
+ let regular = 0, ot = 0, ndot = 0, rdot = 0, rdndot = 0, nightAll = 0;
for (const seg of segs) {
const segMs = seg.endedAt.getTime() - seg.startedAt.getTime();
- // Worked time inside an authorized OT window is overtime; the rest regular.
let otMs = 0;
- for (const w of wins) {
- const ov = Math.min(seg.endedAt.getTime(), w.end) - Math.max(seg.startedAt.getTime(), w.start);
- if (ov > 0) otMs += ov;
+ // Rest-day OT windows take precedence; the two sets are disjoint anyway.
+ for (const iv of intersect(seg, rdWins)) {
+ const c = classifyOvertime(new Date(iv.start), new Date(iv.end), true);
+ rdot += c.rdot; rdndot += c.rdndot; otMs += iv.end - iv.start;
+ }
+ for (const iv of intersect(seg, wins)) {
+ const c = classifyOvertime(new Date(iv.start), new Date(iv.end), false);
+ ot += c.ot; ndot += c.ndot; otMs += iv.end - iv.start;
}
otMs = Math.min(otMs, segMs); // guard against overlapping windows
- overtime += otMs / HOUR;
regular += (segMs - otMs) / HOUR;
- nightHours += this.nightHours(seg.startedAt, seg.endedAt);
+ nightAll += nightOverlapHours(seg.startedAt, seg.endedAt);
}
+ // Night differential is a +% add-on. For OT night hours it's folded into
+ // the NDOT/RDNDOT rates, so the standalone night line covers only the
+ // REGULAR (non-OT) night hours.
+ const regularNight = Math.max(0, nightAll - ndot - rdndot);
const rate = e.hourlyRate ?? 0;
+ const nd = nightPct / 100;
const regularPay = regular * rate;
- const overtimePay = overtime * rate * otMult;
- const nightPay = nightHours * rate * (nightPct / 100);
+ const otPay = ot * rate * otMult;
+ const ndotPay = ndot * rate * (otMult + nd);
+ const rdotPay = rdot * rate * rdOtMult;
+ const rdndotPay = rdndot * rate * (rdOtMult + nd);
+ const nightPay = regularNight * rate * nd;
return {
employeeId: e.id,
employeeCode: e.employeeCode,
@@ -208,12 +243,20 @@ export class PayrollController {
teamId: e.teamId,
rate,
regularHours: round(regular),
- overtimeHours: round(overtime),
- nightHours: round(nightHours),
+ overtimeHours: round(ot + ndot),
+ otHours: round(ot),
+ ndotHours: round(ndot),
+ rdOvertimeHours: round(rdot + rdndot),
+ rdotHours: round(rdot),
+ rdndotHours: round(rdndot),
+ nightHours: round(nightAll),
regularPay: round(regularPay),
- overtimePay: round(overtimePay),
+ otPay: round(otPay),
+ ndotPay: round(ndotPay),
+ rdotPay: round(rdotPay),
+ rdndotPay: round(rdndotPay),
nightPay: round(nightPay),
- gross: round(regularPay + overtimePay + nightPay),
+ gross: round(regularPay + otPay + ndotPay + rdotPay + rdndotPay + nightPay),
};
});
}
@@ -231,6 +274,9 @@ export class PayrollController {
rate: r.rate,
regularHours: r.regularHours,
overtimeHours: r.overtimeHours,
+ ndotHours: r.ndotHours,
+ rdOvertimeHours: r.rdOvertimeHours,
+ rdndotHours: r.rdndotHours,
nightHours: r.nightHours,
gross: r.gross,
}));
@@ -424,7 +470,10 @@ export class PayrollController {
const lines: { category: PayslipLineCategory; label: string; amount: number; origin: string; componentId?: string }[] = [
{ category: 'EARNING', label: 'Regular pay', amount: e.regularPay, origin: 'AUTO' },
];
- if (e.overtimePay > 0) lines.push({ category: 'EARNING', label: 'Overtime pay', amount: e.overtimePay, origin: 'AUTO' });
+ if (e.otPay > 0) lines.push({ category: 'EARNING', label: `Overtime pay (OT, ${e.otHours}h)`, amount: e.otPay, origin: 'AUTO' });
+ if (e.ndotPay > 0) lines.push({ category: 'EARNING', label: `Night-diff OT pay (NDOT, ${e.ndotHours}h)`, amount: e.ndotPay, origin: 'AUTO' });
+ if (e.rdotPay > 0) lines.push({ category: 'EARNING', label: `Rest day OT pay (RDOT, ${e.rdotHours}h)`, amount: e.rdotPay, origin: 'AUTO' });
+ if (e.rdndotPay > 0) lines.push({ category: 'EARNING', label: `Rest day night-diff OT pay (RDNDOT, ${e.rdndotHours}h)`, amount: e.rdndotPay, origin: 'AUTO' });
if (e.nightPay > 0) lines.push({ category: 'EARNING', label: 'Night differential', amount: e.nightPay, origin: 'AUTO' });
// ALLOWANCE / DEDUCTION lines from applicable components.
@@ -641,21 +690,4 @@ export class PayrollController {
});
}
- /** Hours of [a,b] that fall inside the nightly 22:00–06:00 window (local). */
- private nightHours(a: Date, b: Date): number {
- let total = 0;
- const day = new Date(a);
- day.setHours(0, 0, 0, 0);
- day.setDate(day.getDate() - 1); // a night window can start the previous evening
- let guard = 0;
- while (day.getTime() < b.getTime() && guard++ < 400) {
- const winStart = new Date(day); winStart.setHours(NIGHT_START, 0, 0, 0);
- const winEnd = new Date(day); winEnd.setHours(NIGHT_END, 0, 0, 0);
- winEnd.setDate(winEnd.getDate() + 1); // wraps past midnight
- const ov = Math.min(b.getTime(), winEnd.getTime()) - Math.max(a.getTime(), winStart.getTime());
- if (ov > 0) total += ov / HOUR;
- day.setDate(day.getDate() + 1);
- }
- return total;
- }
}
diff --git a/src/profile/profile.controller.ts b/src/profile/profile.controller.ts
index d8b11d7..94b5797 100644
--- a/src/profile/profile.controller.ts
+++ b/src/profile/profile.controller.ts
@@ -12,6 +12,7 @@ import {
import { PrismaService } from '../prisma/prisma.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { PayslipStatus } from '@prisma/client';
+import { classifyOvertime, otClassLabel } from '../common/overtime';
interface AuthedReq {
user: { userId: string; employeeId: string; roles: string[] };
@@ -105,9 +106,12 @@ export class ProfileController {
workDate: { gte: this.utcMidnight(since) },
},
orderBy: { workDate: 'asc' },
- select: { id: true, workDate: true, otStart: true, otEnd: true, isNightShift: true },
+ select: { id: true, workDate: true, otStart: true, otEnd: true, isNightShift: true, isRestDay: true },
});
- return rows;
+ return rows.map((r) => ({
+ ...r,
+ classification: otClassLabel(classifyOvertime(r.otStart, r.otEnd, r.isRestDay)),
+ }));
}
@Post('overtime/:scheduleId/ack')
@@ -122,6 +126,51 @@ export class ProfileController {
return { ok: true };
}
+ // ───────────────────────── MY SCHEDULE ───────────────────────────────
+ // Read-only view of the signed-in employee's own upcoming shifts, so they
+ // can always look up their hours and overtime window — even after they've
+ // dismissed the overtime banner. Self-scoped; no role check.
+
+ @Get('schedule')
+ async mySchedule(@Req() req: AuthedReq) {
+ if (!req.user.employeeId) return [];
+ const since = new Date();
+ since.setDate(since.getDate() - 1); // include yesterday so today's shift still shows
+ const rows = await this.prisma.schedule.findMany({
+ where: {
+ employeeId: req.user.employeeId,
+ workDate: { gte: this.utcMidnight(since) },
+ },
+ orderBy: { workDate: 'asc' },
+ take: 60, // a reasonable look-ahead horizon
+ select: {
+ id: true,
+ workDate: true,
+ isRestDay: true,
+ scheduledStart: true,
+ scheduledEnd: true,
+ otStart: true,
+ otEnd: true,
+ isNightShift: true,
+ otAcknowledgedAt: true,
+ },
+ });
+ // Surface acknowledgement as a boolean; keep the raw timestamp internal.
+ return rows.map((r) => ({
+ id: r.id,
+ workDate: r.workDate,
+ isRestDay: r.isRestDay,
+ scheduledStart: r.scheduledStart,
+ scheduledEnd: r.scheduledEnd,
+ otStart: r.otStart,
+ otEnd: r.otEnd,
+ isNightShift: r.isNightShift,
+ hasOvertime: !!(r.otStart && r.otEnd),
+ otClass: otClassLabel(classifyOvertime(r.otStart, r.otEnd, r.isRestDay)),
+ otAcknowledged: !!r.otAcknowledgedAt,
+ }));
+ }
+
private utcMidnight(d: Date) {
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
}
diff --git a/src/scheduling/schedules.controller.ts b/src/scheduling/schedules.controller.ts
index a4f1dbd..429c3a4 100644
--- a/src/scheduling/schedules.controller.ts
+++ b/src/scheduling/schedules.controller.ts
@@ -18,6 +18,7 @@ import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator';
import { Role, LeaveStatus } from '@prisma/client';
import { manilaDateTime } from '../common/timezone';
+import { classifyOvertime, otClassLabel } from '../common/overtime';
interface AuthedReq {
user: { userId: string; employeeId: string; roles: Role[] };
@@ -33,8 +34,6 @@ interface ApplyBody {
endTime?: string; // HH:mm
isNightShift?: boolean;
restDays?: string[]; // YYYY-MM-DD dates within the range
- otStartTime?: string; // HH:mm — overtime window (individual only)
- otEndTime?: string;
force?: boolean; // bypass compliance warnings after the WFM confirms
}
@@ -69,24 +68,23 @@ export class SchedulesController {
return emp.orgId;
}
- /** Turn pending schedule rows into upsert operations (one per employee/day). */
+ /** Turn pending schedule rows into upsert operations (one per employee/day).
+ * Overtime is managed separately (see the OVERTIME GRANTS endpoints), so a
+ * shift apply/edit NEVER touches the OT window on an existing row — it only
+ * writes shift/rest-day fields. On create it seeds OT from the row (used by
+ * copy-week, which deliberately duplicates OT into fresh days). */
private upsertOps(rows: ScheduleRow[]) {
return rows.map((r) => {
- const data = {
+ const shiftData = {
isRestDay: r.isRestDay,
scheduledStart: r.scheduledStart,
scheduledEnd: r.scheduledEnd,
- otStart: r.otStart,
- otEnd: r.otEnd,
isNightShift: r.isNightShift,
- // Re-applying clears any prior acknowledgment so a fresh OT grant
- // re-surfaces the "you've been given overtime" banner.
- otAcknowledgedAt: null,
};
return this.prisma.schedule.upsert({
where: { employeeId_workDate: { employeeId: r.employeeId, workDate: r.workDate } },
- create: { employeeId: r.employeeId, workDate: r.workDate, ...data },
- update: data,
+ create: { employeeId: r.employeeId, workDate: r.workDate, ...shiftData, otStart: r.otStart, otEnd: r.otEnd },
+ update: shiftData, // leave OT (and its acknowledgment) untouched
});
});
}
@@ -345,11 +343,6 @@ export class SchedulesController {
if (hasWorkingDay && (!startTime || !endTime))
throw new BadRequestException('startTime and endTime are required for working days.');
- // Overtime is an individual-only setting. Ignore it for team applies.
- const wantsOt = !!employeeId && !!body.otStartTime && !!body.otEndTime;
- if (employeeId && (body.otStartTime || body.otEndTime) && !wantsOt)
- throw new BadRequestException('Provide both an overtime start and end time, or neither.');
-
const orgId = await this.orgIdFor(req.user.employeeId);
// Resolve target employees (in-org).
@@ -382,8 +375,11 @@ export class SchedulesController {
for (const eid of employeeIds) {
for (const ds of days) {
const workDate = new Date(ds); // UTC midnight, matches list queries
+ // Overtime is managed separately (OVERTIME GRANTS endpoints), so the
+ // shift builder never sets it — otStart/otEnd stay null here and any
+ // existing OT grant on this day is preserved by upsertOps().
if (restSet.has(ds)) {
- newRows.push({ employeeId: eid, workDate, isRestDay: true, scheduledStart: null, scheduledEnd: null, otStart: null, otEnd: null, isNightShift: false });
+ newRows.push({ employeeId: eid, workDate, isRestDay: true, scheduledStart: null, scheduledEnd: null, otStart: null, otEnd: null, isNightShift: !!body.isNightShift });
} else {
// WFM-entered times are Philippine wall-clock. Parse them with the
// explicit Manila offset — a zone-less string would be read in the
@@ -392,21 +388,9 @@ export class SchedulesController {
let end = manilaDateTime(ds, endTime!);
if (isNaN(start.getTime()) || isNaN(end.getTime()))
throw new BadRequestException('Invalid start or end time.');
- if (end <= start) end = new Date(end.getTime() + 86_400_000); // overnight
-
- // Optional overtime window (individual only). Null when not set, so
- // re-applying without OT clears any previous OT on that day.
- let otStart: Date | null = null;
- let otEnd: Date | null = null;
- if (wantsOt) {
- otStart = manilaDateTime(ds, body.otStartTime!);
- otEnd = manilaDateTime(ds, body.otEndTime!);
- if (isNaN(otStart.getTime()) || isNaN(otEnd.getTime()))
- throw new BadRequestException('Invalid overtime time.');
- if (otEnd <= otStart) otEnd = new Date(otEnd.getTime() + 86_400_000);
- }
-
- newRows.push({ employeeId: eid, workDate, isRestDay: false, scheduledStart: start, scheduledEnd: end, otStart, otEnd, isNightShift: !!body.isNightShift });
+ if (end <= start) end = new Date(end.getTime() + 86_400_000); // crosses midnight
+
+ newRows.push({ employeeId: eid, workDate, isRestDay: false, scheduledStart: start, scheduledEnd: end, otStart: null, otEnd: null, isNightShift: !!body.isNightShift });
}
}
}
@@ -505,4 +489,114 @@ export class SchedulesController {
await this.prisma.schedule.delete({ where: { id } });
return { ok: true };
}
+
+ // ───────────────────────── OVERTIME GRANTS ───────────────────────────
+ // Overtime is managed on its own: grant it for a specific date as a start
+ // time + a number of hours (a duration), independent of the shift builder.
+ // It rides on that day's schedule row (creating one if the day has none).
+
+ private otWorkDate(date: string) {
+ const d = new Date(`${date}T00:00:00.000Z`); // UTC midnight, matches schedule rows
+ if (isNaN(d.getTime())) throw new BadRequestException('Invalid date.');
+ return d;
+ }
+
+ private otHoursOf(otStart: Date | null, otEnd: Date | null) {
+ if (!otStart || !otEnd) return 0;
+ return Math.round((otEnd.getTime() - otStart.getTime()) / 360_000) / 10; // 1-decimal hours
+ }
+
+ /** Grant overtime for one employee on one date: start time + N hours. */
+ @Post('overtime')
+ async grantOvertime(
+ @Req() req: AuthedReq,
+ @Body() body: { employeeId: string; date: string; startTime: string; hours: number },
+ ) {
+ const { employeeId, date, startTime } = body;
+ const hours = Number(body.hours);
+ if (!employeeId || !date || !startTime)
+ throw new BadRequestException('employeeId, date, and startTime are required.');
+ if (!(hours > 0) || hours > 24)
+ throw new BadRequestException('hours must be between 0 and 24.');
+
+ const orgId = await this.orgIdFor(req.user.employeeId);
+ const emp = await this.prisma.employee.findFirst({ where: { id: employeeId, orgId }, select: { id: true } });
+ if (!emp) throw new NotFoundException('Employee not found.');
+
+ // WFM enters Philippine wall-clock; anchor with the Manila offset so the
+ // window is the same absolute instant regardless of server timezone.
+ const otStart = manilaDateTime(date, startTime);
+ if (isNaN(otStart.getTime())) throw new BadRequestException('Invalid start time.');
+ const otEnd = new Date(otStart.getTime() + Math.round(hours * 3_600_000));
+
+ const workDate = this.otWorkDate(date);
+ const existing = await this.prisma.schedule.findUnique({
+ where: { employeeId_workDate: { employeeId, workDate } },
+ });
+ // Rest-day status comes from the SCHEDULE, not from "no shift": OT is
+ // rest-day OT only when the date is actually the employee's scheduled rest
+ // day. A grant on a bare date is ordinary overtime. The final class (OT /
+ // NDOT / RDOT / RDNDOT) is then derived from that plus the time of day.
+ const isRestDay = existing ? existing.isRestDay : false;
+ const saved = await this.prisma.schedule.upsert({
+ where: { employeeId_workDate: { employeeId, workDate } },
+ create: { employeeId, workDate, isRestDay, scheduledStart: null, scheduledEnd: null, otStart, otEnd, otAcknowledgedAt: null },
+ update: { otStart, otEnd, otAcknowledgedAt: null }, // re-surface the banner
+ });
+ const classification = otClassLabel(classifyOvertime(otStart, otEnd, saved.isRestDay));
+ this.events.toEmployee(employeeId, { type: 'OVERTIME_GRANTED', workDate, otStart, otEnd });
+ return { ok: true, id: saved.id, otStart, otEnd, hours, isRestDay: saved.isRestDay, classification };
+ }
+
+ /** Upcoming overtime grants for one employee (for the management list). */
+ @Get('overtime')
+ async listOvertime(@Req() req: AuthedReq, @Query('employeeId') employeeId?: string) {
+ if (!employeeId) throw new BadRequestException('employeeId is required.');
+ const orgId = await this.orgIdFor(req.user.employeeId);
+ const emp = await this.prisma.employee.findFirst({ where: { id: employeeId, orgId }, select: { id: true } });
+ if (!emp) throw new NotFoundException('Employee not found.');
+ const since = new Date();
+ since.setDate(since.getDate() - 1); // include yesterday so a just-past grant still lists
+ const rows = await this.prisma.schedule.findMany({
+ where: {
+ employeeId,
+ otStart: { not: null },
+ workDate: { gte: new Date(Date.UTC(since.getUTCFullYear(), since.getUTCMonth(), since.getUTCDate())) },
+ },
+ orderBy: { workDate: 'asc' },
+ select: { id: true, workDate: true, otStart: true, otEnd: true, isRestDay: true, otAcknowledgedAt: true },
+ });
+ return rows.map((r) => ({
+ id: r.id,
+ workDate: r.workDate,
+ otStart: r.otStart,
+ otEnd: r.otEnd,
+ hours: this.otHoursOf(r.otStart, r.otEnd),
+ isRestDay: r.isRestDay,
+ classification: otClassLabel(classifyOvertime(r.otStart, r.otEnd, r.isRestDay)),
+ acknowledged: !!r.otAcknowledgedAt,
+ }));
+ }
+
+ /** Cancel an overtime grant. Removes the row if it existed only for the OT
+ * (a rest day with no shift); otherwise just clears the OT window. */
+ @Delete('overtime/:id')
+ async clearOvertime(@Req() req: AuthedReq, @Param('id') id: string) {
+ const orgId = await this.orgIdFor(req.user.employeeId);
+ const row = await this.prisma.schedule.findUnique({
+ where: { id },
+ include: { employee: { select: { orgId: true } } },
+ });
+ if (!row || row.employee.orgId !== orgId) throw new NotFoundException('Overtime grant not found.');
+ if (!row.isRestDay && !row.scheduledStart && !row.scheduledEnd) {
+ // Row existed only to hold this OT (no shift, not a scheduled rest day).
+ await this.prisma.schedule.delete({ where: { id } });
+ } else {
+ await this.prisma.schedule.update({
+ where: { id },
+ data: { otStart: null, otEnd: null, otAcknowledgedAt: null },
+ });
+ }
+ return { ok: true };
+ }
}
diff --git a/src/time-tracking/time-tracking.service.ts b/src/time-tracking/time-tracking.service.ts
index add7552..8611ab4 100644
--- a/src/time-tracking/time-tracking.service.ts
+++ b/src/time-tracking/time-tracking.service.ts
@@ -158,9 +158,30 @@ export class TimeTrackingService {
const sched = await this.prisma.schedule.findFirst({
where: { employeeId, workDate: scheduleDate },
});
+
+ // The allowed clock-in window is the assigned shift, WIDENED to include any
+ // granted overtime window — so an OT'd employee can log in past their normal
+ // end (or before their normal start, if OT sits ahead of the shift). OT is a
+ // single contiguous window per day, so the min/max envelope is the union.
+ // The grant itself (otStart/otEnd being set) is the authorisation; the
+ // banner acknowledgement is a UX dismissal, not a precondition for working.
+ const hasOt = !!(sched?.otStart && sched?.otEnd);
+ const windowStart = !sched
+ ? null
+ : hasOt && sched.scheduledStart
+ ? new Date(Math.min(sched.scheduledStart.getTime(), sched.otStart!.getTime()))
+ : sched.scheduledStart ?? sched.otStart;
+ const windowEnd = !sched
+ ? null
+ : hasOt && sched.scheduledEnd
+ ? new Date(Math.max(sched.scheduledEnd.getTime(), sched.otEnd!.getTime()))
+ : sched.scheduledEnd ?? sched.otEnd;
+
if (sched) {
- // A scheduled rest day means no shift at all today.
- if (sched.isRestDay) {
+ // A scheduled rest day means no shift at all today — unless WFM granted
+ // overtime for the rest day (an explicit OT call on a day off), which
+ // opens the OT window enforced below.
+ if (sched.isRestDay && !hasOt) {
await this.recordViolation(
employeeId,
ViolationType.OUT_OF_SCHEDULE_LOGIN,
@@ -170,38 +191,39 @@ export class TimeTrackingService {
'Today is a scheduled rest day — you cannot clock in.',
);
}
- // Before the scheduled start time → block (the explicit requirement).
- if (sched.scheduledStart && new Date() < sched.scheduledStart) {
- const startLabel = manilaTimeLabel(sched.scheduledStart);
+ // Before the window opens → block (the explicit requirement).
+ if (windowStart && new Date() < windowStart) {
+ const startLabel = manilaTimeLabel(windowStart);
await this.recordViolation(
employeeId,
ViolationType.OUT_OF_SCHEDULE_LOGIN,
- `Attempted login before scheduled start ${sched.scheduledStart.toISOString()}`,
+ `Attempted login before window start ${windowStart.toISOString()}`,
);
throw new ForbiddenException(
- `Too early to clock in. Your shift starts at ${startLabel}.`,
+ `Too early to clock in. Your ${hasOt ? 'shift window (incl. overtime) opens' : 'shift starts'} at ${startLabel}.`,
);
}
- // After the scheduled end time → the shift is over for today.
- if (sched.scheduledEnd && new Date() > sched.scheduledEnd) {
- const endLabel = manilaTimeLabel(sched.scheduledEnd);
+ // After the window closes → the shift (plus any granted OT) is over.
+ if (windowEnd && new Date() > windowEnd) {
+ const endLabel = manilaTimeLabel(windowEnd);
await this.recordViolation(
employeeId,
ViolationType.OUT_OF_SCHEDULE_LOGIN,
- `Attempted login after scheduled end ${sched.scheduledEnd.toISOString()}`,
+ `Attempted login after window end ${windowEnd.toISOString()}`,
);
throw new ForbiddenException(
- `Your scheduled shift has already ended for today (ended at ${endLabel}).`,
+ `Your ${hasOt ? 'shift (including overtime)' : 'scheduled shift'} has already ended for today (ended at ${endLabel}).`,
);
}
}
const now = new Date();
- // Follow the assigned schedule's end time when one exists; otherwise fall
- // back to the org's default shift-length policy from the moment of clock-in.
+ // Auto-expiry follows the assigned window END — the scheduled end, widened
+ // by any granted OT — when a schedule exists; otherwise fall back to the
+ // org's default shift-length policy from the moment of clock-in.
const shiftEndsAt =
- sched && sched.scheduledEnd
- ? sched.scheduledEnd
+ sched && windowEnd
+ ? windowEnd
: new Date(now.getTime() + policy.shiftHours * 3600_000);
let entry;
diff --git a/test/clockin-schedule-ot.spec.ts b/test/clockin-schedule-ot.spec.ts
new file mode 100644
index 0000000..f9a3c34
--- /dev/null
+++ b/test/clockin-schedule-ot.spec.ts
@@ -0,0 +1,117 @@
+import { PrismaClient } from '@prisma/client';
+import { TimeTrackingService } from '../src/time-tracking/time-tracking.service';
+import { manilaWorkDate } from '../src/common/timezone';
+import { cleanupOrg } from './helpers';
+
+const prisma = new PrismaClient();
+
+// clockIn() only calls scheduleShiftExpiry() on the enforcement service and the
+// three publisher methods — stubbing both keeps this test free of Redis and the
+// WebSocket gateway while exercising the real DB-backed schedule/OT gate.
+const enforcement = { scheduleShiftExpiry: async () => {} } as any;
+const events = { toEmployee() {}, toApprovers() {}, toActivity() {} } as any;
+const svc = new TimeTrackingService(prisma as any, enforcement, events);
+
+const HOUR = 3600_000;
+const at = (offsetMs: number) => new Date(Date.now() + offsetMs);
+
+// ── Integration against the live DB: schedule-gated clock-in, widened by OT ──
+describe('clockIn — schedule-gated login with overtime', () => {
+ let orgId: string;
+ let empId: string;
+
+ beforeAll(async () => {
+ const org = await prisma.organization.create({
+ data: { name: 'TEST-OT-' + Date.now(), timezone: 'Asia/Manila' },
+ });
+ orgId = org.id;
+ // getPolicy() falls back to the org's default policy (no team set).
+ await prisma.shiftPolicy.create({ data: { orgId, name: 'T', shiftHours: 8 } });
+ const emp = await prisma.employee.create({
+ data: { orgId, employeeCode: 'OT-1', fullName: 'OT Tester', hireDate: new Date(), hourlyRate: 100 },
+ });
+ empId = emp.id;
+ });
+
+ // Each test starts clean: no open/closed entries, schedules, or violations.
+ afterEach(async () => {
+ const tes = await prisma.timeEntry.findMany({ where: { employeeId: empId }, select: { id: true } });
+ const teIds = tes.map((t) => t.id);
+ await prisma.activitySession.deleteMany({ where: { timeEntryId: { in: teIds } } });
+ await prisma.timeEntry.deleteMany({ where: { employeeId: empId } });
+ await prisma.schedule.deleteMany({ where: { employeeId: empId } });
+ await prisma.complianceViolation.deleteMany({ where: { employeeId: empId } });
+ });
+
+ afterAll(async () => {
+ await cleanupOrg(prisma, orgId);
+ await prisma.$disconnect();
+ });
+
+ const setSchedule = (
+ data: Partial<{
+ scheduledStart: Date | null;
+ scheduledEnd: Date | null;
+ otStart: Date | null;
+ otEnd: Date | null;
+ isRestDay: boolean;
+ }>,
+ ) =>
+ prisma.schedule.create({
+ data: {
+ employeeId: empId,
+ workDate: manilaWorkDate(),
+ scheduledStart: null,
+ scheduledEnd: null,
+ otStart: null,
+ otEnd: null,
+ isRestDay: false,
+ isNightShift: false,
+ ...data,
+ },
+ });
+
+ const clockIn = () => svc.clockIn(empId, 'Productivity', { userId: 'test' });
+ const violationType = async () =>
+ (await prisma.complianceViolation.findFirst({ where: { employeeId: empId } }))?.type;
+
+ it('blocks a clock-in before the scheduled start (and logs a violation)', async () => {
+ await setSchedule({ scheduledStart: at(2 * HOUR), scheduledEnd: at(10 * HOUR) });
+ await expect(clockIn()).rejects.toThrow(/Too early/);
+ expect(await violationType()).toBe('OUT_OF_SCHEDULE_LOGIN');
+ });
+
+ it('blocks a clock-in after the scheduled end when no OT is granted', async () => {
+ await setSchedule({ scheduledStart: at(-10 * HOUR), scheduledEnd: at(-2 * HOUR) });
+ await expect(clockIn()).rejects.toThrow(/already ended/);
+ expect(await violationType()).toBe('OUT_OF_SCHEDULE_LOGIN');
+ });
+
+ it('ALLOWS a clock-in past the scheduled end when OT covers now, and auto-expiry follows the OT end', async () => {
+ const otEnd = at(2 * HOUR);
+ await setSchedule({
+ scheduledStart: at(-10 * HOUR),
+ scheduledEnd: at(-2 * HOUR),
+ otStart: at(-2 * HOUR),
+ otEnd,
+ });
+ const state = await clockIn();
+ expect(state.onShift).toBe(true);
+ const te = await prisma.timeEntry.findFirst({ where: { employeeId: empId, status: 'OPEN' } });
+ expect(te).toBeTruthy();
+ // shiftEndsAt is the WIDENED window end (OT end), not the scheduled end.
+ expect(Math.abs(te!.shiftEndsAt.getTime() - otEnd.getTime())).toBeLessThan(1000);
+ // A legitimate OT login records no violation.
+ expect(await violationType()).toBeUndefined();
+ });
+
+ it('blocks a rest-day clock-in, but ALLOWS it when OT is granted for the rest day', async () => {
+ await setSchedule({ isRestDay: true });
+ await expect(clockIn()).rejects.toThrow(/rest day/);
+
+ await prisma.schedule.deleteMany({ where: { employeeId: empId } });
+ await setSchedule({ isRestDay: true, otStart: at(-1 * HOUR), otEnd: at(1 * HOUR) });
+ const state = await clockIn();
+ expect(state.onShift).toBe(true);
+ });
+});
diff --git a/test/overtime-classify.spec.ts b/test/overtime-classify.spec.ts
new file mode 100644
index 0000000..9da6a53
--- /dev/null
+++ b/test/overtime-classify.spec.ts
@@ -0,0 +1,45 @@
+import { classifyOvertime, otClassLabel } from '../src/common/overtime';
+
+// Pure unit tests (no DB). Dates are anchored to Manila time (the frame the
+// night window uses) so results are identical on a UTC or Manila runner.
+const BASE = Date.parse('2026-08-15T00:00:00+08:00');
+const at = (h: number, m = 0) => new Date(BASE + h * 3_600_000 + m * 60_000);
+
+describe('classifyOvertime — OT / NDOT / RDOT / RDNDOT', () => {
+ it('working day, daytime → OT', () => {
+ const c = classifyOvertime(at(10), at(12), false);
+ expect(c).toEqual({ ot: 2, ndot: 0, rdot: 0, rdndot: 0 });
+ expect(otClassLabel(c)).toBe('OT');
+ });
+
+ it('working day, night hours → NDOT', () => {
+ const c = classifyOvertime(at(22), at(24), false); // 22:00–00:00
+ expect(c.ndot).toBeCloseTo(2, 5);
+ expect(c.ot).toBe(0);
+ expect(otClassLabel(c)).toBe('NDOT');
+ });
+
+ it('rest day, daytime → RDOT', () => {
+ const c = classifyOvertime(at(10), at(12), true);
+ expect(c).toEqual({ ot: 0, ndot: 0, rdot: 2, rdndot: 0 });
+ expect(otClassLabel(c)).toBe('RDOT');
+ });
+
+ it('rest day, night hours → RDNDOT', () => {
+ const c = classifyOvertime(at(23), at(25), true); // 23:00–01:00 next day
+ expect(c.rdndot).toBeCloseTo(2, 5);
+ expect(otClassLabel(c)).toBe('RDNDOT');
+ });
+
+ it('window straddling 22:00 splits into a mix', () => {
+ const c = classifyOvertime(at(20), at(23), false); // 2h day + 1h night
+ expect(c.ot).toBeCloseTo(2, 5);
+ expect(c.ndot).toBeCloseTo(1, 5);
+ expect(otClassLabel(c)).toBe('OT 2h · NDOT 1h');
+ });
+
+ it('empty / inverted windows classify as nothing', () => {
+ expect(otClassLabel(classifyOvertime(null, null, true))).toBe('');
+ expect(otClassLabel(classifyOvertime(at(12), at(10), false))).toBe('');
+ });
+});
diff --git a/test/overtime-grant.spec.ts b/test/overtime-grant.spec.ts
new file mode 100644
index 0000000..a648bb8
--- /dev/null
+++ b/test/overtime-grant.spec.ts
@@ -0,0 +1,88 @@
+import { PrismaClient } from '@prisma/client';
+import { SchedulesController } from '../src/scheduling/schedules.controller';
+import { cleanupOrg } from './helpers';
+
+const prisma = new PrismaClient();
+const events = { toEmployee() {}, toApprovers() {}, toActivity() {} } as any;
+const ctrl = new SchedulesController(prisma as any, events);
+
+afterAll(async () => {
+ await prisma.$disconnect();
+});
+
+// ── Integration: the separately-managed overtime grant (date + start + hours) ──
+describe('overtime grants — date + start time + hours', () => {
+ let orgId: string;
+ let empId: string;
+ const req = () => ({ user: { userId: 'u', employeeId: empId, roles: ['WFM'] } }) as any;
+ const workDate = (d: string) => new Date(`${d}T00:00:00.000Z`);
+
+ beforeAll(async () => {
+ const org = await prisma.organization.create({ data: { name: 'TEST-OTG-' + Date.now(), timezone: 'Asia/Manila' } });
+ orgId = org.id;
+ await prisma.shiftPolicy.create({ data: { orgId, name: 'T', shiftHours: 8 } });
+ const emp = await prisma.employee.create({
+ data: { orgId, employeeCode: 'OTG-1', fullName: 'OT Grant Tester', hireDate: new Date(), hourlyRate: 100 },
+ });
+ empId = emp.id;
+ });
+
+ afterEach(async () => {
+ await prisma.schedule.deleteMany({ where: { employeeId: empId } });
+ });
+
+ afterAll(async () => {
+ await cleanupOrg(prisma, orgId);
+ });
+
+ it('grants OT on a bare date as ordinary OT (not rest day) and stores start + N hours', async () => {
+ const res = await ctrl.grantOvertime(req(), { employeeId: empId, date: '2026-08-15', startTime: '18:00', hours: 3 });
+ expect(res.ok).toBe(true);
+ expect(res.isRestDay).toBe(false); // bare date is NOT a rest day
+ expect(res.classification).toBe('OT'); // 6–9 PM is daytime
+ expect(res.hours).toBe(3);
+ const row = await prisma.schedule.findUnique({ where: { employeeId_workDate: { employeeId: empId, workDate: workDate('2026-08-15') } } });
+ expect(row!.isRestDay).toBe(false);
+ expect(row!.scheduledStart).toBeNull();
+ expect((row!.otEnd!.getTime() - row!.otStart!.getTime()) / 3_600_000).toBeCloseTo(3, 5);
+ });
+
+ it('grants OT on a scheduled rest day as RDOT', async () => {
+ await prisma.schedule.create({
+ data: { employeeId: empId, workDate: workDate('2026-08-20'), isRestDay: true, scheduledStart: null, scheduledEnd: null },
+ });
+ const res = await ctrl.grantOvertime(req(), { employeeId: empId, date: '2026-08-20', startTime: '10:00', hours: 2 });
+ expect(res.isRestDay).toBe(true);
+ expect(res.classification).toBe('RDOT');
+ });
+
+ it('grants OT on an existing working day as ordinary OT, preserving the shift', async () => {
+ await prisma.schedule.create({
+ data: { employeeId: empId, workDate: workDate('2026-08-16'), isRestDay: false,
+ scheduledStart: new Date('2026-08-16T09:00:00'), scheduledEnd: new Date('2026-08-16T17:00:00') },
+ });
+ const res = await ctrl.grantOvertime(req(), { employeeId: empId, date: '2026-08-16', startTime: '17:00', hours: 2 });
+ expect(res.isRestDay).toBe(false); // rides on the shift → ordinary OT
+ const row = await prisma.schedule.findUnique({ where: { employeeId_workDate: { employeeId: empId, workDate: workDate('2026-08-16') } } });
+ expect(row!.scheduledStart).not.toBeNull(); // shift preserved
+ expect(row!.otStart).not.toBeNull();
+ });
+
+ it('lists grants with computed hours + classification, and clearing a bare OT day removes the row', async () => {
+ const g = await ctrl.grantOvertime(req(), { employeeId: empId, date: '2026-08-17', startTime: '10:00', hours: 2.5 });
+ const list = await ctrl.listOvertime(req(), empId);
+ expect(list.length).toBe(1);
+ expect(list[0].hours).toBe(2.5);
+ expect(list[0].isRestDay).toBe(false);
+ expect(list[0].classification).toBe('OT');
+
+ await ctrl.clearOvertime(req(), g.id);
+ const row = await prisma.schedule.findUnique({ where: { employeeId_workDate: { employeeId: empId, workDate: workDate('2026-08-17') } } });
+ expect(row).toBeNull(); // OT-only row (no shift, not a rest day) is removed on clear
+ });
+
+ it('rejects invalid hours', async () => {
+ await expect(ctrl.grantOvertime(req(), { employeeId: empId, date: '2026-08-18', startTime: '18:00', hours: 0 }))
+ .rejects.toThrow(/hours/);
+ });
+});
diff --git a/test/payroll-logic.spec.ts b/test/payroll-logic.spec.ts
index ad15e17..19c4dc6 100644
--- a/test/payroll-logic.spec.ts
+++ b/test/payroll-logic.spec.ts
@@ -38,8 +38,8 @@ describe('computeEarnings — authorization-based overtime + session cap', () =>
let orgId: string;
let empId: string;
const day = '2026-06-01';
- const periodStart = new Date(`${day}T00:00:00`);
- const periodEndExcl = new Date('2026-06-02T00:00:00');
+ const periodStart = new Date(`${day}T00:00:00+08:00`);
+ const periodEndExcl = new Date('2026-06-02T00:00:00+08:00');
beforeAll(async () => {
const org = await prisma.organization.create({ data: { name: 'TEST-OT-' + Date.now(), timezone: 'Asia/Manila' } });
@@ -68,15 +68,15 @@ describe('computeEarnings — authorization-based overtime + session cap', () =>
it('pays OT only for worked time inside an authorized OT window', async () => {
await resetShift();
- const clockIn = new Date(`${day}T18:00:00`);
- const clockOut = new Date(`${day}T22:00:00`); // 4h worked
+ const clockIn = new Date(`${day}T18:00:00+08:00`);
+ const clockOut = new Date(`${day}T22:00:00+08:00`); // 4h worked
const te = await prisma.timeEntry.create({
- data: { employeeId: empId, clockInAt: clockIn, shiftEndsAt: new Date(`${day}T23:00:00`), clockOutAt: clockOut, status: 'CLOSED' },
+ data: { employeeId: empId, clockInAt: clockIn, shiftEndsAt: new Date(`${day}T23:00:00+08:00`), clockOutAt: clockOut, status: 'CLOSED' },
});
await prisma.activitySession.create({ data: { timeEntryId: te.id, activityType: 'Inbound Calls', startedAt: clockIn, endedAt: clockOut } });
// WFM-authorized OT window 20:00–22:00 (2h)
await prisma.schedule.create({
- data: { employeeId: empId, workDate: new Date(`${day}T00:00:00Z`), otStart: new Date(`${day}T20:00:00`), otEnd: new Date(`${day}T22:00:00`) },
+ data: { employeeId: empId, workDate: new Date(`${day}T00:00:00Z`), otStart: new Date(`${day}T20:00:00+08:00`), otEnd: new Date(`${day}T22:00:00+08:00`) },
});
const [row] = await earn();
@@ -86,10 +86,10 @@ describe('computeEarnings — authorization-based overtime + session cap', () =>
it('pays NO overtime without an authorized window, even past 8h worked', async () => {
await resetShift();
- const clockIn = new Date(`${day}T14:00:00`);
- const clockOut = new Date(`${day}T23:00:00`); // 9h worked, no OT window
+ const clockIn = new Date(`${day}T14:00:00+08:00`);
+ const clockOut = new Date(`${day}T23:00:00+08:00`); // 9h worked, no OT window
const te = await prisma.timeEntry.create({
- data: { employeeId: empId, clockInAt: clockIn, shiftEndsAt: new Date('2026-06-02T00:00:00'), clockOutAt: clockOut, status: 'CLOSED' },
+ data: { employeeId: empId, clockInAt: clockIn, shiftEndsAt: new Date('2026-06-02T00:00:00+08:00'), clockOutAt: clockOut, status: 'CLOSED' },
});
await prisma.activitySession.create({ data: { timeEntryId: te.id, activityType: 'Inbound Calls', startedAt: clockIn, endedAt: clockOut } });
@@ -101,8 +101,8 @@ describe('computeEarnings — authorization-based overtime + session cap', () =>
it('caps a still-open session at the shift end, not "now" (#5)', async () => {
await resetShift();
- const clockIn = new Date(`${day}T08:00:00`);
- const shiftEnds = new Date(`${day}T16:00:00`); // 8h window, already in the past
+ const clockIn = new Date(`${day}T08:00:00+08:00`);
+ const shiftEnds = new Date(`${day}T16:00:00+08:00`); // 8h window, already in the past
const te = await prisma.timeEntry.create({
data: { employeeId: empId, clockInAt: clockIn, shiftEndsAt: shiftEnds, status: 'OPEN' },
});
@@ -112,4 +112,49 @@ describe('computeEarnings — authorization-based overtime + session cap', () =>
// Capped at 16:00 → 8h. Without the cap it would bill all the way to the period end.
expect(row.regularHours).toBeCloseTo(8, 5);
});
+
+ it('pays rest-day overtime at the RD-OT multiplier, distinct from regular OT', async () => {
+ await resetShift();
+ const clockIn = new Date(`${day}T10:00:00+08:00`);
+ const clockOut = new Date(`${day}T12:00:00+08:00`); // 2h worked, all inside RD OT
+ const te = await prisma.timeEntry.create({
+ data: { employeeId: empId, clockInAt: clockIn, shiftEndsAt: new Date(`${day}T12:00:00+08:00`), clockOutAt: clockOut, status: 'CLOSED' },
+ });
+ await prisma.activitySession.create({ data: { timeEntryId: te.id, activityType: 'Inbound Calls', startedAt: clockIn, endedAt: clockOut } });
+ // Rest day with a WFM-granted OT window 10:00–12:00 (daytime → no night diff).
+ await prisma.schedule.create({
+ data: { employeeId: empId, workDate: new Date(`${day}T00:00:00Z`), isRestDay: true,
+ otStart: new Date(`${day}T10:00:00+08:00`), otEnd: new Date(`${day}T12:00:00+08:00`) },
+ });
+
+ const [row] = await earn();
+ expect(row.rdOvertimeHours).toBeCloseTo(2, 5);
+ expect(row.overtimeHours).toBe(0); // not counted as ordinary OT
+ expect(row.regularHours).toBe(0); // rest-day work isn't regular pay
+ expect(row.nightHours).toBe(0);
+ // 2h × ₱100 × 1.69 (default rdOtMultiplier) = 338.
+ expect(row.gross).toBeCloseTo(338, 2);
+ });
+
+ it('classifies rest-day night OT as RDNDOT and stacks the night differential', async () => {
+ await resetShift();
+ const clockIn = new Date(`${day}T22:00:00+08:00`);
+ const clockOut = new Date('2026-06-02T00:00:00+08:00'); // 2h, all rest-day night
+ const te = await prisma.timeEntry.create({
+ data: { employeeId: empId, clockInAt: clockIn, shiftEndsAt: new Date('2026-06-02T00:00:00+08:00'), clockOutAt: clockOut, status: 'CLOSED' },
+ });
+ await prisma.activitySession.create({ data: { timeEntryId: te.id, activityType: 'Inbound Calls', startedAt: clockIn, endedAt: clockOut } });
+ await prisma.schedule.create({
+ data: { employeeId: empId, workDate: new Date(`${day}T00:00:00Z`), isRestDay: true,
+ otStart: new Date(`${day}T22:00:00+08:00`), otEnd: new Date('2026-06-02T00:00:00+08:00') },
+ });
+
+ const [row] = await earn();
+ expect(row.rdndotHours).toBeCloseTo(2, 5);
+ expect(row.rdOvertimeHours).toBeCloseTo(2, 5); // rdot + rdndot
+ expect(row.overtimeHours).toBe(0);
+ expect(row.nightHours).toBeCloseTo(2, 5);
+ // 2h × ₱100 × (1.69 rest-day + 0.10 night) = 358.
+ expect(row.gross).toBeCloseTo(358, 2);
+ });
});