Commit 8889c208 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Fix: overdue alert shows unpaid invoices, disable proration by default

- OverdueRenewalsAlert now shows participants with unpaid renewal invoices
  (not just those missing invoices), with amounts and program names
- EnrollmentService gates proration behind isEnabled() check
- calculateFirstBillingDate defaults to monthly day-1 when billing_cycle is NULL
- Alert shows total amount owed and links each participant to payment wizard
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 3e0ea572
......@@ -387,13 +387,15 @@ private function createEnrollmentInvoice(Enrollment $enrollment, Participant $pa
return;
}
// Apply proration if enabled
$proration = $this->prorationService->calculate($priceResult->finalAmount);
$finalAmount = $proration->proratedAmount;
$finalAmount = $priceResult->finalAmount;
$lineDescription = "اشتراك: {$program->name_ar}";
if ($proration->applied) {
$lineDescription .= " ({$proration->description})";
if ($this->prorationService->isEnabled()) {
$proration = $this->prorationService->calculate($priceResult->finalAmount);
$finalAmount = $proration->proratedAmount;
if ($proration->applied) {
$lineDescription .= " ({$proration->description})";
}
}
$invoice = $this->invoiceService->create([
......@@ -427,7 +429,7 @@ private function createEnrollmentInvoice(Enrollment $enrollment, Participant $pa
private function calculateFirstBillingDate(?TrainingProgram $program): ?string
{
if (!$program || !$program->billing_cycle) {
if (!$program) {
return null;
}
......@@ -435,18 +437,21 @@ private function calculateFirstBillingDate(?TrainingProgram $program): ?string
return null;
}
// Default to monthly day-1 if not set
$cycle = $program->billing_cycle ?? 'monthly';
$billingDay = $program->billing_day ?? 1;
$start = now();
$next = match ($program->billing_cycle) {
'monthly' => $this->snapToDay($start->copy()->addMonth(), $program->billing_day),
$next = match ($cycle) {
'monthly' => $this->snapToDay($start->copy()->addMonth(), $billingDay),
'quarterly' => $start->copy()->addMonths(3),
'semi_annual' => $start->copy()->addMonths(6),
'annual' => $start->copy()->addYear(),
'per_duration' => $start->copy()->addWeeks($program->program_duration_weeks ?? 4),
default => null,
default => $this->snapToDay($start->copy()->addMonth(), $billingDay),
};
return $next?->toDateString();
return $next->toDateString();
}
private function snapToDay(\Illuminate\Support\Carbon $date, ?int $billingDay): \Illuminate\Support\Carbon
......
......@@ -23,39 +23,88 @@ public function render()
{
$today = now()->toDateString();
// Find active enrollments with overdue billing that DON'T already have an unpaid invoice
// 1. Participants with unpaid renewal invoices (already billed, not yet paid)
$unpaidInvoices = Invoice::whereIn('status', [InvoiceStatus::Sent, InvoiceStatus::PartiallyPaid, InvoiceStatus::Overdue])
->where('due_amount', '>', 0)
->where('billable_type', Participant::class)
->where('notes', 'like', '%تجديد%')
->with(['billable.person'])
->orderBy('due_date')
->get();
// 2. Also find enrollments overdue but NO invoice generated yet
$overdueEnrollments = Enrollment::where('status', EnrollmentStatus::Active)
->whereNotNull('next_billing_date')
->where('next_billing_date', '<=', $today)
->whereHas('program', fn ($q) => $q->whereIn('renewal_policy', [
RenewalPolicy::AutoRenew->value,
RenewalPolicy::ManualRenew->value,
])->whereNotNull('billing_cycle'))
]))
->with(['participant.person', 'program', 'group.branch'])
->orderBy('next_billing_date')
->get();
->get()
->filter(function ($enrollment) {
if (!$enrollment->participant) {
return false;
}
return !Invoice::where('billable_type', Participant::class)
->where('billable_id', $enrollment->participant_id)
->whereIn('status', [InvoiceStatus::Sent, InvoiceStatus::PartiallyPaid, InvoiceStatus::Overdue, InvoiceStatus::Draft])
->where('due_amount', '>', 0)
->where('notes', 'like', '%' . ($enrollment->program?->name_ar ?? 'تجديد') . '%')
->exists();
});
// Build unified list
$items = collect();
// Filter out those who already have an unpaid invoice for this program
$overdueEnrollments = $overdueEnrollments->filter(function ($enrollment) {
if (!$enrollment->participant) {
return false;
foreach ($unpaidInvoices as $invoice) {
$participant = $invoice->billable;
if (!$participant) {
continue;
}
$hasUnpaidInvoice = Invoice::where('billable_type', Participant::class)
->where('billable_id', $enrollment->participant_id)
->whereIn('status', [InvoiceStatus::Sent, InvoiceStatus::PartiallyPaid, InvoiceStatus::Overdue])
->where('due_amount', '>', 0)
->where('notes', 'like', '%' . ($enrollment->program?->name_ar ?? '') . '%')
->exists();
$items->push([
'participant_name' => $participant->person?->name_ar ?? $invoice->contact_name ?? '-',
'participant_phone' => $participant->person?->phone ?? null,
'participant_uuid' => $participant->uuid,
'amount' => $invoice->due_amount,
'program_name' => $this->extractProgramName($invoice->notes),
'days_overdue' => $invoice->due_date?->isPast() ? (int) now()->diffInDays($invoice->due_date) : 0,
'type' => 'invoice',
]);
}
return !$hasUnpaidInvoice;
});
foreach ($overdueEnrollments as $enrollment) {
$items->push([
'participant_name' => $enrollment->participant?->person?->name_ar ?? '-',
'participant_phone' => $enrollment->participant?->person?->phone ?? null,
'participant_uuid' => $enrollment->participant?->uuid,
'amount' => null,
'program_name' => $enrollment->program?->name_ar ?? '-',
'days_overdue' => (int) now()->diffInDays($enrollment->next_billing_date),
'type' => 'no_invoice',
]);
}
$totalOverdue = $overdueEnrollments->count();
$displayList = $this->expanded ? $overdueEnrollments : $overdueEnrollments->take(10);
$items = $items->sortByDesc('days_overdue')->values();
$totalOverdue = $items->count();
$displayList = $this->expanded ? $items : $items->take(15);
$totalAmount = $unpaidInvoices->sum('due_amount');
return view('livewire.dashboard.overdue-renewals-alert', [
'overdueEnrollments' => $displayList,
'items' => $displayList,
'totalOverdue' => $totalOverdue,
'totalAmount' => $totalAmount,
]);
}
private function extractProgramName(?string $notes): string
{
if (!$notes) {
return '-';
}
if (preg_match('/[—:]\s*(.+)$/', $notes, $m)) {
return trim($m[1]);
}
return $notes;
}
}
......@@ -14,6 +14,9 @@
<p class="text-sm text-red-700">
<span class="font-bold text-xl">{{ $totalOverdue }}</span>
{{ __('مشترك لم يجدد اشتراكه حتى الآن') }}
@if($totalAmount > 0)
<span class="font-bold">{{ number_format($totalAmount / 100, 2) }} {{ __('ج.م') }}</span> {{ __('مستحقة') }}
@endif
</p>
</div>
</div>
......@@ -33,41 +36,47 @@ class="inline-flex items-center gap-2 px-5 py-3 bg-red-700 text-white rounded-xl
<tr>
<th class="text-start px-4 py-2.5 font-semibold text-red-800">{{ __('المشترك') }}</th>
<th class="text-start px-4 py-2.5 font-semibold text-red-800">{{ __('البرنامج') }}</th>
<th class="text-start px-4 py-2.5 font-semibold text-red-800 hidden sm:table-cell">{{ __('الفرع') }}</th>
<th class="text-center px-4 py-2.5 font-semibold text-red-800">{{ __('المبلغ') }}</th>
<th class="text-center px-4 py-2.5 font-semibold text-red-800">{{ __('تأخير') }}</th>
<th class="text-center px-4 py-2.5 font-semibold text-red-800">{{ __('إجراء') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-red-100">
@foreach($overdueEnrollments as $enrollment)
@foreach($items as $item)
<tr class="hover:bg-red-50 transition-colors">
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-full bg-red-100 flex items-center justify-center shrink-0">
<svg class="w-4 h-4 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div class="w-8 h-8 rounded-full {{ $item['type'] === 'invoice' ? 'bg-red-100' : 'bg-amber-100' }} flex items-center justify-center shrink-0">
<svg class="w-4 h-4 {{ $item['type'] === 'invoice' ? 'text-red-600' : 'text-amber-600' }}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
</div>
<div class="min-w-0">
<p class="font-medium text-gray-800 truncate">{{ $enrollment->participant?->person?->name_ar ?? '-' }}</p>
@if($enrollment->participant?->person?->phone)
<p class="text-xs text-gray-500" dir="ltr">{{ $enrollment->participant->person->phone }}</p>
<p class="font-medium text-gray-800 truncate">{{ $item['participant_name'] }}</p>
@if($item['participant_phone'])
<p class="text-xs text-gray-500" dir="ltr">{{ $item['participant_phone'] }}</p>
@endif
</div>
</div>
</td>
<td class="px-4 py-3 text-gray-700">{{ $enrollment->program?->name_ar ?? '-' }}</td>
<td class="px-4 py-3 text-gray-500 hidden sm:table-cell">{{ $enrollment->group?->branch?->name_ar ?? '-' }}</td>
<td class="px-4 py-3 text-gray-700">{{ $item['program_name'] }}</td>
<td class="px-4 py-3 text-center">
@if($item['amount'])
<span class="font-bold text-red-700">{{ number_format($item['amount'] / 100, 2) }}</span>
<span class="text-xs text-gray-500">{{ __('ج.م') }}</span>
@else
<span class="text-xs text-amber-600 font-medium">{{ __('بدون فاتورة') }}</span>
@endif
</td>
<td class="px-4 py-3 text-center">
@php $daysDue = (int) now()->diffInDays($enrollment->next_billing_date); @endphp
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-bold
{{ $daysDue > 7 ? 'bg-red-200 text-red-800' : 'bg-amber-100 text-amber-800' }}">
{{ $daysDue }} {{ __('يوم') }}
{{ $item['days_overdue'] > 7 ? 'bg-red-200 text-red-800' : 'bg-amber-100 text-amber-800' }}">
{{ $item['days_overdue'] }} {{ __('يوم') }}
</span>
</td>
<td class="px-4 py-3 text-center">
@if($enrollment->participant?->uuid)
<a href="{{ route('receptionist.collect-payment') }}?participant={{ $enrollment->participant->uuid }}" wire:navigate
@if($item['participant_uuid'])
<a href="{{ route('receptionist.collect-payment') }}?participant={{ $item['participant_uuid'] }}" wire:navigate
class="inline-flex items-center gap-1 px-3 py-1.5 bg-green-600 text-white rounded-lg hover:bg-green-700 text-xs font-bold transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8V7m0 10v1"/>
......@@ -83,7 +92,7 @@ class="inline-flex items-center gap-1 px-3 py-1.5 bg-green-600 text-white rounde
</div>
{{-- Show more / less --}}
@if($totalOverdue > 10)
@if($totalOverdue > 15)
<div class="mt-3 text-center">
<button wire:click="toggleExpanded" class="text-sm text-red-700 hover:text-red-900 font-medium">
@if($expanded)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment