$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); } }