last_billed_percentage ?? 0); $contractValue = (float) ($project->contract_value ?? 0); if ($currentPercentage <= $lastBilled) { throw new \InvalidArgumentException( "Current progress ({$currentPercentage}%) must exceed last billed ({$lastBilled}%)" ); } $incrementalPercent = $currentPercentage - $lastBilled; $subtotal = $contractValue * ($incrementalPercent / 100); $retention = $subtotal * ($retentionRate / 100); $totalAmount = $subtotal - $retention; $invoice = FinancialInvoice::create([ 'project_id' => $project->id, 'invoice_number' => 'INV-' . strtoupper(uniqid()), 'status' => InvoiceStatus::Draft, 'subtotal' => $subtotal, 'retention_amount' => $retention, 'total_amount' => $totalAmount, 'retention_rate' => $retentionRate, 'billed_percentage' => $currentPercentage, 'invoice_date' => now()->toDateString(), 'due_date' => now()->addDays(30)->toDateString(), ]); // Create line item for progress billing InvoiceLineItem::create([ 'invoice_id' => $invoice->id, 'description' => "Progress billing: {$lastBilled}% → {$currentPercentage}% ({$incrementalPercent}%)", 'quantity' => 1, 'unit_price' => $subtotal, 'total' => $subtotal, ]); return $invoice; } /** * On invoice approval, create retention debit entry. */ public function holdRetention(FinancialInvoice $invoice): void { if ($invoice->retention_amount <= 0 || RetentionEntry::where('invoice_id', $invoice->id)->where('type', 'debit')->exists()) { return; } RetentionEntry::create([ 'project_id' => $invoice->project_id, 'invoice_id' => $invoice->id, 'type' => 'debit', 'amount' => $invoice->retention_amount, 'description' => "Retention held on Invoice #{$invoice->invoice_number} ({$invoice->retention_rate}%)", ]); } /** * On invoice payment, update project's last billed percentage. */ /** * Contractor Admin submits 10% payment proof for approved invoice. */ public function submitContractorPaymentProof( FinancialInvoice $invoice, string $mediaPath, string $mediaName, ?string $notes = null, ?int $userId = null ): void { $invoice->update([ 'status' => InvoiceStatus::PaymentSent, 'payment_proof_path' => $mediaPath, 'payment_proof_name' => $mediaName, 'payment_proof_notes' => $notes, 'payment_proof_submitted_by' => $userId ?? auth()->id(), 'payment_proof_submitted_at' => now(), 'paid_amount' => $invoice->total_amount, ]); } /** * Executive confirms payment received (transitions invoice to Paid & updates project progress). */ public function confirmPaymentReceived(FinancialInvoice $invoice, ?string $notes = null): void { $totalAmount = (float) $invoice->total_amount; $invoice->status = InvoiceStatus::Paid; $invoice->paid_amount = $totalAmount; $invoice->paid_at = now(); if ($notes) { $invoice->notes = ($invoice->notes ? $invoice->notes . "\n" : "") . "Executive confirmation: " . $notes; } $invoice->save(); if (\Schema::hasColumn('projects', 'completion_percentage')) { $invoice->project()->update(['completion_percentage' => $invoice->billed_percentage]); } // Settle retention entry in ledger (both base retention and any assessed penalty) so balance becomes 0 $totalRetentionToSettle = (float) $invoice->retention_amount + (float) ($invoice->penalty_amount ?? 0); if ($totalRetentionToSettle > 0) { $desc = (float) ($invoice->penalty_amount ?? 0) > 0 ? "Retention & Late Penalty Remittance Settled & Confirmed for Invoice #{$invoice->invoice_number}" : "10% Retention Remittance Settled & Confirmed for Invoice #{$invoice->invoice_number}"; $creditEntry = RetentionEntry::where([ 'project_id' => $invoice->project_id, 'invoice_id' => $invoice->id, 'type' => 'credit', ])->first(); if ($creditEntry) { $creditEntry->update([ 'amount' => $totalRetentionToSettle, 'status' => 'paid', 'description' => $desc, ]); } else { RetentionEntry::create([ 'project_id' => $invoice->project_id, 'invoice_id' => $invoice->id, 'type' => 'credit', 'status' => 'paid', 'amount' => $totalRetentionToSettle, 'description' => $desc, ]); } } } /** * Executive applies percentage-based penalty on overdue retention / invoice. */ public function applyPenalty( FinancialInvoice $invoice, float $penaltyRate, string $reason, ?int $userId = null ): void { if ($penaltyRate <= 0 || $penaltyRate > 100) { throw new \InvalidArgumentException("Penalty rate must be between 0.01% and 100%."); } // Base calculation on retention amount (or invoice subtotal if 0) $baseAmount = (float) $invoice->retention_amount > 0 ? (float) $invoice->retention_amount : (float) $invoice->subtotal; $penaltyCost = round(($baseAmount * ($penaltyRate / 100)), 2); $invoice->update([ 'penalty_rate' => $penaltyRate, 'penalty_amount' => $penaltyCost, 'penalty_reason' => $reason, 'penalty_applied_by' => $userId ?? auth()->id(), 'penalty_applied_at' => now(), ]); // Upsert penalty debit adjustment in retention ledger (prevent duplicate penalty rows on penalty rate updates) $existingPenalty = RetentionEntry::where('invoice_id', $invoice->id) ->where('type', 'debit') ->where('description', 'like', 'Late Remittance Penalty%') ->first(); $penaltyData = [ 'project_id' => $invoice->project_id, 'invoice_id' => $invoice->id, 'type' => 'debit', 'status' => 'posted', 'amount' => $penaltyCost, 'description' => "Late Remittance Penalty ({$penaltyRate}%) assessed on Invoice #{$invoice->invoice_number}: {$reason}", 'submitted_by' => $userId ?? auth()->id(), 'submitted_at' => now(), ]; if ($existingPenalty) { $existingPenalty->update($penaltyData); } else { RetentionEntry::create($penaltyData); } // If invoice is already paid/settled, adjust existing credit entry to maintain net 0 balance if ($invoice->status === InvoiceStatus::Paid) { $creditEntry = RetentionEntry::where([ 'project_id' => $invoice->project_id, 'invoice_id' => $invoice->id, 'type' => 'credit', ])->first(); if ($creditEntry) { $creditEntry->update([ 'amount' => (float) $invoice->retention_amount + $penaltyCost, ]); } } } /** * Executive releases/records payment (transitions invoice to PaymentSent pending contractor confirmation). */ public function recordExecutivePayment(FinancialInvoice $invoice, float $amount): void { $newPaid = (float) $invoice->paid_amount + $amount; $updates = [ 'paid_amount' => $newPaid, ]; $invoice->transitionTo(InvoiceStatus::PaymentSent); $invoice->update($updates); } /** * Contractor confirms receipt of payment (transitions invoice to Paid & updates project progress). */ public function confirmContractorPayment(FinancialInvoice $invoice): void { $totalAmount = (float) $invoice->total_amount; $invoice->status = InvoiceStatus::Paid; $invoice->paid_amount = $totalAmount; $invoice->paid_at = now(); $invoice->save(); if (\Schema::hasColumn('projects', 'completion_percentage')) { $invoice->project()->update(['completion_percentage' => $invoice->billed_percentage]); } } /** * On invoice payment, update project's last billed percentage. */ public function recordPayment(FinancialInvoice $invoice, float $amount): void { $newPaid = (float) $invoice->paid_amount + $amount; $totalAmount = (float) $invoice->total_amount; $updates = ['paid_amount' => $newPaid, 'paid_at' => now()]; if ($newPaid >= $totalAmount) { $invoice->transitionTo(InvoiceStatus::Paid); // Update project's completion percentage if (\Schema::hasColumn('projects', 'completion_percentage')) { $invoice->project()->update(['completion_percentage' => $invoice->billed_percentage]); } } else { $invoice->transitionTo(InvoiceStatus::PartiallyPaid); } $invoice->update($updates); } /** * Release all retention for a project (on project completion). */ public function releaseRetention(Project $project): void { $totalRetention = RetentionEntry::where('project_id', $project->id) ->where('type', 'debit') ->sum('amount'); $alreadyCredited = RetentionEntry::where('project_id', $project->id) ->where('type', 'credit') ->where('status', 'paid') ->sum('amount'); $remaining = $totalRetention - $alreadyCredited; if ($remaining > 0) { RetentionEntry::create([ 'project_id' => $project->id, 'type' => 'credit', 'status' => 'paid', 'amount' => $remaining, 'description' => 'Retention released on project completion', ]); } } public function submitRetentionRelease(Project $project, string $mediaPath, string $mediaOriginalName): RetentionEntry { $projectStatus = $project->status?->value ?? $project->status; if (!in_array($projectStatus, ['completed', 'closed'], true)) { throw new \InvalidArgumentException('Retention can only be released after the project is completed.'); } $held = (float) RetentionEntry::where('project_id', $project->id) ->where('type', 'debit') ->sum('amount'); $paid = (float) RetentionEntry::where('project_id', $project->id) ->where('type', 'credit') ->where('status', 'paid') ->sum('amount'); $pending = RetentionEntry::where('project_id', $project->id) ->where('type', 'credit') ->whereIn('status', ['submitted', 'paid']) ->exists(); if ($pending || $held <= $paid) { throw new \InvalidArgumentException('There is no unreleased retention available for submission.'); } return RetentionEntry::create([ 'project_id' => $project->id, 'type' => 'credit', 'status' => 'submitted', 'amount' => $held - $paid, 'description' => 'Retention release submitted for payment', 'media_path' => $mediaPath, 'media_original_name' => $mediaOriginalName, 'submitted_by' => auth()->id(), 'submitted_at' => now(), ]); } public function markRetentionAsPaid(RetentionEntry $entry, ?string $mediaPath = null, ?string $mediaOriginalName = null): void { $data = [ 'status' => 'payment_sent', 'paid_by' => auth()->id(), 'paid_at' => now(), ]; if ($mediaPath) { $data['media_path'] = $mediaPath; $data['media_original_name'] = $mediaOriginalName; } if ($entry->type === 'debit') { $data['type'] = 'credit'; $data['description'] = 'Retention released (Payment Sent - Pending Contractor Confirmation)'; $entry->update($data); return; } if ($entry->type !== 'credit' || !in_array($entry->status, ['submitted', 'posted', 'pending', 'payment_sent', null], true)) { throw new \InvalidArgumentException('Retention entry cannot be marked as paid.'); } $data['description'] = 'Retention released (Payment Sent - Pending Contractor Confirmation)'; $entry->update($data); } public function confirmRetentionPayment(RetentionEntry $entry): void { $entry->status = 'paid'; $entry->description = 'Retention released and paid to contractor'; $entry->paid_at = now(); $entry->save(); if ($entry->invoice) { $entry->invoice->status = InvoiceStatus::Paid; $entry->invoice->paid_amount = $entry->invoice->total_amount; $entry->invoice->paid_at = now(); $entry->invoice->save(); if (\Schema::hasColumn('projects', 'completion_percentage')) { $entry->invoice->project()->update(['completion_percentage' => $entry->invoice->billed_percentage]); } } elseif ($entry->project_id) { $invoice = FinancialInvoice::where('project_id', $entry->project_id)->latest()->first(); if ($invoice && $invoice->status !== InvoiceStatus::Paid) { $invoice->status = InvoiceStatus::Paid; $invoice->paid_amount = $invoice->total_amount; $invoice->paid_at = now(); $invoice->save(); } } } }