137 lines
6.1 KiB
PHP
137 lines
6.1 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Finance;
|
|
|
|
use App\Models\InvoiceInstallment;
|
|
use App\Models\InvoiceInstallmentPlan;
|
|
use App\Models\PaymentInstallmentAllocation;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
|
|
class InstallmentPlanService
|
|
{
|
|
public function list(array $filters): array
|
|
{
|
|
$q = InvoiceInstallmentPlan::query();
|
|
foreach (['invoice_id','parent_id','school_year','status'] as $field) {
|
|
if (!empty($filters[$field])) $q->where($field, $filters[$field]);
|
|
}
|
|
return ['rows' => $q->orderByDesc('id')->paginate((int)($filters['per_page'] ?? 25))];
|
|
}
|
|
|
|
public function createForInvoice(int $invoiceId, array $payload, ?int $actorId): array
|
|
{
|
|
return DB::transaction(function () use ($invoiceId, $payload, $actorId) {
|
|
$invoice = DB::table('invoices')->where('id', $invoiceId)->first();
|
|
abort_if(!$invoice, 404, 'Invoice not found.');
|
|
$total = (float) ($payload['total_amount'] ?? ($invoice->balance ?? $invoice->total_amount ?? 0));
|
|
$count = (int) $payload['number_of_installments'];
|
|
$plan = InvoiceInstallmentPlan::query()->create([
|
|
'invoice_id' => $invoiceId,
|
|
'parent_id' => $invoice->parent_id,
|
|
'school_year' => $invoice->school_year ?? null,
|
|
'plan_name' => $payload['plan_name'] ?? ('Invoice #' . $invoiceId . ' installment plan'),
|
|
'total_amount' => $total,
|
|
'number_of_installments' => $count,
|
|
'status' => 'draft',
|
|
'created_by' => $actorId,
|
|
]);
|
|
|
|
$firstDue = Carbon::parse($payload['first_due_date']);
|
|
$interval = (int) ($payload['interval_months'] ?? 1);
|
|
$base = floor(($total / $count) * 100) / 100;
|
|
$allocated = 0.0;
|
|
for ($i = 1; $i <= $count; $i++) {
|
|
$amount = $i === $count ? round($total - $allocated, 2) : $base;
|
|
$allocated += $amount;
|
|
InvoiceInstallment::query()->create([
|
|
'installment_plan_id' => $plan->id,
|
|
'invoice_id' => $invoiceId,
|
|
'sequence' => $i,
|
|
'due_date' => $firstDue->copy()->addMonths(($i - 1) * $interval)->toDateString(),
|
|
'amount_due' => $amount,
|
|
'amount_paid' => 0,
|
|
'balance' => $amount,
|
|
'status' => 'pending',
|
|
]);
|
|
}
|
|
|
|
return $this->show($plan->id);
|
|
});
|
|
}
|
|
|
|
public function show(int $id): array
|
|
{
|
|
$plan = InvoiceInstallmentPlan::query()->findOrFail($id)->toArray();
|
|
$plan['installments'] = InvoiceInstallment::query()->where('installment_plan_id', $id)->orderBy('sequence')->get()->toArray();
|
|
return $plan;
|
|
}
|
|
|
|
public function activate(int $id, ?int $actorId): array
|
|
{
|
|
$plan = InvoiceInstallmentPlan::query()->findOrFail($id);
|
|
$plan->fill(['status' => 'active', 'approved_by' => $actorId])->save();
|
|
InvoiceInstallment::query()->where('installment_plan_id', $id)->where('status', 'pending')->update(['status' => 'due']);
|
|
return $this->show($id);
|
|
}
|
|
|
|
public function cancel(int $id): array
|
|
{
|
|
InvoiceInstallmentPlan::query()->whereKey($id)->update(['status' => 'cancelled']);
|
|
InvoiceInstallment::query()->where('installment_plan_id', $id)->whereNotIn('status', ['paid'])->update(['status' => 'cancelled']);
|
|
return $this->show($id);
|
|
}
|
|
|
|
public function allocatePayment(int $paymentId, array $payload): array
|
|
{
|
|
return DB::transaction(function () use ($paymentId, $payload) {
|
|
$payment = DB::table('payments')->where('id', $paymentId)->first();
|
|
abort_if(!$payment, 404, 'Payment not found.');
|
|
$allocations = $payload['installments'] ?? null;
|
|
if (!$allocations) {
|
|
$remaining = (float) ($payment->paid_amount ?? 0);
|
|
$installments = InvoiceInstallment::query()->where('invoice_id', $payment->invoice_id)->where('balance', '>', 0)->orderBy('due_date')->lockForUpdate()->get();
|
|
$allocations = [];
|
|
foreach ($installments as $inst) {
|
|
if ($remaining <= 0) break;
|
|
$amount = min($remaining, (float) $inst->balance);
|
|
$allocations[] = ['installment_id' => $inst->id, 'amount' => $amount];
|
|
$remaining -= $amount;
|
|
}
|
|
}
|
|
|
|
$created = [];
|
|
foreach ($allocations as $a) {
|
|
$inst = InvoiceInstallment::query()->lockForUpdate()->findOrFail($a['installment_id']);
|
|
$amount = min((float) $a['amount'], (float) $inst->balance);
|
|
if ($amount <= 0) continue;
|
|
$created[] = PaymentInstallmentAllocation::query()->create([
|
|
'payment_id' => $paymentId,
|
|
'invoice_id' => $inst->invoice_id,
|
|
'installment_id' => $inst->id,
|
|
'amount' => $amount,
|
|
])->toArray();
|
|
$inst->amount_paid = (float) $inst->amount_paid + $amount;
|
|
$inst->balance = max(0, (float) $inst->amount_due - (float) $inst->amount_paid);
|
|
$inst->status = $inst->balance <= 0 ? 'paid' : 'partially_paid';
|
|
$inst->paid_at = $inst->balance <= 0 ? now() : null;
|
|
$inst->save();
|
|
}
|
|
return ['allocations' => $created];
|
|
});
|
|
}
|
|
|
|
public function due(bool $overdue = false): array
|
|
{
|
|
$q = InvoiceInstallment::query()->where('balance', '>', 0);
|
|
$overdue ? $q->whereDate('due_date', '<', now()->toDateString()) : $q->whereDate('due_date', '>=', now()->toDateString());
|
|
return ['rows' => $q->orderBy('due_date')->get()->toArray()];
|
|
}
|
|
|
|
public function markOverdue(): int
|
|
{
|
|
return InvoiceInstallment::query()->where('balance', '>', 0)->whereDate('due_date', '<', now()->toDateString())->whereNotIn('status', ['paid','cancelled','waived'])->update(['status' => 'overdue']);
|
|
}
|
|
}
|