Commit 86da35b4 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Fix POS payment collection + retroactive wizard reliability

Payment Collection (CollectPaymentWizard):
- Remove branch filter from participant search (POS sells cross-branch)
- Include Draft status in outstanding invoice query
- Add invoice search mode (search by invoice number/contact name)
- Allow walk-in invoices to be found and paid directly
- Accept inactive/frozen/registered participants (not just active)

Retroactive Enrollment Wizard:
- Fix "paid outside system" to use PaymentService (creates proper Payment
  + Transaction records for financial reports)
- Add server-side validation in confirm() before DB transaction
- Add national_id duplicate check to prevent duplicate participants
- Add price=0 guard (show error if no base price and no override)
- Fix rounding loss: remainder goes to last month's invoice
- Pass skip_auto_invoice to prevent double invoice creation

POS Partial Payment:
- Enable allows_partial_payment on all existing products (migration)
  so the deposit/partial payment UI appears at checkout
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent f1f30cc6
......@@ -100,8 +100,8 @@ public function enroll(Participant $participant, TrainingGroup $group, User $act
$participant->update(['status' => 'active']);
}
// Auto-create invoice if program has a price (skip if invoice already provided via options)
if (empty($options['invoice_id']) && (bool) $this->settings->get('auto_invoice_on_enrollment', false)) {
// Auto-create invoice if program has a price (skip if invoice already provided or explicitly skipped)
if (empty($options['invoice_id']) && empty($options['skip_auto_invoice']) && (bool) $this->settings->get('auto_invoice_on_enrollment', false)) {
$this->createEnrollmentInvoice($enrollment, $participant, $group, $actor);
}
......
......@@ -32,6 +32,7 @@ class CollectPaymentWizard extends Component
// Step 1: Search
public string $search = '';
public string $searchMode = 'participant'; // 'participant' or 'invoice'
public ?int $selected_participant_id = null;
public ?string $selected_participant_name = null;
......@@ -77,9 +78,9 @@ public function mount(?string $participant = null): void
public function rules(): array
{
return match ($this->currentStep) {
1 => [
'selected_participant_id' => 'required|exists:participants,id',
],
1 => $this->searchMode === 'participant'
? ['selected_participant_id' => 'required|exists:participants,id']
: ['selected_invoice_id' => 'required|exists:invoices,id'],
2 => [
'selected_invoice_id' => 'required|exists:invoices,id',
],
......@@ -107,6 +108,36 @@ public function messages(): array
];
}
public function switchSearchMode(string $mode): void
{
$this->searchMode = $mode;
$this->search = '';
$this->selected_participant_id = null;
$this->selected_participant_name = null;
$this->selected_invoice_id = null;
}
public function selectInvoiceDirectly(int $id): void
{
$invoice = Invoice::find($id);
if (!$invoice || $invoice->due_amount <= 0) {
return;
}
if ($invoice->billable_type === Participant::class && $invoice->billable_id) {
$participant = Participant::with('person')->find($invoice->billable_id);
$this->selected_participant_id = $participant?->id;
$this->selected_participant_name = $participant?->person?->name_ar ?? $invoice->contact_name ?? '';
} else {
$this->selected_participant_id = null;
$this->selected_participant_name = $invoice->contact_name ?? __('عميل عابر');
}
$this->selected_invoice_id = $invoice->id;
$this->payment_amount_display = number_format($invoice->due_amount / 100, 2, '.', '');
$this->currentStep = 3;
}
public function selectParticipant(int $id, string $name): void
{
$this->selected_participant_id = $id;
......@@ -410,11 +441,13 @@ public function confirm(PaymentService $service): void
public function render()
{
$searchResults = collect();
$invoiceSearchResults = collect();
if (strlen($this->search) >= 2) {
if ($this->searchMode === 'participant') {
$searchResults = Participant::query()
->with('person')
->where('branch_id', $this->branchId)
->where('status', 'active')
->whereIn('status', ['active', 'registered', 'frozen', 'inactive'])
->where(function ($q) {
$search = $this->search;
$q->where('participant_number', 'ilike', "%{$search}%")
......@@ -427,9 +460,27 @@ public function render()
})
->limit(10)
->get();
} else {
$invoiceSearchResults = Invoice::query()
->whereIn('status', [
InvoiceStatus::Sent,
InvoiceStatus::PartiallyPaid,
InvoiceStatus::Overdue,
InvoiceStatus::Draft,
])
->where('due_amount', '>', 0)
->where(function ($q) {
$search = $this->search;
$q->where('number', 'ilike', "%{$search}%")
->orWhere('contact_name', 'ilike', "%{$search}%");
})
->orderBy('due_date')
->limit(10)
->get();
}
}
// Outstanding invoices for selected participant
// Outstanding invoices for selected participant (all branches — POS can sell cross-branch)
$invoices = collect();
$upcomingPayments = collect();
if ($this->selected_participant_id) {
......@@ -439,6 +490,7 @@ public function render()
InvoiceStatus::Sent,
InvoiceStatus::PartiallyPaid,
InvoiceStatus::Overdue,
InvoiceStatus::Draft,
])
->where('due_amount', '>', 0)
->orderBy('due_date')
......@@ -490,6 +542,7 @@ public function render()
return view('livewire.receptionist.collect-payment-wizard', [
'searchResults' => $searchResults,
'invoiceSearchResults' => $invoiceSearchResults,
'invoices' => $invoices,
'upcomingPayments' => $upcomingPayments,
'selectedInvoice' => $selectedInvoice,
......
......@@ -251,8 +251,36 @@ public function goToStep(int $step): void
public function confirm(): void
{
$this->validate([
'participant_name_ar' => 'required|string|min:3|max:100',
'guardian_name_ar' => 'required|string|min:3|max:100',
'guardian_phone' => 'required|string|min:10|max:20',
'selected_program_id' => 'required|exists:training_programs,id',
'actual_start_date' => 'required|date|before_or_equal:today',
'payment_method' => 'required_if:pay_now,true|in:cash,card,bank_transfer,wallet,online,cheque,other',
]);
$actor = auth()->user();
// Duplicate NID check
if ($this->participant_national_id) {
$existingPerson = Person::where('national_id', $this->participant_national_id)->first();
if ($existingPerson) {
session()->flash('error', __('يوجد شخص مسجل بنفس الرقم القومي: ') . $existingPerson->name_ar);
return;
}
}
// Price validation — don't allow 0-amount invoices unless explicitly overridden
$effectivePrice = $this->monthlyPrice;
if ($this->priceOverrideEnabled && $this->priceOverrideInput !== '' && $actor->is_super_admin) {
$effectivePrice = max(0, (int) round((float) $this->priceOverrideInput * 100));
}
if ($effectivePrice <= 0 && $this->unpaidMonthsCount > 0 && !$this->priceOverrideEnabled) {
session()->flash('error', __('لا يوجد سعر محدد لهذا البرنامج — يرجى إضافة سعر أو استخدام تعديل السعر'));
return;
}
try {
DB::transaction(function () use ($actor) {
$personService = app(PersonService::class);
......@@ -314,6 +342,7 @@ public function confirm(): void
]);
// 6. Enroll in program with retroactive start date
// Pass skip_auto_invoice to prevent auto_invoice_on_enrollment from creating a duplicate
$program = TrainingProgram::findOrFail($this->selected_program_id);
$enrollment = $enrollmentService->enrollInProgram(
$participant,
......@@ -322,6 +351,7 @@ public function confirm(): void
[
'start_date' => $this->actual_start_date,
'payment_status' => 'pending',
'skip_auto_invoice' => true,
]
);
......@@ -352,6 +382,14 @@ public function confirm(): void
$paymentsRecorded = 0;
$totalRetroactiveRevenue = 0;
$unpaidIndex = 0;
$unpaidTotal = collect($this->monthStatuses)->filter(fn ($s) => $s === 'unpaid')->count();
$remainder = 0;
if ($overrideActive && $unpaidTotal > 0) {
$overrideTotalCalc = max(0, (int) round((float) $this->priceOverrideInput * 100));
$remainder = $overrideTotalCalc - ($perMonth * $unpaidTotal);
}
foreach ($this->monthStatuses as $monthKey => $status) {
$monthDate = Carbon::parse($monthKey . '-01');
......@@ -380,28 +418,35 @@ public function confirm(): void
],
], $actor);
$invoice->update([
'status' => InvoiceStatus::Paid,
'paid_amount' => $perMonth,
'due_amount' => 0,
'paid_at' => $monthDate->copy()->addDays(1),
'metadata' => array_merge($invoice->metadata ?? [], [
'paid_outside_system' => true,
'recorded_by' => $actor->name,
'recorded_at' => now()->toIso8601String(),
]),
]);
$invoice->update(['status' => InvoiceStatus::Sent]);
$paymentService->recordPayment([
'invoice_id' => $invoice->id,
'branch_id' => $this->branchId,
'amount' => $perMonth,
'method' => 'cash',
'direction' => 'inbound',
'currency' => 'EGP',
'payment_date' => $monthDate->copy()->addDays(1)->toDateString(),
'notes' => 'مدفوع خارج السيستم — ' . $this->getArabicMonth($monthDate),
], $actor);
$totalRetroactiveRevenue += $perMonth;
$invoicesCreated++;
$paymentsRecorded++;
} elseif ($status === 'unpaid') {
$unpaidIndex++;
$thisMonthAmount = $perMonth;
if ($unpaidIndex === $unpaidTotal && $remainder > 0) {
$thisMonthAmount += $remainder;
}
$invoice = $invoiceService->create([
'academy_id' => $participant->academy_id,
'branch_id' => $this->branchId,
'billable_type' => $participant->getMorphClass(),
'billable_id' => $participant->id,
'total_amount' => $perMonth,
'subtotal_amount' => $perMonth,
'total_amount' => $thisMonthAmount,
'subtotal_amount' => $thisMonthAmount,
'discount_amount' => 0,
'tax_amount' => 0,
'service_fee_amount' => 0,
......@@ -413,21 +458,21 @@ public function confirm(): void
[
'description' => 'اشتراك: ' . $program->name_ar . ' (' . $this->getArabicMonth($monthDate) . ')',
'quantity' => 1,
'unit_price' => $perMonth,
'unit_price' => $thisMonthAmount,
'discount_amount' => 0,
'tax_amount' => 0,
],
], $actor);
$invoice->update(['status' => InvoiceStatus::Sent]);
$totalRetroactiveRevenue += $perMonth;
$totalRetroactiveRevenue += $thisMonthAmount;
$invoicesCreated++;
if ($this->pay_now && $perMonth > 0) {
if ($this->pay_now && $thisMonthAmount > 0) {
$paymentService->recordPayment([
'invoice_id' => $invoice->id,
'branch_id' => $this->branchId,
'amount' => $perMonth,
'amount' => $thisMonthAmount,
'method' => $this->payment_method,
'direction' => 'inbound',
'currency' => 'EGP',
......
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::table('products')->update(['allows_partial_payment' => true]);
}
public function down(): void
{
DB::table('products')->update(['allows_partial_payment' => false]);
}
};
......@@ -73,18 +73,33 @@ class="flex items-center gap-2 {{ $num < $currentStep ? 'cursor-pointer' : 'curs
{{-- Step Content --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6">
{{-- Step 1: Search Participant --}}
{{-- Step 1: Search Participant or Invoice --}}
@if($currentStep === 1)
<div>
<h2 class="text-lg font-semibold text-gray-800 mb-6">{{ __('بحث عن المشترك') }}</h2>
<h2 class="text-lg font-semibold text-gray-800 mb-4">{{ __('بحث عن المشترك أو الفاتورة') }}</h2>
{{-- Search Mode Tabs --}}
<div class="flex gap-2 mb-4">
<button wire:click="switchSearchMode('participant')"
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors
{{ $searchMode === 'participant' ? 'bg-amber-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200' }}">
{{ __('بحث بالمشترك') }}
</button>
<button wire:click="switchSearchMode('invoice')"
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors
{{ $searchMode === 'invoice' ? 'bg-amber-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200' }}">
{{ __('بحث برقم الفاتورة') }}
</button>
</div>
{{-- Search Input --}}
<div class="mb-6">
<input type="text" wire:model.live.debounce.300ms="search"
class="w-full px-4 py-3 min-h-16 border border-gray-300 rounded-lg focus:ring-2 focus:ring-amber-500 focus:border-amber-500 text-lg"
placeholder="{{ __('بحث بالاسم، الهاتف، الرقم القومي، أو رقم المشترك...') }}">
placeholder="{{ $searchMode === 'participant' ? __('بحث بالاسم، الهاتف، الرقم القومي، أو رقم المشترك...') : __('بحث برقم الفاتورة أو اسم العميل...') }}">
</div>
@if($searchMode === 'participant')
{{-- Selected Participant Indicator --}}
@if($selected_participant_id)
<div class="mb-4 p-4 bg-amber-50 border border-amber-200 rounded-xl flex items-center justify-between">
......@@ -102,11 +117,11 @@ class="w-full px-4 py-3 min-h-16 border border-gray-300 rounded-lg focus:ring-2
</div>
@endif
{{-- Search Results --}}
{{-- Participant Search Results --}}
@if(strlen($search) >= 2 && !$selected_participant_id)
<div class="space-y-2" wire:loading.class="opacity-50">
@forelse($searchResults as $result)
<button wire:click="selectParticipant({{ $result->id }}, '{{ $result->person?->name_ar }}')"
<button wire:click="selectParticipant({{ $result->id }}, '{{ addslashes($result->person?->name_ar) }}')"
class="w-full p-4 min-h-16 border border-gray-200 rounded-xl text-start hover:border-amber-300 hover:bg-amber-50 transition-all flex items-center justify-between">
<div>
<p class="font-medium text-gray-800">{{ $result->person?->name_ar }}</p>
......@@ -155,6 +170,44 @@ class="inline-flex items-center gap-2 px-6 py-3 min-h-16 bg-amber-600 text-white
</svg>
</button>
</div>
@else
{{-- Invoice Search Results --}}
@if(strlen($search) >= 2)
<div class="space-y-2" wire:loading.class="opacity-50">
@forelse($invoiceSearchResults as $inv)
<button wire:click="selectInvoiceDirectly({{ $inv->id }})"
class="w-full p-4 border border-gray-200 rounded-xl text-start hover:border-amber-300 hover:bg-amber-50 transition-all">
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-gray-800" dir="ltr">{{ $inv->number }}</p>
<p class="text-sm text-gray-500 mt-1">{{ $inv->contact_name ?? __('عميل عابر') }}</p>
</div>
<div class="text-end">
<p class="font-bold text-red-600">{{ number_format($inv->due_amount / 100, 2) }} {{ __('ج.م') }}</p>
<p class="text-xs text-gray-400 mt-0.5">{{ __('من أصل') }} {{ number_format($inv->total_amount / 100, 2) }}</p>
</div>
</div>
</button>
@empty
<div class="text-center py-8 text-gray-500">
<svg class="w-12 h-12 mx-auto text-gray-300 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<p>{{ __('لا توجد فواتير مستحقة بهذا الرقم') }}</p>
</div>
@endforelse
</div>
@else
<div class="text-center py-8 text-gray-400">
<svg class="w-16 h-16 mx-auto text-gray-200 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
<p class="text-sm">{{ __('اكتب رقم الفاتورة أو اسم العميل للبحث') }}</p>
</div>
@endif
@error('selected_invoice_id') <p class="mt-2 text-sm text-red-600">{{ $message }}</p> @enderror
@endif
</div>
@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