get(route('admin.finance.payroll-periods.index')); $response->assertRedirect(route('login')); }); test('guests are redirected to the login page from payroll period show', function () { $period = PayrollPeriod::factory()->create(); $response = $this->get(route('admin.finance.payroll-periods.show', $period)); $response->assertRedirect(route('login')); }); test('guests are redirected to the login page from current', function () { $response = $this->get(route('admin.finance.payroll-periods.current')); $response->assertRedirect(route('login')); }); test('guests are redirected to the login page from close', function () { $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); $response = $this->post(route('admin.finance.payroll-periods.close', $period)); $response->assertRedirect(route('login')); }); test('guests are redirected to the login page from reopen', function () { $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::CLOSED]); $response = $this->post(route('admin.finance.payroll-periods.reopen', $period)); $response->assertRedirect(route('login')); }); test('guests are redirected to the login page from pay', function () { $payroll = Payroll::factory()->create(['status' => PayrollStatus::UNPAID]); $response = $this->post(route('admin.finance.payrolls.pay', $payroll)); $response->assertRedirect(route('login')); }); test('guests are redirected to the login page from cancel', function () { $payroll = Payroll::factory()->create(['status' => PayrollStatus::UNPAID]); $response = $this->post(route('admin.finance.payrolls.cancel', $payroll)); $response->assertRedirect(route('login')); }); test('guests are redirected to the login page from store adjustment', function () { $payroll = Payroll::factory()->create(['status' => PayrollStatus::UNPAID]); $response = $this->post(route('admin.finance.payrolls.adjustments.store', $payroll), [ 'type' => 'bonus', 'amount' => 500000, 'description' => 'Test', ]); $response->assertRedirect(route('login')); }); test('guests are redirected to the login page from delete adjustment', function () { $adjustment = PayrollAdjustment::factory()->create(); $response = $this->delete(route('admin.finance.payroll-adjustments.destroy', $adjustment)); $response->assertRedirect(route('login')); }); /* |-------------------------------------------------------------------------- | PAYROLL PERIOD - INDEX |-------------------------------------------------------------------------- */ test('authenticated users can visit the payroll period index page', function () { $user = User::factory()->create(); $this->actingAs($user); $response = $this->get(route('admin.finance.payroll-periods.index')); $response->assertOk(); }); test('index page displays payroll periods', function () { $user = User::factory()->create(); $this->actingAs($user); $period = PayrollPeriod::factory()->create(['year' => 2026, 'month' => 7]); $employee = Employee::factory()->create(['base_salary' => 3000000]); Payroll::factory()->create([ 'payroll_period_id' => $period->id, 'employee_id' => $employee->id, 'base_salary' => 3000000, 'total_amount' => 3000000, ]); $response = $this->get(route('admin.finance.payroll-periods.index')); $response->assertOk(); $response->assertInertia(fn (Assert $page) => $page ->component('admin/finance/payroll-period/index') ->has('payrollPeriods', 1) ); }); test('index page works when no payroll periods exist', function () { $user = User::factory()->create(); $this->actingAs($user); $response = $this->get(route('admin.finance.payroll-periods.index')); $response->assertOk(); $response->assertInertia(fn (Assert $page) => $page ->component('admin/finance/payroll-period/index') ->has('payrollPeriods', 0) ); }); test('index page displays correct aggregates', function () { $user = User::factory()->create(); $this->actingAs($user); $period = PayrollPeriod::factory()->create(['year' => 2026, 'month' => 7]); $emp1 = Employee::factory()->create(['base_salary' => 3000000]); $emp2 = Employee::factory()->create(['base_salary' => 4000000]); Payroll::factory()->create([ 'payroll_period_id' => $period->id, 'employee_id' => $emp1->id, 'base_salary' => 3000000, 'bonus_amount' => 500000, 'deduction_amount' => 100000, 'total_amount' => 3400000, 'status' => PayrollStatus::PAID, ]); Payroll::factory()->create([ 'payroll_period_id' => $period->id, 'employee_id' => $emp2->id, 'base_salary' => 4000000, 'bonus_amount' => 0, 'deduction_amount' => 0, 'total_amount' => 4000000, 'status' => PayrollStatus::UNPAID, ]); $response = $this->get(route('admin.finance.payroll-periods.index')); $response->assertOk(); $response->assertInertia(fn (Assert $page) => $page ->component('admin/finance/payroll-period/index') ->has('payrollPeriods', 1) ->where('payrollPeriods.0.payrolls_count', 2) ->where('payrollPeriods.0.paid_count', 1) ); }); /* |-------------------------------------------------------------------------- | PAYROLL PERIOD - SHOW |-------------------------------------------------------------------------- */ test('authenticated users can visit the payroll period show page', function () { $user = User::factory()->create(); $this->actingAs($user); $period = PayrollPeriod::factory()->create(['year' => 2026, 'month' => 7]); $response = $this->get(route('admin.finance.payroll-periods.show', $period)); $response->assertOk(); }); test('show page displays payroll period detail with payrolls', function () { $user = User::factory()->create(); $this->actingAs($user); $period = PayrollPeriod::factory()->create(['year' => 2026, 'month' => 7]); $employee = Employee::factory()->create(['base_salary' => 3000000]); Payroll::factory()->create([ 'payroll_period_id' => $period->id, 'employee_id' => $employee->id, 'base_salary' => 3000000, 'total_amount' => 3000000, ]); $response = $this->get(route('admin.finance.payroll-periods.show', $period)); $response->assertOk(); $response->assertInertia(fn (Assert $page) => $page ->component('admin/finance/payroll-period/show') ->has('payrollPeriod') ->where('payrollPeriod.year', 2026) ->where('payrollPeriod.month', 7) ->has('payrollPeriod.payrolls', 1) ); }); /* |-------------------------------------------------------------------------- | PAYROLL PERIOD - CURRENT |-------------------------------------------------------------------------- */ test('current route creates period if not exists and redirects', function () { $user = User::factory()->create(); $this->actingAs($user); $response = $this->get(route('admin.finance.payroll-periods.current')); $response->assertRedirect(); $now = now(); $this->assertDatabaseHas('payroll_periods', [ 'year' => $now->year, 'month' => $now->month, 'status' => PayrollPeriodStatus::OPEN->value, ]); }); test('current route redirects to existing period', function () { $user = User::factory()->create(); $this->actingAs($user); $now = now(); $period = PayrollPeriod::factory()->create([ 'year' => $now->year, 'month' => $now->month, 'status' => PayrollPeriodStatus::OPEN, ]); $response = $this->get(route('admin.finance.payroll-periods.current')); $response->assertRedirect(); $this->assertDatabaseCount('payroll_periods', 1); }); /* |-------------------------------------------------------------------------- | PAYROLL PERIOD - CLOSE |-------------------------------------------------------------------------- */ test('open period can be closed when all payrolls are paid', function () { $user = User::factory()->create(); $this->actingAs($user); $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); $employee = Employee::factory()->create(); Payroll::factory()->create([ 'payroll_period_id' => $period->id, 'employee_id' => $employee->id, 'status' => PayrollStatus::PAID, ]); $response = $this->post(route('admin.finance.payroll-periods.close', $period)); $response ->assertSessionHasNoErrors() ->assertRedirect(route('admin.finance.payroll-periods.index')); $period->refresh(); expect($period->status)->toBe(PayrollPeriodStatus::CLOSED); expect($period->closed_by_id)->toBe($user->id); expect($period->closed_at)->not->toBeNull(); }); test('close fails when period already closed', function () { $user = User::factory()->create(); $this->actingAs($user); $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::CLOSED]); $response = $this->post(route('admin.finance.payroll-periods.close', $period)); $response->assertRedirect(); $period->refresh(); expect($period->status)->toBe(PayrollPeriodStatus::CLOSED); }); test('close fails when there are unpaid payrolls', function () { $user = User::factory()->create(); $this->actingAs($user); $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); $employee = Employee::factory()->create(); Payroll::factory()->create([ 'payroll_period_id' => $period->id, 'employee_id' => $employee->id, 'status' => PayrollStatus::UNPAID, ]); $response = $this->post(route('admin.finance.payroll-periods.close', $period)); $response->assertRedirect(); $period->refresh(); expect($period->status)->toBe(PayrollPeriodStatus::OPEN); }); /* |-------------------------------------------------------------------------- | PAYROLL PERIOD - REOPEN |-------------------------------------------------------------------------- */ test('closed period can be reopened', function () { $user = User::factory()->create(); $this->actingAs($user); $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::CLOSED]); $response = $this->post(route('admin.finance.payroll-periods.reopen', $period)); $response ->assertSessionHasNoErrors() ->assertRedirect(route('admin.finance.payroll-periods.index')); $period->refresh(); expect($period->status)->toBe(PayrollPeriodStatus::OPEN); expect($period->closed_by_id)->toBeNull(); expect($period->closed_at)->toBeNull(); }); test('reopen fails when period already open', function () { $user = User::factory()->create(); $this->actingAs($user); $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); $response = $this->post(route('admin.finance.payroll-periods.reopen', $period)); $response->assertRedirect(); $period->refresh(); expect($period->status)->toBe(PayrollPeriodStatus::OPEN); }); /* |-------------------------------------------------------------------------- | PAYROLL - PAY |-------------------------------------------------------------------------- */ test('unpaid payroll can be paid', function () { $user = User::factory()->create(); $this->actingAs($user); CashAccount::factory()->create(['balance' => 0]); $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); $employee = Employee::factory()->create(['base_salary' => 3000000]); $payroll = Payroll::factory()->create([ 'payroll_period_id' => $period->id, 'employee_id' => $employee->id, 'base_salary' => 3000000, 'bonus_amount' => 500000, 'deduction_amount' => 100000, 'total_amount' => 3400000, 'status' => PayrollStatus::UNPAID, ]); $response = $this->post(route('admin.finance.payrolls.pay', $payroll)); $response ->assertSessionHasNoErrors() ->assertRedirect(route('admin.finance.payroll-periods.show', $period)); $payroll->refresh(); expect($payroll->status)->toBe(PayrollStatus::PAID); expect($payroll->paid_by_id)->toBe($user->id); expect($payroll->paid_at)->not->toBeNull(); expect($payroll->cash_transaction_id)->not->toBeNull(); expect(CashAccount::first()->balance)->toBe(3400000); }); test('pay creates cash transaction with correct data', function () { $user = User::factory()->create(); $this->actingAs($user); CashAccount::factory()->create(['balance' => 0]); $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); $employee = Employee::factory()->create(['base_salary' => 3000000]); $payroll = Payroll::factory()->create([ 'payroll_period_id' => $period->id, 'employee_id' => $employee->id, 'base_salary' => 3000000, 'total_amount' => 3000000, 'status' => PayrollStatus::UNPAID, ]); $this->post(route('admin.finance.payrolls.pay', $payroll)); $this->assertDatabaseHas('cash_transactions', [ 'cash_account_id' => CashAccount::first()->id, 'created_by_id' => $user->id, 'amount' => 3000000, 'type' => 'deposit', 'description' => 'Pembayaran gaji karyawan', ]); }); test('already paid payroll cannot be paid again', function () { $user = User::factory()->create(); $this->actingAs($user); CashAccount::factory()->create(['balance' => 0]); $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); $employee = Employee::factory()->create(); $payroll = Payroll::factory()->create([ 'payroll_period_id' => $period->id, 'employee_id' => $employee->id, 'status' => PayrollStatus::PAID, 'total_amount' => 3000000, ]); $response = $this->post(route('admin.finance.payrolls.pay', $payroll)); $response->assertRedirect(); expect(CashAccount::first()->balance)->toBe(0); }); test('cancelled payroll cannot be paid', function () { $user = User::factory()->create(); $this->actingAs($user); CashAccount::factory()->create(['balance' => 0]); $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); $employee = Employee::factory()->create(); $payroll = Payroll::factory()->create([ 'payroll_period_id' => $period->id, 'employee_id' => $employee->id, 'status' => PayrollStatus::CANCELLED, ]); $response = $this->post(route('admin.finance.payrolls.pay', $payroll)); $response->assertRedirect(); expect(CashAccount::first()->balance)->toBe(0); }); test('paying non-existent payroll returns 404', function () { $user = User::factory()->create(); $this->actingAs($user); $response = $this->post(route('admin.finance.payrolls.pay', 999999)); $response->assertStatus(404); }); /* |-------------------------------------------------------------------------- | PAYROLL - CANCEL |-------------------------------------------------------------------------- */ test('unpaid payroll can be cancelled', function () { $user = User::factory()->create(); $this->actingAs($user); $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); $employee = Employee::factory()->create(); $payroll = Payroll::factory()->create([ 'payroll_period_id' => $period->id, 'employee_id' => $employee->id, 'status' => PayrollStatus::UNPAID, ]); $response = $this->post(route('admin.finance.payrolls.cancel', $payroll)); $response ->assertSessionHasNoErrors() ->assertRedirect(route('admin.finance.payroll-periods.show', $period)); $payroll->refresh(); expect($payroll->status)->toBe(PayrollStatus::CANCELLED); }); test('paid payroll cannot be cancelled', function () { $user = User::factory()->create(); $this->actingAs($user); $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); $employee = Employee::factory()->create(); $payroll = Payroll::factory()->create([ 'payroll_period_id' => $period->id, 'employee_id' => $employee->id, 'status' => PayrollStatus::PAID, ]); $response = $this->post(route('admin.finance.payrolls.cancel', $payroll)); $response->assertRedirect(); $payroll->refresh(); expect($payroll->status)->toBe(PayrollStatus::PAID); }); test('already cancelled payroll cannot be cancelled again', function () { $user = User::factory()->create(); $this->actingAs($user); $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); $employee = Employee::factory()->create(); $payroll = Payroll::factory()->create([ 'payroll_period_id' => $period->id, 'employee_id' => $employee->id, 'status' => PayrollStatus::CANCELLED, ]); $response = $this->post(route('admin.finance.payrolls.cancel', $payroll)); $response->assertRedirect(); $payroll->refresh(); expect($payroll->status)->toBe(PayrollStatus::CANCELLED); }); test('cancelling non-existent payroll returns 404', function () { $user = User::factory()->create(); $this->actingAs($user); $response = $this->post(route('admin.finance.payrolls.cancel', 999999)); $response->assertStatus(404); }); /* |-------------------------------------------------------------------------- | PAYROLL ADJUSTMENT - STORE |-------------------------------------------------------------------------- */ test('bonus adjustment can be added to unpaid payroll', function () { $user = User::factory()->create(); $this->actingAs($user); $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); $employee = Employee::factory()->create(['base_salary' => 3000000]); $payroll = Payroll::factory()->create([ 'payroll_period_id' => $period->id, 'employee_id' => $employee->id, 'base_salary' => 3000000, 'bonus_amount' => 0, 'deduction_amount' => 0, 'total_amount' => 3000000, 'status' => PayrollStatus::UNPAID, ]); $response = $this->post(route('admin.finance.payrolls.adjustments.store', $payroll), [ 'type' => 'bonus', 'amount' => 500000, 'description' => 'Bonus performa', ]); $response ->assertSessionHasNoErrors() ->assertRedirect(route('admin.finance.payroll-periods.show', $period)); $this->assertDatabaseHas('payroll_adjustments', [ 'payroll_id' => $payroll->id, 'type' => PayrollAdjustmentType::BONUS->value, 'amount' => 500000, 'description' => 'Bonus performa', ]); $payroll->refresh(); expect($payroll->bonus_amount)->toBe(500000); expect($payroll->total_amount)->toBe(3500000); }); test('deduction adjustment can be added to unpaid payroll', function () { $user = User::factory()->create(); $this->actingAs($user); $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); $employee = Employee::factory()->create(['base_salary' => 3000000]); $payroll = Payroll::factory()->create([ 'payroll_period_id' => $period->id, 'employee_id' => $employee->id, 'base_salary' => 3000000, 'bonus_amount' => 0, 'deduction_amount' => 0, 'total_amount' => 3000000, 'status' => PayrollStatus::UNPAID, ]); $response = $this->post(route('admin.finance.payrolls.adjustments.store', $payroll), [ 'type' => 'deduction', 'amount' => 200000, 'description' => 'Potongan telat', ]); $response ->assertSessionHasNoErrors() ->assertRedirect(route('admin.finance.payroll-periods.show', $period)); $payroll->refresh(); expect($payroll->deduction_amount)->toBe(200000); expect($payroll->total_amount)->toBe(2800000); }); test('multiple adjustments accumulate correctly', function () { $user = User::factory()->create(); $this->actingAs($user); $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); $employee = Employee::factory()->create(['base_salary' => 3000000]); $payroll = Payroll::factory()->create([ 'payroll_period_id' => $period->id, 'employee_id' => $employee->id, 'base_salary' => 3000000, 'bonus_amount' => 0, 'deduction_amount' => 0, 'total_amount' => 3000000, 'status' => PayrollStatus::UNPAID, ]); $this->post(route('admin.finance.payrolls.adjustments.store', $payroll), [ 'type' => 'bonus', 'amount' => 500000, 'description' => 'Bonus 1', ]); $this->post(route('admin.finance.payrolls.adjustments.store', $payroll), [ 'type' => 'bonus', 'amount' => 300000, 'description' => 'Bonus 2', ]); $this->post(route('admin.finance.payrolls.adjustments.store', $payroll), [ 'type' => 'deduction', 'amount' => 100000, 'description' => 'Potongan 1', ]); $payroll->refresh(); expect($payroll->bonus_amount)->toBe(800000); expect($payroll->deduction_amount)->toBe(100000); expect($payroll->total_amount)->toBe(3700000); }); test('adjustment cannot be added to paid payroll', function () { $user = User::factory()->create(); $this->actingAs($user); $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); $employee = Employee::factory()->create(); $payroll = Payroll::factory()->create([ 'payroll_period_id' => $period->id, 'employee_id' => $employee->id, 'status' => PayrollStatus::PAID, ]); $response = $this->post(route('admin.finance.payrolls.adjustments.store', $payroll), [ 'type' => 'bonus', 'amount' => 500000, 'description' => 'Bonus', ]); $response->assertRedirect(); $this->assertDatabaseCount('payroll_adjustments', 0); }); test('adjustment cannot be added to cancelled payroll', function () { $user = User::factory()->create(); $this->actingAs($user); $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); $employee = Employee::factory()->create(); $payroll = Payroll::factory()->create([ 'payroll_period_id' => $period->id, 'employee_id' => $employee->id, 'status' => PayrollStatus::CANCELLED, ]); $response = $this->post(route('admin.finance.payrolls.adjustments.store', $payroll), [ 'type' => 'bonus', 'amount' => 500000, 'description' => 'Bonus', ]); $response->assertRedirect(); $this->assertDatabaseCount('payroll_adjustments', 0); }); test('adjustment type is required', function () { $user = User::factory()->create(); $this->actingAs($user); $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); $employee = Employee::factory()->create(); $payroll = Payroll::factory()->create([ 'payroll_period_id' => $period->id, 'employee_id' => $employee->id, 'status' => PayrollStatus::UNPAID, ]); $response = $this->post(route('admin.finance.payrolls.adjustments.store', $payroll), [ 'type' => '', 'amount' => 500000, 'description' => 'Test', ]); $response->assertSessionHasErrors('type'); }); test('adjustment type must be valid', function () { $user = User::factory()->create(); $this->actingAs($user); $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); $employee = Employee::factory()->create(); $payroll = Payroll::factory()->create([ 'payroll_period_id' => $period->id, 'employee_id' => $employee->id, 'status' => PayrollStatus::UNPAID, ]); $response = $this->post(route('admin.finance.payrolls.adjustments.store', $payroll), [ 'type' => 'invalid_type', 'amount' => 500000, 'description' => 'Test', ]); $response->assertSessionHasErrors('type'); }); test('adjustment amount is required', function () { $user = User::factory()->create(); $this->actingAs($user); $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); $employee = Employee::factory()->create(); $payroll = Payroll::factory()->create([ 'payroll_period_id' => $period->id, 'employee_id' => $employee->id, 'status' => PayrollStatus::UNPAID, ]); $response = $this->post(route('admin.finance.payrolls.adjustments.store', $payroll), [ 'type' => 'bonus', 'amount' => '', 'description' => 'Test', ]); $response->assertSessionHasErrors('amount'); }); test('adjustment amount must be at least 1', function () { $user = User::factory()->create(); $this->actingAs($user); $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); $employee = Employee::factory()->create(); $payroll = Payroll::factory()->create([ 'payroll_period_id' => $period->id, 'employee_id' => $employee->id, 'status' => PayrollStatus::UNPAID, ]); $response = $this->post(route('admin.finance.payrolls.adjustments.store', $payroll), [ 'type' => 'bonus', 'amount' => 0, 'description' => 'Test', ]); $response->assertSessionHasErrors('amount'); }); test('adjustment description is required', function () { $user = User::factory()->create(); $this->actingAs($user); $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); $employee = Employee::factory()->create(); $payroll = Payroll::factory()->create([ 'payroll_period_id' => $period->id, 'employee_id' => $employee->id, 'status' => PayrollStatus::UNPAID, ]); $response = $this->post(route('admin.finance.payrolls.adjustments.store', $payroll), [ 'type' => 'bonus', 'amount' => 500000, 'description' => '', ]); $response->assertSessionHasErrors('description'); }); test('adjustment description max length is 100', function () { $user = User::factory()->create(); $this->actingAs($user); $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); $employee = Employee::factory()->create(); $payroll = Payroll::factory()->create([ 'payroll_period_id' => $period->id, 'employee_id' => $employee->id, 'status' => PayrollStatus::UNPAID, ]); $response = $this->post(route('admin.finance.payrolls.adjustments.store', $payroll), [ 'type' => 'bonus', 'amount' => 500000, 'description' => str_repeat('a', 101), ]); $response->assertSessionHasErrors('description'); }); test('adjustment description exactly 100 characters is accepted', function () { $user = User::factory()->create(); $this->actingAs($user); $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); $employee = Employee::factory()->create(); $payroll = Payroll::factory()->create([ 'payroll_period_id' => $period->id, 'employee_id' => $employee->id, 'status' => PayrollStatus::UNPAID, ]); $response = $this->post(route('admin.finance.payrolls.adjustments.store', $payroll), [ 'type' => 'bonus', 'amount' => 500000, 'description' => str_repeat('a', 100), ]); $response->assertSessionHasNoErrors(); }); /* |-------------------------------------------------------------------------- | PAYROLL ADJUSTMENT - DELETE |-------------------------------------------------------------------------- */ test('adjustment can be deleted from unpaid payroll', function () { $user = User::factory()->create(); $this->actingAs($user); $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); $employee = Employee::factory()->create(['base_salary' => 3000000]); $payroll = Payroll::factory()->create([ 'payroll_period_id' => $period->id, 'employee_id' => $employee->id, 'base_salary' => 3000000, 'bonus_amount' => 500000, 'deduction_amount' => 0, 'total_amount' => 3500000, 'status' => PayrollStatus::UNPAID, ]); $adjustment = PayrollAdjustment::factory()->create([ 'payroll_id' => $payroll->id, 'created_by_id' => $user->id, 'type' => PayrollAdjustmentType::BONUS, 'amount' => 500000, 'description' => 'Bonus performa', ]); $response = $this->delete(route('admin.finance.payroll-adjustments.destroy', $adjustment)); $response ->assertSessionHasNoErrors() ->assertRedirect(route('admin.finance.payroll-periods.show', $period)); $this->assertSoftDeleted('payroll_adjustments', ['id' => $adjustment->id]); $payroll->refresh(); expect($payroll->bonus_amount)->toBe(0); expect($payroll->total_amount)->toBe(3000000); }); test('deleting deduction adjustment recalculates correctly', function () { $user = User::factory()->create(); $this->actingAs($user); $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); $employee = Employee::factory()->create(['base_salary' => 3000000]); $payroll = Payroll::factory()->create([ 'payroll_period_id' => $period->id, 'employee_id' => $employee->id, 'base_salary' => 3000000, 'bonus_amount' => 0, 'deduction_amount' => 200000, 'total_amount' => 2800000, 'status' => PayrollStatus::UNPAID, ]); $adjustment = PayrollAdjustment::factory()->create([ 'payroll_id' => $payroll->id, 'created_by_id' => $user->id, 'type' => PayrollAdjustmentType::DEDUCTION, 'amount' => 200000, 'description' => 'Potongan telat', ]); $this->delete(route('admin.finance.payroll-adjustments.destroy', $adjustment)); $payroll->refresh(); expect($payroll->deduction_amount)->toBe(0); expect($payroll->total_amount)->toBe(3000000); }); test('adjustment cannot be deleted from paid payroll', function () { $user = User::factory()->create(); $this->actingAs($user); $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); $employee = Employee::factory()->create(); $payroll = Payroll::factory()->create([ 'payroll_period_id' => $period->id, 'employee_id' => $employee->id, 'status' => PayrollStatus::PAID, ]); $adjustment = PayrollAdjustment::factory()->create([ 'payroll_id' => $payroll->id, 'created_by_id' => $user->id, 'type' => PayrollAdjustmentType::BONUS, 'amount' => 500000, 'description' => 'Test', ]); $response = $this->delete(route('admin.finance.payroll-adjustments.destroy', $adjustment)); $response->assertRedirect(); $this->assertDatabaseHas('payroll_adjustments', ['id' => $adjustment->id, 'deleted_at' => null]); }); test('deleting non-existent adjustment returns 404', function () { $user = User::factory()->create(); $this->actingAs($user); $response = $this->delete(route('admin.finance.payroll-adjustments.destroy', 999999)); $response->assertStatus(404); }); /* |-------------------------------------------------------------------------- | GENERATE PAYROLL COMMAND |-------------------------------------------------------------------------- */ test('generate payroll creates period and payrolls for active employees', function () { Employee::factory()->count(3)->create([ 'base_salary' => 3000000, 'resign_date' => null, ]); $this->artisan('payroll:generate') ->expectsOutputToContain('Berhasil generate 3 gaji') ->assertSuccessful(); $now = now(); $this->assertDatabaseHas('payroll_periods', [ 'year' => $now->year, 'month' => $now->month, 'status' => PayrollPeriodStatus::OPEN->value, ]); $period = PayrollPeriod::where('year', $now->year)->where('month', $now->month)->first(); $this->assertDatabaseCount('payrolls', 3); $payrolls = Payroll::where('payroll_period_id', $period->id)->get(); expect($payrolls->every(fn ($p) => $p->status === PayrollStatus::UNPAID))->toBeTrue(); expect($payrolls->every(fn ($p) => $p->base_salary === 3000000))->toBeTrue(); expect($payrolls->every(fn ($p) => $p->total_amount === 3000000))->toBeTrue(); }); test('generate payroll does not duplicate existing payrolls', function () { $now = now(); $period = PayrollPeriod::factory()->create([ 'year' => $now->year, 'month' => $now->month, 'status' => PayrollPeriodStatus::OPEN, ]); $employee = Employee::factory()->create(['base_salary' => 3000000]); Payroll::factory()->create([ 'payroll_period_id' => $period->id, 'employee_id' => $employee->id, ]); $this->artisan('payroll:generate') ->expectsOutputToContain('Berhasil generate 0 gaji') ->assertSuccessful(); $this->assertDatabaseCount('payrolls', 1); }); test('generate payroll excludes resigned employees', function () { Employee::factory()->create([ 'base_salary' => 3000000, 'resign_date' => now()->subMonth()->toDateString(), ]); $this->artisan('payroll:generate')->assertSuccessful(); $this->assertDatabaseCount('payrolls', 0); }); test('generate payroll uses employee base salary', function () { Employee::factory()->create(['base_salary' => 3500000]); Employee::factory()->create(['base_salary' => 4000000]); $this->artisan('payroll:generate') ->expectsOutputToContain('Berhasil generate 2 gaji') ->assertSuccessful(); $this->assertDatabaseHas('payrolls', ['base_salary' => 3500000, 'total_amount' => 3500000]); $this->assertDatabaseHas('payrolls', ['base_salary' => 4000000, 'total_amount' => 4000000]); }); test('generate payroll creates period with open status', function () { Employee::factory()->create(); $this->artisan('payroll:generate')->assertSuccessful(); $now = now(); $period = PayrollPeriod::where('year', $now->year)->where('month', $now->month)->first(); expect($period->status)->toBe(PayrollPeriodStatus::OPEN); }); test('generate payroll does not change closed period', function () { $now = now(); PayrollPeriod::factory()->create([ 'year' => $now->year, 'month' => $now->month, 'status' => PayrollPeriodStatus::CLOSED, ]); Employee::factory()->create(); $this->artisan('payroll:generate') ->expectsOutputToContain('sudah ditutup') ->assertExitCode(1); }); /* |-------------------------------------------------------------------------- | MODEL & RELATIONSHIPS |-------------------------------------------------------------------------- */ test('payroll factory creates valid payroll', function () { $payroll = Payroll::factory()->create(); expect($payroll->employee)->not->toBeNull(); expect($payroll->payrollPeriod)->not->toBeNull(); expect($payroll->base_salary)->toBeGreaterThan(0); expect($payroll->total_amount)->toBeGreaterThanOrEqual(0); expect($payroll->status)->toBeInstanceOf(PayrollStatus::class); }); test('payroll has correct relationships', function () { $payroll = Payroll::factory()->create(); expect($payroll->employee)->not->toBeNull(); expect($payroll->payrollPeriod)->not->toBeNull(); expect($payroll->payrollAdjustments)->not->toBeNull(); }); test('payroll period factory creates valid payroll period', function () { $period = PayrollPeriod::factory()->create(); expect($period->year)->toBeGreaterThan(0); expect($period->month)->toBeGreaterThanOrEqual(1); expect($period->month)->toBeLessThanOrEqual(12); expect($period->status)->toBeInstanceOf(PayrollPeriodStatus::class); }); test('payroll adjustment factory creates valid adjustment', function () { $adjustment = PayrollAdjustment::factory()->create(); expect($adjustment->payroll)->not->toBeNull(); expect($adjustment->createdBy)->not->toBeNull(); expect($adjustment->amount)->toBeGreaterThan(0); expect($adjustment->type)->toBeInstanceOf(PayrollAdjustmentType::class); }); /* |-------------------------------------------------------------------------- | REALISTIC USER SCENARIOS |-------------------------------------------------------------------------- */ test('full payroll workflow: generate, adjust, pay', function () { $user = User::factory()->create(); $this->actingAs($user); CashAccount::factory()->create(['balance' => 0]); $employee = Employee::factory()->create(['base_salary' => 3000000]); $this->artisan('payroll:generate')->assertSuccessful(); $now = now(); $period = PayrollPeriod::where('year', $now->year)->where('month', $now->month)->first(); $payroll = Payroll::where('payroll_period_id', $period->id)->first(); expect($payroll->status)->toBe(PayrollStatus::UNPAID); expect($payroll->total_amount)->toBe(3000000); $this->post(route('admin.finance.payrolls.adjustments.store', $payroll), [ 'type' => 'bonus', 'amount' => 500000, 'description' => 'Bonus performa', ]); $payroll->refresh(); expect($payroll->bonus_amount)->toBe(500000); expect($payroll->total_amount)->toBe(3500000); $this->post(route('admin.finance.payrolls.adjustments.store', $payroll), [ 'type' => 'deduction', 'amount' => 100000, 'description' => 'Potongan telat', ]); $payroll->refresh(); expect($payroll->deduction_amount)->toBe(100000); expect($payroll->total_amount)->toBe(3400000); $this->post(route('admin.finance.payrolls.pay', $payroll)); $payroll->refresh(); expect($payroll->status)->toBe(PayrollStatus::PAID); expect(CashAccount::first()->balance)->toBe(3400000); }); test('full payroll workflow: generate, adjust, delete adjustment, pay', function () { $user = User::factory()->create(); $this->actingAs($user); CashAccount::factory()->create(['balance' => 0]); $employee = Employee::factory()->create(['base_salary' => 3000000]); $this->artisan('payroll:generate')->assertSuccessful(); $now = now(); $period = PayrollPeriod::where('year', $now->year)->where('month', $now->month)->first(); $payroll = Payroll::where('payroll_period_id', $period->id)->first(); $this->post(route('admin.finance.payrolls.adjustments.store', $payroll), [ 'type' => 'bonus', 'amount' => 500000, 'description' => 'Bonus', ]); $adjustment = PayrollAdjustment::where('payroll_id', $payroll->id)->first(); $this->delete(route('admin.finance.payroll-adjustments.destroy', $adjustment)); $payroll->refresh(); expect($payroll->bonus_amount)->toBe(0); expect($payroll->total_amount)->toBe(3000000); $this->post(route('admin.finance.payrolls.pay', $payroll)); expect(CashAccount::first()->balance)->toBe(3000000); }); test('full payroll workflow: generate, close period', function () { $user = User::factory()->create(); $this->actingAs($user); CashAccount::factory()->create(['balance' => 0]); $employee = Employee::factory()->create(['base_salary' => 3000000]); $this->artisan('payroll:generate')->assertSuccessful(); $now = now(); $period = PayrollPeriod::where('year', $now->year)->where('month', $now->month)->first(); $payroll = Payroll::where('payroll_period_id', $period->id)->first(); $this->post(route('admin.finance.payrolls.pay', $payroll)); $this->post(route('admin.finance.payroll-periods.close', $period)); $period->refresh(); expect($period->status)->toBe(PayrollPeriodStatus::CLOSED); $this->post(route('admin.finance.payroll-periods.reopen', $period)); $period->refresh(); expect($period->status)->toBe(PayrollPeriodStatus::OPEN); });