Commit 6472b1f0 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Add payment refund flow with automatic enrollment cancellation

Super admin and financial admin can select up to 2 confirmed payments from
a participant's profile and refund them in a single atomic transaction.
If a refunded payment is linked to a program enrollment, the enrollment is
automatically cancelled and the group seat is freed.

Changes:
- RefundService: creates outbound refund record, marks original as refunded,
  reverses invoice paid_amount — all within the caller's DB::transaction
- ParticipantShow: checkbox selection on confirmed inbound payments, preview
  modal showing impact (including enrollment cancellation warning), reason
  field, atomic commit via DB::transaction wrapping both domains
Co-Authored-By: 's avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent e9da2cab
<?php
namespace App\Domain\Financial\Services;
use App\Domain\Financial\Enums\PaymentStatus;
use App\Domain\Financial\Models\Payment;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Training\Models\Enrollment;
use App\Models\User;
class RefundService
{
public function __construct(
private readonly InvoiceService $invoiceService,
) {}
/**
* Preview the impact of refunding the given payment UUIDs.
* Returns plain arrays (safe for Livewire state storage).
*/
public function preview(array $paymentUuids): array
{
$results = [];
foreach ($paymentUuids as $uuid) {
$payment = Payment::where('uuid', $uuid)->with('invoice')->first();
if (! $payment) {
continue;
}
$enrollmentName = null;
if ($payment->invoice_id) {
$enrollment = Enrollment::where('invoice_id', $payment->invoice_id)
->whereIn('status', ['active', 'pending'])
->with('program')
->first();
if ($enrollment) {
$enrollmentName = $enrollment->program?->name_ar
?? $enrollment->program?->name
?? 'برنامج';
}
}
$results[] = [
'uuid' => $payment->uuid,
'reference' => $payment->reference,
'amount' => $payment->amount,
'method_label' => $payment->method->label(),
'enrollment_name' => $enrollmentName,
];
}
return $results;
}
/**
* Execute the financial side of a refund for each payment UUID.
* MUST be called inside a DB::transaction() by the caller.
* Returns [['refund' => Payment, 'enrollment' => Enrollment|null, 'original' => Payment], ...]
* so the caller can cancel enrollments in the same transaction.
*/
public function processRefunds(array $paymentUuids, string $reason, User $actor): array
{
if (count($paymentUuids) > 2) {
throw new DomainException('يمكن استرداد دفعتين كحد أقصى في عملية واحدة');
}
if (empty($paymentUuids)) {
throw new DomainException('لم يتم تحديد أي دفعات للاسترداد');
}
$results = [];
foreach ($paymentUuids as $uuid) {
$payment = Payment::where('uuid', $uuid)
->lockForUpdate()
->first();
if (! $payment) {
throw new DomainException("الدفعة غير موجودة");
}
if ($payment->status !== PaymentStatus::Confirmed) {
throw new DomainException("الدفعة {$payment->reference} غير مؤكدة ولا يمكن استردادها");
}
if ($payment->direction !== 'inbound') {
throw new DomainException("لا يمكن استرداد دفعة صادرة");
}
// Find linked enrollment BEFORE mutations (for caller to cancel)
$enrollment = null;
if ($payment->invoice_id) {
$enrollment = Enrollment::where('invoice_id', $payment->invoice_id)
->whereIn('status', ['active', 'pending'])
->with(['program', 'group'])
->first();
}
// Create outbound refund payment record
$refundPayment = Payment::create([
'academy_id' => $payment->academy_id,
'branch_id' => $payment->branch_id,
'invoice_id' => $payment->invoice_id,
'reference' => 'REF-' . now()->format('YmdHis') . '-' . $payment->id,
'direction' => 'outbound',
'method' => $payment->method,
'status' => PaymentStatus::Confirmed,
'payer_type' => $payment->payer_type,
'payer_id' => $payment->payer_id,
'amount' => $payment->amount,
'currency' => $payment->currency ?? 'EGP',
'payment_date' => now()->toDateString(),
'confirmed_at' => now(),
'notes' => "استرداد للدفعة {$payment->reference}" . ($reason ? " — {$reason}" : ''),
'created_by' => $actor->id,
]);
// Mark original payment as refunded (this was missing from PaymentService::refund())
$payment->update(['status' => PaymentStatus::Refunded]);
// Reverse the invoice balance
if ($payment->invoice_id) {
$this->invoiceService->updatePaidAmount(
$payment->invoice->fresh(),
-$payment->amount,
$actor
);
}
$results[] = [
'refund' => $refundPayment,
'enrollment' => $enrollment,
'original' => $payment,
];
}
return $results;
}
}
......@@ -6,10 +6,13 @@
use App\Domain\Attendance\Models\AttendanceRecord;
use App\Domain\Financial\Models\Payment;
use App\Domain\Financial\Models\Wallet;
use App\Domain\Financial\Services\RefundService;
use App\Domain\Participant\Models\Participant;
use App\Domain\Participant\Services\ParticipantService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Training\Services\EnrollmentService;
use App\Domain\Training\Models\Enrollment;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
......@@ -26,6 +29,12 @@ class ParticipantShow extends Component
public string $newStatus = '';
public string $statusReason = '';
// Refund flow
public array $selectedPaymentUuids = [];
public bool $showRefundModal = false;
public string $refundReason = '';
public array $refundPreview = []; // from RefundService::preview()
public function mount(Participant $participant): void
{
$this->participant = $participant->load(['person', 'primaryActivity', 'primaryGuardian', 'guardians', 'creator']);
......@@ -66,6 +75,88 @@ public function changeStatus(ParticipantService $service): void
}
}
public function togglePaymentSelection(string $uuid): void
{
if (in_array($uuid, $this->selectedPaymentUuids)) {
$this->selectedPaymentUuids = array_values(
array_filter($this->selectedPaymentUuids, fn ($u) => $u !== $uuid)
);
} else {
if (count($this->selectedPaymentUuids) >= 2) {
$this->addError('refund', 'يمكن اختيار دفعتين كحد أقصى');
return;
}
$this->selectedPaymentUuids[] = $uuid;
}
$this->resetErrorBag('refund');
}
public function openRefundModal(RefundService $service): void
{
abort_unless(auth()->user()->is_super_admin || auth()->user()->hasPermission('payments.refund'), 403);
if (empty($this->selectedPaymentUuids)) {
$this->addError('refund', 'اختر دفعة واحدة على الأقل للاسترداد');
return;
}
$this->refundPreview = $service->preview($this->selectedPaymentUuids);
$this->refundReason = '';
$this->showRefundModal = true;
}
public function closeRefundModal(): void
{
$this->showRefundModal = false;
$this->refundPreview = [];
$this->refundReason = '';
}
public function confirmRefund(RefundService $refundService, EnrollmentService $enrollmentService): void
{
abort_unless(auth()->user()->is_super_admin || auth()->user()->hasPermission('payments.refund'), 403);
$this->validate([
'refundReason' => 'required|string|min:3|max:500',
], [
'refundReason.required' => 'سبب الاسترداد مطلوب',
'refundReason.min' => 'السبب يجب أن يكون 3 أحرف على الأقل',
]);
try {
DB::transaction(function () use ($refundService, $enrollmentService) {
$results = $refundService->processRefunds(
$this->selectedPaymentUuids,
$this->refundReason,
auth()->user()
);
foreach ($results as $result) {
if ($result['enrollment'] instanceof Enrollment) {
$enrollmentService->cancel(
$result['enrollment'],
'استرداد: ' . $this->refundReason,
auth()->user()
);
}
}
});
$this->selectedPaymentUuids = [];
$this->showRefundModal = false;
$this->refundPreview = [];
$this->refundReason = '';
session()->flash('success', __('تم الاسترداد بنجاح'));
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
$this->showRefundModal = false;
} catch (\Exception $e) {
session()->flash('error', __('حدث خطأ أثناء الاسترداد، يرجى المحاولة مجدداً'));
$this->showRefundModal = false;
}
}
public function render()
{
$currentStatus = $this->participant->status->value ?? $this->participant->status;
......
......@@ -581,45 +581,89 @@ class="text-blue-600 hover:text-blue-800 hover:underline">
{{-- Payments Tab --}}
<div x-show="activeTab === 'payments'" x-cloak>
@php
$canRefund = auth()->user()?->is_super_admin || auth()->user()?->hasPermission('payments.refund');
$methodColors = ['cash'=>'green','card'=>'blue','bank_transfer'=>'purple','wallet'=>'amber','online'=>'cyan','cheque'=>'gray','other'=>'gray'];
$pStatusColors = ['confirmed'=>'green','pending'=>'amber','failed'=>'red','refunded'=>'purple','partially_refunded'=>'orange'];
$pStatusLabels = ['confirmed'=>'مؤكد','pending'=>'معلق','failed'=>'فشل','refunded'=>'مسترد','partially_refunded'=>'مسترد جزئياً'];
@endphp
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
{{-- Header --}}
<div class="p-4 border-b border-gray-100 flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div class="flex items-center gap-3">
<h3 class="text-base font-semibold text-gray-800">{{ __('سجل المدفوعات') }}</h3>
@if(count($selectedPaymentUuids) > 0)
<span class="px-2 py-0.5 text-xs bg-blue-100 text-blue-700 rounded-full">
{{ count($selectedPaymentUuids) }} {{ __('محددة') }}
</span>
@endif
</div>
<div class="flex items-center gap-2">
@if($wallet)
<div class="flex items-center gap-2 px-3 py-1.5 bg-amber-50 rounded-lg">
<svg class="w-4 h-4 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"/>
</svg>
<svg class="w-4 h-4 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"/></svg>
<span class="text-sm font-medium text-amber-700">{{ __('الرصيد') }}:</span>
<span class="text-sm font-bold text-amber-800" dir="ltr">{{ number_format($wallet->balance / 100, 2) }} {{ __('ج.م') }}</span>
</div>
@endif
@if($canRefund && count($selectedPaymentUuids) > 0)
<button type="button" wire:click="openRefundModal"
wire:loading.attr="disabled" wire:target="openRefundModal"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-red-50 text-red-700 border border-red-200 rounded-lg hover:bg-red-100 disabled:opacity-50 transition">
<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="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/></svg>
{{ __('استرداد المحدد') }} ({{ count($selectedPaymentUuids) }})
</button>
<button type="button" wire:click="$set('selectedPaymentUuids', [])"
class="text-xs text-gray-500 hover:text-gray-700 transition">
{{ __('إلغاء التحديد') }}
</button>
@endif
</div>
</div>
@error('refund')
<div class="mx-4 mt-3 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">{{ $message }}</div>
@enderror
@if($recentPayments->isNotEmpty())
<div class="divide-y divide-gray-100">
@foreach($recentPayments as $payment)
<div class="p-4 hover:bg-gray-50 transition">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
@php
$methodColors = [
'cash' => 'green',
'card' => 'blue',
'bank_transfer' => 'purple',
'wallet' => 'amber',
'online' => 'cyan',
'cheque' => 'gray',
'other' => 'gray',
];
$methodValue = $payment->method->value ?? $payment->method;
$mColor = $methodColors[$methodValue] ?? 'gray';
$statusValue = $payment->status->value ?? $payment->status;
$pColor = $pStatusColors[$statusValue] ?? 'gray';
$isSelected = in_array($payment->uuid, $selectedPaymentUuids);
$isConfirmed = $statusValue === 'confirmed' && $payment->direction === 'inbound';
$isRefunded = in_array($statusValue, ['refunded', 'partially_refunded']);
@endphp
<div class="w-8 h-8 rounded-full bg-{{ $mColor }}-100 flex items-center justify-center">
<div class="p-4 transition {{ $isSelected ? 'bg-red-50 border-s-4 border-red-400' : 'hover:bg-gray-50' }}">
<div class="flex items-center gap-3">
{{-- Checkbox (only for confirmed inbound, not already refunded) --}}
@if($canRefund && $isConfirmed && !$isRefunded)
<button type="button" wire:click="togglePaymentSelection('{{ $payment->uuid }}')"
class="flex-shrink-0 w-5 h-5 rounded border-2 transition
{{ $isSelected ? 'bg-red-500 border-red-500' : 'border-gray-300 hover:border-red-400' }}
flex items-center justify-center">
@if($isSelected)
<svg class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"/></svg>
@endif
</button>
@else
<div class="flex-shrink-0 w-5 h-5"></div>
@endif
{{-- Icon --}}
<div class="w-8 h-8 rounded-full bg-{{ $mColor }}-100 flex items-center justify-center flex-shrink-0">
<svg class="w-4 h-4 text-{{ $mColor }}-600" 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-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<div>
{{-- Details --}}
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-800" dir="ltr">
@if($payment->invoice_id)
<a href="{{ route('invoices.show', $payment->invoice) }}" wire:navigate
......@@ -629,8 +673,11 @@ class="text-blue-600 hover:text-blue-800 hover:underline">
@else
{{ number_format($payment->amount / 100, 2) }} {{ __('ج.م') }}
@endif
@if($payment->direction === 'outbound')
<span class="text-xs text-purple-600 me-1">({{ __('استرداد') }})</span>
@endif
</p>
<p class="text-xs text-gray-500">
<p class="text-xs text-gray-500 truncate">
{{ $payment->method->label() }}
@if($payment->reference)
<span class="text-gray-400 mx-1">|</span>
......@@ -638,15 +685,10 @@ class="text-blue-600 hover:text-blue-800 hover:underline">
@endif
</p>
</div>
</div>
<div class="text-end">
{{-- Right side: date + status --}}
<div class="text-end flex-shrink-0">
<p class="text-xs text-gray-500" dir="ltr">{{ $payment->payment_date?->format('Y-m-d') }}</p>
@php
$statusValue = $payment->status->value ?? $payment->status;
$pStatusColors = ['confirmed' => 'green', 'pending' => 'amber', 'failed' => 'red', 'refunded' => 'purple', 'partially_refunded' => 'orange'];
$pStatusLabels = ['confirmed' => 'مؤكد', 'pending' => 'معلق', 'failed' => 'فشل', 'refunded' => 'مسترد', 'partially_refunded' => 'مسترد جزئياً'];
$pColor = $pStatusColors[$statusValue] ?? 'gray';
@endphp
<span class="px-1.5 py-0.5 text-xs bg-{{ $pColor }}-100 text-{{ $pColor }}-700 rounded">
{{ __($pStatusLabels[$statusValue] ?? $statusValue) }}
</span>
......@@ -664,6 +706,80 @@ class="text-blue-600 hover:text-blue-800 hover:underline">
</div>
@endif
</div>
{{-- Refund Confirmation Modal --}}
@if($showRefundModal)
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
wire:click.self="closeRefundModal">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-md" @click.stop>
{{-- Modal header --}}
<div class="flex items-center justify-between p-5 border-b border-gray-100">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-full bg-red-100 flex items-center justify-center">
<svg class="w-4 h-4 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/></svg>
</div>
<h3 class="text-base font-bold text-gray-800">{{ __('تأكيد الاسترداد') }}</h3>
</div>
<button type="button" wire:click="closeRefundModal"
class="text-gray-400 hover:text-gray-600 transition">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
{{-- Preview list --}}
<div class="p-5 space-y-3">
@foreach($refundPreview as $item)
<div class="rounded-lg border {{ $item['enrollment_name'] ? 'border-orange-200 bg-orange-50' : 'border-gray-200 bg-gray-50' }} p-3">
<div class="flex items-center justify-between mb-1">
<span class="text-sm font-semibold text-gray-700" dir="ltr">
{{ number_format($item['amount'] / 100, 2) }} {{ __('ج.م') }}
</span>
<span class="text-xs text-gray-500 font-mono" dir="ltr">{{ $item['reference'] }}</span>
</div>
<p class="text-xs text-gray-500">{{ $item['method_label'] }}</p>
@if($item['enrollment_name'])
<div class="mt-2 flex items-start gap-1.5">
<svg class="w-3.5 h-3.5 text-orange-500 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
<p class="text-xs text-orange-700 font-medium">
{{ __('سيتم إلغاء تسجيل اللاعب من برنامج') }}: {{ $item['enrollment_name'] }}
</p>
</div>
@endif
</div>
@endforeach
{{-- Reason input --}}
<div class="pt-1">
<label class="block text-sm font-medium text-gray-700 mb-1">
{{ __('سبب الاسترداد') }} <span class="text-red-500">*</span>
</label>
<textarea wire:model="refundReason" rows="2"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-red-400 focus:border-red-400"
placeholder="{{ __('اكتب سبب الاسترداد...') }}"></textarea>
@error('refundReason') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
</div>
</div>
{{-- Actions --}}
<div class="flex items-center gap-3 px-5 pb-5">
<button type="button" wire:click="confirmRefund"
wire:loading.attr="disabled" wire:target="confirmRefund"
class="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 bg-red-600 text-white rounded-lg hover:bg-red-700 active:scale-[0.97] font-medium text-sm disabled:opacity-50 transition-all">
<span wire:loading.remove wire:target="confirmRefund">{{ __('تأكيد الاسترداد') }}</span>
<span wire:loading wire:target="confirmRefund">
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
</span>
<span wire:loading wire:target="confirmRefund">{{ __('جارٍ الاسترداد...') }}</span>
</button>
<button type="button" wire:click="closeRefundModal"
class="px-4 py-2.5 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 font-medium text-sm transition">
{{ __('إلغاء') }}
</button>
</div>
</div>
</div>
@endif
</div>
{{-- Status Tab --}}
......
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