363 lines
14 KiB
PHP
363 lines
14 KiB
PHP
<?php
|
|
|
|
namespace Modules\FinancialManagement\Tests\Feature;
|
|
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Modules\FinancialManagement\Enums\InvoiceStatus;
|
|
use Modules\FinancialManagement\Models\FinancialInvoice;
|
|
use Modules\FinancialManagement\Models\RetentionEntry;
|
|
use Modules\ProjectManagement\Models\Project;
|
|
use Spatie\Permission\Models\Permission;
|
|
use Spatie\Permission\Models\Role;
|
|
use Tests\TestCase;
|
|
|
|
class RevisedRetentionFlowTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
protected User $superAdmin;
|
|
protected User $admin;
|
|
protected User $projectManager;
|
|
protected User $contractorAdmin;
|
|
protected Project $project;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
Role::firstOrCreate(['name' => 'Super Admin']);
|
|
Role::firstOrCreate(['name' => 'Admin']);
|
|
Role::firstOrCreate(['name' => 'Project Manager']);
|
|
Role::firstOrCreate(['name' => 'Main Contractor Admin']);
|
|
|
|
$financePermission = Permission::firstOrCreate(['name' => 'finance.access']);
|
|
|
|
$this->superAdmin = User::factory()->create(['user_type' => 'admin']);
|
|
$this->superAdmin->assignRole('Super Admin');
|
|
$this->superAdmin->givePermissionTo($financePermission);
|
|
|
|
$this->admin = User::factory()->create(['user_type' => 'admin']);
|
|
$this->admin->assignRole('Admin');
|
|
$this->admin->givePermissionTo($financePermission);
|
|
|
|
$this->projectManager = User::factory()->create(['user_type' => 'employee']);
|
|
$this->projectManager->assignRole('Project Manager');
|
|
$this->projectManager->givePermissionTo($financePermission);
|
|
|
|
$this->contractorAdmin = User::factory()->create(['user_type' => 'contractor']);
|
|
$this->contractorAdmin->assignRole('Main Contractor Admin');
|
|
$this->contractorAdmin->givePermissionTo($financePermission);
|
|
|
|
$this->project = Project::create([
|
|
'name' => 'Metro Office Tower',
|
|
'code' => 'PRJ-METRO-01',
|
|
'status' => 'in_progress',
|
|
'budget' => 5000000,
|
|
'contract_value' => 5000000,
|
|
'total_capitalization' => 5000000,
|
|
'completion_percentage' => 0,
|
|
'current_wizard_step' => 8,
|
|
]);
|
|
}
|
|
|
|
public function test_contractor_admin_can_generate_progress_invoice(): void
|
|
{
|
|
$response = $this->actingAs($this->contractorAdmin)->post(route('finance.store'), [
|
|
'project_id' => $this->project->ulid,
|
|
'current_percentage' => 20.00,
|
|
'retention_rate' => 10.00,
|
|
]);
|
|
|
|
$response->assertRedirect(route('finance.index'));
|
|
$this->assertDatabaseHas('financial_invoices', [
|
|
'project_id' => $this->project->id,
|
|
'billed_percentage' => 20.00,
|
|
'retention_rate' => 10.00,
|
|
'status' => 'draft',
|
|
]);
|
|
}
|
|
|
|
public function test_superadmin_admin_and_pm_can_approve_invoice_and_hold_retention(): void
|
|
{
|
|
$invoice = FinancialInvoice::create([
|
|
'project_id' => $this->project->id,
|
|
'invoice_number' => 'INV-TEST-001',
|
|
'status' => InvoiceStatus::Submitted,
|
|
'subtotal' => 1000000.00,
|
|
'retention_amount' => 100000.00,
|
|
'total_amount' => 900000.00,
|
|
'retention_rate' => 10.00,
|
|
'billed_percentage' => 20.00,
|
|
'invoice_date' => now()->toDateString(),
|
|
]);
|
|
|
|
// Project Manager approves invoice
|
|
$response = $this->actingAs($this->projectManager)
|
|
->patch(route('finance.approve', $invoice->ulid));
|
|
|
|
$response->assertSessionHas('success');
|
|
$invoice->refresh();
|
|
$this->assertEquals(InvoiceStatus::Approved, $invoice->status);
|
|
|
|
// Retention debit entry created in ledger
|
|
$this->assertDatabaseHas('retention_ledger', [
|
|
'project_id' => $this->project->id,
|
|
'invoice_id' => $invoice->id,
|
|
'type' => 'debit',
|
|
'amount' => 100000.00,
|
|
]);
|
|
}
|
|
|
|
public function test_contractor_admin_can_send_10_percent_payment_proof(): void
|
|
{
|
|
Storage::fake('public');
|
|
|
|
$invoice = FinancialInvoice::create([
|
|
'project_id' => $this->project->id,
|
|
'invoice_number' => 'INV-TEST-002',
|
|
'status' => InvoiceStatus::Approved,
|
|
'subtotal' => 1000000.00,
|
|
'retention_amount' => 100000.00,
|
|
'total_amount' => 900000.00,
|
|
'retention_rate' => 10.00,
|
|
'billed_percentage' => 20.00,
|
|
'invoice_date' => now()->toDateString(),
|
|
]);
|
|
|
|
$file = UploadedFile::fake()->create('payment_receipt_10percent.pdf', 200, 'application/pdf');
|
|
|
|
$response = $this->actingAs($this->contractorAdmin)->post(route('finance.send-payment-proof', $invoice->ulid), [
|
|
'media' => $file,
|
|
'notes' => '10% Retention Remittance via BDO Bank Transfer Ref #992144',
|
|
]);
|
|
|
|
$response->assertSessionHas('success');
|
|
$invoice->refresh();
|
|
|
|
$this->assertEquals(InvoiceStatus::PaymentSent, $invoice->status);
|
|
$this->assertNotNull($invoice->payment_proof_path);
|
|
$this->assertEquals('payment_receipt_10percent.pdf', $invoice->payment_proof_name);
|
|
$this->assertEquals('10% Retention Remittance via BDO Bank Transfer Ref #992144', $invoice->payment_proof_notes);
|
|
$this->assertEquals($this->contractorAdmin->id, $invoice->payment_proof_submitted_by);
|
|
$this->assertNotNull($invoice->payment_proof_submitted_at);
|
|
|
|
Storage::disk('public')->assertExists($invoice->payment_proof_path);
|
|
}
|
|
|
|
public function test_executive_can_view_proof_and_confirm_payment_received(): void
|
|
{
|
|
Storage::fake('public');
|
|
$file = UploadedFile::fake()->create('proof.pdf', 100, 'application/pdf');
|
|
$path = $file->store('invoice_payment_proofs/1', 'public');
|
|
|
|
$invoice = FinancialInvoice::create([
|
|
'project_id' => $this->project->id,
|
|
'invoice_number' => 'INV-TEST-003',
|
|
'status' => InvoiceStatus::PaymentSent,
|
|
'subtotal' => 1000000.00,
|
|
'retention_amount' => 100000.00,
|
|
'total_amount' => 900000.00,
|
|
'retention_rate' => 10.00,
|
|
'billed_percentage' => 20.00,
|
|
'invoice_date' => now()->toDateString(),
|
|
'payment_proof_path' => $path,
|
|
'payment_proof_name' => 'proof.pdf',
|
|
'payment_proof_submitted_at' => now(),
|
|
'payment_proof_submitted_by' => $this->contractorAdmin->id,
|
|
]);
|
|
|
|
// Executive can view payment proof
|
|
$viewResponse = $this->actingAs($this->superAdmin)
|
|
->get(route('finance.payment-proof.view', $invoice->ulid));
|
|
$viewResponse->assertOk();
|
|
|
|
// Project Manager confirms received payment
|
|
$confirmResponse = $this->actingAs($this->projectManager)
|
|
->patch(route('finance.receive-payment', $invoice->ulid), [
|
|
'notes' => 'Verified with Treasury. Payment confirmed.',
|
|
]);
|
|
|
|
$confirmResponse->assertSessionHas('success');
|
|
$invoice->refresh();
|
|
|
|
$this->assertEquals(InvoiceStatus::Paid, $invoice->status);
|
|
$this->assertEquals(900000.00, (float) $invoice->paid_amount);
|
|
$this->assertNotNull($invoice->paid_at);
|
|
$this->assertEquals(20.00, (float) $this->project->refresh()->completion_percentage);
|
|
}
|
|
|
|
public function test_contractor_cannot_confirm_received_payment_on_behalf_of_executive(): void
|
|
{
|
|
$invoice = FinancialInvoice::create([
|
|
'project_id' => $this->project->id,
|
|
'invoice_number' => 'INV-TEST-004',
|
|
'status' => InvoiceStatus::PaymentSent,
|
|
'subtotal' => 1000000.00,
|
|
'retention_amount' => 100000.00,
|
|
'total_amount' => 900000.00,
|
|
'retention_rate' => 10.00,
|
|
'billed_percentage' => 20.00,
|
|
'invoice_date' => now()->toDateString(),
|
|
]);
|
|
|
|
$response = $this->actingAs($this->contractorAdmin)
|
|
->patch(route('finance.receive-payment', $invoice->ulid));
|
|
|
|
$response->assertSessionHas('error');
|
|
$invoice->refresh();
|
|
$this->assertNotEquals(InvoiceStatus::Paid, $invoice->status);
|
|
}
|
|
|
|
public function test_overdue_invoices_past_2_months_appear_in_retention_index(): void
|
|
{
|
|
// Invoice created 75 days ago (older than 2 months)
|
|
$overdueInvoice = FinancialInvoice::create([
|
|
'project_id' => $this->project->id,
|
|
'invoice_number' => 'INV-OVERDUE-01',
|
|
'status' => InvoiceStatus::Approved,
|
|
'subtotal' => 2000000.00,
|
|
'retention_amount' => 200000.00,
|
|
'total_amount' => 1800000.00,
|
|
'retention_rate' => 10.00,
|
|
'billed_percentage' => 40.00,
|
|
'invoice_date' => now()->subDays(75)->toDateString(),
|
|
'approved_at' => now()->subDays(75),
|
|
]);
|
|
|
|
$response = $this->actingAs($this->superAdmin)
|
|
->get(route('retention.index'));
|
|
|
|
$response->assertOk();
|
|
$response->assertInertia(fn ($page) => $page
|
|
->component('FinancialManagement::Retention/Index', false)
|
|
->has('overdueInvoices', 1)
|
|
->where('overdueInvoices.0.invoice_number', 'INV-OVERDUE-01')
|
|
->where('overdueInvoices.0.retention_amount', 200000)
|
|
);
|
|
}
|
|
|
|
public function test_executive_can_apply_percentage_penalty_to_overdue_invoice(): void
|
|
{
|
|
$overdueInvoice = FinancialInvoice::create([
|
|
'project_id' => $this->project->id,
|
|
'invoice_number' => 'INV-OVERDUE-02',
|
|
'status' => InvoiceStatus::Approved,
|
|
'subtotal' => 1000000.00,
|
|
'retention_amount' => 100000.00,
|
|
'total_amount' => 900000.00,
|
|
'retention_rate' => 10.00,
|
|
'billed_percentage' => 20.00,
|
|
'invoice_date' => now()->subDays(65)->toDateString(),
|
|
]);
|
|
|
|
// Super Admin applies a 5% penalty
|
|
$response = $this->actingAs($this->superAdmin)
|
|
->post(route('finance.apply-penalty', $overdueInvoice->ulid), [
|
|
'penalty_rate' => 5.00,
|
|
'reason' => 'Overdue 65 days without remittance confirmation.',
|
|
]);
|
|
|
|
$response->assertSessionHas('success');
|
|
$overdueInvoice->refresh();
|
|
|
|
// 5% of 100,000 retention = 5,000.00 penalty
|
|
$this->assertEquals(5.00, (float) $overdueInvoice->penalty_rate);
|
|
$this->assertEquals(5000.00, (float) $overdueInvoice->penalty_amount);
|
|
$this->assertEquals('Overdue 65 days without remittance confirmation.', $overdueInvoice->penalty_reason);
|
|
$this->assertEquals($this->superAdmin->id, $overdueInvoice->penalty_applied_by);
|
|
$this->assertNotNull($overdueInvoice->penalty_applied_at);
|
|
|
|
// Penalty adjustment recorded in retention ledger
|
|
$this->assertDatabaseHas('retention_ledger', [
|
|
'project_id' => $this->project->id,
|
|
'invoice_id' => $overdueInvoice->id,
|
|
'type' => 'debit',
|
|
'amount' => 5000.00,
|
|
]);
|
|
}
|
|
|
|
public function test_contractor_cannot_apply_penalty(): void
|
|
{
|
|
$overdueInvoice = FinancialInvoice::create([
|
|
'project_id' => $this->project->id,
|
|
'invoice_number' => 'INV-OVERDUE-03',
|
|
'status' => InvoiceStatus::Approved,
|
|
'subtotal' => 1000000.00,
|
|
'retention_amount' => 100000.00,
|
|
'total_amount' => 900000.00,
|
|
'retention_rate' => 10.00,
|
|
'billed_percentage' => 20.00,
|
|
'invoice_date' => now()->subDays(70)->toDateString(),
|
|
]);
|
|
|
|
$response = $this->actingAs($this->contractorAdmin)
|
|
->post(route('finance.apply-penalty', $overdueInvoice->ulid), [
|
|
'penalty_rate' => 5.00,
|
|
'reason' => 'Attempted unauthorized penalty.',
|
|
]);
|
|
|
|
$response->assertForbidden();
|
|
$this->assertNull($overdueInvoice->refresh()->penalty_rate);
|
|
}
|
|
|
|
public function test_main_contractor_admin_can_view_retention_index(): void
|
|
{
|
|
$response = $this->actingAs($this->contractorAdmin)->get(route('retention.index'));
|
|
$response->assertOk();
|
|
}
|
|
|
|
public function test_site_operations_roles_are_forbidden_from_retention(): void
|
|
{
|
|
Role::firstOrCreate(['name' => 'Site Technical']);
|
|
Role::firstOrCreate(['name' => 'Construction Supervisor']);
|
|
|
|
$siteTech = User::factory()->create(['user_type' => 'employee']);
|
|
$siteTech->assignRole('Site Technical');
|
|
$siteTech->givePermissionTo('finance.access');
|
|
|
|
$supervisor = User::factory()->create(['user_type' => 'employee']);
|
|
$supervisor->assignRole('Construction Supervisor');
|
|
$supervisor->givePermissionTo('finance.access');
|
|
|
|
// Site Technical gets 403
|
|
$this->actingAs($siteTech)->get(route('retention.index'))->assertForbidden();
|
|
|
|
// Construction Supervisor gets 403
|
|
$this->actingAs($supervisor)->get(route('retention.index'))->assertForbidden();
|
|
}
|
|
|
|
public function test_site_operations_cannot_submit_payment_proof(): void
|
|
{
|
|
Role::firstOrCreate(['name' => 'Site Technical']);
|
|
$siteTech = User::factory()->create(['user_type' => 'employee']);
|
|
$siteTech->assignRole('Site Technical');
|
|
$siteTech->givePermissionTo('finance.access');
|
|
|
|
$invoice = FinancialInvoice::create([
|
|
'project_id' => $this->project->id,
|
|
'invoice_number' => 'INV-PROOF-TEST-01',
|
|
'status' => InvoiceStatus::Approved,
|
|
'subtotal' => 1000000.00,
|
|
'retention_amount' => 100000.00,
|
|
'total_amount' => 900000.00,
|
|
'retention_rate' => 10.00,
|
|
'billed_percentage' => 20.00,
|
|
'invoice_date' => now()->toDateString(),
|
|
]);
|
|
|
|
Storage::fake('public');
|
|
$file = UploadedFile::fake()->create('proof.pdf', 500, 'application/pdf');
|
|
|
|
$response = $this->actingAs($siteTech)
|
|
->post(route('finance.send-payment-proof', $invoice->ulid), [
|
|
'media' => $file,
|
|
'notes' => 'Attempted unauthorized upload',
|
|
]);
|
|
|
|
$response->assertForbidden();
|
|
}
|
|
}
|