diff --git a/app/Models/PayrollAdjustment.php b/app/Models/PayrollAdjustment.php index 38b7a18..0430399 100644 --- a/app/Models/PayrollAdjustment.php +++ b/app/Models/PayrollAdjustment.php @@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Illuminate\Database\Query\Builder; +use Illuminate\Database\Eloquent\Builder; #[Guarded(['id'])] #[Appends(['amount_formatted', 'type_label', 'created_at_formatted', 'created_by_name'])] diff --git a/tests/Feature/Admin/Finance/CashTest.php b/tests/Feature/Admin/Finance/CashTest.php new file mode 100644 index 0000000..e1b3168 --- /dev/null +++ b/tests/Feature/Admin/Finance/CashTest.php @@ -0,0 +1,479 @@ +seed(RolePermissionSeeder::class); + User::factory()->create(); // Required by CashAccountSeeder + $this->seed(CashAccountSeeder::class); + Storage::fake('public'); +}); + +// ─── Helper ─────────────────────────────────────────────── + +function createCashUserWithPermission(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 createCashAccountWithBalance(int $balance = 1000000): CashAccount +{ + return CashAccount::factory()->create(['balance' => $balance]); +} + +function depositPayload(?int $amount = null, ?string $description = null): array +{ + return [ + 'amount' => $amount ?? 100000, + 'description' => $description ?? 'Setoran kas', + 'photos' => [UploadedFile::fake()->image('bukti.jpg', 100, 100)], + ]; +} + +function withdrawPayload(?int $amount = null, ?string $description = null): array +{ + return [ + 'amount' => $amount ?? 50000, + 'description' => $description ?? 'Penarikan kas', + 'photos' => [UploadedFile::fake()->image('bukti.jpg', 100, 100)], + ]; +} + +// ─── Index ──────────────────────────────────────────────── + +describe('Cash Index', function () { + test('authenticated user with permission can view cash index', function () { + $user = createCashUserWithPermission(PermissionEnum::CASH_VIEW); + + $this->actingAs($user) + ->get(route('admin.finance.cash.index')) + ->assertOk(); + }); + + test('guest is redirected to login', function () { + $this->get(route('admin.finance.cash.index')) + ->assertRedirect(route('login')); + }); + + test('user without permission is forbidden', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get(route('admin.finance.cash.index')) + ->assertForbidden(); + }); + + test('index displays cash transactions', function () { + $user = createCashUserWithPermission(PermissionEnum::CASH_VIEW); + + $account = CashAccount::query()->first(); + CashTransaction::factory()->count(3)->create([ + 'cash_account_id' => $account->id, + 'created_by_id' => $user->id, + ]); + + $this->actingAs($user) + ->get(route('admin.finance.cash.index')) + ->assertOk(); + }); + + test('index can search transactions by description', function () { + $user = createCashUserWithPermission(PermissionEnum::CASH_VIEW); + + $account = CashAccount::query()->first(); + CashTransaction::factory()->create([ + 'cash_account_id' => $account->id, + 'created_by_id' => $user->id, + 'description' => 'Setoran modal awal', + ]); + + $this->actingAs($user) + ->get(route('admin.finance.cash.index', ['search' => 'modal'])) + ->assertOk(); + }); +}); + +// ─── Deposit ────────────────────────────────────────────── + +describe('Cash Deposit', function () { + test('authenticated user with permission can deposit', function () { + $user = createCashUserWithPermission(PermissionEnum::CASH_VIEW, PermissionEnum::CASH_DEPOSIT); + + $this->actingAs($user) + ->post(route('admin.finance.cash.deposit'), depositPayload(200000)) + ->assertRedirect(route('admin.finance.cash.index')); + + $account = CashAccount::query()->first(); + expect($account->fresh()->balance)->toBe(200000); + }); + + test('guest cannot deposit', function () { + $this->post(route('admin.finance.cash.deposit'), depositPayload()) + ->assertRedirect(route('login')); + }); + + test('user without deposit permission is forbidden', function () { + $user = createCashUserWithPermission(PermissionEnum::CASH_VIEW); + + $this->actingAs($user) + ->post(route('admin.finance.cash.deposit'), depositPayload()) + ->assertForbidden(); + }); + + test('amount is required', function () { + $user = createCashUserWithPermission(PermissionEnum::CASH_VIEW, PermissionEnum::CASH_DEPOSIT); + + $this->actingAs($user) + ->post(route('admin.finance.cash.deposit'), [ + 'amount' => '', + 'description' => 'Test', + 'photos' => [UploadedFile::fake()->image('bukti.jpg')], + ]) + ->assertSessionHasErrors('amount'); + }); + + test('amount must be at least 1', function () { + $user = createCashUserWithPermission(PermissionEnum::CASH_VIEW, PermissionEnum::CASH_DEPOSIT); + + $this->actingAs($user) + ->post(route('admin.finance.cash.deposit'), [ + 'amount' => 0, + 'description' => 'Test', + 'photos' => [UploadedFile::fake()->image('bukti.jpg')], + ]) + ->assertSessionHasErrors('amount'); + }); + + test('description is required', function () { + $user = createCashUserWithPermission(PermissionEnum::CASH_VIEW, PermissionEnum::CASH_DEPOSIT); + + $this->actingAs($user) + ->post(route('admin.finance.cash.deposit'), [ + 'amount' => 100000, + 'description' => '', + 'photos' => [UploadedFile::fake()->image('bukti.jpg')], + ]) + ->assertSessionHasErrors('description'); + }); + + test('description must not exceed 100 characters', function () { + $user = createCashUserWithPermission(PermissionEnum::CASH_VIEW, PermissionEnum::CASH_DEPOSIT); + + $this->actingAs($user) + ->post(route('admin.finance.cash.deposit'), [ + 'amount' => 100000, + 'description' => str_repeat('a', 101), + 'photos' => [UploadedFile::fake()->image('bukti.jpg')], + ]) + ->assertSessionHasErrors('description'); + }); + + test('deposit creates a cash transaction', function () { + $user = createCashUserWithPermission(PermissionEnum::CASH_VIEW, PermissionEnum::CASH_DEPOSIT); + + $this->actingAs($user) + ->post(route('admin.finance.cash.deposit'), depositPayload(500000, 'Setoran modal')); + + $this->assertDatabaseHas('cash_transactions', [ + 'description' => 'Setoran modal', + 'amount' => 500000, + ]); + }); + + test('deposit updates account balance', function () { + $user = createCashUserWithPermission(PermissionEnum::CASH_VIEW, PermissionEnum::CASH_DEPOSIT); + + $this->actingAs($user) + ->post(route('admin.finance.cash.deposit'), depositPayload(300000)); + + $account = CashAccount::query()->first(); + expect($account->fresh()->balance)->toBe(300000); + + $this->actingAs($user) + ->post(route('admin.finance.cash.deposit'), depositPayload(200000)); + + expect($account->fresh()->balance)->toBe(500000); + }); +}); + +// ─── Withdraw ───────────────────────────────────────────── + +describe('Cash Withdraw', function () { + test('authenticated user with permission can withdraw', function () { + $user = createCashUserWithPermission(PermissionEnum::CASH_VIEW, PermissionEnum::CASH_WITHDRAW); + + CashAccount::query()->first()->update(['balance' => 500000]); + + $this->actingAs($user) + ->post(route('admin.finance.cash.withdraw'), withdrawPayload(100000)) + ->assertRedirect(route('admin.finance.cash.index')); + + $account = CashAccount::query()->first(); + expect($account->fresh()->balance)->toBe(400000); + }); + + test('guest cannot withdraw', function () { + $this->post(route('admin.finance.cash.withdraw'), withdrawPayload()) + ->assertRedirect(route('login')); + }); + + test('user without withdraw permission is forbidden', function () { + $user = createCashUserWithPermission(PermissionEnum::CASH_VIEW); + + $this->actingAs($user) + ->post(route('admin.finance.cash.withdraw'), withdrawPayload()) + ->assertForbidden(); + }); + + test('amount is required', function () { + $user = createCashUserWithPermission(PermissionEnum::CASH_VIEW, PermissionEnum::CASH_WITHDRAW); + + $this->actingAs($user) + ->post(route('admin.finance.cash.withdraw'), [ + 'amount' => '', + 'description' => 'Test', + 'photos' => [UploadedFile::fake()->image('bukti.jpg')], + ]) + ->assertSessionHasErrors('amount'); + }); + + test('amount must be at least 1', function () { + $user = createCashUserWithPermission(PermissionEnum::CASH_VIEW, PermissionEnum::CASH_WITHDRAW); + + $this->actingAs($user) + ->post(route('admin.finance.cash.withdraw'), [ + 'amount' => 0, + 'description' => 'Test', + 'photos' => [UploadedFile::fake()->image('bukti.jpg')], + ]) + ->assertSessionHasErrors('amount'); + }); + + test('description is required', function () { + $user = createCashUserWithPermission(PermissionEnum::CASH_VIEW, PermissionEnum::CASH_WITHDRAW); + + $this->actingAs($user) + ->post(route('admin.finance.cash.withdraw'), [ + 'amount' => 100000, + 'description' => '', + 'photos' => [UploadedFile::fake()->image('bukti.jpg')], + ]) + ->assertSessionHasErrors('description'); + }); + + test('withdraw fails if insufficient balance', function () { + $user = createCashUserWithPermission(PermissionEnum::CASH_VIEW, PermissionEnum::CASH_WITHDRAW); + + CashAccount::query()->first()->update(['balance' => 50000]); + + $this->actingAs($user) + ->post(route('admin.finance.cash.withdraw'), withdrawPayload(100000)) + ->assertSessionHasErrors('amount'); + }); + + test('withdraw creates a cash transaction', function () { + $user = createCashUserWithPermission(PermissionEnum::CASH_VIEW, PermissionEnum::CASH_WITHDRAW); + + CashAccount::query()->first()->update(['balance' => 500000]); + + $this->actingAs($user) + ->post(route('admin.finance.cash.withdraw'), withdrawPayload(200000, 'Pembelian bahan')); + + $this->assertDatabaseHas('cash_transactions', [ + 'description' => 'Pembelian bahan', + 'amount' => 200000, + ]); + }); +}); + +// ─── Update Transaction ─────────────────────────────────── + +describe('Cash Update Transaction', function () { + test('authenticated user with permission can update a manual transaction', function () { + $user = createCashUserWithPermission(PermissionEnum::CASH_VIEW, PermissionEnum::CASH_UPDATE); + + $account = CashAccount::query()->first(); + $transaction = CashTransaction::factory()->deposit()->create([ + 'cash_account_id' => $account->id, + 'created_by_id' => $user->id, + 'amount' => 100000, + 'balance_after' => 100000, + ]); + + $this->actingAs($user) + ->put(route('admin.finance.cash.transactions.update', $transaction), [ + 'amount' => 150000, + 'description' => 'Deskripsi diperbarui', + 'photos' => [UploadedFile::fake()->image('bukti.jpg')], + ]) + ->assertRedirect(route('admin.finance.cash.index')); + + expect($transaction->fresh()->amount)->toBe(150000); + expect($transaction->fresh()->description)->toBe('Deskripsi diperbarui'); + }); + + test('guest cannot update a transaction', function () { + $account = CashAccount::query()->first(); + $transaction = CashTransaction::factory()->deposit()->create([ + 'cash_account_id' => $account->id, + ]); + + $this->put(route('admin.finance.cash.transactions.update', $transaction), [ + 'amount' => 100000, + 'description' => 'Test', + ])->assertRedirect(route('login')); + }); + + test('user without update permission is forbidden', function () { + $user = createCashUserWithPermission(PermissionEnum::CASH_VIEW); + + $account = CashAccount::query()->first(); + $transaction = CashTransaction::factory()->deposit()->create([ + 'cash_account_id' => $account->id, + ]); + + $this->actingAs($user) + ->put(route('admin.finance.cash.transactions.update', $transaction), [ + 'amount' => 100000, + 'description' => 'Test', + ]) + ->assertForbidden(); + }); + + test('referenced transaction cannot be updated', function () { + $user = createCashUserWithPermission(PermissionEnum::CASH_VIEW, PermissionEnum::CASH_UPDATE); + + $account = CashAccount::query()->first(); + $transaction = CashTransaction::factory()->create([ + 'cash_account_id' => $account->id, + 'reference_type' => 'App\\Models\\Expense', + 'reference_id' => 1, + ]); + + $this->actingAs($user) + ->put(route('admin.finance.cash.transactions.update', $transaction), [ + 'amount' => 100000, + 'description' => 'Test', + ]) + ->assertForbidden(); + }); +}); + +// ─── Destroy Transaction ────────────────────────────────── + +describe('Cash Destroy Transaction', function () { + test('authenticated user with permission can delete a manual transaction', function () { + $user = createCashUserWithPermission(PermissionEnum::CASH_VIEW, PermissionEnum::CASH_DELETE); + + $account = CashAccount::query()->first(); + $transaction = CashTransaction::factory()->deposit()->create([ + 'cash_account_id' => $account->id, + 'created_by_id' => $user->id, + 'amount' => 100000, + 'balance_after' => 100000, + ]); + + $this->actingAs($user) + ->delete(route('admin.finance.cash.transactions.destroy', $transaction)) + ->assertRedirect(route('admin.finance.cash.index')); + + $this->assertSoftDeleted('cash_transactions', ['id' => $transaction->id]); + }); + + test('guest cannot delete a transaction', function () { + $account = CashAccount::query()->first(); + $transaction = CashTransaction::factory()->deposit()->create([ + 'cash_account_id' => $account->id, + ]); + + $this->delete(route('admin.finance.cash.transactions.destroy', $transaction)) + ->assertRedirect(route('login')); + + $this->assertNotSoftDeleted('cash_transactions', ['id' => $transaction->id]); + }); + + test('user without delete permission is forbidden', function () { + $user = createCashUserWithPermission(PermissionEnum::CASH_VIEW); + + $account = CashAccount::query()->first(); + $transaction = CashTransaction::factory()->deposit()->create([ + 'cash_account_id' => $account->id, + ]); + + $this->actingAs($user) + ->delete(route('admin.finance.cash.transactions.destroy', $transaction)) + ->assertForbidden(); + + $this->assertNotSoftDeleted('cash_transactions', ['id' => $transaction->id]); + }); +}); + +// ─── Cash Model ─────────────────────────────────────────── + +describe('Cash Model', function () { + test('cash transaction uses soft deletes', function () { + $account = CashAccount::query()->first(); + $transaction = CashTransaction::factory()->create([ + 'cash_account_id' => $account->id, + ]); + + $transaction->delete(); + + expect($transaction->trashed())->toBeTrue(); + }); + + test('cash account has balance cast to integer', function () { + $account = CashAccount::factory()->create(['balance' => 500000]); + + expect($account->balance)->toBeInt(); + expect($account->balance)->toBe(500000); + }); + + test('cash account has balance formatted accessor', function () { + $account = CashAccount::factory()->create(['balance' => 1500000]); + + expect($account->balance_formatted)->toBe('Rp 1.500.000'); + }); + + test('cash transaction has amount formatted accessor', function () { + $account = CashAccount::query()->first(); + $transaction = CashTransaction::factory()->create([ + 'cash_account_id' => $account->id, + 'amount' => 2500000, + ]); + + expect($transaction->amount_formatted)->toBe('Rp 2.500.000'); + }); + + test('cash transaction belongs to cash account', function () { + $account = CashAccount::query()->first(); + $transaction = CashTransaction::factory()->create([ + 'cash_account_id' => $account->id, + ]); + + expect($transaction->cashAccount)->not->toBeNull(); + expect($transaction->cashAccount->id)->toBe($account->id); + }); +}); diff --git a/tests/Feature/Admin/Finance/EmployeeAdvanceTest.php b/tests/Feature/Admin/Finance/EmployeeAdvanceTest.php new file mode 100644 index 0000000..8daa3a6 --- /dev/null +++ b/tests/Feature/Admin/Finance/EmployeeAdvanceTest.php @@ -0,0 +1,558 @@ +seed(RolePermissionSeeder::class); + User::factory()->create(); // Required by CashAccountSeeder + $this->seed(CashAccountSeeder::class); + Storage::fake('public'); +}); + +// ─── Helper ─────────────────────────────────────────────── + +function createAdvanceUserWithPermission(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 createEmployeeUser(): User +{ + $user = User::factory()->create(); + UserProfile::factory()->create([ + 'user_id' => $user->id, + 'full_name' => fake()->name(), + ]); + Employee::factory()->create(['user_id' => $user->id]); + + return $user; +} + +function createEmployeeUserWithAdvancePermission(): User +{ + $user = createEmployeeUser(); + + $user->givePermissionTo( + PermissionEnum::DASHBOARD_VIEW->value, + PermissionEnum::EMPLOYEE_ADVANCES_VIEW->value, + PermissionEnum::EMPLOYEE_ADVANCES_CREATE->value, + PermissionEnum::EMPLOYEE_ADVANCES_UPDATE->value, + PermissionEnum::EMPLOYEE_ADVANCES_DELETE->value, + ); + + $user->forgetCachedPermissions(); + + return $user; +} + +function advancePayload(?int $amount = null, ?string $description = null, ?string $dueDate = null): array +{ + return [ + 'amount' => $amount ?? 500000, + 'description' => $description ?? 'Kasbon untuk keperluan pribadi', + 'due_date' => $dueDate ?? now()->addMonth()->format('Y-m-d'), + ]; +} + +// ─── Index ──────────────────────────────────────────────── + +describe('Employee Advance Index', function () { + test('authenticated user with permission can view index', function () { + $user = createAdvanceUserWithPermission(PermissionEnum::EMPLOYEE_ADVANCES_VIEW); + + $this->actingAs($user) + ->get(route('admin.finance.employee_advances.index')) + ->assertOk(); + }); + + test('guest is redirected to login', function () { + $this->get(route('admin.finance.employee_advances.index')) + ->assertRedirect(route('login')); + }); + + test('user without permission is forbidden', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get(route('admin.finance.employee_advances.index')) + ->assertForbidden(); + }); + + test('employee can view their own advances', function () { + $user = createEmployeeUserWithAdvancePermission(); + + $employee = $user->employee; + EmployeeAdvance::factory()->count(2)->create(['employee_id' => $employee->id]); + + $this->actingAs($user) + ->get(route('admin.finance.employee_advances.index')) + ->assertOk(); + }); + + test('index can filter by status', function () { + $user = createAdvanceUserWithPermission(PermissionEnum::EMPLOYEE_ADVANCES_VIEW); + + $this->actingAs($user) + ->get(route('admin.finance.employee_advances.index', ['status' => 'pending'])) + ->assertOk(); + }); +}); + +// ─── Store ──────────────────────────────────────────────── + +describe('Employee Advance Store', function () { + test('employee with permission can submit kasbon', function () { + $user = createEmployeeUserWithAdvancePermission(); + + $this->actingAs($user) + ->post(route('admin.finance.employee_advances.store'), advancePayload()) + ->assertRedirect(route('admin.finance.employee_advances.index')); + + $this->assertDatabaseHas('employee_advances', [ + 'employee_id' => $user->employee->id, + 'amount' => 500000, + 'status' => EmployeeAdvanceStatus::PENDING->value, + ]); + }); + + test('guest cannot submit kasbon', function () { + $this->post(route('admin.finance.employee_advances.store'), advancePayload()) + ->assertRedirect(route('login')); + }); + + test('user without create permission cannot submit kasbon', function () { + $user = createAdvanceUserWithPermission(PermissionEnum::EMPLOYEE_ADVANCES_VIEW); + + $this->actingAs($user) + ->post(route('admin.finance.employee_advances.store'), advancePayload()) + ->assertForbidden(); + }); + + test('user without employee record cannot submit kasbon', function () { + $user = createAdvanceUserWithPermission( + PermissionEnum::EMPLOYEE_ADVANCES_VIEW, + PermissionEnum::EMPLOYEE_ADVANCES_CREATE, + ); + + $this->actingAs($user) + ->post(route('admin.finance.employee_advances.store'), advancePayload()) + ->assertForbidden(); + }); + + test('verifier cannot submit kasbon', function () { + $user = createEmployeeUser(); + + $user->givePermissionTo( + PermissionEnum::DASHBOARD_VIEW->value, + PermissionEnum::EMPLOYEE_ADVANCES_VIEW->value, + PermissionEnum::EMPLOYEE_ADVANCES_VERIFY->value, + ); + + $user->forgetCachedPermissions(); + + $this->actingAs($user) + ->post(route('admin.finance.employee_advances.store'), advancePayload()) + ->assertForbidden(); + }); + + test('amount is required', function () { + $user = createEmployeeUserWithAdvancePermission(); + + $this->actingAs($user) + ->post(route('admin.finance.employee_advances.store'), [ + 'amount' => '', + 'description' => 'Test', + 'due_date' => now()->addMonth()->format('Y-m-d'), + ]) + ->assertSessionHasErrors('amount'); + }); + + test('amount must be at least 1', function () { + $user = createEmployeeUserWithAdvancePermission(); + + $this->actingAs($user) + ->post(route('admin.finance.employee_advances.store'), advancePayload(amount: 0)) + ->assertSessionHasErrors('amount'); + }); + + test('description is required', function () { + $user = createEmployeeUserWithAdvancePermission(); + + $this->actingAs($user) + ->post(route('admin.finance.employee_advances.store'), advancePayload(description: '')) + ->assertSessionHasErrors('description'); + }); + + test('due_date is required', function () { + $user = createEmployeeUserWithAdvancePermission(); + + $this->actingAs($user) + ->post(route('admin.finance.employee_advances.store'), advancePayload(dueDate: '')) + ->assertSessionHasErrors('due_date'); + }); + + test('due_date must be today or after', function () { + $user = createEmployeeUserWithAdvancePermission(); + + $this->actingAs($user) + ->post(route('admin.finance.employee_advances.store'), advancePayload(dueDate: '2020-01-01')) + ->assertSessionHasErrors('due_date'); + }); +}); + +// ─── Update ─────────────────────────────────────────────── + +describe('Employee Advance Update', function () { + test('employee can update their own pending kasbon', function () { + $user = createEmployeeUserWithAdvancePermission(); + + $advance = EmployeeAdvance::factory()->create([ + 'employee_id' => $user->employee->id, + 'status' => EmployeeAdvanceStatus::PENDING->value, + ]); + + $this->actingAs($user) + ->put(route('admin.finance.employee_advances.update', $advance), advancePayload(700000, 'Deskripsi baru')) + ->assertRedirect(route('admin.finance.employee_advances.index')); + + expect($advance->fresh()->amount)->toBe(700000); + expect($advance->fresh()->description)->toBe('Deskripsi baru'); + }); + + test('guest cannot update kasbon', function () { + $advance = EmployeeAdvance::factory()->create(); + + $this->put(route('admin.finance.employee_advances.update', $advance), advancePayload()) + ->assertRedirect(route('login')); + }); + + test('employee cannot update others kasbon', function () { + $user = createEmployeeUserWithAdvancePermission(); + + $otherAdvance = EmployeeAdvance::factory()->create([ + 'status' => EmployeeAdvanceStatus::PENDING->value, + ]); + + $this->actingAs($user) + ->put(route('admin.finance.employee_advances.update', $otherAdvance), advancePayload()) + ->assertForbidden(); + }); + + test('cannot update non-pending kasbon', function () { + $user = createEmployeeUserWithAdvancePermission(); + + $advance = EmployeeAdvance::factory()->approved()->create([ + 'employee_id' => $user->employee->id, + ]); + + $this->actingAs($user) + ->put(route('admin.finance.employee_advances.update', $advance), advancePayload()) + ->assertForbidden(); + }); +}); + +// ─── Destroy ────────────────────────────────────────────── + +describe('Employee Advance Destroy', function () { + test('employee can delete their own kasbon', function () { + $user = createEmployeeUserWithAdvancePermission(); + + $advance = EmployeeAdvance::factory()->create([ + 'employee_id' => $user->employee->id, + ]); + + $this->actingAs($user) + ->delete(route('admin.finance.employee_advances.destroy', $advance)) + ->assertRedirect(route('admin.finance.employee_advances.index')); + + $this->assertDatabaseMissing('employee_advances', ['id' => $advance->id]); + }); + + test('guest cannot delete kasbon', function () { + $advance = EmployeeAdvance::factory()->create(); + + $this->delete(route('admin.finance.employee_advances.destroy', $advance)) + ->assertRedirect(route('login')); + }); + + test('user without delete permission cannot delete kasbon', function () { + $user = createAdvanceUserWithPermission(PermissionEnum::EMPLOYEE_ADVANCES_VIEW); + + $advance = EmployeeAdvance::factory()->create(); + + $this->actingAs($user) + ->delete(route('admin.finance.employee_advances.destroy', $advance)) + ->assertForbidden(); + }); +}); + +// ─── Approve ────────────────────────────────────────────── + +describe('Employee Advance Approve', function () { + test('user with verify permission can approve kasbon', function () { + $user = createAdvanceUserWithPermission( + PermissionEnum::EMPLOYEE_ADVANCES_VIEW, + PermissionEnum::EMPLOYEE_ADVANCES_VERIFY, + ); + + $account = CashAccount::query()->first(); + $account->update(['balance' => 5000000]); + + $advance = EmployeeAdvance::factory()->create([ + 'amount' => 500000, + 'status' => EmployeeAdvanceStatus::PENDING->value, + ]); + + $this->actingAs($user) + ->post(route('admin.finance.employee_advances.approve', $advance)) + ->assertRedirect(route('admin.finance.employee_advances.index')); + + expect($advance->fresh()->status)->toBe(EmployeeAdvanceStatus::APPROVED); + expect($advance->fresh()->verified_at)->not->toBeNull(); + expect($advance->fresh()->verified_by_id)->toBe($user->id); + }); + + test('guest cannot approve kasbon', function () { + $advance = EmployeeAdvance::factory()->create(); + + $this->post(route('admin.finance.employee_advances.approve', $advance)) + ->assertRedirect(route('login')); + }); + + test('user without verify permission cannot approve kasbon', function () { + $user = createAdvanceUserWithPermission(PermissionEnum::EMPLOYEE_ADVANCES_VIEW); + + $advance = EmployeeAdvance::factory()->create(); + + $this->actingAs($user) + ->post(route('admin.finance.employee_advances.approve', $advance)) + ->assertForbidden(); + }); + + test('approving kasbon creates cash transaction', function () { + $user = createAdvanceUserWithPermission( + PermissionEnum::EMPLOYEE_ADVANCES_VIEW, + PermissionEnum::EMPLOYEE_ADVANCES_VERIFY, + ); + + $account = CashAccount::query()->first(); + $account->update(['balance' => 5000000]); + + $advance = EmployeeAdvance::factory()->create([ + 'amount' => 500000, + 'status' => EmployeeAdvanceStatus::PENDING->value, + ]); + + $this->actingAs($user) + ->post(route('admin.finance.employee_advances.approve', $advance)); + + expect($advance->fresh()->cash_transaction_id)->not->toBeNull(); + }); +}); + +// ─── Reject ─────────────────────────────────────────────── + +describe('Employee Advance Reject', function () { + test('user with verify permission can reject kasbon', function () { + $user = createAdvanceUserWithPermission( + PermissionEnum::EMPLOYEE_ADVANCES_VIEW, + PermissionEnum::EMPLOYEE_ADVANCES_VERIFY, + ); + + $advance = EmployeeAdvance::factory()->create([ + 'status' => EmployeeAdvanceStatus::PENDING->value, + ]); + + $this->actingAs($user) + ->post(route('admin.finance.employee_advances.reject', $advance), [ + 'reason' => 'Alasan ditolak', + ]) + ->assertRedirect(route('admin.finance.employee_advances.index')); + + expect($advance->fresh()->status)->toBe(EmployeeAdvanceStatus::REJECTED); + }); + + test('guest cannot reject kasbon', function () { + $advance = EmployeeAdvance::factory()->create(); + + $this->post(route('admin.finance.employee_advances.reject', $advance), [ + 'reason' => 'Alasan', + ])->assertRedirect(route('login')); + }); + + test('user without verify permission cannot reject kasbon', function () { + $user = createAdvanceUserWithPermission(PermissionEnum::EMPLOYEE_ADVANCES_VIEW); + + $advance = EmployeeAdvance::factory()->create(); + + $this->actingAs($user) + ->post(route('admin.finance.employee_advances.reject', $advance), [ + 'reason' => 'Alasan', + ]) + ->assertForbidden(); + }); + + test('reason is required for rejection', function () { + $user = createAdvanceUserWithPermission( + PermissionEnum::EMPLOYEE_ADVANCES_VIEW, + PermissionEnum::EMPLOYEE_ADVANCES_VERIFY, + ); + + $advance = EmployeeAdvance::factory()->create(); + + $this->actingAs($user) + ->post(route('admin.finance.employee_advances.reject', $advance), [ + 'reason' => '', + ]) + ->assertSessionHasErrors('reason'); + }); + + test('rejecting kasbon creates rejection record', function () { + $user = createAdvanceUserWithPermission( + PermissionEnum::EMPLOYEE_ADVANCES_VIEW, + PermissionEnum::EMPLOYEE_ADVANCES_VERIFY, + ); + + $advance = EmployeeAdvance::factory()->create([ + 'status' => EmployeeAdvanceStatus::PENDING->value, + ]); + + $this->actingAs($user) + ->post(route('admin.finance.employee_advances.reject', $advance), [ + 'reason' => 'Budget tidak tersedia', + ]); + + $this->assertDatabaseHas('rejections', [ + 'rejectable_type' => EmployeeAdvance::class, + 'rejectable_id' => $advance->id, + 'reason' => 'Budget tidak tersedia', + ]); + }); +}); + +// ─── Pay ────────────────────────────────────────────────── + +describe('Employee Advance Pay', function () { + test('user with pay permission can mark kasbon as paid', function () { + $user = createAdvanceUserWithPermission( + PermissionEnum::EMPLOYEE_ADVANCES_VIEW, + PermissionEnum::EMPLOYEE_ADVANCES_PAY, + ); + + $account = CashAccount::query()->first(); + $account->update(['balance' => 5000000]); + + $advance = EmployeeAdvance::factory()->approved()->create([ + 'amount' => 500000, + 'cash_transaction_id' => null, + ]); + + $this->actingAs($user) + ->post(route('admin.finance.employee_advances.pay', $advance)) + ->assertRedirect(route('admin.finance.employee_advances.index')); + + expect($advance->fresh()->status)->toBe(EmployeeAdvanceStatus::PAID); + expect($advance->fresh()->paid_at)->not->toBeNull(); + }); + + test('guest cannot mark kasbon as paid', function () { + $advance = EmployeeAdvance::factory()->approved()->create(); + + $this->post(route('admin.finance.employee_advances.pay', $advance)) + ->assertRedirect(route('login')); + }); + + test('user without pay permission cannot mark kasbon as paid', function () { + $user = createAdvanceUserWithPermission(PermissionEnum::EMPLOYEE_ADVANCES_VIEW); + + $advance = EmployeeAdvance::factory()->approved()->create(); + + $this->actingAs($user) + ->post(route('admin.finance.employee_advances.pay', $advance)) + ->assertForbidden(); + }); +}); + +// ─── Employee Advance Model ─────────────────────────────── + +describe('Employee Advance Model', function () { + test('employee advance has amount cast to integer', function () { + $advance = EmployeeAdvance::factory()->create(['amount' => 1000000]); + + expect($advance->amount)->toBeInt(); + expect($advance->amount)->toBe(1000000); + }); + + test('employee advance has status cast to enum', function () { + $advance = EmployeeAdvance::factory()->create([ + 'status' => EmployeeAdvanceStatus::PENDING->value, + ]); + + expect($advance->status)->toBe(EmployeeAdvanceStatus::PENDING); + }); + + test('employee advance has amount formatted accessor', function () { + $advance = EmployeeAdvance::factory()->create(['amount' => 1500000]); + + expect($advance->amount_formatted)->toBe('Rp 1.500.000'); + }); + + test('employee advance has status label accessor', function () { + $advance = EmployeeAdvance::factory()->create([ + 'status' => EmployeeAdvanceStatus::PENDING->value, + ]); + + expect($advance->status_label)->toBe('Menunggu'); + }); + + test('employee advance belongs to employee', function () { + $advance = EmployeeAdvance::factory()->create(); + + expect($advance->employee)->not->toBeNull(); + }); + + test('pending scope returns only pending advances', function () { + EmployeeAdvance::factory()->count(2)->create([ + 'status' => EmployeeAdvanceStatus::PENDING->value, + ]); + EmployeeAdvance::factory()->approved()->create(); + + $pending = EmployeeAdvance::query()->pending()->get(); + + expect($pending)->toHaveCount(2); + }); + + test('approved scope returns only approved advances', function () { + EmployeeAdvance::factory()->count(3)->approved()->create(); + EmployeeAdvance::factory()->create([ + 'status' => EmployeeAdvanceStatus::PENDING->value, + ]); + + $approved = EmployeeAdvance::query()->approved()->get(); + + expect($approved)->toHaveCount(3); + }); +}); diff --git a/tests/Feature/Admin/Finance/ExpenseTest.php b/tests/Feature/Admin/Finance/ExpenseTest.php new file mode 100644 index 0000000..80f9f4d --- /dev/null +++ b/tests/Feature/Admin/Finance/ExpenseTest.php @@ -0,0 +1,368 @@ +seed(RolePermissionSeeder::class); + User::factory()->create(); // Required by CashAccountSeeder + $this->seed(CashAccountSeeder::class); + Storage::fake('public'); +}); + +// ─── Helper ─────────────────────────────────────────────── + +function createExpenseUserWithPermission(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 createExpenseWithCashTransaction(User $user): Expense +{ + $account = CashAccount::query()->first(); + $account->update(['balance' => 5000000]); + + $expense = Expense::factory()->create([ + 'created_by_id' => $user->id, + 'amount' => 100000, + ]); + + $cashTransaction = CashTransaction::factory()->create([ + 'cash_account_id' => $account->id, + 'created_by_id' => $user->id, + 'reference_type' => Expense::class, + 'reference_id' => $expense->id, + 'amount' => $expense->amount, + 'balance_after' => $account->balance - $expense->amount, + ]); + + $expense->update(['cash_transaction_id' => $cashTransaction->id]); + + return $expense; +} + +function expensePayload(?int $amount = null, ?string $description = null): array +{ + return [ + 'amount' => $amount ?? 100000, + 'description' => $description ?? 'Pembelian ATK', + 'photos' => [UploadedFile::fake()->image('bukti.jpg', 100, 100)], + ]; +} + +// ─── Index ──────────────────────────────────────────────── + +describe('Expense Index', function () { + test('authenticated user with permission can view expense index', function () { + $user = createExpenseUserWithPermission(PermissionEnum::EXPENSES_VIEW); + + $this->actingAs($user) + ->get(route('admin.finance.expenses.index')) + ->assertOk(); + }); + + test('guest is redirected to login', function () { + $this->get(route('admin.finance.expenses.index')) + ->assertRedirect(route('login')); + }); + + test('user without permission is forbidden', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get(route('admin.finance.expenses.index')) + ->assertForbidden(); + }); + + test('index displays expenses', function () { + $user = createExpenseUserWithPermission(PermissionEnum::EXPENSES_VIEW); + + Expense::factory()->count(3)->create(['created_by_id' => $user->id]); + + $this->actingAs($user) + ->get(route('admin.finance.expenses.index')) + ->assertOk(); + }); + + test('index can search expenses by description', function () { + $user = createExpenseUserWithPermission(PermissionEnum::EXPENSES_VIEW); + + Expense::factory()->create([ + 'created_by_id' => $user->id, + 'description' => 'Pembelian kain', + ]); + + $this->actingAs($user) + ->get(route('admin.finance.expenses.index', ['search' => 'kain'])) + ->assertOk(); + }); +}); + +// ─── Store ──────────────────────────────────────────────── + +describe('Expense Store', function () { + test('authenticated user with permission can create an expense', function () { + $user = createExpenseUserWithPermission(PermissionEnum::EXPENSES_VIEW, PermissionEnum::EXPENSES_CREATE); + + CashAccount::query()->first()->update(['balance' => 5000000]); + + $this->actingAs($user) + ->post(route('admin.finance.expenses.store'), expensePayload(250000, 'Beli mesin jahit')) + ->assertRedirect(route('admin.finance.expenses.index')); + + $this->assertDatabaseHas('expenses', [ + 'amount' => 250000, + 'description' => 'Beli mesin jahit', + ]); + }); + + test('guest cannot create an expense', function () { + $this->post(route('admin.finance.expenses.store'), expensePayload()) + ->assertRedirect(route('login')); + }); + + test('user without create permission is forbidden', function () { + $user = createExpenseUserWithPermission(PermissionEnum::EXPENSES_VIEW); + + $this->actingAs($user) + ->post(route('admin.finance.expenses.store'), expensePayload()) + ->assertForbidden(); + }); + + test('amount is required', function () { + $user = createExpenseUserWithPermission(PermissionEnum::EXPENSES_VIEW, PermissionEnum::EXPENSES_CREATE); + + $this->actingAs($user) + ->post(route('admin.finance.expenses.store'), [ + 'amount' => '', + 'description' => 'Test', + 'photos' => [UploadedFile::fake()->image('bukti.jpg')], + ]) + ->assertSessionHasErrors('amount'); + }); + + test('amount must be at least 1', function () { + $user = createExpenseUserWithPermission(PermissionEnum::EXPENSES_VIEW, PermissionEnum::EXPENSES_CREATE); + + $this->actingAs($user) + ->post(route('admin.finance.expenses.store'), [ + 'amount' => 0, + 'description' => 'Test', + 'photos' => [UploadedFile::fake()->image('bukti.jpg')], + ]) + ->assertSessionHasErrors('amount'); + }); + + test('description is required', function () { + $user = createExpenseUserWithPermission(PermissionEnum::EXPENSES_VIEW, PermissionEnum::EXPENSES_CREATE); + + $this->actingAs($user) + ->post(route('admin.finance.expenses.store'), [ + 'amount' => 100000, + 'description' => '', + 'photos' => [UploadedFile::fake()->image('bukti.jpg')], + ]) + ->assertSessionHasErrors('description'); + }); + + test('description must not exceed 100 characters', function () { + $user = createExpenseUserWithPermission(PermissionEnum::EXPENSES_VIEW, PermissionEnum::EXPENSES_CREATE); + + $this->actingAs($user) + ->post(route('admin.finance.expenses.store'), [ + 'amount' => 100000, + 'description' => str_repeat('a', 101), + 'photos' => [UploadedFile::fake()->image('bukti.jpg')], + ]) + ->assertSessionHasErrors('description'); + }); + + test('creating expense also creates cash transaction', function () { + $user = createExpenseUserWithPermission(PermissionEnum::EXPENSES_VIEW, PermissionEnum::EXPENSES_CREATE); + + CashAccount::query()->first()->update(['balance' => 5000000]); + + $this->actingAs($user) + ->post(route('admin.finance.expenses.store'), expensePayload(300000, 'Operasional')); + + $expense = Expense::where('description', 'Operasional')->first(); + expect($expense)->not->toBeNull(); + expect($expense->cash_transaction_id)->not->toBeNull(); + + $this->assertDatabaseHas('cash_transactions', [ + 'id' => $expense->cash_transaction_id, + 'amount' => 300000, + ]); + }); +}); + +// ─── Update ─────────────────────────────────────────────── + +describe('Expense Update', function () { + test('authenticated user with permission can update an expense', function () { + $user = createExpenseUserWithPermission(PermissionEnum::EXPENSES_VIEW, PermissionEnum::EXPENSES_UPDATE); + + $expense = createExpenseWithCashTransaction($user); + + $this->actingAs($user) + ->put(route('admin.finance.expenses.update', $expense), expensePayload(200000, 'Deskripsi baru')) + ->assertRedirect(route('admin.finance.expenses.index')); + + expect($expense->fresh()->amount)->toBe(200000); + expect($expense->fresh()->description)->toBe('Deskripsi baru'); + }); + + test('guest cannot update an expense', function () { + $user = User::factory()->create(); + $expense = createExpenseWithCashTransaction($user); + + $this->put(route('admin.finance.expenses.update', $expense), expensePayload()) + ->assertRedirect(route('login')); + }); + + test('user without update permission is forbidden', function () { + $user = createExpenseUserWithPermission(PermissionEnum::EXPENSES_VIEW); + $expense = createExpenseWithCashTransaction($user); + + $this->actingAs($user) + ->put(route('admin.finance.expenses.update', $expense), expensePayload()) + ->assertForbidden(); + }); + + test('amount is required on update', function () { + $user = createExpenseUserWithPermission(PermissionEnum::EXPENSES_VIEW, PermissionEnum::EXPENSES_UPDATE); + $expense = createExpenseWithCashTransaction($user); + + $this->actingAs($user) + ->put(route('admin.finance.expenses.update', $expense), [ + 'amount' => '', + 'description' => 'Test', + 'photos' => [UploadedFile::fake()->image('bukti.jpg')], + ]) + ->assertSessionHasErrors('amount'); + }); + + test('description is required on update', function () { + $user = createExpenseUserWithPermission(PermissionEnum::EXPENSES_VIEW, PermissionEnum::EXPENSES_UPDATE); + $expense = createExpenseWithCashTransaction($user); + + $this->actingAs($user) + ->put(route('admin.finance.expenses.update', $expense), [ + 'amount' => 100000, + 'description' => '', + 'photos' => [UploadedFile::fake()->image('bukti.jpg')], + ]) + ->assertSessionHasErrors('description'); + }); +}); + +// ─── Destroy ────────────────────────────────────────────── + +describe('Expense Destroy', function () { + test('authenticated user with permission can delete an expense', function () { + $user = createExpenseUserWithPermission(PermissionEnum::EXPENSES_VIEW, PermissionEnum::EXPENSES_DELETE); + + $expense = createExpenseWithCashTransaction($user); + + $this->actingAs($user) + ->delete(route('admin.finance.expenses.destroy', $expense)) + ->assertRedirect(route('admin.finance.expenses.index')); + + $this->assertSoftDeleted('expenses', ['id' => $expense->id]); + }); + + test('guest cannot delete an expense', function () { + $user = User::factory()->create(); + $expense = createExpenseWithCashTransaction($user); + + $this->delete(route('admin.finance.expenses.destroy', $expense)) + ->assertRedirect(route('login')); + + $this->assertNotSoftDeleted('expenses', ['id' => $expense->id]); + }); + + test('user without delete permission is forbidden', function () { + $user = createExpenseUserWithPermission(PermissionEnum::EXPENSES_VIEW); + $expense = createExpenseWithCashTransaction($user); + + $this->actingAs($user) + ->delete(route('admin.finance.expenses.destroy', $expense)) + ->assertForbidden(); + + $this->assertNotSoftDeleted('expenses', ['id' => $expense->id]); + }); + + test('deleting expense also deletes cash transaction', function () { + $user = createExpenseUserWithPermission(PermissionEnum::EXPENSES_VIEW, PermissionEnum::EXPENSES_DELETE); + + $expense = createExpenseWithCashTransaction($user); + $cashTransactionId = $expense->cash_transaction_id; + + $this->actingAs($user) + ->delete(route('admin.finance.expenses.destroy', $expense)); + + $this->assertSoftDeleted('expenses', ['id' => $expense->id]); + $this->assertSoftDeleted('cash_transactions', ['id' => $cashTransactionId]); + }); +}); + +// ─── Expense Model ──────────────────────────────────────── + +describe('Expense Model', function () { + test('expense uses soft deletes', function () { + $expense = Expense::factory()->create(); + + $expense->delete(); + + expect($expense->trashed())->toBeTrue(); + }); + + test('expense has amount cast to integer', function () { + $expense = Expense::factory()->create(['amount' => 500000]); + + expect($expense->amount)->toBeInt(); + expect($expense->amount)->toBe(500000); + }); + + test('expense has amount formatted accessor', function () { + $expense = Expense::factory()->create(['amount' => 1500000]); + + expect($expense->amount_formatted)->toBe('Rp 1.500.000'); + }); + + test('expense belongs to cash transaction', function () { + $user = User::factory()->create(); + $expense = createExpenseWithCashTransaction($user); + + expect($expense->cashTransaction)->not->toBeNull(); + }); + + test('expense belongs to created by user', function () { + $user = User::factory()->create(); + $expense = Expense::factory()->create(['created_by_id' => $user->id]); + + expect($expense->createdBy)->not->toBeNull(); + expect($expense->createdBy->id)->toBe($user->id); + }); +}); diff --git a/tests/Feature/Admin/Finance/PayrollTest.php b/tests/Feature/Admin/Finance/PayrollTest.php new file mode 100644 index 0000000..2be014c --- /dev/null +++ b/tests/Feature/Admin/Finance/PayrollTest.php @@ -0,0 +1,563 @@ +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); + }); +});