826 lines
32 KiB
PHP
826 lines
32 KiB
PHP
<?php
|
|
|
|
namespace Modules\FinancialManagement\Http\Controllers;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use Illuminate\Http\Request;
|
|
use Inertia\Inertia;
|
|
use Modules\FinancialManagement\Enums\InvoiceStatus;
|
|
use Modules\FinancialManagement\Models\FinancialInvoice;
|
|
use Modules\FinancialManagement\Models\InvoiceLineItem;
|
|
use Modules\FinancialManagement\Models\RetentionEntry;
|
|
use Modules\FinancialManagement\Services\ProgressBillingService;
|
|
use Modules\ProjectManagement\Models\Project;
|
|
|
|
use Modules\ApprovalWorkflow\Services\ApprovalService;
|
|
use App\Models\User;
|
|
|
|
class FinanceController extends Controller
|
|
{
|
|
public function __construct(
|
|
private ProgressBillingService $billingService,
|
|
private ApprovalService $approvalService,
|
|
) {}
|
|
|
|
// --- Invoice List ---
|
|
public function index(Request $request)
|
|
{
|
|
$query = FinancialInvoice::with([
|
|
'project' => 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) (clone $allInvoices)->where('status', 'paid')->sum('paid_amount'),
|
|
'outstanding' => (float) (clone $allInvoices)->where('status', '!=', 'paid')->sum('total_amount'),
|
|
'total_retention' => abs(
|
|
(float) RetentionEntry::where('type', 'debit')
|
|
->whereIn('project_id', $this->availableProjectIdsQuery())->sum('amount')
|
|
- (float) RetentionEntry::where('type', 'credit')
|
|
->whereIn('status', ['posted', 'paid'])
|
|
->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)
|
|
->where('status', '!=', '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', 'paymentProofSubmittedBy:id,name,email']);
|
|
|
|
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.');
|
|
}
|
|
|
|
private function isExecutiveApprover(?User $user): bool
|
|
{
|
|
if (!$user) return false;
|
|
$userType = strtolower($user->user_type ?? '');
|
|
if (in_array($userType, ['admin', 'super_admin', 'project_manager'], true)) {
|
|
return true;
|
|
}
|
|
return $user->roles()->whereIn('name', ['Super Admin', 'admin', 'Admin', 'Project Manager', 'project_manager', 'Executive'])->exists();
|
|
}
|
|
|
|
public function approve(FinancialInvoice $invoice)
|
|
{
|
|
$user = auth()->user();
|
|
abort_unless($this->canAccessProject($user, $invoice->project_id), 403);
|
|
$isApprover = $this->isExecutiveApprover($user);
|
|
|
|
if (!$isApprover) {
|
|
return back()->with('error', 'Unauthorized. Only Super Admin, Admin, or Project Manager 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 = $this->isExecutiveApprover($user);
|
|
|
|
if (!$isApprover) {
|
|
return back()->with('error', 'Unauthorized. Only Super Admin, Admin, or Project Manager 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 sendPaymentProof(Request $request, FinancialInvoice $invoice)
|
|
{
|
|
$user = $request->user();
|
|
abort_unless($this->canAccessProject($user, $invoice->project_id), 403);
|
|
abort_unless($this->isContractorAdmin($user), 403, 'Only Contractor Admin can submit payment proof.');
|
|
|
|
$hasFileInfo = extension_loaded('fileinfo');
|
|
$rules = [
|
|
'media' => 'required|file|max:10240',
|
|
'notes' => 'nullable|string|max:1000',
|
|
];
|
|
if ($hasFileInfo) {
|
|
$rules['media'] .= '|mimes:pdf,jpg,jpeg,png';
|
|
}
|
|
$validated = $request->validate($rules);
|
|
$file = $request->file('media');
|
|
|
|
if (!$hasFileInfo) {
|
|
$extension = strtolower($file->getClientOriginalExtension() ?: pathinfo($file->getClientOriginalName(), PATHINFO_EXTENSION));
|
|
if (!in_array($extension, ['pdf', 'jpg', 'jpeg', 'png'], true)) {
|
|
return back()->withErrors(['media' => 'The proof must be a file of type: pdf, jpg, jpeg, png.']);
|
|
}
|
|
}
|
|
|
|
$path = $file->store("invoice_payment_proofs/{$invoice->id}", 'public');
|
|
|
|
try {
|
|
$this->billingService->submitContractorPaymentProof(
|
|
$invoice,
|
|
$path,
|
|
$file->getClientOriginalName(),
|
|
$validated['notes'] ?? null,
|
|
$user->id
|
|
);
|
|
} catch (\InvalidArgumentException $e) {
|
|
return back()->with('error', $e->getMessage());
|
|
}
|
|
|
|
return back()->with('success', '10% Payment proof sent to Executive. Awaiting payment receipt confirmation.');
|
|
}
|
|
|
|
public function receivePayment(Request $request, FinancialInvoice $invoice)
|
|
{
|
|
$user = $request->user();
|
|
abort_unless($this->canAccessProject($user, $invoice->project_id), 403);
|
|
$isApprover = $this->isExecutiveApprover($user);
|
|
|
|
if (!$isApprover) {
|
|
return back()->with('error', 'Only Super Admin, Admin, or Project Manager can confirm received payment.');
|
|
}
|
|
|
|
$validated = $request->validate([
|
|
'notes' => 'nullable|string|max:1000',
|
|
]);
|
|
|
|
try {
|
|
$this->billingService->confirmPaymentReceived($invoice, $validated['notes'] ?? null);
|
|
} catch (\InvalidArgumentException $e) {
|
|
return back()->with('error', $e->getMessage());
|
|
}
|
|
|
|
return back()->with('success', 'Payment receipt confirmed successfully. Invoice is now Paid.');
|
|
}
|
|
|
|
public function viewPaymentProof(Request $request, FinancialInvoice $invoice)
|
|
{
|
|
abort_unless($this->canAccessProject($request->user(), $invoice->project_id), 403);
|
|
|
|
if (!$invoice->payment_proof_path) {
|
|
abort(404, 'No payment proof available.');
|
|
}
|
|
|
|
$disk = \Storage::disk('public');
|
|
if (!$disk->exists($invoice->payment_proof_path)) {
|
|
abort(404, 'Proof file not found on disk.');
|
|
}
|
|
|
|
$mimeType = $disk->mimeType($invoice->payment_proof_path);
|
|
return response()->file($disk->path($invoice->payment_proof_path), [
|
|
'Content-Type' => $mimeType,
|
|
'Content-Disposition' => 'inline; filename="' . ($invoice->payment_proof_name ?: 'payment-proof') . '"',
|
|
]);
|
|
}
|
|
|
|
public function applyPenalty(Request $request, FinancialInvoice $invoice)
|
|
{
|
|
$user = $request->user();
|
|
abort_unless($this->canAccessProject($user, $invoice->project_id), 403);
|
|
abort_unless($this->isExecutiveApprover($user), 403, 'Only Super Admin, Admin, or Project Manager can assess retention penalties.');
|
|
|
|
$validated = $request->validate([
|
|
'penalty_rate' => 'required|numeric|min:0.01|max:100',
|
|
'reason' => 'required|string|min:3|max:1000',
|
|
]);
|
|
|
|
try {
|
|
$this->billingService->applyPenalty(
|
|
$invoice,
|
|
(float) $validated['penalty_rate'],
|
|
$validated['reason'],
|
|
$user->id
|
|
);
|
|
} catch (\InvalidArgumentException $e) {
|
|
return back()->with('error', $e->getMessage());
|
|
}
|
|
|
|
return back()->with('success', "Penalty of {$validated['penalty_rate']}% applied successfully to Invoice #{$invoice->invoice_number}.");
|
|
}
|
|
|
|
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 {
|
|
$user = $request->user();
|
|
$isExecutive = $this->isExecutiveApprover($user);
|
|
|
|
if ($isExecutive) {
|
|
$this->billingService->recordExecutivePayment($invoice, (float) $validated['amount']);
|
|
$msg = 'Payment recorded and sent for contractor receipt confirmation.';
|
|
} else {
|
|
$this->billingService->confirmContractorPayment($invoice);
|
|
$msg = 'Payment receipt confirmed.';
|
|
}
|
|
} catch (\InvalidArgumentException $e) {
|
|
return back()->with('error', $e->getMessage());
|
|
}
|
|
|
|
return back()->with('success', $msg);
|
|
}
|
|
|
|
public function confirmPayment(Request $request, FinancialInvoice $invoice)
|
|
{
|
|
abort_unless($this->canAccessProject($request->user(), $invoice->project_id), 403);
|
|
abort_unless($this->isContractorAdmin($request->user()), 403, 'Only Contractor Admin can confirm payment receipts.');
|
|
|
|
try {
|
|
$this->billingService->confirmContractorPayment($invoice);
|
|
} catch (\InvalidArgumentException $e) {
|
|
return back()->with('error', $e->getMessage());
|
|
}
|
|
|
|
return back()->with('success', 'Payment receipt confirmed successfully.');
|
|
}
|
|
|
|
private function canAccessRetention(User $user): bool
|
|
{
|
|
// Site Operations roles are strictly excluded from retention
|
|
$isSiteOps = $user->hasAnyRole([
|
|
'Site Technical',
|
|
'Construction Supervisor',
|
|
'Site Operations',
|
|
'Site Engineer',
|
|
'Site Supervisor',
|
|
]);
|
|
|
|
if ($isSiteOps) {
|
|
return false;
|
|
}
|
|
|
|
$isAdmin = $user->user_type === 'admin'
|
|
|| $user->hasAnyRole(['Super Admin', 'admin', 'Admin', 'Project Manager', 'project_manager']);
|
|
|
|
return $isAdmin || $this->isContractorAdmin($user);
|
|
}
|
|
|
|
// --- Retention Ledger ---
|
|
public function retention(Request $request)
|
|
{
|
|
abort_unless($this->canAccessRetention($request->user()), 403, 'Unauthorized. Retention management is restricted to Contractor Admin and Executive roles.');
|
|
|
|
// Dynamic reconciliation: Ensure every Paid invoice has its full retention + penalty settled in the ledger
|
|
$paidInvoices = FinancialInvoice::whereIn('project_id', $this->availableProjectIdsQuery())
|
|
->where('status', InvoiceStatus::Paid)
|
|
->where('retention_amount', '>', 0)
|
|
->get();
|
|
|
|
foreach ($paidInvoices as $paidInv) {
|
|
$totalDue = (float) $paidInv->retention_amount + (float) ($paidInv->penalty_amount ?? 0);
|
|
$credit = RetentionEntry::where([
|
|
'project_id' => $paidInv->project_id,
|
|
'invoice_id' => $paidInv->id,
|
|
'type' => 'credit',
|
|
])->first();
|
|
|
|
$desc = (float) ($paidInv->penalty_amount ?? 0) > 0
|
|
? "Retention & Late Penalty Remittance Settled & Confirmed for Invoice #{$paidInv->invoice_number}"
|
|
: "10% Retention Remittance Settled & Confirmed for Invoice #{$paidInv->invoice_number}";
|
|
|
|
if (!$credit) {
|
|
RetentionEntry::create([
|
|
'project_id' => $paidInv->project_id,
|
|
'invoice_id' => $paidInv->id,
|
|
'type' => 'credit',
|
|
'status' => 'paid',
|
|
'amount' => $totalDue,
|
|
'description' => $desc,
|
|
]);
|
|
} elseif ((float) $credit->amount !== $totalDue) {
|
|
$credit->update([
|
|
'amount' => $totalDue,
|
|
'status' => 'paid',
|
|
'description' => $desc,
|
|
]);
|
|
}
|
|
}
|
|
|
|
$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', 'status')->get();
|
|
|
|
$pendingReleases = RetentionEntry::whereIn('project_id', $this->availableProjectIdsQuery())
|
|
->where('type', 'credit')
|
|
->where('status', '!=', 'paid')
|
|
->get(['id', 'ulid', 'project_id', 'amount', 'status', 'media_path', 'media_original_name'])
|
|
->keyBy('project_id');
|
|
|
|
// Compute per-project totals
|
|
$projectTotals = RetentionEntry::whereIn('project_id', $this->availableProjectIdsQuery())
|
|
->selectRaw('project_id, type, status, SUM(amount) as total')
|
|
->groupBy('project_id', 'type', 'status')
|
|
->get()
|
|
->groupBy('project_id')
|
|
->map(function ($items) use ($pendingReleases) {
|
|
$debits = $items->where('type', 'debit')->sum('total');
|
|
$credits = $items->where('type', 'credit')->whereIn('status', ['posted', 'paid'])->sum('total');
|
|
$pending = $pendingReleases->get($items->first()->project_id);
|
|
return [
|
|
'held' => (float) $debits,
|
|
'released' => (float) $credits,
|
|
'balance' => (float) $debits - (float) $credits,
|
|
'pending_release_id' => $pending?->id,
|
|
'pending_release_ulid' => $pending?->ulid,
|
|
'pending_release_status' => $pending?->status,
|
|
'pending_release_amount' => $pending ? (float) $pending->amount : null,
|
|
'pending_release_media_path' => $pending?->media_path,
|
|
'pending_release_media_name' => $pending?->media_original_name,
|
|
];
|
|
});
|
|
|
|
// Query overdue invoices: unpaid and older than 2 months (60 days)
|
|
$twoMonthsAgo = now()->subMonths(2);
|
|
$overdueInvoices = FinancialInvoice::with([
|
|
'project' => fn ($q) => $q->withoutGlobalScopes()->select('id', 'ulid', 'name', 'code'),
|
|
'penaltyAppliedBy:id,name',
|
|
])
|
|
->whereIn('project_id', $this->availableProjectIdsQuery())
|
|
->whereIn('status', [InvoiceStatus::Approved, InvoiceStatus::Sent, InvoiceStatus::PaymentSent, InvoiceStatus::Submitted])
|
|
->where(function ($q) use ($twoMonthsAgo) {
|
|
$q->where('invoice_date', '<=', $twoMonthsAgo)
|
|
->orWhere('approved_at', '<=', $twoMonthsAgo)
|
|
->orWhere('created_at', '<=', $twoMonthsAgo);
|
|
})
|
|
->latest('invoice_date')
|
|
->get()
|
|
->map(function ($inv) {
|
|
$referenceDate = $inv->approved_at ?? $inv->invoice_date ?? $inv->created_at;
|
|
$daysOverdue = (int) abs(now()->diffInDays($referenceDate));
|
|
return [
|
|
'id' => $inv->id,
|
|
'ulid' => $inv->ulid,
|
|
'invoice_number' => $inv->invoice_number,
|
|
'project' => $inv->project ? [
|
|
'id' => $inv->project->id,
|
|
'ulid' => $inv->project->ulid,
|
|
'name' => $inv->project->name,
|
|
'code' => $inv->project->code,
|
|
] : null,
|
|
'status' => $inv->status instanceof InvoiceStatus ? $inv->status->value : $inv->status,
|
|
'invoice_date' => $inv->invoice_date?->format('Y-m-d'),
|
|
'approved_at' => $inv->approved_at?->format('Y-m-d H:i'),
|
|
'subtotal' => (float) $inv->subtotal,
|
|
'retention_amount' => (float) $inv->retention_amount,
|
|
'retention_rate' => (float) $inv->retention_rate,
|
|
'total_amount' => (float) $inv->total_amount,
|
|
'paid_amount' => (float) $inv->paid_amount,
|
|
'days_overdue' => $daysOverdue,
|
|
'penalty_rate' => $inv->penalty_rate !== null ? (float) $inv->penalty_rate : null,
|
|
'penalty_amount' => (float) $inv->penalty_amount,
|
|
'penalty_reason' => $inv->penalty_reason,
|
|
'penalty_applied_by' => $inv->penaltyAppliedBy?->name,
|
|
'penalty_applied_at' => $inv->penalty_applied_at?->format('Y-m-d H:i'),
|
|
];
|
|
});
|
|
|
|
return Inertia::render('FinancialManagement::Retention/Index', [
|
|
'entries' => $entries,
|
|
'projects' => $projects,
|
|
'projectTotals' => $projectTotals,
|
|
'overdueInvoices' => $overdueInvoices,
|
|
'filters' => $request->only(['project_id']),
|
|
]);
|
|
}
|
|
|
|
public function exportRetentionCsv(Request $request)
|
|
{
|
|
abort_unless($this->canAccessRetention($request->user()), 403);
|
|
|
|
$entries = RetentionEntry::with(['project', 'invoice'])
|
|
->whereIn('project_id', $this->availableProjectIdsQuery())
|
|
->latest()
|
|
->get();
|
|
|
|
$filename = "retention_ledger_report_" . date('Y-m-d_His') . ".csv";
|
|
|
|
$headers = [
|
|
"Content-type" => "text/csv; charset=UTF-8",
|
|
"Content-Disposition" => "attachment; filename={$filename}",
|
|
"Pragma" => "no-cache",
|
|
"Cache-Control" => "must-revalidate, post-check=0, pre-check=0",
|
|
"Expires" => "0",
|
|
];
|
|
|
|
$callback = function () use ($entries) {
|
|
$file = fopen('php://output', 'w');
|
|
fputs($file, "\xEF\xBB\xBF");
|
|
|
|
fputcsv($file, ['GSB Construction - Retention & Penalty Management Report']);
|
|
fputcsv($file, ['Generated Date:', date('Y-m-d H:i:s')]);
|
|
fputcsv($file, []);
|
|
|
|
fputcsv($file, ['Project Name', 'Invoice #', 'Entry Type', 'Amount (PHP)', 'Description', 'Date Recorded']);
|
|
|
|
foreach ($entries as $e) {
|
|
fputcsv($file, [
|
|
$e->project->name ?? 'N/A',
|
|
$e->invoice->invoice_number ?? 'N/A',
|
|
$e->type === 'debit' ? 'Retention Holdback / Penalty' : 'Settled & Released',
|
|
number_format((float) $e->amount, 2, '.', ''),
|
|
$e->description ?? '',
|
|
$e->created_at ? $e->created_at->format('Y-m-d H:i') : '',
|
|
]);
|
|
}
|
|
|
|
fclose($file);
|
|
};
|
|
|
|
return response()->stream($callback, 200, $headers);
|
|
}
|
|
|
|
public function exportInvoicePdf(FinancialInvoice $invoice)
|
|
{
|
|
abort_unless($this->canAccessProject(auth()->user(), $invoice->project_id), 403);
|
|
|
|
$invoice->load(['project', 'lineItems']);
|
|
|
|
$pdf = \Barryvdh\DomPDF\Facade\Pdf::loadView('financialmanagement::pdf.retention_statement', [
|
|
'invoice' => $invoice,
|
|
]);
|
|
|
|
return $pdf->download("Retention_Statement_{$invoice->invoice_number}.pdf");
|
|
}
|
|
|
|
public function submitRetentionRelease(Request $request, Project $project)
|
|
{
|
|
abort_unless($this->canAccessProject($request->user(), $project->id), 403);
|
|
|
|
$hasFileInfo = extension_loaded('fileinfo');
|
|
$rules = ['media' => 'required|file|max:10240'];
|
|
if ($hasFileInfo) {
|
|
$rules['media'] .= '|mimes:pdf';
|
|
}
|
|
$validated = $request->validate($rules);
|
|
$file = $request->file('media');
|
|
|
|
if (!$hasFileInfo) {
|
|
$extension = strtolower($file->getClientOriginalExtension() ?: pathinfo($file->getClientOriginalName(), PATHINFO_EXTENSION));
|
|
if ($extension !== 'pdf') {
|
|
return back()->withErrors(['media' => 'The proof must be a file of type: pdf, jpg, jpeg, png.']);
|
|
}
|
|
}
|
|
|
|
$path = $file->store("retention-releases/{$project->id}", 'public');
|
|
|
|
try {
|
|
$this->billingService->submitRetentionRelease($project, $path, $file->getClientOriginalName());
|
|
} catch (\InvalidArgumentException $e) {
|
|
return back()->with('error', $e->getMessage());
|
|
}
|
|
|
|
return back()->with('success', 'Retention release submitted for payment.');
|
|
}
|
|
|
|
public function markRetentionAsPaid(Request $request, RetentionEntry $retentionEntry)
|
|
{
|
|
abort_unless($this->canAccessProject($request->user(), $retentionEntry->project_id), 403);
|
|
$isApprover = $request->user()->user_type === 'admin'
|
|
|| $request->user()->roles()->whereIn('name', ['Super Admin', 'admin', 'Admin', 'Project Manager', 'project_manager'])->exists();
|
|
|
|
if (!$isApprover) {
|
|
return back()->with('error', 'Only Project Manager, Admin, or Super Admin can mark retention as paid.');
|
|
}
|
|
|
|
$request->validate([
|
|
'media' => ['nullable', 'file', 'mimes:pdf', 'max:10240'],
|
|
]);
|
|
|
|
$mediaPath = null;
|
|
$mediaName = null;
|
|
if ($request->hasFile('media')) {
|
|
$file = $request->file('media');
|
|
$mediaPath = $file->store('retention_proofs', 'public');
|
|
$mediaName = $file->getClientOriginalName();
|
|
}
|
|
|
|
try {
|
|
$this->billingService->markRetentionAsPaid($retentionEntry, $mediaPath, $mediaName);
|
|
} catch (\InvalidArgumentException $e) {
|
|
return back()->with('error', $e->getMessage());
|
|
}
|
|
|
|
return back()->with('success', 'Retention payment recorded and sent for contractor receipt confirmation.');
|
|
}
|
|
|
|
public function confirmRetentionPayment(Request $request, RetentionEntry $retentionEntry)
|
|
{
|
|
abort_unless($this->canAccessProject($request->user(), $retentionEntry->project_id), 403);
|
|
abort_unless($this->isContractorAdmin($request->user()), 403, 'Only Contractor Admin can confirm payment receipts.');
|
|
|
|
try {
|
|
$this->billingService->confirmRetentionPayment($retentionEntry);
|
|
} catch (\InvalidArgumentException $e) {
|
|
return back()->with('error', $e->getMessage());
|
|
}
|
|
|
|
return back()->with('success', 'Retention payment receipt confirmed successfully.');
|
|
}
|
|
|
|
private function isContractorAdmin(?User $user): bool
|
|
{
|
|
if (! $user) return false;
|
|
|
|
$isSiteOps = $user->hasAnyRole([
|
|
'Site Technical',
|
|
'Construction Supervisor',
|
|
'Site Operations',
|
|
'Site Engineer',
|
|
'Site Supervisor',
|
|
]);
|
|
|
|
if ($isSiteOps) {
|
|
return false;
|
|
}
|
|
|
|
$userType = strtolower($user->user_type ?? '');
|
|
$roles = strtolower($user->getRoleNames()->implode(','));
|
|
|
|
$isExecutive = in_array($userType, ['admin', 'super_admin', 'project_manager'])
|
|
|| $user->hasAnyRole(['Super Admin', 'admin', 'Admin', 'Project Manager', 'Executive']);
|
|
|
|
if ($isExecutive) {
|
|
return false;
|
|
}
|
|
|
|
return !is_null($user->contractor_id)
|
|
|| $userType === 'contractor'
|
|
|| $user->hasAnyRole(['Main Contractor Admin', 'Contractor Admin'])
|
|
|| \Illuminate\Support\Str::contains($roles, 'contractor');
|
|
}
|
|
|
|
public function viewRetentionMedia(Request $request, RetentionEntry $retentionEntry)
|
|
{
|
|
abort_unless($this->canAccessProject($request->user(), $retentionEntry->project_id), 403);
|
|
|
|
if (!$retentionEntry->media_path) {
|
|
abort(404, 'No supporting document uploaded.');
|
|
}
|
|
|
|
$disk = \Storage::disk('public');
|
|
if (!$disk->exists($retentionEntry->media_path)) {
|
|
abort(404, 'Supporting document not found.');
|
|
}
|
|
|
|
return response()->file($disk->path($retentionEntry->media_path), [
|
|
'Content-Type' => $disk->mimeType($retentionEntry->media_path) ?: 'application/octet-stream',
|
|
'Content-Disposition' => 'inline; filename="' . addslashes($retentionEntry->media_original_name ?: 'retention-proof') . '"',
|
|
]);
|
|
}
|
|
|
|
// 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::whereIn('id', $this->availableProjectIdsQuery())
|
|
->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.');
|
|
}
|
|
|
|
public function rejectCashAdvance(string $cashAdvance)
|
|
{
|
|
$user = auth()->user();
|
|
$cashAdvance = \Modules\FinancialManagement\Models\CashAdvance::withoutGlobalScopes()
|
|
->where('ulid', $cashAdvance)->firstOrFail();
|
|
|
|
if (! $this->canApproveCashAdvance($user)) {
|
|
return back()->with('error', 'Unauthorized to reject cash advance requests.');
|
|
}
|
|
|
|
$cashAdvance->update(['status' => 'rejected', 'approved_by' => $user->id]);
|
|
return back()->with('success', 'Cash advance request rejected.');
|
|
}
|
|
|
|
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());
|
|
}
|
|
}
|