Files
GSB-Construction/Modules/FinancialManagement/tests/Feature/RevisedRetentionFlowTest.php
Ajjj b98951f65f
Some checks failed
Tests / PHP 8.3 (push) Has been cancelled
Tests / PHP 8.4 (push) Has been cancelled
Tests / PHP 8.5 (push) Has been cancelled
feat: decompose project god components, add database performance indexes, and isolate contractor admin retention
2026-08-14 16:20:15 +08:00

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();
}
}