store/tests/Feature/Admin/Finance/PayrollTest.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);
});
});