Commit e6a97e1c authored by Mahmoud Aglan's avatar Mahmoud Aglan

Fix: Collect Payment Wizard - auto-generate renewal invoices and fix selection

- Search now shows ALL active participants (not just those with existing invoices)
- Auto-generates renewal invoices on-the-fly when participant has overdue billing
- Radio buttons use wire:model.live for immediate state update (fixes Next button)
- Upcoming renewals section shows "Generate Invoice" button as fallback
- Next button always visible (disabled until invoice selected)
- Advances next_billing_date after generating renewal invoice
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 32f5e059
...@@ -5,12 +5,16 @@ ...@@ -5,12 +5,16 @@
use App\Domain\Financial\Enums\InvoiceStatus; use App\Domain\Financial\Enums\InvoiceStatus;
use App\Domain\Financial\Models\Installment; use App\Domain\Financial\Models\Installment;
use App\Domain\Financial\Models\Invoice; use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Models\PaymentPlan; use App\Domain\Financial\Services\InvoiceService;
use App\Domain\Financial\Services\PaymentService; use App\Domain\Financial\Services\PaymentService;
use App\Domain\Participant\Models\Participant; use App\Domain\Participant\Models\Participant;
use App\Domain\Pricing\Services\PricingService;
use App\Domain\Shared\Exceptions\DomainException; use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Traits\UsesBranchScope; use App\Domain\Shared\Traits\UsesBranchScope;
use App\Domain\Training\Enums\EnrollmentStatus;
use App\Domain\Training\Enums\RenewalPolicy;
use App\Domain\Training\Models\Enrollment; use App\Domain\Training\Models\Enrollment;
use Illuminate\Support\Facades\Log;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\Title; use Livewire\Attributes\Title;
use Livewire\Component; use Livewire\Component;
...@@ -57,6 +61,7 @@ public function mount(?string $participant = null): void ...@@ -57,6 +61,7 @@ public function mount(?string $participant = null): void
if ($p) { if ($p) {
$this->selected_participant_id = $p->id; $this->selected_participant_id = $p->id;
$this->selected_participant_name = $p->person?->name_ar ?? $p->person?->name ?? ''; $this->selected_participant_name = $p->person?->name_ar ?? $p->person?->name ?? '';
$this->generatePendingRenewals();
$this->currentStep = 2; $this->currentStep = 2;
} }
} }
...@@ -99,6 +104,131 @@ public function selectParticipant(int $id, string $name): void ...@@ -99,6 +104,131 @@ public function selectParticipant(int $id, string $name): void
{ {
$this->selected_participant_id = $id; $this->selected_participant_id = $id;
$this->selected_participant_name = $name; $this->selected_participant_name = $name;
$this->generatePendingRenewals();
}
public function generateRenewalForEnrollment(int $enrollmentId): void
{
$enrollment = Enrollment::with(['program', 'participant.person', 'group'])->find($enrollmentId);
if (!$enrollment || $enrollment->participant_id !== $this->selected_participant_id) {
return;
}
try {
$invoiceService = app(InvoiceService::class);
$pricingService = app(PricingService::class);
$participant = $enrollment->participant;
$program = $enrollment->program;
$group = $enrollment->group;
$priceResult = $pricingService->calculate(
priceable: $program,
participant: $participant,
branchId: $group?->branch_id ?? $participant->branch_id,
);
if ($priceResult->finalAmount <= 0) {
$this->advanceEnrollmentBillingDate($enrollment);
session()->flash('info', __('الاشتراك مجاني - تم التجديد بدون فاتورة'));
return;
}
$invoice = $invoiceService->create([
'academy_id' => $enrollment->academy_id ?? $program->academy_id,
'branch_id' => $group?->branch_id ?? $participant->branch_id,
'billable_type' => $participant->getMorphClass(),
'billable_id' => $participant->id,
'total_amount' => $priceResult->finalAmount,
'subtotal_amount' => $priceResult->baseAmount,
'discount_amount' => $priceResult->totalDiscount,
'tax_amount' => 0,
'due_date' => now()->addDays(7)->toDateString(),
'contact_name' => $participant->person?->name_ar ?? $participant->person?->name,
'notes' => 'تجديد اشتراك — ' . $program->name_ar,
], [
[
'description' => "تجديد اشتراك: {$program->name_ar}",
'quantity' => 1,
'unit_price' => $priceResult->baseAmount,
'discount_amount' => $priceResult->totalDiscount,
'tax_amount' => 0,
],
], auth()->user());
// Mark invoice as sent so it appears as payable
$invoice->update(['status' => InvoiceStatus::Sent]);
$this->advanceEnrollmentBillingDate($enrollment);
$enrollment->update([
'last_billed_at' => now()->toDateString(),
'payment_status' => 'pending',
]);
$this->selected_invoice_id = $invoice->id;
$this->payment_amount_display = number_format($invoice->due_amount / 100, 2, '.', '');
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
} catch (\Throwable $e) {
Log::error('Renewal invoice generation failed', ['enrollment_id' => $enrollmentId, 'error' => $e->getMessage()]);
session()->flash('error', __('خطأ في إنشاء فاتورة التجديد'));
}
}
private function generatePendingRenewals(): void
{
if (!$this->selected_participant_id) {
return;
}
$enrollments = Enrollment::where('participant_id', $this->selected_participant_id)
->where('status', EnrollmentStatus::Active)
->whereNotNull('next_billing_date')
->where('next_billing_date', '<=', now()->toDateString())
->whereHas('program', function ($q) {
$q->whereIn('renewal_policy', [
RenewalPolicy::AutoRenew->value,
RenewalPolicy::ManualRenew->value,
])->whereNotNull('billing_cycle');
})
->with(['program', 'participant.person', 'group'])
->get();
foreach ($enrollments as $enrollment) {
$existingUnpaid = Invoice::where('billable_type', Participant::class)
->where('billable_id', $this->selected_participant_id)
->whereIn('status', [InvoiceStatus::Sent, InvoiceStatus::PartiallyPaid, InvoiceStatus::Overdue, InvoiceStatus::Draft])
->where('notes', 'like', '%' . ($enrollment->program?->name_ar ?? 'تجديد') . '%')
->exists();
if (!$existingUnpaid) {
$this->generateRenewalForEnrollment($enrollment->id);
}
}
}
private function advanceEnrollmentBillingDate(Enrollment $enrollment): void
{
$program = $enrollment->program;
$current = $enrollment->next_billing_date;
$next = match ($program->billing_cycle) {
'monthly' => $current->copy()->addMonth(),
'quarterly' => $current->copy()->addMonths(3),
'semi_annual' => $current->copy()->addMonths(6),
'annual' => $current->copy()->addYear(),
'per_duration' => $current->copy()->addWeeks($program->program_duration_weeks ?? 4),
default => $current->copy()->addMonth(),
};
if ($program->billing_cycle === 'monthly' && $program->billing_day) {
$maxDay = $next->daysInMonth;
$next->day = min($program->billing_day, $maxDay);
}
$enrollment->update(['next_billing_date' => $next->toDateString()]);
} }
public function selectInvoice(int $id): void public function selectInvoice(int $id): void
...@@ -198,7 +328,7 @@ public function render() ...@@ -198,7 +328,7 @@ public function render()
$searchResults = Participant::query() $searchResults = Participant::query()
->with('person') ->with('person')
->where('branch_id', $this->branchId) ->where('branch_id', $this->branchId)
->whereHas('invoices', fn ($iq) => $iq->whereIn('status', ['sent', 'partially_paid', 'overdue'])) ->where('status', 'active')
->where(function ($q) { ->where(function ($q) {
$search = $this->search; $search = $this->search;
$q->where('participant_number', 'ilike', "%{$search}%") $q->where('participant_number', 'ilike', "%{$search}%")
...@@ -261,7 +391,8 @@ public function render() ...@@ -261,7 +391,8 @@ public function render()
'type' => 'renewal', 'type' => 'renewal',
'label' => 'تجديد: ' . ($enr->program?->name_ar ?? '-'), 'label' => 'تجديد: ' . ($enr->program?->name_ar ?? '-'),
'amount' => null, 'amount' => null,
'due_date' => $enr->next_billing_date, // Carbon from model cast 'due_date' => $enr->next_billing_date,
'enrollment_id' => $enr->id,
]); ]);
} }
$upcomingPayments = $upcomingPayments->sortBy(fn ($item) => $item['due_date']->timestamp)->values(); $upcomingPayments = $upcomingPayments->sortBy(fn ($item) => $item['due_date']->timestamp)->values();
......
...@@ -170,7 +170,7 @@ class="inline-flex items-center gap-2 px-6 py-3 min-h-16 bg-amber-600 text-white ...@@ -170,7 +170,7 @@ class="inline-flex items-center gap-2 px-6 py-3 min-h-16 bg-amber-600 text-white
<div class="space-y-3"> <div class="space-y-3">
@foreach($invoices as $invoice) @foreach($invoices as $invoice)
<label class="relative cursor-pointer block"> <label class="relative cursor-pointer block">
<input type="radio" wire:model="selected_invoice_id" value="{{ $invoice->id }}" class="peer sr-only"> <input type="radio" wire:model.live="selected_invoice_id" value="{{ $invoice->id }}" class="peer sr-only">
<div class="p-5 min-h-16 border border-gray-200 rounded-xl transition-all <div class="p-5 min-h-16 border border-gray-200 rounded-xl transition-all
peer-checked:border-amber-500 peer-checked:bg-amber-50 peer-checked:shadow-sm peer-checked:border-amber-500 peer-checked:bg-amber-50 peer-checked:shadow-sm
hover:border-gray-300"> hover:border-gray-300">
...@@ -244,9 +244,18 @@ class="inline-flex items-center gap-2 px-6 py-3 min-h-16 bg-amber-600 text-white ...@@ -244,9 +244,18 @@ class="inline-flex items-center gap-2 px-6 py-3 min-h-16 bg-amber-600 text-white
@if($upcoming['amount']) @if($upcoming['amount'])
<span class="text-sm font-medium text-gray-700" dir="ltr">{{ number_format($upcoming['amount'] / 100, 2) }} {{ __('ج.م') }}</span> <span class="text-sm font-medium text-gray-700" dir="ltr">{{ number_format($upcoming['amount'] / 100, 2) }} {{ __('ج.م') }}</span>
@endif @endif
@if($upcoming['type'] === 'renewal' && $upcoming['due_date']->isPast())
<button wire:click="generateRenewalForEnrollment({{ $upcoming['enrollment_id'] }})"
wire:loading.attr="disabled"
class="text-xs px-3 py-1 rounded-lg bg-green-600 text-white hover:bg-green-700 font-medium transition-colors">
<span wire:loading.remove wire:target="generateRenewalForEnrollment({{ $upcoming['enrollment_id'] }})">{{ __('إنشاء فاتورة') }}</span>
<span wire:loading wire:target="generateRenewalForEnrollment({{ $upcoming['enrollment_id'] }})">{{ __('جارٍ...') }}</span>
</button>
@else
<span class="text-xs px-2 py-0.5 rounded-full {{ $upcoming['due_date']->isPast() ? 'bg-red-100 text-red-700' : ($upcoming['due_date']->isToday() ? 'bg-amber-100 text-amber-700' : 'bg-gray-100 text-gray-600') }}" dir="ltr"> <span class="text-xs px-2 py-0.5 rounded-full {{ $upcoming['due_date']->isPast() ? 'bg-red-100 text-red-700' : ($upcoming['due_date']->isToday() ? 'bg-amber-100 text-amber-700' : 'bg-gray-100 text-gray-600') }}" dir="ltr">
{{ $upcoming['due_date']->format('Y-m-d') }} {{ $upcoming['due_date']->format('Y-m-d') }}
</span> </span>
@endif
</div> </div>
</div> </div>
@endforeach @endforeach
...@@ -262,7 +271,6 @@ class="inline-flex items-center gap-2 px-6 py-3 min-h-16 text-gray-600 bg-gray-1 ...@@ -262,7 +271,6 @@ class="inline-flex items-center gap-2 px-6 py-3 min-h-16 text-gray-600 bg-gray-1
</svg> </svg>
{{ __('السابق') }} {{ __('السابق') }}
</button> </button>
@if($invoices->isNotEmpty())
<button wire:click="nextStep" wire:loading.attr="disabled" <button wire:click="nextStep" wire:loading.attr="disabled"
@if(!$selected_invoice_id) disabled @endif @if(!$selected_invoice_id) disabled @endif
class="inline-flex items-center gap-2 px-6 py-3 min-h-16 bg-amber-600 text-white rounded-lg hover:bg-amber-700 text-base font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"> class="inline-flex items-center gap-2 px-6 py-3 min-h-16 bg-amber-600 text-white rounded-lg hover:bg-amber-700 text-base font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
...@@ -272,7 +280,6 @@ class="inline-flex items-center gap-2 px-6 py-3 min-h-16 bg-amber-600 text-white ...@@ -272,7 +280,6 @@ class="inline-flex items-center gap-2 px-6 py-3 min-h-16 bg-amber-600 text-white
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg> </svg>
</button> </button>
@endif
</div> </div>
</div> </div>
@endif @endif
......
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