442 lines
17 KiB
PHP
442 lines
17 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Models\User;
|
|
use App\Models\Employee;
|
|
use App\Models\EmployeeSalary;
|
|
use App\Models\Holiday;
|
|
use App\Models\AttendanceRecord;
|
|
use App\Models\OvertimeApplication;
|
|
use App\Models\LeaveType;
|
|
use App\Models\LeavePolicy;
|
|
use App\Models\LeaveApplication;
|
|
use App\Services\PayrollService;
|
|
use Database\Seeders\StatutoryBracketSeeder;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Tests\TestCase;
|
|
use Carbon\Carbon;
|
|
|
|
class PayrollScenarioTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private $service;
|
|
private $company;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->service = new PayrollService();
|
|
|
|
// 1. Seed statutory brackets
|
|
(new StatutoryBracketSeeder())->run();
|
|
|
|
// 2. Create Company user
|
|
$this->company = User::create([
|
|
'name' => 'Test Company',
|
|
'email' => 'company@test.com',
|
|
'password' => bcrypt('password'),
|
|
'type' => 'company',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Create an employee with a salary structure.
|
|
*/
|
|
private function createEmployee(string $name, float $salary, string $frequency = 'semi-monthly', bool $isDaily = false, array $restDays = ['saturday', 'sunday'])
|
|
{
|
|
$user = User::create([
|
|
'name' => $name,
|
|
'email' => strtolower(str_replace(' ', '', $name)) . '@test.com',
|
|
'password' => bcrypt('password'),
|
|
'type' => 'employee',
|
|
]);
|
|
|
|
$employee = Employee::create([
|
|
'user_id' => $user->id,
|
|
'employee_id' => 'EMP-' . rand(1000, 9999),
|
|
'date_of_joining' => '2026-01-01',
|
|
'employee_status' => 'active',
|
|
'rest_days' => $restDays,
|
|
'created_by' => $this->company->id,
|
|
]);
|
|
|
|
EmployeeSalary::create([
|
|
'employee_id' => $user->id,
|
|
'basic_salary' => $salary,
|
|
'pay_frequency' => $frequency,
|
|
'is_active' => true,
|
|
'calculation_status' => 'calculated',
|
|
'is_time_exempt' => false,
|
|
'is_taxable' => false,
|
|
'created_by' => $this->company->id,
|
|
]);
|
|
|
|
return $employee;
|
|
}
|
|
|
|
/**
|
|
* Helper to seed present attendances for work days in June 1-15 2026.
|
|
*/
|
|
private function seedAttendancesForPeriod($employeeId, array $excludeDates = [])
|
|
{
|
|
$startDate = Carbon::parse('2026-06-01');
|
|
$endDate = Carbon::parse('2026-06-15');
|
|
|
|
for ($date = $startDate->copy(); $date->lte($endDate); $date->addDay()) {
|
|
$dateStr = $date->toDateString();
|
|
if ($date->isWeekend() || in_array($dateStr, $excludeDates)) {
|
|
continue;
|
|
}
|
|
|
|
AttendanceRecord::create([
|
|
'employee_id' => $employeeId,
|
|
'date' => $dateStr,
|
|
'clock_in' => '08:00:00',
|
|
'clock_out' => '17:00:00',
|
|
'total_hours' => 9.0, // 8 work hours + 1 break
|
|
'status' => 'present',
|
|
'is_absent' => false,
|
|
'is_rest_day' => false,
|
|
'created_by' => $this->company->id,
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Scenario 1: Regular Holiday worked/unworked (200% / 100%)
|
|
*/
|
|
public function test_scenario_1_regular_holiday()
|
|
{
|
|
// 1. Worked Regular Holiday
|
|
$employeeWorked = $this->createEmployee('Worked Regular', 26000.00); // Daily rate = 1000
|
|
|
|
Holiday::create([
|
|
'name' => 'Regular Holiday Test',
|
|
'start_date' => '2026-06-12',
|
|
'end_date' => '2026-06-12',
|
|
'holiday_type' => 'regular',
|
|
'category' => 'National',
|
|
'created_by' => $this->company->id,
|
|
]);
|
|
|
|
// Attendance on holiday (June 12 is Friday, working day)
|
|
AttendanceRecord::create([
|
|
'employee_id' => $employeeWorked->user_id,
|
|
'date' => '2026-06-12',
|
|
'clock_in' => '08:00:00',
|
|
'clock_out' => '17:00:00',
|
|
'total_hours' => 9.0, // 8 work hours + 1 break
|
|
'status' => 'present',
|
|
'is_absent' => false,
|
|
'is_rest_day' => false,
|
|
'created_by' => $this->company->id,
|
|
]);
|
|
|
|
$compWorked = $this->service->calculateForPeriod($employeeWorked, '2026-06-01', '2026-06-15 23:59:59');
|
|
|
|
// Worked holiday premium should be 100% of daily rate (1,000.00 PHP)
|
|
$holidayEarning = collect($compWorked['earnings'])->firstWhere('name', 'Holiday Pay');
|
|
$this->assertEquals(1000.00, (float)$holidayEarning['amount']);
|
|
|
|
// 2. Unworked Regular Holiday
|
|
$employeeUnworked = $this->createEmployee('Unworked Regular', 26000.00);
|
|
|
|
$compUnworked = $this->service->calculateForPeriod($employeeUnworked, '2026-06-01', '2026-06-15 23:59:59');
|
|
|
|
// Unworked holiday should still get 100% of daily rate (1,000.00 PHP) as Holiday Pay
|
|
$holidayEarningUnworked = collect($compUnworked['earnings'])->firstWhere('name', 'Holiday Pay');
|
|
$this->assertEquals(1000.00, (float)$holidayEarningUnworked['amount']);
|
|
}
|
|
|
|
/**
|
|
* Scenario 2: Special Non-Working Holiday worked/unworked (130% / 0%)
|
|
*/
|
|
public function test_scenario_2_special_holiday()
|
|
{
|
|
// 1. Worked Special Holiday
|
|
$employeeWorked = $this->createEmployee('Worked Special', 26000.00); // Daily rate = 1000
|
|
|
|
Holiday::create([
|
|
'name' => 'Special Holiday Test',
|
|
'start_date' => '2026-06-12',
|
|
'end_date' => '2026-06-12',
|
|
'holiday_type' => 'special_non_working',
|
|
'category' => 'National',
|
|
'created_by' => $this->company->id,
|
|
]);
|
|
|
|
AttendanceRecord::create([
|
|
'employee_id' => $employeeWorked->user_id,
|
|
'date' => '2026-06-12',
|
|
'clock_in' => '08:00:00',
|
|
'clock_out' => '17:00:00',
|
|
'total_hours' => 9.0,
|
|
'status' => 'present',
|
|
'is_absent' => false,
|
|
'is_rest_day' => false,
|
|
'created_by' => $this->company->id,
|
|
]);
|
|
|
|
$compWorked = $this->service->calculateForPeriod($employeeWorked, '2026-06-01', '2026-06-15 23:59:59');
|
|
|
|
// Worked special holiday premium should be 30% of daily rate (300.00 PHP)
|
|
$holidayEarning = collect($compWorked['earnings'])->firstWhere('name', 'Holiday Pay');
|
|
$this->assertEquals(300.00, (float)$holidayEarning['amount']);
|
|
|
|
// 2. Unworked Special Holiday
|
|
$employeeUnworked = $this->createEmployee('Unworked Special', 26000.00);
|
|
|
|
// Seed present attendance for all other working days
|
|
$this->seedAttendancesForPeriod($employeeUnworked->user_id, ['2026-06-12']);
|
|
|
|
$compUnworked = $this->service->calculateForPeriod($employeeUnworked, '2026-06-01', '2026-06-15 23:59:59');
|
|
|
|
// Unworked special holiday gets 0 holiday pay and no absence deduction (it is a non-working day)
|
|
$holidayEarningUnworked = collect($compUnworked['earnings'])->firstWhere('name', 'Holiday Pay');
|
|
$this->assertEquals(0.00, (float)$holidayEarningUnworked['amount']);
|
|
$this->assertEquals(0, $compUnworked['summary']['absences']);
|
|
}
|
|
|
|
/**
|
|
* Scenario 3: Rest Day worked
|
|
*/
|
|
public function test_scenario_3_rest_day()
|
|
{
|
|
$employee = $this->createEmployee('Worked RestDay', 26000.00); // Daily rate = 1000
|
|
|
|
// June 14 is a Sunday (Rest Day)
|
|
AttendanceRecord::create([
|
|
'employee_id' => $employee->user_id,
|
|
'date' => '2026-06-14',
|
|
'clock_in' => '08:00:00',
|
|
'clock_out' => '17:00:00',
|
|
'total_hours' => 9.0,
|
|
'status' => 'present',
|
|
'is_absent' => false,
|
|
'is_rest_day' => true,
|
|
'created_by' => $this->company->id,
|
|
]);
|
|
|
|
$comp = $this->service->calculateForPeriod($employee, '2026-06-01', '2026-06-15 23:59:59');
|
|
|
|
// Per client instruction comment: "Rest Day Premium removed per client request"
|
|
// It does not add extra premium to the earnings, but treats it as a day worked.
|
|
$this->assertEquals(1, $comp['summary']['days_worked']);
|
|
}
|
|
|
|
/**
|
|
* Scenario 4: Compounding Premium (Regular Holiday on Rest Day = 260%)
|
|
*/
|
|
public function test_scenario_4_compounding_premium()
|
|
{
|
|
$employee = $this->createEmployee('Compounded RestHoliday', 26000.00); // Daily rate = 1000
|
|
|
|
// Create Regular Holiday on June 14 (Sunday / Rest Day)
|
|
Holiday::create([
|
|
'name' => 'Regular Holiday on Rest Day',
|
|
'start_date' => '2026-06-14',
|
|
'end_date' => '2026-06-14',
|
|
'holiday_type' => 'regular',
|
|
'category' => 'National',
|
|
'created_by' => $this->company->id,
|
|
]);
|
|
|
|
AttendanceRecord::create([
|
|
'employee_id' => $employee->user_id,
|
|
'date' => '2026-06-14',
|
|
'clock_in' => '08:00:00',
|
|
'clock_out' => '17:00:00',
|
|
'total_hours' => 9.0,
|
|
'status' => 'present',
|
|
'is_absent' => false,
|
|
'is_rest_day' => true,
|
|
'created_by' => $this->company->id,
|
|
]);
|
|
|
|
$comp = $this->service->calculateForPeriod($employee, '2026-06-01', '2026-06-15 23:59:59');
|
|
|
|
// Regular Holiday on Rest Day worked premium: 160% of daily rate (1,600.00 PHP)
|
|
$holidayEarning = collect($comp['earnings'])->firstWhere('name', 'Holiday Pay');
|
|
$this->assertEquals(1600.00, (float)$holidayEarning['amount']);
|
|
}
|
|
|
|
/**
|
|
* Scenario 5: Night Differential & Overtime with break deductions (meal breaks)
|
|
*/
|
|
public function test_scenario_5_night_diff_and_ot()
|
|
{
|
|
$employee = $this->createEmployee('OT and ND Employee', 26000.00); // Hourly rate = 1000 / 8 = 125.00
|
|
|
|
// June 10 (Wednesday)
|
|
AttendanceRecord::create([
|
|
'employee_id' => $employee->user_id,
|
|
'date' => '2026-06-10',
|
|
'clock_in' => '14:00:00',
|
|
'clock_out' => '23:00:00',
|
|
'total_hours' => 9.0,
|
|
'night_diff_hours' => 4.0, // 4 hours in ND bracket (10 PM onwards)
|
|
'status' => 'present',
|
|
'is_absent' => false,
|
|
'is_rest_day' => false,
|
|
'created_by' => $this->company->id,
|
|
]);
|
|
|
|
// Approved OT application for 2 hours
|
|
OvertimeApplication::create([
|
|
'user_id' => $employee->user_id,
|
|
'date' => '2026-06-10',
|
|
'requested_hours' => 2.0,
|
|
'approved_hours' => 2.0,
|
|
'status' => 'approved',
|
|
'reason' => 'Busy day',
|
|
]);
|
|
|
|
$comp = $this->service->calculateForPeriod($employee, '2026-06-01', '2026-06-15 23:59:59');
|
|
|
|
// ND calculation: (night_diff_hours - 1.5 shift meal break deduction) * hourly_rate * 10%
|
|
// (4.0 - 1.5) = 2.5 hours * (125.00 * 0.10) = 2.5 * 12.50 = 31.25 PHP
|
|
$ndEarning = collect($comp['earnings'])->firstWhere('name', 'Night Differential');
|
|
$this->assertEquals(31.25, (float)$ndEarning['amount']);
|
|
|
|
// OT calculation: 2.0 hours * (125.00 * 1.25 multiplier) = 2.0 * 156.25 = 312.50 PHP
|
|
$otEarning = collect($comp['earnings'])->firstWhere('name', 'Overtime (Regular)');
|
|
$this->assertEquals(312.50, (float)$otEarning['amount']);
|
|
}
|
|
|
|
/**
|
|
* Scenario 6: Paid vs Unpaid Leaves (LWOP) proration
|
|
*/
|
|
public function test_scenario_6_leaves()
|
|
{
|
|
$employee = $this->createEmployee('Leave Employee', 26000.00); // Daily rate = 1000
|
|
|
|
$paidType = LeaveType::create([
|
|
'name' => 'Paid Vacation Leave',
|
|
'code' => 'PVL',
|
|
'is_paid' => true,
|
|
'created_by' => $this->company->id,
|
|
]);
|
|
|
|
$paidPolicy = LeavePolicy::create([
|
|
'name' => 'Paid Policy',
|
|
'leave_type_id' => $paidType->id,
|
|
'requires_approval' => true,
|
|
'created_by' => $this->company->id,
|
|
]);
|
|
|
|
$unpaidType = LeaveType::create([
|
|
'name' => 'Unpaid Leave (LWOP)',
|
|
'code' => 'LWOP',
|
|
'is_paid' => false,
|
|
'created_by' => $this->company->id,
|
|
]);
|
|
|
|
$unpaidPolicy = LeavePolicy::create([
|
|
'name' => 'Unpaid Policy',
|
|
'leave_type_id' => $unpaidType->id,
|
|
'requires_approval' => true,
|
|
'created_by' => $this->company->id,
|
|
]);
|
|
|
|
// 1. Paid Leave on June 8
|
|
LeaveApplication::create([
|
|
'employee_id' => $employee->user_id,
|
|
'leave_type_id' => $paidType->id,
|
|
'leave_policy_id' => $paidPolicy->id,
|
|
'start_date' => '2026-06-08',
|
|
'end_date' => '2026-06-08',
|
|
'total_days' => 1,
|
|
'status' => 'approved',
|
|
'reason' => 'Vacation',
|
|
'created_by' => $this->company->id,
|
|
]);
|
|
|
|
// 2. Unpaid Leave on June 9
|
|
LeaveApplication::create([
|
|
'employee_id' => $employee->user_id,
|
|
'leave_type_id' => $unpaidType->id,
|
|
'leave_policy_id' => $unpaidPolicy->id,
|
|
'start_date' => '2026-06-09',
|
|
'end_date' => '2026-06-09',
|
|
'total_days' => 1,
|
|
'status' => 'approved',
|
|
'reason' => 'LWOP reason',
|
|
'created_by' => $this->company->id,
|
|
]);
|
|
|
|
// Seed present attendances for all other working days
|
|
$this->seedAttendancesForPeriod($employee->user_id, ['2026-06-08', '2026-06-09']);
|
|
|
|
$comp = $this->service->calculateForPeriod($employee, '2026-06-01', '2026-06-15 23:59:59');
|
|
|
|
// Cut-off salary = 13,000.00
|
|
// Paid leaves amount = 1,000.00 PHP (shown as separate earning line)
|
|
// Unpaid leave = counts as 1 absence, leading to 1,000.00 PHP deduction
|
|
$paidLeaveEarning = collect($comp['earnings'])->firstWhere('name', 'Paid Leaves');
|
|
$this->assertEquals(1000.00, (float)$paidLeaveEarning['amount']);
|
|
|
|
$absenceDeduction = collect($comp['deductions'])->firstWhere('name', 'Absences / LWOP');
|
|
$this->assertEquals(1000.00, (float)$absenceDeduction['amount']);
|
|
|
|
// Basic salary = Cut-off salary - paid leaves amount - unworked holiday (0) = 13,000 - 1,000 = 12,000.00
|
|
$this->assertEquals(12000.00, $comp['basic_salary']);
|
|
}
|
|
|
|
/**
|
|
* Scenario 7: Daily Wage vs Fixed Monthly Wage calculation proration
|
|
*/
|
|
public function test_scenario_7_wage_types()
|
|
{
|
|
// Monthly wage: 26000.00 (Cut-off salary = 13000.00)
|
|
$employeeMonthly = $this->createEmployee('Monthly Employee', 26000.00, 'semi-monthly', false);
|
|
|
|
// Daily wage: 1000.00 basic_salary, pay_frequency = daily
|
|
$employeeDaily = $this->createEmployee('Daily Employee', 1000.00, 'daily', true);
|
|
|
|
// Attendance on 5 days in period
|
|
$dates = ['2026-06-01', '2026-06-02', '2026-06-03', '2026-06-04', '2026-06-05'];
|
|
foreach ($dates as $date) {
|
|
AttendanceRecord::create([
|
|
'employee_id' => $employeeMonthly->user_id,
|
|
'date' => $date,
|
|
'clock_in' => '08:00:00',
|
|
'clock_out' => '17:00:00',
|
|
'total_hours' => 9.0,
|
|
'status' => 'present',
|
|
'is_absent' => false,
|
|
'created_by' => $this->company->id,
|
|
]);
|
|
|
|
AttendanceRecord::create([
|
|
'employee_id' => $employeeDaily->user_id,
|
|
'date' => $date,
|
|
'clock_in' => '08:00:00',
|
|
'clock_out' => '17:00:00',
|
|
'total_hours' => 9.0,
|
|
'status' => 'present',
|
|
'is_absent' => false,
|
|
'created_by' => $this->company->id,
|
|
]);
|
|
}
|
|
|
|
$compMonthly = $this->service->calculateForPeriod($employeeMonthly, '2026-06-01', '2026-06-15 23:59:59');
|
|
$compDaily = $this->service->calculateForPeriod($employeeDaily, '2026-06-01', '2026-06-15 23:59:59');
|
|
|
|
// Monthly earner gets full cut-off salary (13,000.00) minus absences deduction
|
|
// expected working days = 11. worked = 5. Absences = 6 * 1,000 = 6,000.00 deduction.
|
|
// basic_salary remains 13,000.00 (since no leaves/holidays), net total earnings is 13,000.00, net pay is 13,000 - 6,000 = 7,000 (excluding statutory).
|
|
$this->assertEquals(13000.00, $compMonthly['basic_salary']);
|
|
$absenceDeduction = collect($compMonthly['deductions'])->firstWhere('name', 'Absences / LWOP');
|
|
$this->assertEquals(6000.00, (float)$absenceDeduction['amount']);
|
|
|
|
// Daily earner gets strictly daily rate * days worked = 1000.00 * 5 = 5,000.00 PHP basic salary
|
|
// with no absences deduction
|
|
$this->assertEquals(5000.00, $compDaily['basic_salary']);
|
|
$absenceDeductionDaily = collect($compDaily['deductions'])->firstWhere('name', 'Absences / LWOP');
|
|
$this->assertNull($absenceDeductionDaily);
|
|
}
|
|
}
|