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 ...@@ -100,8 +100,8 @@ public function enroll(Participant $participant, TrainingGroup $group, User $act
$participant->update(['status' => 'active']); $participant->update(['status' => 'active']);
} }
// Auto-create invoice if program has a price (skip if invoice already provided via options) // Auto-create invoice if program has a price (skip if invoice already provided or explicitly skipped)
if (empty($options['invoice_id']) && (bool) $this->settings->get('auto_invoice_on_enrollment', false)) { 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); $this->createEnrollmentInvoice($enrollment, $participant, $group, $actor);
} }
......
...@@ -32,6 +32,7 @@ class CollectPaymentWizard extends Component ...@@ -32,6 +32,7 @@ class CollectPaymentWizard extends Component
// Step 1: Search // Step 1: Search
public string $search = ''; public string $search = '';
public string $searchMode = 'participant'; // 'participant' or 'invoice'
public ?int $selected_participant_id = null; public ?int $selected_participant_id = null;
public ?string $selected_participant_name = null; public ?string $selected_participant_name = null;
...@@ -77,9 +78,9 @@ public function mount(?string $participant = null): void ...@@ -77,9 +78,9 @@ public function mount(?string $participant = null): void
public function rules(): array public function rules(): array
{ {
return match ($this->currentStep) { return match ($this->currentStep) {
1 => [ 1 => $this->searchMode === 'participant'
'selected_participant_id' => 'required|exists:participants,id', ? ['selected_participant_id' => 'required|exists:participants,id']
], : ['selected_invoice_id' => 'required|exists:invoices,id'],
2 => [ 2 => [
'selected_invoice_id' => 'required|exists:invoices,id', 'selected_invoice_id' => 'required|exists:invoices,id',
], ],
...@@ -107,6 +108,36 @@ public function messages(): array ...@@ -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 public function selectParticipant(int $id, string $name): void
{ {
$this->selected_participant_id = $id; $this->selected_participant_id = $id;
...@@ -410,26 +441,46 @@ public function confirm(PaymentService $service): void ...@@ -410,26 +441,46 @@ public function confirm(PaymentService $service): void
public function render() public function render()
{ {
$searchResults = collect(); $searchResults = collect();
$invoiceSearchResults = collect();
if (strlen($this->search) >= 2) { if (strlen($this->search) >= 2) {
$searchResults = Participant::query() if ($this->searchMode === 'participant') {
->with('person') $searchResults = Participant::query()
->where('branch_id', $this->branchId) ->with('person')
->where('status', 'active') ->whereIn('status', ['active', 'registered', 'frozen', 'inactive'])
->where(function ($q) { ->where(function ($q) {
$search = $this->search; $search = $this->search;
$q->where('participant_number', 'ilike', "%{$search}%") $q->where('participant_number', 'ilike', "%{$search}%")
->orWhereHas('person', function ($pq) use ($search) { ->orWhereHas('person', function ($pq) use ($search) {
$pq->where('name_ar', 'ilike', "%{$search}%") $pq->where('name_ar', 'ilike', "%{$search}%")
->orWhere('name', 'ilike', "%{$search}%") ->orWhere('name', 'ilike', "%{$search}%")
->orWhere('phone', 'like', "%{$search}%") ->orWhere('phone', 'like', "%{$search}%")
->orWhere('national_id', 'like', "%{$search}%"); ->orWhere('national_id', 'like', "%{$search}%");
}); });
}) })
->limit(10) ->limit(10)
->get(); ->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(); $invoices = collect();
$upcomingPayments = collect(); $upcomingPayments = collect();
if ($this->selected_participant_id) { if ($this->selected_participant_id) {
...@@ -439,6 +490,7 @@ public function render() ...@@ -439,6 +490,7 @@ public function render()
InvoiceStatus::Sent, InvoiceStatus::Sent,
InvoiceStatus::PartiallyPaid, InvoiceStatus::PartiallyPaid,
InvoiceStatus::Overdue, InvoiceStatus::Overdue,
InvoiceStatus::Draft,
]) ])
->where('due_amount', '>', 0) ->where('due_amount', '>', 0)
->orderBy('due_date') ->orderBy('due_date')
...@@ -490,6 +542,7 @@ public function render() ...@@ -490,6 +542,7 @@ public function render()
return view('livewire.receptionist.collect-payment-wizard', [ return view('livewire.receptionist.collect-payment-wizard', [
'searchResults' => $searchResults, 'searchResults' => $searchResults,
'invoiceSearchResults' => $invoiceSearchResults,
'invoices' => $invoices, 'invoices' => $invoices,
'upcomingPayments' => $upcomingPayments, 'upcomingPayments' => $upcomingPayments,
'selectedInvoice' => $selectedInvoice, 'selectedInvoice' => $selectedInvoice,
......
...@@ -251,8 +251,36 @@ public function goToStep(int $step): void ...@@ -251,8 +251,36 @@ public function goToStep(int $step): void
public function confirm(): 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(); $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 { try {
DB::transaction(function () use ($actor) { DB::transaction(function () use ($actor) {
$personService = app(PersonService::class); $personService = app(PersonService::class);
...@@ -314,6 +342,7 @@ public function confirm(): void ...@@ -314,6 +342,7 @@ public function confirm(): void
]); ]);
// 6. Enroll in program with retroactive start date // 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); $program = TrainingProgram::findOrFail($this->selected_program_id);
$enrollment = $enrollmentService->enrollInProgram( $enrollment = $enrollmentService->enrollInProgram(
$participant, $participant,
...@@ -322,6 +351,7 @@ public function confirm(): void ...@@ -322,6 +351,7 @@ public function confirm(): void
[ [
'start_date' => $this->actual_start_date, 'start_date' => $this->actual_start_date,
'payment_status' => 'pending', 'payment_status' => 'pending',
'skip_auto_invoice' => true,
] ]
); );
...@@ -352,6 +382,14 @@ public function confirm(): void ...@@ -352,6 +382,14 @@ public function confirm(): void
$paymentsRecorded = 0; $paymentsRecorded = 0;
$totalRetroactiveRevenue = 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) { foreach ($this->monthStatuses as $monthKey => $status) {
$monthDate = Carbon::parse($monthKey . '-01'); $monthDate = Carbon::parse($monthKey . '-01');
...@@ -380,28 +418,35 @@ public function confirm(): void ...@@ -380,28 +418,35 @@ public function confirm(): void
], ],
], $actor); ], $actor);
$invoice->update([ $invoice->update(['status' => InvoiceStatus::Sent]);
'status' => InvoiceStatus::Paid,
'paid_amount' => $perMonth, $paymentService->recordPayment([
'due_amount' => 0, 'invoice_id' => $invoice->id,
'paid_at' => $monthDate->copy()->addDays(1), 'branch_id' => $this->branchId,
'metadata' => array_merge($invoice->metadata ?? [], [ 'amount' => $perMonth,
'paid_outside_system' => true, 'method' => 'cash',
'recorded_by' => $actor->name, 'direction' => 'inbound',
'recorded_at' => now()->toIso8601String(), 'currency' => 'EGP',
]), 'payment_date' => $monthDate->copy()->addDays(1)->toDateString(),
]); 'notes' => 'مدفوع خارج السيستم — ' . $this->getArabicMonth($monthDate),
], $actor);
$totalRetroactiveRevenue += $perMonth; $totalRetroactiveRevenue += $perMonth;
$invoicesCreated++; $invoicesCreated++;
$paymentsRecorded++;
} elseif ($status === 'unpaid') { } elseif ($status === 'unpaid') {
$unpaidIndex++;
$thisMonthAmount = $perMonth;
if ($unpaidIndex === $unpaidTotal && $remainder > 0) {
$thisMonthAmount += $remainder;
}
$invoice = $invoiceService->create([ $invoice = $invoiceService->create([
'academy_id' => $participant->academy_id, 'academy_id' => $participant->academy_id,
'branch_id' => $this->branchId, 'branch_id' => $this->branchId,
'billable_type' => $participant->getMorphClass(), 'billable_type' => $participant->getMorphClass(),
'billable_id' => $participant->id, 'billable_id' => $participant->id,
'total_amount' => $perMonth, 'total_amount' => $thisMonthAmount,
'subtotal_amount' => $perMonth, 'subtotal_amount' => $thisMonthAmount,
'discount_amount' => 0, 'discount_amount' => 0,
'tax_amount' => 0, 'tax_amount' => 0,
'service_fee_amount' => 0, 'service_fee_amount' => 0,
...@@ -413,21 +458,21 @@ public function confirm(): void ...@@ -413,21 +458,21 @@ public function confirm(): void
[ [
'description' => 'اشتراك: ' . $program->name_ar . ' (' . $this->getArabicMonth($monthDate) . ')', 'description' => 'اشتراك: ' . $program->name_ar . ' (' . $this->getArabicMonth($monthDate) . ')',
'quantity' => 1, 'quantity' => 1,
'unit_price' => $perMonth, 'unit_price' => $thisMonthAmount,
'discount_amount' => 0, 'discount_amount' => 0,
'tax_amount' => 0, 'tax_amount' => 0,
], ],
], $actor); ], $actor);
$invoice->update(['status' => InvoiceStatus::Sent]); $invoice->update(['status' => InvoiceStatus::Sent]);
$totalRetroactiveRevenue += $perMonth; $totalRetroactiveRevenue += $thisMonthAmount;
$invoicesCreated++; $invoicesCreated++;
if ($this->pay_now && $perMonth > 0) { if ($this->pay_now && $thisMonthAmount > 0) {
$paymentService->recordPayment([ $paymentService->recordPayment([
'invoice_id' => $invoice->id, 'invoice_id' => $invoice->id,
'branch_id' => $this->branchId, 'branch_id' => $this->branchId,
'amount' => $perMonth, 'amount' => $thisMonthAmount,
'method' => $this->payment_method, 'method' => $this->payment_method,
'direction' => 'inbound', 'direction' => 'inbound',
'currency' => 'EGP', '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]);
}
};
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