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,26 +441,46 @@ public function confirm(PaymentService $service): void
public function render()
{
$searchResults = collect();
$invoiceSearchResults = collect();
if (strlen($this->search) >= 2) {
$searchResults = Participant::query()
->with('person')
->where('branch_id', $this->branchId)
->where('status', 'active')
->where(function ($q) {
$search = $this->search;
$q->where('participant_number', 'ilike', "%{$search}%")
->orWhereHas('person', function ($pq) use ($search) {
$pq->where('name_ar', 'ilike', "%{$search}%")
->orWhere('name', 'ilike', "%{$search}%")
->orWhere('phone', 'like', "%{$search}%")
->orWhere('national_id', 'like', "%{$search}%");
});
})
->limit(10)
->get();
if ($this->searchMode === 'participant') {
$searchResults = Participant::query()
->with('person')
->whereIn('status', ['active', 'registered', 'frozen', 'inactive'])
->where(function ($q) {
$search = $this->search;
$q->where('participant_number', 'ilike', "%{$search}%")
->orWhereHas('person', function ($pq) use ($search) {
$pq->where('name_ar', 'ilike', "%{$search}%")
->orWhere('name', 'ilike', "%{$search}%")
->orWhere('phone', 'like', "%{$search}%")
->orWhere('national_id', 'like', "%{$search}%");
});
})
->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]);
}
};
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