user_id)->first(); $grossMonthly = $salaryData ? $salaryData->basic_salary : 20000; $payFrequency = $salaryData ? $salaryData->pay_frequency : 'monthly'; $isSemiMonthly = ($payFrequency === 'semi-monthly'); $divisor = $isSemiMonthly ? 2 : 1; // Daily rate: monthly salary / 26 standard working days $actualDailyRate = $grossMonthly / 26; $hourlyRate = $actualDailyRate / 8; // 2. Fetch Attendance $attendances = AttendanceRecord::where('employee_id', $employee->user_id) ->whereBetween('date', [$startDate, $endDate]) ->get(); $daysWorked = 0; $renderedHours = 0; $lateHours = 0; $absencesCount = 0; $nightDiffHours = 0; $totalOvertimeHours = 0; $expectedWorkingDays = 0; $expectedHours = 0; $earnedBasicSalary = 0; $holidayPay = 0; $regularOtTotal = 0; $nightDiffAmount = 0; $currentDate = Carbon::parse($startDate); $periodEndObj = Carbon::parse($endDate); // Index attendances by date for fast lookup $indexedAttendances = $attendances->keyBy(function($item) { return $item->date instanceof Carbon ? $item->date->toDateString() : substr((string)$item->date, 0, 10); }); // 2. Fetch Holidays for the period $holidays = \App\Models\Holiday::where(function($q) use ($startDate, $endDate) { $q->whereBetween('start_date', [$startDate, $endDate]) ->orWhereBetween('end_date', [$startDate, $endDate]) ->orWhere(function($q2) use ($startDate, $endDate) { $q2->where('start_date', '<=', $startDate)->where('end_date', '>=', $endDate); }); })->get(); while ($currentDate->lte($periodEndObj)) { $dateString = $currentDate->toDateString(); $att = $indexedAttendances->get($dateString); $isRestDay = $att ? $att->is_rest_day : false; // Expected if (!$isRestDay) { $expectedWorkingDays++; $expectedHours += 8; // Standard 8 hours } // Holiday Check $isRegularHoliday = false; $isSpecialHoliday = false; foreach ($holidays as $holiday) { if ($currentDate->betweenIncluded($holiday->start_date, $holiday->end_date ?? $holiday->start_date)) { if ($holiday->category === 'regular') { $isRegularHoliday = true; } elseif ($holiday->category === 'special_non_working') { $isSpecialHoliday = true; } } } // Paid Leave Check $isPaidLeave = false; if ($att && $att->status === 'on_leave' && $att->leaveApplication) { $isPaidLeave = $att->leaveApplication->leaveType->is_paid ?? false; } $didWork = $att && !$att->is_absent && $att->status !== 'absent' && $att->total_hours > 0; // DOLE Multiplier Logic (Day Premium) $dayMultiplier = 1.0; if ($isRegularHoliday && $isRestDay) { $dayMultiplier = 2.60; } elseif ($isRegularHoliday) { $dayMultiplier = 2.00; } elseif ($isSpecialHoliday && $isRestDay) { $dayMultiplier = 1.50; } elseif ($isSpecialHoliday) { $dayMultiplier = 1.30; } elseif ($isRestDay) { $dayMultiplier = 1.30; } // Daily Salary Accrual if ($didWork) { $daysWorked++; $renderedHours += $att->total_hours; $lateHours += (float) ($att->late_hours ?? 0); // 1. Basic Pay for the day (Daily Rate * Day Multiplier) $earnedDaily = $actualDailyRate * $dayMultiplier; // Separate out the holiday/premium pay from the basic salary for the payslip breakdown $earnedBasicSalary += $actualDailyRate; if ($dayMultiplier > 1.0) { $holidayPay += ($earnedDaily - $actualDailyRate); } // 2. Overtime Calculation (Compounded) $otHours = (float) ($att->overtime_hours ?? 0); $totalOvertimeHours += $otHours; // OT Rate Multiplier: 1.25 for regular, 1.30 for premium days $otRateMultiplier = ($dayMultiplier > 1.0) ? 1.30 : 1.25; // 3. Night Differential Calculation (Compounded) $ndHours = (float) ($att->night_diff_hours ?? 0); $nightDiffHours += $ndHours; // Standard Shift Working Hours (to detect OT overlap) $standardHours = ($att->shift && $att->shift->working_hours > 0) ? (float)$att->shift->working_hours : 8.0; // Calculate ND that overlaps with OT $ndOtHours = $this->calculateNdOtOverlap($att, $standardHours); $ndRegHours = max(0, $ndHours - $ndOtHours); // ND Amount (Premium part only: 10%) // Compounded: Base * DayMult * 10% for reg, Base * DayMult * OTMult * 10% for OT $ndRegAmount = $ndRegHours * ($hourlyRate * $dayMultiplier * 0.10); $ndOtAmount = $ndOtHours * ($hourlyRate * $dayMultiplier * $otRateMultiplier * 0.10); $nightDiffAmount += ($ndRegAmount + $ndOtAmount); // Final OT Total (already includes the Day Multiplier) $regularOtTotal += $otHours * ($hourlyRate * $dayMultiplier * $otRateMultiplier); } else { // Did NOT work. if ($isPaidLeave) { $earnedBasicSalary += $actualDailyRate; $daysWorked++; // Treat as paid day } elseif ($isRegularHoliday) { // Regular Holidays are paid 100% even if not worked $holidayPay += $actualDailyRate; } else { if (!$isRestDay) { $absencesCount++; } } } $currentDate->addDay(); } // Late Deduction $lateDeduction = $lateHours * $hourlyRate; // Manual Override Application $payrollType = $salaryData ? $salaryData->payroll_type : 'automatic'; if ($payrollType === 'manual') { // For manual, they get their exact fixed monthly pay divided by divisor $earnedBasicSalary = $grossMonthly / $divisor; } if (!is_null($salaryData?->fixed_overtime)) { $regularOtTotal = (float) $salaryData->fixed_overtime; } if (!is_null($salaryData?->fixed_holiday_pay)) { $holidayPay = (float) $salaryData->fixed_holiday_pay; } if (!is_null($salaryData?->fixed_late_deduction)) { $lateDeduction = (float) $salaryData->fixed_late_deduction; } // Apply Time Exemption (salaried exempt employees) if ($salaryData && $salaryData->is_time_exempt) { $lateDeduction = 0; $nightDiffAmount = 0; $regularOtTotal = 0; } // 4. Statutory Contributions — only if employee has earnings (prevents negative payroll) $sssCutoff = 0; $phCutoff = 0; $pagibigCutoff = 0; $sssEmployer = 0; $phEmployer = 0; $pagibigEmployer = 0; $tax = 0.00; if ($daysWorked > 0) { // SSS Calculation if (!is_null($salaryData->sss_fixed)) { $sssMonthly = $salaryData->sss_fixed; $sssEmployerMonthly = 0; // If fixed is provided, we assume manual EE only unless they specify ER } else { $sssCalc = $this->calculateStatutoryContribution('sss', $grossMonthly); $sssMonthly = $sssCalc['employee']; $sssEmployerMonthly = $sssCalc['employer']; } // PhilHealth Calculation if (!is_null($salaryData->philhealth_fixed)) { $philhealthMonthly = $salaryData->philhealth_fixed; $phEmployerMonthly = 0; } else { $phCalc = $this->calculateStatutoryContribution('philhealth', $grossMonthly); $philhealthMonthly = $phCalc['employee']; $phEmployerMonthly = $phCalc['employer']; } // Pag-IBIG Calculation if (!is_null($salaryData->pagibig_fixed)) { $pagibigMonthly = $salaryData->pagibig_fixed; $pagibigEmployerMonthly = 0; } else { $pagibigCalc = $this->calculateStatutoryContribution('pagibig', $grossMonthly); $pagibigMonthly = $pagibigCalc['employee']; $pagibigEmployerMonthly = $pagibigCalc['employer']; } $sssCutoff = $sssMonthly / $divisor; $phCutoff = $philhealthMonthly / $divisor; $pagibigCutoff = $pagibigMonthly / $divisor; $sssEmployer = $sssEmployerMonthly / $divisor; $phEmployer = $phEmployerMonthly / $divisor; $pagibigEmployer = $pagibigEmployerMonthly / $divisor; } // 5. Build earnings array $earnings = [ ['name' => 'Basic Salary', 'amount' => number_format($earnedBasicSalary, 2, '.', '')], ['name' => 'Overtime (Regular)', 'amount' => number_format($regularOtTotal, 2, '.', '')], ['name' => 'Night Differential', 'amount' => number_format($nightDiffAmount, 2, '.', '')], ['name' => 'Holiday Pay', 'amount' => number_format($holidayPay, 2, '.', '')], ]; // 6. Build deductions array — NO absence deduction (cumulative model) $deductions = []; if ($lateDeduction > 0) { $deductions[] = ['name' => 'Late Deduction', 'amount' => number_format($lateDeduction, 2, '.', '')]; } $deductions[] = ['name' => 'SSS Contribution (EE)', 'amount' => number_format($sssCutoff, 2, '.', '')]; $deductions[] = ['name' => 'PhilHealth Contribution (EE)', 'amount' => number_format($phCutoff, 2, '.', '')]; $deductions[] = ['name' => 'Pag-IBIG Contribution (EE)', 'amount' => number_format($pagibigCutoff, 2, '.', '')]; $deductions[] = ['name' => 'Withholding Tax', 'amount' => number_format($tax, 2, '.', '')]; // 7. Totals $totalEarnings = $earnedBasicSalary + $nightDiffAmount + $regularOtTotal + $holidayPay; $totalDeductions = $lateDeduction + $sssCutoff + $phCutoff + $pagibigCutoff + $tax; // Ensure net pay is never negative. Capping deductions for safety. $netPay = max(0, $totalEarnings - $totalDeductions); return [ 'gross_monthly' => $grossMonthly, 'pay_type' => $isSemiMonthly ? 'Semi-Monthly' : 'Monthly', 'daily_rate' => $actualDailyRate, 'basic_salary' => $earnedBasicSalary, 'earnings' => $earnings, 'deductions' => $deductions, 'total_earnings' => $totalEarnings, 'total_deductions' => $totalDeductions, 'net_pay' => $netPay, 'employer_contributions' => [ 'sss' => $sssEmployer, 'philhealth' => $phEmployer, 'pagibig' => $pagibigEmployer, 'total' => $sssEmployer + $phEmployer + $pagibigEmployer ], 'summary' => [ 'days_worked' => $daysWorked, 'expected_working_days' => $expectedWorkingDays, 'expected_hours' => number_format($expectedHours, 2), 'rendered_hours' => number_format($renderedHours, 2), 'night_diff_hours' => number_format($nightDiffHours, 2), 'absences' => $absencesCount, 'leaves' => 0, 'reg_ot_hours' => number_format($totalOvertimeHours, 2), 'reg_ot_amount' => $regularOtTotal, 'night_diff_amount' => $nightDiffAmount, 'holiday' => 0, ] ]; } /** * Calculate how many Night Differential hours fall within Overtime. * Assumes OT starts after standard hours of work. */ private function calculateNdOtOverlap($record, $standardHours) { if (!$record->clock_in || !$record->clock_out || $record->overtime_hours <= 0 || $record->night_diff_hours <= 0) { return 0; } $clockIn = Carbon::parse($record->date->format('Y-m-d') . ' ' . $record->clock_in); $clockOut = Carbon::parse($record->date->format('Y-m-d') . ' ' . $record->clock_out); if ($clockOut->lt($clockIn)) { $clockOut->addDay(); } // OT starts after $standardHours of rendered time // Note: For simplicity, we assume continuous work. // We include break time in the offset if it happened between clockIn and OT start. $breakMinutes = ($record->shift && $record->shift->break_duration) ? $record->shift->break_duration : 0; $otStartOffsetMinutes = ($standardHours * 60) + $breakMinutes; $otStart = $clockIn->copy()->addMinutes($otStartOffsetMinutes); // ND period is 10PM to 6AM $ndStart = $clockIn->copy()->setTime(22, 0, 0); $ndEnd = $ndStart->copy()->addHours(8); // 6AM next day // Handle case where ND start is before clockIn (e.g. shift starts at 5AM) if ($ndStart->gt($clockIn->copy()->addHours(12))) { $ndStart->subDay(); $ndEnd->subDay(); } $calculateOverlap = function($s1, $e1, $s2, $e2) { $latestStart = $s1->max($s2); $earliestEnd = $e1->min($e2); return $latestStart->lt($earliestEnd) ? $latestStart->diffInMinutes($earliestEnd) / 60 : 0; }; $overlap = $calculateOverlap($otStart, $clockOut, $ndStart, $ndEnd); // Check next ND period too (just in case) $ndStart2 = $ndStart->copy()->addDay(); $ndEnd2 = $ndEnd->copy()->addDay(); $overlap += $calculateOverlap($otStart, $clockOut, $ndStart2, $ndEnd2); return round($overlap, 2); } private function calculateStatutoryContribution($type, $grossMonthly) { $bracket = \App\Models\StatutoryBracket::where('type', $type) ->where('is_active', true) ->where('min_salary', '<=', $grossMonthly) ->where(function($q) use ($grossMonthly) { $q->whereNull('max_salary') ->orWhere('max_salary', '>=', $grossMonthly); }) ->first(); if (!$bracket) { return ['employee' => 0, 'employer' => 0]; } $ee = $bracket->employee_share_amount ?? ($grossMonthly * ($bracket->employee_share_percentage / 100)); $er = $bracket->employer_share_amount ?? ($grossMonthly * ($bracket->employer_share_percentage / 100)); // For SSS, calculation is usually on MSC not actual gross if percentage is used, // DOLE min MSC is 4000. if ($type === 'sss' && is_null($bracket->employee_share_amount)) { $msc = min(floor($grossMonthly / 500) * 500, 30000); $msc = max(4000, $msc); $ee = $msc * ($bracket->employee_share_percentage / 100); $er = $msc * ($bracket->employer_share_percentage / 100); } return [ 'employee' => $ee, 'employer' => $er ]; } }