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

329 lines
13 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 += 9;
}
// 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
$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);
// If they worked, they earn Base 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);
}
// Night Diff (from engine)
$ndHours = (float) ($att->night_diff_hours ?? 0);
$nightDiffHours += $ndHours;
$nightDiffAmount += $ndHours * ($hourlyRate * $dayMultiplier * 0.10);
// Overtime (from engine)
$otHours = (float) ($att->overtime_hours ?? 0);
$totalOvertimeHours += $otHours;
// Overtime DOLE is +25% on ordinary days, +30% on premium days
$otRateMultiplier = ($dayMultiplier > 1.0) ? 1.30 : 1.25;
$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,
]
];
}
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
];
}
}