1192 lines
40 KiB
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);
|
|
});
|