dstpabuaran.com/tests/Feature/Admin/Finance/PayrollTest.php

1192 lines
40 KiB
PHP

<?php
use App\Enums\PayrollAdjustmentType;
use App\Enums\PayrollPeriodStatus;
use App\Enums\PayrollStatus;
use App\Models\CashAccount;
use App\Models\Employee;
use App\Models\Payroll;
use App\Models\PayrollAdjustment;
use App\Models\PayrollPeriod;
use App\Models\User;
use Inertia\Testing\AssertableInertia as Assert;
/*
|--------------------------------------------------------------------------
| AUTHENTICATION
|--------------------------------------------------------------------------
*/
test('guests are redirected to the login page from payroll period index', function () {
$response = $this->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);
});