207 lines
8.3 KiB
PHP
207 lines
8.3 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;
|
||
|
||
$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);
|
||
});
|
||
|
||
while ($currentDate->lte($periodEndObj)) {
|
||
$dateString = $currentDate->toDateString();
|
||
$att = $indexedAttendances->get($dateString);
|
||
|
||
// Skip rest days — not a working day
|
||
if ($att && $att->is_rest_day) {
|
||
$currentDate->addDay();
|
||
continue;
|
||
}
|
||
|
||
// Count expected working days
|
||
$expectedWorkingDays++;
|
||
$expectedHours += 9;
|
||
|
||
if (!$att || $att->is_absent || $att->status === 'absent') {
|
||
// Absent — simply not paid (cumulative model)
|
||
$absencesCount++;
|
||
} else {
|
||
$daysWorked++;
|
||
if ($att->total_hours) {
|
||
$renderedHours += $att->total_hours;
|
||
}
|
||
if ($att->is_late) {
|
||
$lateHours += (float) ($att->late_hours ?? 0);
|
||
}
|
||
|
||
// --- NIGHT DIFFERENTIAL ENGINE ---
|
||
if (!empty($att->clock_in) && !empty($att->clock_out)) {
|
||
try {
|
||
$in = Carbon::parse($dateString . ' ' . $att->clock_in);
|
||
$out = Carbon::parse($dateString . ' ' . $att->clock_out);
|
||
|
||
if ($out->lt($in)) {
|
||
$out->addDay();
|
||
}
|
||
|
||
// DOLE ND Windows: 10PM–6AM
|
||
$windows = [
|
||
[Carbon::parse($dateString)->startOfDay(), Carbon::parse($dateString)->setTime(6, 0)],
|
||
[Carbon::parse($dateString)->setTime(22, 0), Carbon::parse($dateString)->addDay()->setTime(6, 0)]
|
||
];
|
||
|
||
foreach ($windows as $window) {
|
||
$overlapStart = $in->copy()->max($window[0]);
|
||
$overlapEnd = $out->copy()->min($window[1]);
|
||
|
||
if ($overlapEnd->gt($overlapStart)) {
|
||
$nightDiffHours += $overlapEnd->diffInMinutes($overlapStart, true) / 60;
|
||
}
|
||
}
|
||
} catch (\Exception $e) {
|
||
// Skip malformed logs
|
||
}
|
||
}
|
||
|
||
// --- OVERTIME ENGINE ---
|
||
if ($att->overtime_hours) {
|
||
$totalOvertimeHours += (float) $att->overtime_hours;
|
||
}
|
||
}
|
||
|
||
$currentDate->addDay();
|
||
}
|
||
|
||
// Apply Time Exemption (salaried exempt employees)
|
||
if ($salaryData && $salaryData->is_time_exempt) {
|
||
$lateHours = 0;
|
||
$nightDiffHours = 0;
|
||
$totalOvertimeHours = 0;
|
||
}
|
||
|
||
$holidayPay = 0;
|
||
|
||
// 3. CUMULATIVE EARNINGS: daily_rate × days_worked
|
||
$earnedBasicSalary = $daysWorked * $actualDailyRate;
|
||
|
||
// Late deduction remains as explicit hourly penalty
|
||
$lateDeduction = $lateHours * $hourlyRate;
|
||
|
||
// OT and ND premiums
|
||
$regularOtTotal = $totalOvertimeHours * ($hourlyRate * 1.25);
|
||
$nightDiffAmount = $nightDiffHours * ($hourlyRate * 0.10);
|
||
|
||
// 4. Statutory Contributions — only if employee has earnings (prevents negative payroll)
|
||
$sssCutoff = 0;
|
||
$phCutoff = 0;
|
||
$pagibigCutoff = 0;
|
||
$tax = 0.00;
|
||
|
||
if ($daysWorked > 0) {
|
||
// Based on FULL monthly salary bracket (PH law)
|
||
$msc = min(floor($grossMonthly / 500) * 500, 30000);
|
||
|
||
$sssMonthly = $salaryData->sss_fixed ?? ($msc * 0.045);
|
||
$philhealthMonthly = $salaryData->philhealth_fixed ?? ($grossMonthly * 0.025);
|
||
if ($salaryData && is_null($salaryData->philhealth_fixed) && $grossMonthly <= 10000) $philhealthMonthly = 250;
|
||
if ($salaryData && is_null($salaryData->philhealth_fixed) && $grossMonthly >= 100000) $philhealthMonthly = 2500;
|
||
|
||
$pagibigMonthly = $salaryData->pagibig_fixed ?? (100 * $divisor);
|
||
|
||
$sssCutoff = $sssMonthly / $divisor;
|
||
$phCutoff = $philhealthMonthly / $divisor;
|
||
$pagibigCutoff = $pagibigMonthly / $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;
|
||
$netPay = $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,
|
||
'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,
|
||
]
|
||
];
|
||
}
|
||
}
|