149 lines
5.3 KiB
PHP
149 lines
5.3 KiB
PHP
<?php
|
|
|
|
namespace Modules\Accounting\Services;
|
|
|
|
use Carbon\Carbon;
|
|
use Modules\Accounting\Domain\FinancialPostingRequest;
|
|
use Modules\Accounting\Entities\AccountingInstallmentPlan;
|
|
use Modules\Accounting\Entities\AccountingInstallmentSchedule;
|
|
|
|
class InstallmentService
|
|
{
|
|
public function __construct(protected FinancialPostingService $postingService) {}
|
|
|
|
public function createPlan(array $data, int $businessId, int $userId): AccountingInstallmentPlan
|
|
{
|
|
$count = (int) $data['installment_count'];
|
|
$principal = (float) $data['principal_amount'];
|
|
$installmentAmount = round($principal / max($count, 1), 4);
|
|
|
|
$plan = AccountingInstallmentPlan::create([
|
|
'business_id' => $businessId,
|
|
'transaction_id' => $data['transaction_id'],
|
|
'contact_id' => $data['contact_id'] ?? null,
|
|
'principal_amount' => $principal,
|
|
'installment_count' => $count,
|
|
'installment_amount' => $installmentAmount,
|
|
'interest_rate' => $data['interest_rate'] ?? 0,
|
|
'start_date' => $data['start_date'],
|
|
'frequency' => $data['frequency'] ?? 'monthly',
|
|
'status' => 'active',
|
|
'created_by' => $userId,
|
|
]);
|
|
|
|
$dueDate = Carbon::parse($data['start_date']);
|
|
for ($i = 1; $i <= $count; $i++) {
|
|
AccountingInstallmentSchedule::create([
|
|
'installment_plan_id' => $plan->id,
|
|
'installment_number' => $i,
|
|
'due_date' => $dueDate->copy()->toDateString(),
|
|
'amount' => $installmentAmount,
|
|
'status' => 'pending',
|
|
]);
|
|
|
|
$dueDate = match ($plan->frequency) {
|
|
'weekly' => $dueDate->addWeek(),
|
|
'quarterly' => $dueDate->addMonths(3),
|
|
default => $dueDate->addMonth(),
|
|
};
|
|
}
|
|
|
|
return $plan->load('schedule');
|
|
}
|
|
|
|
public function recordPayment(
|
|
AccountingInstallmentSchedule $schedule,
|
|
float $amount,
|
|
int $userId,
|
|
int $debitAccountId,
|
|
int $creditAccountId
|
|
): AccountingInstallmentSchedule {
|
|
$mapping = $this->postingService->post(new FinancialPostingRequest(
|
|
businessId: $schedule->plan->business_id,
|
|
userId: $userId,
|
|
sourceType: 'installment_payment',
|
|
sourceId: $schedule->id,
|
|
note: 'Installment #'.$schedule->installment_number,
|
|
lines: [
|
|
['accounting_account_id' => $debitAccountId, 'amount' => $amount, 'type' => 'debit'],
|
|
['accounting_account_id' => $creditAccountId, 'amount' => $amount, 'type' => 'credit'],
|
|
],
|
|
operationDate: now()->toDateString(),
|
|
));
|
|
|
|
$schedule->paid_amount = (float) $schedule->paid_amount + $amount;
|
|
$schedule->journal_mapping_id = $mapping?->id;
|
|
$totalDue = (float) $schedule->amount + (float) $schedule->penalty_amount;
|
|
$schedule->status = $schedule->paid_amount + 0.01 >= $totalDue ? 'paid' : 'partial';
|
|
$schedule->save();
|
|
|
|
$plan = $schedule->plan;
|
|
if ($plan->schedule()->whereNotIn('status', ['paid'])->count() === 0) {
|
|
$plan->update(['status' => 'completed']);
|
|
}
|
|
|
|
return $schedule;
|
|
}
|
|
|
|
public function markOverdue(?int $businessId = null): int
|
|
{
|
|
$query = AccountingInstallmentSchedule::query()
|
|
->whereIn('status', ['pending', 'partial'])
|
|
->whereDate('due_date', '<', now()->toDateString())
|
|
->whereHas('plan', function ($q) use ($businessId) {
|
|
$q->where('status', 'active');
|
|
if ($businessId) {
|
|
$q->where('business_id', $businessId);
|
|
}
|
|
});
|
|
|
|
$count = 0;
|
|
foreach ($query->with('plan')->get() as $schedule) {
|
|
$this->applyOverduePenalty($schedule);
|
|
if ($schedule->status !== 'paid') {
|
|
$schedule->status = $schedule->paid_amount > 0 ? 'partial' : 'overdue';
|
|
}
|
|
$schedule->save();
|
|
$count++;
|
|
}
|
|
|
|
return $count;
|
|
}
|
|
|
|
public function calculatePenalty(AccountingInstallmentSchedule $schedule): float
|
|
{
|
|
$plan = $schedule->plan;
|
|
$rate = (float) ($plan->interest_rate ?: config('accounting.installments.default_penalty_rate', 0));
|
|
if ($rate <= 0) {
|
|
return 0.0;
|
|
}
|
|
|
|
$outstanding = max(0, (float) $schedule->amount - (float) $schedule->paid_amount);
|
|
if ($outstanding < 0.01) {
|
|
return 0.0;
|
|
}
|
|
|
|
$daysOverdue = Carbon::parse($schedule->due_date)->diffInDays(now());
|
|
if ($daysOverdue < 1) {
|
|
return 0.0;
|
|
}
|
|
|
|
$months = max(1, (int) ceil($daysOverdue / 30));
|
|
|
|
return round($outstanding * ($rate / 100) * $months, 4);
|
|
}
|
|
|
|
protected function applyOverduePenalty(AccountingInstallmentSchedule $schedule): void
|
|
{
|
|
$penalty = $this->calculatePenalty($schedule);
|
|
if ($penalty > (float) $schedule->penalty_amount) {
|
|
$schedule->penalty_amount = $penalty;
|
|
}
|
|
}
|
|
|
|
public function totalDue(AccountingInstallmentSchedule $schedule): float
|
|
{
|
|
return round((float) $schedule->amount + (float) $schedule->penalty_amount - (float) $schedule->paid_amount, 4);
|
|
}
|
|
}
|