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 ...@@ -387,13 +387,15 @@ private function createEnrollmentInvoice(Enrollment $enrollment, Participant $pa
return; return;
} }
// Apply proration if enabled $finalAmount = $priceResult->finalAmount;
$proration = $this->prorationService->calculate($priceResult->finalAmount);
$finalAmount = $proration->proratedAmount;
$lineDescription = "اشتراك: {$program->name_ar}"; $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([ $invoice = $this->invoiceService->create([
...@@ -427,7 +429,7 @@ private function createEnrollmentInvoice(Enrollment $enrollment, Participant $pa ...@@ -427,7 +429,7 @@ private function createEnrollmentInvoice(Enrollment $enrollment, Participant $pa
private function calculateFirstBillingDate(?TrainingProgram $program): ?string private function calculateFirstBillingDate(?TrainingProgram $program): ?string
{ {
if (!$program || !$program->billing_cycle) { if (!$program) {
return null; return null;
} }
...@@ -435,18 +437,21 @@ private function calculateFirstBillingDate(?TrainingProgram $program): ?string ...@@ -435,18 +437,21 @@ private function calculateFirstBillingDate(?TrainingProgram $program): ?string
return null; return null;
} }
// Default to monthly day-1 if not set
$cycle = $program->billing_cycle ?? 'monthly';
$billingDay = $program->billing_day ?? 1;
$start = now(); $start = now();
$next = match ($program->billing_cycle) { $next = match ($cycle) {
'monthly' => $this->snapToDay($start->copy()->addMonth(), $program->billing_day), 'monthly' => $this->snapToDay($start->copy()->addMonth(), $billingDay),
'quarterly' => $start->copy()->addMonths(3), 'quarterly' => $start->copy()->addMonths(3),
'semi_annual' => $start->copy()->addMonths(6), 'semi_annual' => $start->copy()->addMonths(6),
'annual' => $start->copy()->addYear(), 'annual' => $start->copy()->addYear(),
'per_duration' => $start->copy()->addWeeks($program->program_duration_weeks ?? 4), '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 private function snapToDay(\Illuminate\Support\Carbon $date, ?int $billingDay): \Illuminate\Support\Carbon
......
...@@ -23,39 +23,88 @@ public function render() ...@@ -23,39 +23,88 @@ public function render()
{ {
$today = now()->toDateString(); $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) $overdueEnrollments = Enrollment::where('status', EnrollmentStatus::Active)
->whereNotNull('next_billing_date') ->whereNotNull('next_billing_date')
->where('next_billing_date', '<=', $today) ->where('next_billing_date', '<=', $today)
->whereHas('program', fn ($q) => $q->whereIn('renewal_policy', [ ->whereHas('program', fn ($q) => $q->whereIn('renewal_policy', [
RenewalPolicy::AutoRenew->value, RenewalPolicy::AutoRenew->value,
RenewalPolicy::ManualRenew->value, RenewalPolicy::ManualRenew->value,
])->whereNotNull('billing_cycle')) ]))
->with(['participant.person', 'program', 'group.branch']) ->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 foreach ($unpaidInvoices as $invoice) {
$overdueEnrollments = $overdueEnrollments->filter(function ($enrollment) { $participant = $invoice->billable;
if (!$enrollment->participant) { if (!$participant) {
return false; continue;
} }
$hasUnpaidInvoice = Invoice::where('billable_type', Participant::class) $items->push([
->where('billable_id', $enrollment->participant_id) 'participant_name' => $participant->person?->name_ar ?? $invoice->contact_name ?? '-',
->whereIn('status', [InvoiceStatus::Sent, InvoiceStatus::PartiallyPaid, InvoiceStatus::Overdue]) 'participant_phone' => $participant->person?->phone ?? null,
->where('due_amount', '>', 0) 'participant_uuid' => $participant->uuid,
->where('notes', 'like', '%' . ($enrollment->program?->name_ar ?? '') . '%') 'amount' => $invoice->due_amount,
->exists(); '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(); $items = $items->sortByDesc('days_overdue')->values();
$displayList = $this->expanded ? $overdueEnrollments : $overdueEnrollments->take(10); $totalOverdue = $items->count();
$displayList = $this->expanded ? $items : $items->take(15);
$totalAmount = $unpaidInvoices->sum('due_amount');
return view('livewire.dashboard.overdue-renewals-alert', [ return view('livewire.dashboard.overdue-renewals-alert', [
'overdueEnrollments' => $displayList, 'items' => $displayList,
'totalOverdue' => $totalOverdue, '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 @@ ...@@ -14,6 +14,9 @@
<p class="text-sm text-red-700"> <p class="text-sm text-red-700">
<span class="font-bold text-xl">{{ $totalOverdue }}</span> <span class="font-bold text-xl">{{ $totalOverdue }}</span>
{{ __('مشترك لم يجدد اشتراكه حتى الآن') }} {{ __('مشترك لم يجدد اشتراكه حتى الآن') }}
@if($totalAmount > 0)
<span class="font-bold">{{ number_format($totalAmount / 100, 2) }} {{ __('ج.م') }}</span> {{ __('مستحقة') }}
@endif
</p> </p>
</div> </div>
</div> </div>
...@@ -33,41 +36,47 @@ class="inline-flex items-center gap-2 px-5 py-3 bg-red-700 text-white rounded-xl ...@@ -33,41 +36,47 @@ class="inline-flex items-center gap-2 px-5 py-3 bg-red-700 text-white rounded-xl
<tr> <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">{{ __('البرنامج') }}</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>
<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> </tr>
</thead> </thead>
<tbody class="divide-y divide-red-100"> <tbody class="divide-y divide-red-100">
@foreach($overdueEnrollments as $enrollment) @foreach($items as $item)
<tr class="hover:bg-red-50 transition-colors"> <tr class="hover:bg-red-50 transition-colors">
<td class="px-4 py-3"> <td class="px-4 py-3">
<div class="flex items-center gap-2"> <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"> <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 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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"/> <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> </svg>
</div> </div>
<div class="min-w-0"> <div class="min-w-0">
<p class="font-medium text-gray-800 truncate">{{ $enrollment->participant?->person?->name_ar ?? '-' }}</p> <p class="font-medium text-gray-800 truncate">{{ $item['participant_name'] }}</p>
@if($enrollment->participant?->person?->phone) @if($item['participant_phone'])
<p class="text-xs text-gray-500" dir="ltr">{{ $enrollment->participant->person->phone }}</p> <p class="text-xs text-gray-500" dir="ltr">{{ $item['participant_phone'] }}</p>
@endif @endif
</div> </div>
</div> </div>
</td> </td>
<td class="px-4 py-3 text-gray-700">{{ $enrollment->program?->name_ar ?? '-' }}</td> <td class="px-4 py-3 text-gray-700">{{ $item['program_name'] }}</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-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"> <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 <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' }}"> {{ $item['days_overdue'] > 7 ? 'bg-red-200 text-red-800' : 'bg-amber-100 text-amber-800' }}">
{{ $daysDue }} {{ __('يوم') }} {{ $item['days_overdue'] }} {{ __('يوم') }}
</span> </span>
</td> </td>
<td class="px-4 py-3 text-center"> <td class="px-4 py-3 text-center">
@if($enrollment->participant?->uuid) @if($item['participant_uuid'])
<a href="{{ route('receptionist.collect-payment') }}?participant={{ $enrollment->participant->uuid }}" wire:navigate <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"> 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"> <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"/> <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 ...@@ -83,7 +92,7 @@ class="inline-flex items-center gap-1 px-3 py-1.5 bg-green-600 text-white rounde
</div> </div>
{{-- Show more / less --}} {{-- Show more / less --}}
@if($totalOverdue > 10) @if($totalOverdue > 15)
<div class="mt-3 text-center"> <div class="mt-3 text-center">
<button wire:click="toggleExpanded" class="text-sm text-red-700 hover:text-red-900 font-medium"> <button wire:click="toggleExpanded" class="text-sm text-red-700 hover:text-red-900 font-medium">
@if($expanded) @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