Files
GSB-Construction/Modules/FinancialManagement/app/Services/ProgressBillingService.php

390 lines
14 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.
*/
/**
* 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();
}
}
}
}