Files
GSB-Construction/Modules/FinancialManagement/app/Services/ProgressBillingService.php
Ajjj 10eb7be033
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: implement module API routes, controllers, and comprehensive system testing with role-based access control
2026-08-06 19:38:51 +08:00

190 lines
6.7 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.
*/
public function recordPayment(FinancialInvoice $invoice, float $amount): void
{
$newPaid = (float) $invoice->paid_amount + $amount;
$totalAmount = (float) $invoice->total_amount;
$updates = ['paid_amount' => $newPaid];
if ($newPaid >= $totalAmount) {
$invoice->transitionTo(InvoiceStatus::Paid);
// Update project's last billed percentage
$invoice->project()->update(['last_billed_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' => 'paid',
'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 and paid';
$entry->update($data);
return;
}
if ($entry->type !== 'credit' || !in_array($entry->status, ['submitted', 'posted', 'pending', null], true)) {
throw new \InvalidArgumentException('Retention entry cannot be marked as paid.');
}
$data['description'] = 'Retention released and paid to contractor';
$entry->update($data);
}
}