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

576 lines
25 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';
$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;
$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 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->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;
}
// 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 (Compounded)
$ndHours = (float) ($att->night_diff_hours ?? 0);
$nightDiffHours += $ndHours;
// Calculate ND that overlaps with OT (standardHours already computed above)
$ndOtHours = $this->calculateNdOtOverlap($att, $standardHours);
$ndRegHours = max(0, $ndHours - $ndOtHours);
// ND Amount (Premium part only: flat 10% of hourly rate)
// Per client: do NOT compound with dayMultiplier
$ndRegAmount = $ndRegHours * ($hourlyRate * 0.10);
$ndOtAmount = $ndOtHours * ($hourlyRate * $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) {
// 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, then add them back as separate earning lines
$earnedBasicSalary = max(0, $cutOffSalary - $paidLeavesAmount - $holidayPay);
// 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
];
}
}