$year, 'month' => $month], ['status' => 'open'] ); $user = User::factory()->create(['is_active' => true]); $employee = Employee::factory()->create([ 'user_id' => $user->id, 'join_date' => Carbon::createFromDate($year, $month, 1)->subMonth()->toDateString(), 'resign_date' => null, 'employment_status' => 'full_time', 'base_salary' => 5000000, ]); $payroll = Payroll::factory()->create([ 'payroll_period_id' => $period->id, 'employee_id' => $employee->id, 'base_salary' => $employee->base_salary, 'bonus_amount' => 0, 'deduction_amount' => 0, 'total_amount' => $employee->base_salary, 'status' => $status, ]); return compact('period', 'user', 'employee', 'payroll'); } function setHrSettings(int $latePenalty, int $absentPenalty, string $checkInTime = '08:00'): void { $settings = app(HRSettings::class); $settings->scheduled_check_in_time = $checkInTime; $settings->scheduled_check_out_time = '17:00'; $settings->late_penalty_amount = $latePenalty; $settings->absent_penalty_amount = $absentPenalty; $settings->save(); } /* |-------------------------------------------------------------------------- | SKIP CONDITIONS |-------------------------------------------------------------------------- */ test('job skips when yesterday is sunday', function () { $sunday = Carbon::parse('2024-07-07'); // Sunday Carbon::setTestNow($sunday->copy()->addDay()); setHrSettings(50000, 100000); $data = createActiveEmployeeWithPayroll($sunday->year, $sunday->month); Attendance::factory()->create([ 'employee_id' => $data['employee']->id, 'attendance_date' => $sunday->toDateString(), 'check_in_at' => $sunday->copy()->setHour(9), ]); (new CheckAttendancePenaltiesJob)->handle(); $this->assertDatabaseCount('payroll_adjustments', 0); }); test('job skips when yesterday is saturday', function () { $saturday = Carbon::parse('2024-07-06'); // Saturday Carbon::setTestNow($saturday->copy()->addDay()); setHrSettings(50000, 100000); $data = createActiveEmployeeWithPayroll($saturday->year, $saturday->month); Attendance::factory()->create([ 'employee_id' => $data['employee']->id, 'attendance_date' => $saturday->toDateString(), 'check_in_at' => $saturday->copy()->setHour(9), ]); (new CheckAttendancePenaltiesJob)->handle(); $this->assertDatabaseCount('payroll_adjustments', 0); }); test('job skips when both penalties are zero', function () { $yesterday = now()->subDay(); Carbon::setTestNow(now()); setHrSettings(0, 0); $data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month); Attendance::factory()->create([ 'employee_id' => $data['employee']->id, 'attendance_date' => $yesterday->toDateString(), 'check_in_at' => $yesterday->copy()->setHour(9), ]); (new CheckAttendancePenaltiesJob)->handle(); $this->assertDatabaseCount('payroll_adjustments', 0); }); test('job skips when no payroll period exists', function () { $yesterday = now()->subDay(); Carbon::setTestNow(now()); setHrSettings(50000, 100000); $user = User::factory()->create(['is_active' => true]); $employee = Employee::factory()->create([ 'user_id' => $user->id, 'join_date' => $yesterday->copy()->subMonth()->toDateString(), 'resign_date' => null, 'employment_status' => 'full_time', 'base_salary' => 5000000, ]); (new CheckAttendancePenaltiesJob)->handle(); $this->assertDatabaseCount('payroll_adjustments', 0); }); test('job skips when no system user exists', function () { $yesterday = now()->subDay(); Carbon::setTestNow(now()); setHrSettings(50000, 100000); $data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month); User::query()->delete(); (new CheckAttendancePenaltiesJob)->handle(); $this->assertDatabaseCount('payroll_adjustments', 0); }); test('job skips inactive employees', function () { $yesterday = now()->subDay(); Carbon::setTestNow(now()); setHrSettings(50000, 100000); $data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month); $data['user']->update(['is_active' => false]); (new CheckAttendancePenaltiesJob)->handle(); $this->assertDatabaseCount('payroll_adjustments', 0); }); test('job skips resigned employees', function () { $yesterday = now()->subDay(); Carbon::setTestNow(now()); setHrSettings(50000, 100000); $data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month); $data['employee']->update(['resign_date' => $yesterday->copy()->subDays(5)->toDateString()]); (new CheckAttendancePenaltiesJob)->handle(); $this->assertDatabaseCount('payroll_adjustments', 0); }); test('job skips when payroll is already paid', function () { $yesterday = now()->subDay(); Carbon::setTestNow(now()); setHrSettings(50000, 100000); $data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month, PayrollStatus::PAID); (new CheckAttendancePenaltiesJob)->handle(); $this->assertDatabaseCount('payroll_adjustments', 0); }); test('job skips when payroll is cancelled', function () { $yesterday = now()->subDay(); Carbon::setTestNow(now()); setHrSettings(50000, 100000); $data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month, PayrollStatus::CANCELLED); (new CheckAttendancePenaltiesJob)->handle(); $this->assertDatabaseCount('payroll_adjustments', 0); }); /* |-------------------------------------------------------------------------- | LATE PENALTY |-------------------------------------------------------------------------- */ test('job creates late penalty when employee checks in late', function () { $yesterday = now()->subDay(); Carbon::setTestNow(now()); setHrSettings(50000, 100000, '08:00'); $data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month); Attendance::factory()->create([ 'employee_id' => $data['employee']->id, 'attendance_date' => $yesterday->toDateString(), 'check_in_at' => $yesterday->copy()->setHour(8)->setMinute(30), ]); (new CheckAttendancePenaltiesJob)->handle(); $this->assertDatabaseHas('payroll_adjustments', [ 'payroll_id' => $data['payroll']->id, 'type' => PayrollAdjustmentType::DEDUCTION, 'amount' => 50000, ]); }); test('job does not create late penalty when employee checks in on time', function () { $yesterday = now()->subDay(); Carbon::setTestNow(now()); setHrSettings(50000, 100000, '08:00'); $data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month); Attendance::factory()->create([ 'employee_id' => $data['employee']->id, 'attendance_date' => $yesterday->toDateString(), 'check_in_at' => $yesterday->copy()->setTime(8, 0, 0), ]); (new CheckAttendancePenaltiesJob)->handle(); $this->assertDatabaseCount('payroll_adjustments', 0); }); test('job does not create late penalty when employee checks in early', function () { $yesterday = now()->subDay(); Carbon::setTestNow(now()); setHrSettings(50000, 100000, '08:00'); $data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month); Attendance::factory()->create([ 'employee_id' => $data['employee']->id, 'attendance_date' => $yesterday->toDateString(), 'check_in_at' => $yesterday->copy()->setTime(7, 30, 0), ]); (new CheckAttendancePenaltiesJob)->handle(); $this->assertDatabaseCount('payroll_adjustments', 0); }); test('job does not create late penalty when late_penalty_amount is zero', function () { $yesterday = now()->subDay(); Carbon::setTestNow(now()); setHrSettings(0, 100000, '08:00'); $data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month); Attendance::factory()->create([ 'employee_id' => $data['employee']->id, 'attendance_date' => $yesterday->toDateString(), 'check_in_at' => $yesterday->copy()->setHour(9), ]); (new CheckAttendancePenaltiesJob)->handle(); $this->assertDatabaseCount('payroll_adjustments', 0); }); test('late penalty description includes date and minutes', function () { $yesterday = now()->subDay(); Carbon::setTestNow(now()); setHrSettings(50000, 100000, '08:00'); $data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month); Attendance::factory()->create([ 'employee_id' => $data['employee']->id, 'attendance_date' => $yesterday->toDateString(), 'check_in_at' => $yesterday->copy()->setHour(8)->setMinute(30), ]); (new CheckAttendancePenaltiesJob)->handle(); $adjustment = PayrollAdjustment::where('payroll_id', $data['payroll']->id)->first(); expect($adjustment->description)->toContain('Denda keterlambatan'); expect($adjustment->description)->toContain($yesterday->format('d/m/Y')); expect($adjustment->description)->toContain('30 menit'); }); test('late penalty links to attendance record', function () { $yesterday = now()->subDay(); Carbon::setTestNow(now()); setHrSettings(50000, 100000, '08:00'); $data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month); $attendance = Attendance::factory()->create([ 'employee_id' => $data['employee']->id, 'attendance_date' => $yesterday->toDateString(), 'check_in_at' => $yesterday->copy()->setHour(9), ]); (new CheckAttendancePenaltiesJob)->handle(); $adjustment = PayrollAdjustment::where('payroll_id', $data['payroll']->id)->first(); expect($adjustment->attendance_id)->toBe($attendance->id); }); /* |-------------------------------------------------------------------------- | ABSENT PENALTY |-------------------------------------------------------------------------- */ test('job creates absent penalty when employee has no attendance', function () { $yesterday = now()->subDay(); Carbon::setTestNow(now()); setHrSettings(50000, 100000); $data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month); (new CheckAttendancePenaltiesJob)->handle(); $this->assertDatabaseHas('payroll_adjustments', [ 'payroll_id' => $data['payroll']->id, 'type' => PayrollAdjustmentType::DEDUCTION, 'amount' => 100000, 'attendance_id' => null, ]); }); test('job does not create absent penalty when absent_penalty_amount is zero', function () { $yesterday = now()->subDay(); Carbon::setTestNow(now()); setHrSettings(50000, 0); $data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month); (new CheckAttendancePenaltiesJob)->handle(); $this->assertDatabaseCount('payroll_adjustments', 0); }); test('absent penalty description includes date', function () { $yesterday = now()->subDay(); Carbon::setTestNow(now()); setHrSettings(50000, 100000); $data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month); (new CheckAttendancePenaltiesJob)->handle(); $adjustment = PayrollAdjustment::where('payroll_id', $data['payroll']->id)->first(); expect($adjustment->description)->toContain('Denda ketidakhadiran'); expect($adjustment->description)->toContain($yesterday->format('d/m/Y')); }); test('absent penalty has null attendance_id', function () { $yesterday = now()->subDay(); Carbon::setTestNow(now()); setHrSettings(50000, 100000); $data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month); (new CheckAttendancePenaltiesJob)->handle(); $adjustment = PayrollAdjustment::where('payroll_id', $data['payroll']->id)->first(); expect($adjustment->attendance_id)->toBeNull(); }); /* |-------------------------------------------------------------------------- | DEDUPLICATION |-------------------------------------------------------------------------- */ test('job does not create duplicate late penalty', function () { $yesterday = now()->subDay(); Carbon::setTestNow(now()); setHrSettings(50000, 100000, '08:00'); $data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month); $attendance = Attendance::factory()->create([ 'employee_id' => $data['employee']->id, 'attendance_date' => $yesterday->toDateString(), 'check_in_at' => $yesterday->copy()->setHour(9), ]); PayrollAdjustment::create([ 'payroll_id' => $data['payroll']->id, 'created_by_id' => User::first()->id, 'attendance_id' => $attendance->id, 'type' => PayrollAdjustmentType::DEDUCTION, 'amount' => 50000, 'description' => 'Denda keterlambatan manual', ]); (new CheckAttendancePenaltiesJob)->handle(); $this->assertDatabaseCount('payroll_adjustments', 1); }); test('job does not create duplicate absent penalty', function () { $yesterday = now()->subDay(); Carbon::setTestNow(now()); setHrSettings(50000, 100000); $data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month); PayrollAdjustment::create([ 'payroll_id' => $data['payroll']->id, 'created_by_id' => User::first()->id, 'attendance_id' => null, 'type' => PayrollAdjustmentType::DEDUCTION, 'amount' => 100000, 'description' => 'Denda ketidakhadiran '.$yesterday->format('d/m/Y'), ]); (new CheckAttendancePenaltiesJob)->handle(); $this->assertDatabaseCount('payroll_adjustments', 1); }); /* |-------------------------------------------------------------------------- | PAYROLL RECALCULATION |-------------------------------------------------------------------------- */ test('job recalculates payroll total_amount after late penalty', function () { $yesterday = now()->subDay(); Carbon::setTestNow(now()); setHrSettings(50000, 100000, '08:00'); $data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month); Attendance::factory()->create([ 'employee_id' => $data['employee']->id, 'attendance_date' => $yesterday->toDateString(), 'check_in_at' => $yesterday->copy()->setHour(9), ]); (new CheckAttendancePenaltiesJob)->handle(); $data['payroll']->refresh(); expect($data['payroll']->deduction_amount)->toBe(50000); expect($data['payroll']->total_amount)->toBe($data['payroll']->base_salary - 50000); }); test('job recalculates payroll total_amount after absent penalty', function () { $yesterday = now()->subDay(); Carbon::setTestNow(now()); setHrSettings(50000, 100000); $data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month); (new CheckAttendancePenaltiesJob)->handle(); $data['payroll']->refresh(); expect($data['payroll']->deduction_amount)->toBe(100000); expect($data['payroll']->total_amount)->toBe($data['payroll']->base_salary - 100000); }); test('job recalculates payroll with existing bonus adjustments', function () { $yesterday = now()->subDay(); Carbon::setTestNow(now()); setHrSettings(50000, 100000, '08:00'); $data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month); PayrollAdjustment::create([ 'payroll_id' => $data['payroll']->id, 'created_by_id' => User::first()->id, 'attendance_id' => null, 'type' => PayrollAdjustmentType::BONUS, 'amount' => 200000, 'description' => 'Bonus test', ]); Attendance::factory()->create([ 'employee_id' => $data['employee']->id, 'attendance_date' => $yesterday->toDateString(), 'check_in_at' => $yesterday->copy()->setHour(9), ]); (new CheckAttendancePenaltiesJob)->handle(); $data['payroll']->refresh(); expect($data['payroll']->bonus_amount)->toBe(200000); expect($data['payroll']->deduction_amount)->toBe(50000); expect($data['payroll']->total_amount)->toBe($data['payroll']->base_salary + 200000 - 50000); }); /* |-------------------------------------------------------------------------- | MULTIPLE EMPLOYEES |-------------------------------------------------------------------------- */ test('job processes multiple employees', function () { $yesterday = now()->subDay(); Carbon::setTestNow(now()); setHrSettings(50000, 100000, '08:00'); $data1 = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month); $data2 = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month); Attendance::factory()->create([ 'employee_id' => $data1['employee']->id, 'attendance_date' => $yesterday->toDateString(), 'check_in_at' => $yesterday->copy()->setHour(9), ]); (new CheckAttendancePenaltiesJob)->handle(); $this->assertDatabaseHas('payroll_adjustments', [ 'payroll_id' => $data1['payroll']->id, 'type' => PayrollAdjustmentType::DEDUCTION, 'amount' => 50000, ]); $this->assertDatabaseHas('payroll_adjustments', [ 'payroll_id' => $data2['payroll']->id, 'type' => PayrollAdjustmentType::DEDUCTION, 'amount' => 100000, ]); }); test('job handles mix of late and absent employees', function () { $yesterday = now()->subDay(); Carbon::setTestNow(now()); setHrSettings(50000, 100000, '08:00'); $lateEmp = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month); $absentEmp = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month); Attendance::factory()->create([ 'employee_id' => $lateEmp['employee']->id, 'attendance_date' => $yesterday->toDateString(), 'check_in_at' => $yesterday->copy()->setHour(9), ]); (new CheckAttendancePenaltiesJob)->handle(); $this->assertDatabaseHas('payroll_adjustments', [ 'payroll_id' => $lateEmp['payroll']->id, 'amount' => 50000, ]); $this->assertDatabaseHas('payroll_adjustments', [ 'payroll_id' => $absentEmp['payroll']->id, 'amount' => 100000, ]); }); /* |-------------------------------------------------------------------------- | EDGE CASES |-------------------------------------------------------------------------- */ test('job handles employee with no user gracefully', function () { $yesterday = now()->subDay(); Carbon::setTestNow(now()); setHrSettings(50000, 100000); $data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month); (new CheckAttendancePenaltiesJob)->handle(); $this->assertDatabaseHas('payroll_adjustments', [ 'payroll_id' => $data['payroll']->id, ]); }); test('job uses system user as created_by for adjustments', function () { $yesterday = now()->subDay(); Carbon::setTestNow(now()); setHrSettings(50000, 100000); $data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month); $systemUser = User::first(); Attendance::factory()->create([ 'employee_id' => $data['employee']->id, 'attendance_date' => $yesterday->toDateString(), 'check_in_at' => $yesterday->copy()->setHour(9), ]); (new CheckAttendancePenaltiesJob)->handle(); $adjustment = PayrollAdjustment::where('payroll_id', $data['payroll']->id)->first(); expect($adjustment->created_by_id)->toBe($systemUser->id); }); test('job can be dispatched to queue', function () { Queue::fake(); CheckAttendancePenaltiesJob::dispatch(); Queue::assertPushed(CheckAttendancePenaltiesJob::class); }); test('job has correct tries property', function () { $job = new CheckAttendancePenaltiesJob; expect($job->tries)->toBe(3); });