fn ($query) => $query ->withoutGlobalScopes() ->select('id', 'ulid', 'name', 'code'), ]); if ($status = $request->status) { $query->where('status', $status); } if ($projectId = $request->project_id) { $query->where('project_id', $projectId); } $query->whereIn('project_id', $this->availableProjectIdsQuery()); $invoices = $query->latest()->paginate(15)->withQueryString(); $projects = Project::whereIn('projects.id', $this->availableProjectIdsQuery()) ->select('id', 'ulid', 'name', 'code')->get(); // Compute summary stats $allInvoices = FinancialInvoice::whereIn('project_id', $this->availableProjectIdsQuery()); $summary = [ 'total_billed' => (float) $allInvoices->sum('total_amount'), 'total_paid' => (float) $allInvoices->sum('paid_amount'), 'outstanding' => (float) $allInvoices->whereNotIn('status', ['paid'])->sum(\DB::raw('total_amount - paid_amount')), 'total_retention' => (float) RetentionEntry::where('type', 'debit') ->whereIn('project_id', $this->availableProjectIdsQuery())->sum('amount') - (float) RetentionEntry::where('type', 'credit') ->whereIn('project_id', $this->availableProjectIdsQuery())->sum('amount'), ]; return Inertia::render('FinancialManagement::Invoices/Index', [ 'invoices' => $invoices, 'projects' => $projects, 'summary' => $summary, 'filters' => $request->only(['status', 'project_id']), ]); } // --- Create (Progress Billing) --- public function create() { $projects = Project::whereIn('projects.id', $this->availableProjectIdsQuery()) ->select('id', 'ulid', 'name', 'code', 'contract_value', 'last_billed_percentage') ->where('current_wizard_step', '>=', 7) ->whereNotIn('status', ['completed', 'closed']) ->get(); return Inertia::render('FinancialManagement::Invoices/Create', [ 'projects' => $projects, ]); } public function store(Request $request) { $validated = $request->validate([ 'project_id' => 'required|string', 'current_percentage' => 'required|numeric|min:0.01|max:100', 'retention_rate' => 'nullable|numeric|min:0|max:50', ]); $project = Project::findByUlid($validated['project_id']); if (!$project) { return back()->with('error', 'Project not found.'); } try { $this->billingService->generateInvoice( $project, $validated['current_percentage'], $validated['retention_rate'] ?? 10.00, ); } catch (\InvalidArgumentException $e) { return back()->with('error', $e->getMessage()); } return redirect()->route('finance.index')->with('success', 'Progress invoice generated.'); } // --- Show Invoice --- public function show(FinancialInvoice $invoice) { abort_unless($this->canAccessProject(auth()->user(), $invoice->project_id), 403); $invoice->load(['project:id,name,code', 'lineItems', 'retentionEntries']); return Inertia::render('FinancialManagement::Invoices/Show', [ 'invoice' => $invoice, ]); } // --- State Transitions --- public function submit(Request $request, FinancialInvoice $invoice) { abort_unless($this->canAccessProject($request->user(), $invoice->project_id), 403); try { $invoice->transitionTo(InvoiceStatus::Submitted); // Fetch Admins and Super Admins as approvers $adminIds = User::where('user_type', 'admin') ->orWhereHas('roles', function ($q) { $q->whereIn('name', ['Super Admin', 'admin', 'Project Manager', 'Main Contractor Admin']); }) ->pluck('id') ->toArray(); if (!empty($adminIds)) { $this->approvalService->createChain( approvable: $invoice, approverIds: $adminIds, type: 'financial_invoice', initiatedBy: $request->user()->id, notes: "Progress Invoice {$invoice->invoice_number} submitted for approval.", ); } } catch (\InvalidArgumentException $e) { return back()->with('error', $e->getMessage()); } return back()->with('success', 'Invoice submitted for approval.'); } public function approve(FinancialInvoice $invoice) { $user = auth()->user(); abort_unless($this->canAccessProject($user, $invoice->project_id), 403); $isApprover = $user->user_type === 'admin' || $user->roles()->whereIn('name', ['Super Admin', 'admin'])->exists(); if (!$isApprover) { return back()->with('error', 'Unauthorized. Only Admin or Super Admin can approve client invoices.'); } try { $invoice->transitionTo(InvoiceStatus::Approved); $this->billingService->holdRetention($invoice); } catch (\InvalidArgumentException $e) { return back()->with('error', $e->getMessage()); } return back()->with('success', 'Invoice approved. Retention held.'); } public function reject(FinancialInvoice $invoice) { $user = auth()->user(); abort_unless($this->canAccessProject($user, $invoice->project_id), 403); $isApprover = $user->user_type === 'admin' || $user->roles()->whereIn('name', ['Super Admin', 'admin'])->exists(); if (!$isApprover) { return back()->with('error', 'Unauthorized. Only Admin or Super Admin can reject client invoices.'); } try { $invoice->transitionTo(InvoiceStatus::Rejected); } catch (\InvalidArgumentException $e) { return back()->with('error', $e->getMessage()); } return back()->with('success', 'Invoice rejected.'); } public function send(FinancialInvoice $invoice) { abort_unless($this->canAccessProject(auth()->user(), $invoice->project_id), 403); try { $invoice->transitionTo(InvoiceStatus::Sent); } catch (\InvalidArgumentException $e) { return back()->with('error', $e->getMessage()); } return back()->with('success', 'Invoice sent to client.'); } public function recordPayment(Request $request, FinancialInvoice $invoice) { abort_unless($this->canAccessProject($request->user(), $invoice->project_id), 403); $validated = $request->validate([ 'amount' => 'required|numeric|min:0.01', ]); try { $this->billingService->recordPayment($invoice, $validated['amount']); } catch (\InvalidArgumentException $e) { return back()->with('error', $e->getMessage()); } return back()->with('success', 'Payment recorded.'); } // --- Retention Ledger --- public function retention(Request $request) { $query = RetentionEntry::with([ 'project' => fn ($query) => $query ->withoutGlobalScopes() ->select('id', 'ulid', 'name', 'code'), 'invoice:id,invoice_number', ]); if ($projectId = $request->project_id) { $query->where('project_id', $projectId); } $query->whereIn('project_id', $this->availableProjectIdsQuery()); $entries = $query->latest()->paginate(20)->withQueryString(); $projects = Project::whereIn('projects.id', $this->availableProjectIdsQuery()) ->select('id', 'ulid', 'name', 'code')->get(); // Compute per-project totals $projectTotals = RetentionEntry::whereIn('project_id', $this->availableProjectIdsQuery()) ->selectRaw('project_id, type, SUM(amount) as total') ->groupBy('project_id', 'type') ->get() ->groupBy('project_id') ->map(function ($items) { $debits = $items->where('type', 'debit')->sum('total'); $credits = $items->where('type', 'credit')->sum('total'); return ['held' => (float) $debits, 'released' => (float) $credits, 'balance' => (float) $debits - (float) $credits]; }); return Inertia::render('FinancialManagement::Retention/Index', [ 'entries' => $entries, 'projects' => $projects, 'projectTotals' => $projectTotals, 'filters' => $request->only(['project_id']), ]); } // Cash Advances public function cashAdvances(Request $request) { $user = $request->user(); $isApprover = $this->canApproveCashAdvance($user); $query = $isApprover ? \Modules\FinancialManagement\Models\CashAdvance::withoutGlobalScopes() : \Modules\FinancialManagement\Models\CashAdvance::query(); $query->with(['project:id,name,code', 'requester:id,name,email', 'approver:id,name,email']); $query->whereIn('project_id', $this->availableProjectIdsQuery()); if ($projectId = $request->project_id) { $query->where('project_id', $projectId); } $cashAdvances = $query->latest()->paginate(20)->withQueryString(); $projects = Project::whereIn('projects.id', $this->availableProjectIdsQuery()) ->select('id', 'ulid', 'name', 'code')->get(); return Inertia::render('FinancialManagement::CashAdvances/Index', [ 'cashAdvances' => $cashAdvances, 'projects' => $projects, 'filters' => $request->only(['project_id']), ]); } public function storeCashAdvance(Request $request) { $validated = $request->validate([ 'project_ulid' => 'required|string', 'amount' => 'required|numeric|min:1', 'reason' => 'required|string|max:1000', ]); $project = Project::where('ulid', $validated['project_ulid'])->firstOrFail(); $cashAdvance = \Modules\FinancialManagement\Models\CashAdvance::create([ 'project_id' => $project->id, 'amount' => $validated['amount'], 'reason' => $validated['reason'], 'status' => 'pending', 'requested_by' => auth()->id(), ]); return back()->with('success', 'Cash advance request submitted successfully.'); } public function approveCashAdvance(string $cashAdvance) { $user = auth()->user(); $cashAdvance = \Modules\FinancialManagement\Models\CashAdvance::withoutGlobalScopes() ->where('ulid', $cashAdvance) ->firstOrFail(); if ($cashAdvance->requested_by === $user->id && $user->user_type !== 'admin' && !$user->hasRole('Super Admin')) { return back()->with('error', 'You cannot approve your own cash advance request.'); } $isApprover = $this->canApproveCashAdvance($user); if (! $isApprover) { return back()->with('error', 'Unauthorized to approve cash advance requests.'); } if (! $this->isPlatformUser($user) && ! $this->availableProjectIdsQuery()->where('projects.id', $cashAdvance->project_id)->exists()) { return back()->with('error', 'You cannot approve a cash advance for an unrelated project.'); } $cashAdvance->update([ 'status' => 'approved', 'approved_by' => auth()->id(), ]); return back()->with('success', 'Cash advance request approved.'); } private function canApproveCashAdvance(User $user): bool { return $user->user_type === 'admin' || $user->hasAnyRole(['Super Admin', 'admin', 'Project Manager', 'Main Contractor Admin']); } private function availableProjectIdsQuery() { return Project::query()->select('projects.id'); } private function isPlatformUser(User $user): bool { return $user->user_type === 'admin' || $user->hasAnyRole(['Super Admin', 'admin']); } private function canAccessProject(User $user, ?int $projectId): bool { return $projectId !== null && ($this->isPlatformUser($user) || $this->availableProjectIdsQuery()->where('projects.id', $projectId)->exists()); } }