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

452 lines
18 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);
// 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 manual attendance record override (from the Shift Calendar)
if ($att && $att->is_rest_day) {
$isRestDay = true;
}
// 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 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, '.', '')],
['name' => 'Overtime (Regular)', 'amount' => number_format($regularOtTotal, 2, '.', '')],
['name' => 'Night Differential', 'amount' => number_format($nightDiffAmount, 2, '.', '')],
['name' => 'Holiday Pay', 'amount' => number_format($holidayPay, 2, '.', '')],
];
// 7. Totals (Intermediate for Tax)
$totalEarnings = $earnedBasicSalary + $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 — 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, '.', '')];
$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 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
];
}
}