393 lines
16 KiB
PHP
393 lines
16 KiB
PHP
<?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
|
||
];
|
||
}
|
||
}
|