Files
HRM-System/app/Services/PayrollService.php

393 lines
16 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services;
use App\Models\Employee;
use App\Models\AttendanceRecord;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
class PayrollService
{
/**
* Compute cumulative payroll: daily_rate × days_worked.
* Philippine statutory contributions based on full monthly salary bracket.
*/
public function calculateForPeriod(Employee $employee, $startDate, $endDate)
{
// 1. Fetch salary config
$salaryData = \App\Models\EmployeeSalary::where('employee_id', $employee->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
];
}
}