Files
HRM-System/tests/Feature/PayrollScenarioTest.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);
}
}