user_id)->first(); $grossMonthly = $salaryData ? $salaryData->basic_salary : 20000; $payFrequency = $salaryData ? $salaryData->pay_frequency : 'monthly'; $payrollRun = \App\Models\PayrollRun::where('pay_period_start', $startDate) ->where('pay_period_end', $endDate) ->first(); $runFrequency = $payrollRun ? $payrollRun->payroll_frequency : 'semi-monthly'; $divisor = ($runFrequency === 'monthly') ? 1 : (($runFrequency === 'weekly') ? 4 : 2); $isSemiMonthly = ($payFrequency === 'semi-monthly'); $isDaily = ($payFrequency === 'daily'); if ($isDaily) { $actualDailyRate = $salaryData->basic_salary; $grossMonthly = $actualDailyRate * 26; // Approximate for statutory deductions } else { // 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; $undertimeHours = 0; $absencesCount = 0; $nightDiffHours = 0; $totalOvertimeHours = 0; $expectedWorkingDays = 0; $expectedHours = 0; $earnedBasicSalary = 0; $holidayPay = 0; $holidayPremiumPay = 0; $holidayDaysNotWorked = 0; $regularOtTotal = 0; $nightDiffAmount = 0; $paidLeavesCount = 0; $paidLeavesAmount = 0; // Pre-compute employee wage type (needed inside the daily loop) $isDailyWage = ($employee->type === 'daily_wage' || $employee->employment_status === 'rank_and_file' || $isDaily); $currentDate = Carbon::parse($startDate); $periodEndObj = Carbon::parse($endDate); // Fetch approved leave applications for the period $leaveApps = \App\Models\LeaveApplication::where('employee_id', $employee->user_id) ->where('status', 'approved') ->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); }); })->with('leaveType')->get(); // 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); // Determine Rest Day based on Employee Profile first, then fallback to global weekend $dayName = strtolower($currentDate->englishDayOfWeek); $isRestDay = false; if (!empty($employee->rest_days)) { $isRestDay = in_array($dayName, $employee->rest_days); } else { $isRestDay = $currentDate->isWeekend(); } // Allow attendance record override for rest day (attendance is ground truth) if ($att) { $isRestDay = (bool) $att->is_rest_day; } // 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->holiday_type === 'regular') { $isRegularHoliday = true; } elseif ($holiday->holiday_type === 'special_non_working') { $isSpecialHoliday = true; } } } // Paid Leave Check $isPaidLeave = false; $leaveAppForDate = null; // Check Attendance record first if ($att && $att->status === 'on_leave' && $att->leaveApplication) { $isPaidLeave = $att->leaveApplication->leaveType->is_paid ?? false; } else { // Check direct LeaveApplications table foreach ($leaveApps as $leave) { if ($currentDate->betweenIncluded($leave->start_date, $leave->end_date)) { $isPaidLeave = $leave->leaveType->is_paid ?? false; $leaveAppForDate = $leave; break; } } } $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 += abs((float) ($att->late_hours ?? 0)); $undertimeHours += abs((float) ($att->early_hours ?? 0)); // Standard shift hours for proration $standardHours = ($att->shift && $att->shift->working_hours > 0) ? (float)$att->shift->working_hours : 8.0; // For daily wage: prorate by actual hours worked vs standard shift // For monthly: always full daily rate (they're salaried) $hoursWorked = min((float)$att->total_hours, $standardHours); $hoursRatio = $isDailyWage ? ($hoursWorked / $standardHours) : 1.0; // 1. Basic Pay for the day (prorated for daily, full for monthly) $proratedDailyRate = $actualDailyRate * $hoursRatio; $earnedDaily = $proratedDailyRate * $dayMultiplier; // Separate out the holiday/premium pay from the basic salary for the payslip breakdown $earnedBasicSalary += $proratedDailyRate; if ($dayMultiplier > 1.0) { $premium = ($earnedDaily - $proratedDailyRate); if ($isRegularHoliday || $isSpecialHoliday) { $holidayPay += $premium; $holidayPremiumPay += $premium; } // Rest Day Premium removed per client request } // 2. Overtime Calculation (Compounded) // OT must be explicitly approved via OvertimeApplication. Ignore raw late clock-out minutes. $otHours = 0; $otApp = \App\Models\OvertimeApplication::where('user_id', $employee->user_id) ->where('date', $dateString) ->where('status', 'approved') ->first(); if ($otApp && !empty($otApp->approved_hours)) { $otHours = (float) $otApp->approved_hours; } $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 $ndHoursRaw = (float) ($att->night_diff_hours ?? 0); // Deduct untracked meal break from ND hours. // Biometrics does not record break in/out, so raw ND is inflated. // Per client: deduct 1.5h per night shift day for the meal break. $ndBreakDeduction = ($ndHoursRaw > 0) ? 1.5 : 0; $ndHours = max(0, $ndHoursRaw - $ndBreakDeduction); $nightDiffHours += $ndHours; // Calculate ND that overlaps with OT (standardHours already computed above) $ndOtHours = min($this->calculateNdOtOverlap($att, $standardHours), $ndHours); $ndRegHours = max(0, $ndHours - $ndOtHours); // ND Amount: flat 10% of base hourly rate for ALL ND hours. // Per client: do NOT compound with dayMultiplier or otRateMultiplier. $ndAmount = $ndHours * ($hourlyRate * 0.10); $nightDiffAmount += $ndAmount; // Final OT Total (already includes the Day Multiplier) $regularOtTotal += $otHours * ($hourlyRate * $dayMultiplier * $otRateMultiplier); } else { // Did NOT work. if ($isPaidLeave) { // Extract paid leave as a separate earning line $paidLeavesAmount += $actualDailyRate; $daysWorked++; // Treat as paid day $paidLeavesCount++; } elseif ($isRegularHoliday) { // All employees: extract holiday as separate earning line $holidayPay += $actualDailyRate; $holidayDaysNotWorked++; } else { // Before counting as absent, check if there's a leave application // (handles cases where attendance status is 'absent' but a leave was filed) $leaveMatchedFromApp = false; if (!$isPaidLeave && !$leaveAppForDate) { foreach ($leaveApps as $leave) { if ($currentDate->betweenIncluded($leave->start_date, $leave->end_date)) { $leaveAppForDate = $leave; break; } } } if ($leaveAppForDate) { $isLeaveForDatePaid = $leaveAppForDate->leaveType->is_paid ?? false; if ($isLeaveForDatePaid) { $paidLeavesAmount += $actualDailyRate; $daysWorked++; $paidLeavesCount++; } else { // Unpaid leave (LWOP) — count as absence for deduction if (!$isRestDay && !$isSpecialHoliday) { $absencesCount++; } } $leaveMatchedFromApp = true; } if (!$leaveMatchedFromApp) { // Only count as absent if NOT a rest day AND NOT a special holiday if (!$isRestDay && !$isSpecialHoliday) { $absencesCount++; } } } } $currentDate->addDay(); } // Late & Undertime Deduction $lateDeduction = $lateHours * $hourlyRate; $undertimeDeduction = $undertimeHours * $hourlyRate; $absenceDeduction = 0; // isDailyWage was pre-computed before the loop (line ~68) if ($isDailyWage) { // For daily wage earners, their basic salary is strictly daily rate x days worked // It is already correctly accumulated in $earnedBasicSalary during the loop above. } else { // For all monthly earners (Office Base, Restaurant Base, etc.), // they get their exact fixed cut-off pay (Monthly / Divisor) $cutOffSalary = $grossMonthly / $divisor; // Extract paid leaves AND holiday pay from basic to show them as separate line items // This matches the client's expected payslip format: // Basic = CutOff - Leaves - Holidays (only the unworked holiday portion), then add them back as separate earning lines // Any extra premium earned from working on a holiday is an addition, NOT deducted from base salary. $holidayPayNotWorked = max(0, $holidayPay - $holidayPremiumPay); $earnedBasicSalary = max(0, $cutOffSalary - $paidLeavesAmount - $holidayPayNotWorked); // We explicitly deduct absent days (LWOP) $absenceDeduction = $absencesCount * $actualDailyRate; } 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) { $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 will be calculated after total earnings are known 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, '.', '')], ]; if ($paidLeavesAmount > 0) { $earnings[] = ['name' => 'Paid Leaves', 'amount' => number_format($paidLeavesAmount, 2, '.', '')]; } $earnings = array_merge($earnings, [ ['name' => 'Overtime (Regular)', 'amount' => number_format($regularOtTotal, 2, '.', '')], ['name' => 'Night Differential', 'amount' => number_format($nightDiffAmount, 2, '.', '')], ['name' => 'Holiday Pay', 'amount' => number_format($holidayPay, 2, '.', '')], ]); // Rest Day Premium removed per client request // 7. Totals (Intermediate for Tax) $totalEarnings = $earnedBasicSalary + $paidLeavesAmount + $nightDiffAmount + $regularOtTotal + $holidayPay; // 8. Withholding Tax Calculation (Based on Gross per user request) $tax = 0.00; if ($salaryData && $salaryData->is_taxable) { $tax = $this->calculateWithholdingTax($salaryData, $totalEarnings, $divisor); } // 6. Build deductions array $deductions = []; if ($absenceDeduction > 0) { $deductions[] = ['name' => 'Absences / LWOP', 'amount' => number_format($absenceDeduction, 2, '.', '')]; } if ($lateDeduction > 0) { $deductions[] = ['name' => 'Late Deduction', 'amount' => number_format($lateDeduction, 2, '.', '')]; } if ($undertimeDeduction > 0) { $deductions[] = ['name' => 'Undertime Deduction', 'amount' => number_format($undertimeDeduction, 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, '.', '')]; $totalDeductions = $absenceDeduction + $lateDeduction + $undertimeDeduction + $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' => $paidLeavesCount, 'reg_ot_hours' => number_format($totalOvertimeHours, 2), 'reg_ot_amount' => $regularOtTotal, 'night_diff_amount' => $nightDiffAmount, 'holiday' => $holidayDaysNotWorked, ] ]; } /** * Calculate Withholding Tax based on Gross Earnings. * Supports TRAIN Law graduated table and manual percentage overrides. */ private function calculateWithholdingTax($salaryData, $grossEarnings, $divisor) { // Monthly annualized gross for tax table lookup // We use $grossEarnings * $divisor to get the "Monthly equivalent" of the current cut-off $monthlyGross = $grossEarnings * $divisor; if ($salaryData->tax_calculation_mode === 'manual') { $taxPercentage = (float) ($salaryData->tax_percentage ?? 0); return $grossEarnings * ($taxPercentage / 100); } // TRAIN LAW Monthly Tax Table (2023 - 2027) // Brackets: // 0 - 20,833: 0 // 20,833 - 33,333: 15% of excess over 20,833 // 33,333 - 66,667: 1,875 + 20% of excess over 33,333 // 66,667 - 166,667: 8,541.67 + 25% of excess over 66,667 // 166,667 - 666,667: 33,541.67 + 30% of excess over 166,667 // Over 666,667: 183,541.67 + 35% of excess over 666,667 $taxMonthly = 0; if ($monthlyGross <= 20833) { $taxMonthly = 0; } elseif ($monthlyGross <= 33333) { $taxMonthly = ($monthlyGross - 20833) * 0.15; } elseif ($monthlyGross <= 66667) { $taxMonthly = 1875 + ($monthlyGross - 33333) * 0.20; } elseif ($monthlyGross <= 166667) { $taxMonthly = 8541.67 + ($monthlyGross - 66667) * 0.25; } elseif ($monthlyGross <= 666667) { $taxMonthly = 33541.67 + ($monthlyGross - 166667) * 0.30; } else { $taxMonthly = 183541.67 + ($monthlyGross - 666667) * 0.35; } // Return the tax for the current cut-off return $taxMonthly / $divisor; } 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 ]; } }