576 lines
25 KiB
PHP
576 lines
25 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';
|
||
|
||
$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
|
||
];
|
||
}
|
||
}
|