Files
GSB-Construction/Modules/FinancialManagement/app/Services/ProgressBillingService.php
Ajjj ccbd23d474
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: enhance module RBAC permissions, financial billing workflows, task evidence uploading, and role analytics
2026-08-13 18:46:26 +08:00

254 lines
9.2 KiB
PHP

<?php
namespace Modules\FinancialManagement\Services;
use Modules\FinancialManagement\Enums\InvoiceStatus;
use Modules\FinancialManagement\Models\FinancialInvoice;
use Modules\FinancialManagement\Models\InvoiceLineItem;
use Modules\FinancialManagement\Models\RetentionEntry;
use Modules\ProjectManagement\Models\Project;
class ProgressBillingService
{
/**
* Generate a progress invoice for a project based on completion %.
*/
public function generateInvoice(
Project $project,
float $currentPercentage,
float $retentionRate = 10.00,
): FinancialInvoice {
$lastBilled = (float) ($project->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.
*/
/**
* 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();
}
}
}
}