390 lines
14 KiB
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();
|
|
}
|
|
}
|
|
}
|
|
}
|