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;
......
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