562 lines
20 KiB
PHP
562 lines
20 KiB
PHP
<?php
|
|
|
|
use App\Enums\PayrollAdjustmentType;
|
|
use App\Enums\PayrollPeriodStatus;
|
|
use App\Enums\PayrollStatus;
|
|
use App\Enums\Permission as PermissionEnum;
|
|
use App\Models\Employee;
|
|
use App\Models\Payroll;
|
|
use App\Models\PayrollAdjustment;
|
|
use App\Models\PayrollPeriod;
|
|
use App\Models\User;
|
|
use Database\Seeders\CashAccountSeeder;
|
|
use Database\Seeders\RolePermissionSeeder;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
$this->seed(RolePermissionSeeder::class);
|
|
User::factory()->create(); // Required by CashAccountSeeder
|
|
$this->seed(CashAccountSeeder::class);
|
|
Storage::fake('public');
|
|
});
|
|
|
|
// ─── Helper ───────────────────────────────────────────────
|
|
|
|
function createPayrollUserWithPermission(PermissionEnum ...$permissions): User
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
$user->givePermissionTo(
|
|
array_merge(
|
|
[PermissionEnum::DASHBOARD_VIEW->value],
|
|
array_map(fn (PermissionEnum $p) => $p->value, $permissions)
|
|
)
|
|
);
|
|
|
|
$user->forgetCachedPermissions();
|
|
|
|
return $user;
|
|
}
|
|
|
|
function createPayrollWithPeriod(?Employee $employee = null): Payroll
|
|
{
|
|
$period = PayrollPeriod::factory()->create([
|
|
'status' => PayrollPeriodStatus::OPEN->value,
|
|
]);
|
|
|
|
$employee ??= Employee::factory()->create();
|
|
|
|
return Payroll::factory()->create([
|
|
'payroll_period_id' => $period->id,
|
|
'employee_id' => $employee->id,
|
|
'status' => PayrollStatus::UNPAID->value,
|
|
]);
|
|
}
|
|
|
|
function adjustmentPayload(?string $type = null, ?int $amount = null, ?string $description = null): array
|
|
{
|
|
return [
|
|
'type' => $type ?? PayrollAdjustmentType::BONUS->value,
|
|
'amount' => $amount ?? 500000,
|
|
'description' => $description ?? 'Tunjangan transport',
|
|
];
|
|
}
|
|
|
|
// ─── Index ────────────────────────────────────────────────
|
|
|
|
describe('Payroll Index', function () {
|
|
test('authenticated user with permission can view payroll index', function () {
|
|
$user = createPayrollUserWithPermission(PermissionEnum::PAYROLL_VIEW);
|
|
|
|
$this->actingAs($user)
|
|
->get(route('admin.finance.payroll.index'))
|
|
->assertOk();
|
|
});
|
|
|
|
test('guest is redirected to login', function () {
|
|
$this->get(route('admin.finance.payroll.index'))
|
|
->assertRedirect(route('login'));
|
|
});
|
|
|
|
test('user without permission is forbidden', function () {
|
|
$user = User::factory()->create();
|
|
|
|
$this->actingAs($user)
|
|
->get(route('admin.finance.payroll.index'))
|
|
->assertForbidden();
|
|
});
|
|
|
|
test('index displays payrolls for a period', function () {
|
|
$user = createPayrollUserWithPermission(PermissionEnum::PAYROLL_VIEW);
|
|
|
|
$period = PayrollPeriod::factory()->create();
|
|
Payroll::factory()->count(3)->create(['payroll_period_id' => $period->id]);
|
|
|
|
$this->actingAs($user)
|
|
->get(route('admin.finance.payroll.index', ['period_id' => $period->id]))
|
|
->assertOk();
|
|
});
|
|
|
|
test('index can search by employee name', function () {
|
|
$user = createPayrollUserWithPermission(PermissionEnum::PAYROLL_VIEW);
|
|
|
|
$period = PayrollPeriod::factory()->create();
|
|
Payroll::factory()->create(['payroll_period_id' => $period->id]);
|
|
|
|
$this->actingAs($user)
|
|
->get(route('admin.finance.payroll.index', [
|
|
'period_id' => $period->id,
|
|
'search' => 'test',
|
|
]))
|
|
->assertOk();
|
|
});
|
|
});
|
|
|
|
// ─── Store Adjustment ─────────────────────────────────────
|
|
|
|
describe('Payroll Store Adjustment', function () {
|
|
test('authenticated user with permission can add adjustment', function () {
|
|
$user = createPayrollUserWithPermission(PermissionEnum::PAYROLL_VIEW, PermissionEnum::PAYROLL_ADJUST);
|
|
|
|
$payroll = createPayrollWithPeriod();
|
|
|
|
$this->actingAs($user)
|
|
->post(route('admin.finance.payroll.adjustments.store', $payroll), adjustmentPayload())
|
|
->assertRedirect();
|
|
|
|
$this->assertDatabaseHas('payroll_adjustments', [
|
|
'payroll_id' => $payroll->id,
|
|
'type' => PayrollAdjustmentType::BONUS->value,
|
|
'amount' => 500000,
|
|
'description' => 'Tunjangan transport',
|
|
]);
|
|
});
|
|
|
|
test('guest cannot add adjustment', function () {
|
|
$payroll = createPayrollWithPeriod();
|
|
|
|
$this->post(route('admin.finance.payroll.adjustments.store', $payroll), adjustmentPayload())
|
|
->assertRedirect(route('login'));
|
|
});
|
|
|
|
test('user without adjust permission is forbidden', function () {
|
|
$user = createPayrollUserWithPermission(PermissionEnum::PAYROLL_VIEW);
|
|
|
|
$payroll = createPayrollWithPeriod();
|
|
|
|
$this->actingAs($user)
|
|
->post(route('admin.finance.payroll.adjustments.store', $payroll), adjustmentPayload())
|
|
->assertForbidden();
|
|
});
|
|
|
|
test('type is required', function () {
|
|
$user = createPayrollUserWithPermission(PermissionEnum::PAYROLL_VIEW, PermissionEnum::PAYROLL_ADJUST);
|
|
|
|
$payroll = createPayrollWithPeriod();
|
|
|
|
$this->actingAs($user)
|
|
->post(route('admin.finance.payroll.adjustments.store', $payroll), [
|
|
'type' => '',
|
|
'amount' => 500000,
|
|
'description' => 'Test',
|
|
])
|
|
->assertSessionHasErrors('type');
|
|
});
|
|
|
|
test('type must be valid enum value', function () {
|
|
$user = createPayrollUserWithPermission(PermissionEnum::PAYROLL_VIEW, PermissionEnum::PAYROLL_ADJUST);
|
|
|
|
$payroll = createPayrollWithPeriod();
|
|
|
|
$this->actingAs($user)
|
|
->post(route('admin.finance.payroll.adjustments.store', $payroll), [
|
|
'type' => 'invalid',
|
|
'amount' => 500000,
|
|
'description' => 'Test',
|
|
])
|
|
->assertSessionHasErrors('type');
|
|
});
|
|
|
|
test('amount is required', function () {
|
|
$user = createPayrollUserWithPermission(PermissionEnum::PAYROLL_VIEW, PermissionEnum::PAYROLL_ADJUST);
|
|
|
|
$payroll = createPayrollWithPeriod();
|
|
|
|
$this->actingAs($user)
|
|
->post(route('admin.finance.payroll.adjustments.store', $payroll), [
|
|
'type' => PayrollAdjustmentType::BONUS->value,
|
|
'amount' => '',
|
|
'description' => 'Test',
|
|
])
|
|
->assertSessionHasErrors('amount');
|
|
});
|
|
|
|
test('amount must be at least 1', function () {
|
|
$user = createPayrollUserWithPermission(PermissionEnum::PAYROLL_VIEW, PermissionEnum::PAYROLL_ADJUST);
|
|
|
|
$payroll = createPayrollWithPeriod();
|
|
|
|
$this->actingAs($user)
|
|
->post(route('admin.finance.payroll.adjustments.store', $payroll), [
|
|
'type' => PayrollAdjustmentType::BONUS->value,
|
|
'amount' => 0,
|
|
'description' => 'Test',
|
|
])
|
|
->assertSessionHasErrors('amount');
|
|
});
|
|
|
|
test('description is required', function () {
|
|
$user = createPayrollUserWithPermission(PermissionEnum::PAYROLL_VIEW, PermissionEnum::PAYROLL_ADJUST);
|
|
|
|
$payroll = createPayrollWithPeriod();
|
|
|
|
$this->actingAs($user)
|
|
->post(route('admin.finance.payroll.adjustments.store', $payroll), [
|
|
'type' => PayrollAdjustmentType::BONUS->value,
|
|
'amount' => 500000,
|
|
'description' => '',
|
|
])
|
|
->assertSessionHasErrors('description');
|
|
});
|
|
|
|
test('adding bonus adjustment recalculates payroll amounts', function () {
|
|
$user = createPayrollUserWithPermission(PermissionEnum::PAYROLL_VIEW, PermissionEnum::PAYROLL_ADJUST);
|
|
|
|
$payroll = Payroll::factory()->create([
|
|
'base_salary' => 3000000,
|
|
'bonus_amount' => 0,
|
|
'deduction_amount' => 0,
|
|
'total_amount' => 3000000,
|
|
'status' => PayrollStatus::UNPAID->value,
|
|
]);
|
|
|
|
$this->actingAs($user)
|
|
->post(route('admin.finance.payroll.adjustments.store', $payroll), adjustmentPayload(
|
|
type: PayrollAdjustmentType::BONUS->value,
|
|
amount: 500000,
|
|
));
|
|
|
|
expect($payroll->fresh()->bonus_amount)->toBe(500000);
|
|
expect($payroll->fresh()->total_amount)->toBe(3500000);
|
|
});
|
|
|
|
test('adding deduction adjustment recalculates payroll amounts', function () {
|
|
$user = createPayrollUserWithPermission(PermissionEnum::PAYROLL_VIEW, PermissionEnum::PAYROLL_ADJUST);
|
|
|
|
$payroll = Payroll::factory()->create([
|
|
'base_salary' => 3000000,
|
|
'bonus_amount' => 0,
|
|
'deduction_amount' => 0,
|
|
'total_amount' => 3000000,
|
|
'status' => PayrollStatus::UNPAID->value,
|
|
]);
|
|
|
|
$this->actingAs($user)
|
|
->post(route('admin.finance.payroll.adjustments.store', $payroll), adjustmentPayload(
|
|
type: PayrollAdjustmentType::DEDUCTION->value,
|
|
amount: 200000,
|
|
));
|
|
|
|
expect($payroll->fresh()->deduction_amount)->toBe(200000);
|
|
expect($payroll->fresh()->total_amount)->toBe(2800000);
|
|
});
|
|
});
|
|
|
|
// ─── Update Adjustment ────────────────────────────────────
|
|
|
|
describe('Payroll Update Adjustment', function () {
|
|
test('authenticated user with permission can update adjustment', function () {
|
|
$user = createPayrollUserWithPermission(PermissionEnum::PAYROLL_VIEW, PermissionEnum::PAYROLL_ADJUST);
|
|
|
|
$payroll = createPayrollWithPeriod();
|
|
$adjustment = PayrollAdjustment::factory()->bonus()->create([
|
|
'payroll_id' => $payroll->id,
|
|
'amount' => 500000,
|
|
]);
|
|
|
|
$this->actingAs($user)
|
|
->put(route('admin.finance.payroll.adjustments.update', $adjustment), adjustmentPayload(
|
|
type: PayrollAdjustmentType::BONUS->value,
|
|
amount: 700000,
|
|
description: 'Deskripsi baru',
|
|
))
|
|
->assertRedirect();
|
|
|
|
expect($adjustment->fresh()->amount)->toBe(700000);
|
|
expect($adjustment->fresh()->description)->toBe('Deskripsi baru');
|
|
});
|
|
|
|
test('guest cannot update adjustment', function () {
|
|
$adjustment = PayrollAdjustment::factory()->create();
|
|
|
|
$this->put(route('admin.finance.payroll.adjustments.update', $adjustment), adjustmentPayload())
|
|
->assertRedirect(route('login'));
|
|
});
|
|
|
|
test('user without adjust permission is forbidden', function () {
|
|
$user = createPayrollUserWithPermission(PermissionEnum::PAYROLL_VIEW);
|
|
|
|
$adjustment = PayrollAdjustment::factory()->create();
|
|
|
|
$this->actingAs($user)
|
|
->put(route('admin.finance.payroll.adjustments.update', $adjustment), adjustmentPayload())
|
|
->assertForbidden();
|
|
});
|
|
|
|
test('updating adjustment recalculates payroll amounts', function () {
|
|
$user = createPayrollUserWithPermission(PermissionEnum::PAYROLL_VIEW, PermissionEnum::PAYROLL_ADJUST);
|
|
|
|
$payroll = Payroll::factory()->create([
|
|
'base_salary' => 3000000,
|
|
'bonus_amount' => 500000,
|
|
'deduction_amount' => 0,
|
|
'total_amount' => 3500000,
|
|
'status' => PayrollStatus::UNPAID->value,
|
|
]);
|
|
|
|
$adjustment = PayrollAdjustment::factory()->bonus()->create([
|
|
'payroll_id' => $payroll->id,
|
|
'amount' => 500000,
|
|
]);
|
|
|
|
$this->actingAs($user)
|
|
->put(route('admin.finance.payroll.adjustments.update', $adjustment), adjustmentPayload(
|
|
type: PayrollAdjustmentType::BONUS->value,
|
|
amount: 800000,
|
|
));
|
|
|
|
expect($payroll->fresh()->bonus_amount)->toBe(800000);
|
|
expect($payroll->fresh()->total_amount)->toBe(3800000);
|
|
});
|
|
});
|
|
|
|
// ─── Destroy Adjustment ───────────────────────────────────
|
|
|
|
describe('Payroll Destroy Adjustment', function () {
|
|
test('authenticated user with permission can delete adjustment', function () {
|
|
$user = createPayrollUserWithPermission(PermissionEnum::PAYROLL_VIEW, PermissionEnum::PAYROLL_ADJUST);
|
|
|
|
$payroll = createPayrollWithPeriod();
|
|
$adjustment = PayrollAdjustment::factory()->create(['payroll_id' => $payroll->id]);
|
|
|
|
$this->actingAs($user)
|
|
->delete(route('admin.finance.payroll.adjustments.destroy', $adjustment))
|
|
->assertRedirect();
|
|
|
|
$this->assertDatabaseMissing('payroll_adjustments', ['id' => $adjustment->id]);
|
|
});
|
|
|
|
test('guest cannot delete adjustment', function () {
|
|
$adjustment = PayrollAdjustment::factory()->create();
|
|
|
|
$this->delete(route('admin.finance.payroll.adjustments.destroy', $adjustment))
|
|
->assertRedirect(route('login'));
|
|
});
|
|
|
|
test('user without adjust permission is forbidden', function () {
|
|
$user = createPayrollUserWithPermission(PermissionEnum::PAYROLL_VIEW);
|
|
|
|
$adjustment = PayrollAdjustment::factory()->create();
|
|
|
|
$this->actingAs($user)
|
|
->delete(route('admin.finance.payroll.adjustments.destroy', $adjustment))
|
|
->assertForbidden();
|
|
});
|
|
|
|
test('deleting adjustment recalculates payroll amounts', function () {
|
|
$user = createPayrollUserWithPermission(PermissionEnum::PAYROLL_VIEW, PermissionEnum::PAYROLL_ADJUST);
|
|
|
|
$payroll = Payroll::factory()->create([
|
|
'base_salary' => 3000000,
|
|
'bonus_amount' => 500000,
|
|
'deduction_amount' => 0,
|
|
'total_amount' => 3500000,
|
|
'status' => PayrollStatus::UNPAID->value,
|
|
]);
|
|
|
|
$adjustment = PayrollAdjustment::factory()->bonus()->create([
|
|
'payroll_id' => $payroll->id,
|
|
'amount' => 500000,
|
|
]);
|
|
|
|
$this->actingAs($user)
|
|
->delete(route('admin.finance.payroll.adjustments.destroy', $adjustment));
|
|
|
|
expect($payroll->fresh()->bonus_amount)->toBe(0);
|
|
expect($payroll->fresh()->total_amount)->toBe(3000000);
|
|
});
|
|
});
|
|
|
|
// ─── Payroll Model ────────────────────────────────────────
|
|
|
|
describe('Payroll Model', function () {
|
|
test('payroll has amounts cast to integer', function () {
|
|
$payroll = Payroll::factory()->create([
|
|
'base_salary' => 3000000,
|
|
'bonus_amount' => 500000,
|
|
'deduction_amount' => 200000,
|
|
'total_amount' => 3300000,
|
|
]);
|
|
|
|
expect($payroll->base_salary)->toBeInt();
|
|
expect($payroll->bonus_amount)->toBeInt();
|
|
expect($payroll->deduction_amount)->toBeInt();
|
|
expect($payroll->total_amount)->toBeInt();
|
|
});
|
|
|
|
test('payroll has status cast to enum', function () {
|
|
$payroll = Payroll::factory()->create([
|
|
'status' => PayrollStatus::UNPAID->value,
|
|
]);
|
|
|
|
expect($payroll->status)->toBe(PayrollStatus::UNPAID);
|
|
});
|
|
|
|
test('payroll has formatted amount accessors', function () {
|
|
$payroll = Payroll::factory()->create([
|
|
'base_salary' => 3000000,
|
|
'bonus_amount' => 500000,
|
|
'deduction_amount' => 200000,
|
|
'total_amount' => 3300000,
|
|
]);
|
|
|
|
expect($payroll->base_salary_formatted)->toBe('Rp 3.000.000');
|
|
expect($payroll->bonus_amount_formatted)->toBe('Rp 500.000');
|
|
expect($payroll->deduction_amount_formatted)->toBe('Rp 200.000');
|
|
expect($payroll->total_amount_formatted)->toBe('Rp 3.300.000');
|
|
});
|
|
|
|
test('payroll has status label accessor', function () {
|
|
$payroll = Payroll::factory()->create([
|
|
'status' => PayrollStatus::UNPAID->value,
|
|
]);
|
|
|
|
expect($payroll->status_label)->toBe('Belum Dibayar');
|
|
});
|
|
|
|
test('payroll belongs to employee', function () {
|
|
$payroll = Payroll::factory()->create();
|
|
|
|
expect($payroll->employee)->not->toBeNull();
|
|
});
|
|
|
|
test('payroll belongs to payroll period', function () {
|
|
$payroll = Payroll::factory()->create();
|
|
|
|
expect($payroll->payrollPeriod)->not->toBeNull();
|
|
});
|
|
|
|
test('payroll can have adjustments', function () {
|
|
$payroll = Payroll::factory()->create();
|
|
PayrollAdjustment::factory()->count(3)->create(['payroll_id' => $payroll->id]);
|
|
|
|
expect($payroll->fresh()->adjustments)->toHaveCount(3);
|
|
});
|
|
});
|
|
|
|
// ─── Payroll Period Model ─────────────────────────────────
|
|
|
|
describe('Payroll Period Model', function () {
|
|
test('payroll period has status cast to enum', function () {
|
|
$period = PayrollPeriod::factory()->create([
|
|
'status' => PayrollPeriodStatus::OPEN->value,
|
|
]);
|
|
|
|
expect($period->status)->toBe(PayrollPeriodStatus::OPEN);
|
|
});
|
|
|
|
test('payroll period has period label accessor', function () {
|
|
$period = PayrollPeriod::factory()->create([
|
|
'year' => 2026,
|
|
'month' => 6,
|
|
]);
|
|
|
|
expect($period->period_label)->not->toBeEmpty();
|
|
});
|
|
|
|
test('payroll period has status label accessor', function () {
|
|
$period = PayrollPeriod::factory()->create([
|
|
'status' => PayrollPeriodStatus::OPEN->value,
|
|
]);
|
|
|
|
expect($period->status_label)->toBe('Dibuka');
|
|
});
|
|
|
|
test('payroll period isOpen method', function () {
|
|
$openPeriod = PayrollPeriod::factory()->create([
|
|
'status' => PayrollPeriodStatus::OPEN->value,
|
|
]);
|
|
|
|
$closedPeriod = PayrollPeriod::factory()->closed()->create();
|
|
|
|
expect($openPeriod->isOpen())->toBeTrue();
|
|
expect($closedPeriod->isOpen())->toBeFalse();
|
|
});
|
|
|
|
test('payroll period uses soft deletes', function () {
|
|
$period = PayrollPeriod::factory()->create();
|
|
|
|
$period->delete();
|
|
|
|
expect($period->trashed())->toBeTrue();
|
|
});
|
|
});
|
|
|
|
// ─── Payroll Adjustment Model ─────────────────────────────
|
|
|
|
describe('Payroll Adjustment Model', function () {
|
|
test('payroll adjustment has type cast to enum', function () {
|
|
$adjustment = PayrollAdjustment::factory()->create([
|
|
'type' => PayrollAdjustmentType::BONUS->value,
|
|
]);
|
|
|
|
expect($adjustment->type)->toBe(PayrollAdjustmentType::BONUS);
|
|
});
|
|
|
|
test('payroll adjustment has amount cast to integer', function () {
|
|
$adjustment = PayrollAdjustment::factory()->create(['amount' => 500000]);
|
|
|
|
expect($adjustment->amount)->toBeInt();
|
|
expect($adjustment->amount)->toBe(500000);
|
|
});
|
|
|
|
test('payroll adjustment has formatted accessors', function () {
|
|
$adjustment = PayrollAdjustment::factory()->create(['amount' => 750000]);
|
|
|
|
expect($adjustment->amount_formatted)->toBe('Rp 750.000');
|
|
});
|
|
|
|
test('payroll adjustment has type label accessor', function () {
|
|
$adjustment = PayrollAdjustment::factory()->bonus()->create();
|
|
|
|
expect($adjustment->type_label)->toBe('Tunjangan');
|
|
});
|
|
|
|
test('payroll adjustment belongs to payroll', function () {
|
|
$adjustment = PayrollAdjustment::factory()->create();
|
|
|
|
expect($adjustment->payroll)->not->toBeNull();
|
|
});
|
|
|
|
test('bonus scope returns only bonus adjustments', function () {
|
|
PayrollAdjustment::factory()->count(2)->bonus()->create();
|
|
PayrollAdjustment::factory()->deduction()->create();
|
|
|
|
$bonuses = PayrollAdjustment::query()->bonus()->get();
|
|
|
|
expect($bonuses)->toHaveCount(2);
|
|
});
|
|
|
|
test('deduction scope returns only deduction adjustments', function () {
|
|
PayrollAdjustment::factory()->count(3)->deduction()->create();
|
|
PayrollAdjustment::factory()->bonus()->create();
|
|
|
|
$deductions = PayrollAdjustment::query()->deduction()->get();
|
|
|
|
expect($deductions)->toHaveCount(3);
|
|
});
|
|
});
|