From 231f5679ef05297b4c55b173a3edbdf7e28a8ef8 Mon Sep 17 00:00:00 2001 From: Nyan Lin Paing <117423022+LinPaing21@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:46:26 +0700 Subject: [PATCH] Add refund action to booking list/detail with full-refund toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New RefundBookingTableAction on the booking list row and detail page, refunding a Confirmed booking directly via RefundBookingAction — no need to hunt up its Payment on the Refunds resource first. - Payment::refundableBalance() extracted from RefundBookingAction's private balance check so both refund forms can display and cap against it. - RefundBookingAction::resolveRefundablePayment() made public for the same reason (round-trip leg resolution reused by the UI). - Both refund forms (ProcessRefundAction and the new booking action) gain a "Full refund" toggle, on by default, which refunds the payment's whole remaining balance without requiring a manually typed amount. Turning it off reveals an amount field capped at the refundable balance. --- .../Resources/Bookings/Pages/ViewBooking.php | 2 + .../Bookings/Tables/BookingsTable.php | 2 + .../tests/Feature/BookingResourceTest.php | 131 ++++++++++++++++++ .../src/Actions/RefundBookingAction.php | 30 ++-- .../Actions/RefundBookingTableAction.php | 77 ++++++++++ .../Refunds/Actions/ProcessRefundAction.php | 18 ++- app-modules/payment/src/Models/Payment.php | 14 ++ .../tests/Feature/RefundResourceTest.php | 81 ++++++++++- .../Unit/PaymentRefundableBalanceTest.php | 28 ++++ 9 files changed, 368 insertions(+), 15 deletions(-) create mode 100644 app-modules/payment/src/Filament/Actions/RefundBookingTableAction.php create mode 100644 app-modules/payment/tests/Unit/PaymentRefundableBalanceTest.php diff --git a/app-modules/booking/src/Filament/Resources/Bookings/Pages/ViewBooking.php b/app-modules/booking/src/Filament/Resources/Bookings/Pages/ViewBooking.php index ad496a5..925d504 100644 --- a/app-modules/booking/src/Filament/Resources/Bookings/Pages/ViewBooking.php +++ b/app-modules/booking/src/Filament/Resources/Bookings/Pages/ViewBooking.php @@ -7,6 +7,7 @@ use Modules\Booking\Filament\Resources\Bookings\Actions\AssignDriverTableAction; use Modules\Booking\Filament\Resources\Bookings\Actions\CancelBookingTableAction; use Modules\Booking\Filament\Resources\Bookings\Actions\SetRemarkTableAction; use Modules\Booking\Filament\Resources\Bookings\BookingResource; +use Modules\Payment\Filament\Actions\RefundBookingTableAction; class ViewBooking extends ViewRecord { @@ -18,6 +19,7 @@ class ViewBooking extends ViewRecord AssignDriverTableAction::make(), SetRemarkTableAction::make(), CancelBookingTableAction::make(), + RefundBookingTableAction::make(), ]; } } diff --git a/app-modules/booking/src/Filament/Resources/Bookings/Tables/BookingsTable.php b/app-modules/booking/src/Filament/Resources/Bookings/Tables/BookingsTable.php index f764d24..a1b3a7a 100644 --- a/app-modules/booking/src/Filament/Resources/Bookings/Tables/BookingsTable.php +++ b/app-modules/booking/src/Filament/Resources/Bookings/Tables/BookingsTable.php @@ -20,6 +20,7 @@ use Modules\Booking\Filament\Resources\Bookings\Actions\RestoreBookingTableActio use Modules\Booking\Filament\Resources\Bookings\Actions\SetRemarkTableAction; use Modules\Booking\Models\Booking; use Modules\Catalog\Models\EvCompany; +use Modules\Payment\Filament\Actions\RefundBookingTableAction; use Modules\Routing\Models\EvRoute; class BookingsTable @@ -148,6 +149,7 @@ class BookingsTable AssignDriverTableAction::make(), SetRemarkTableAction::make(), CancelBookingTableAction::make(), + RefundBookingTableAction::make(), DeleteBookingTableAction::make(), RestoreBookingTableAction::make(), ]); diff --git a/app-modules/booking/tests/Feature/BookingResourceTest.php b/app-modules/booking/tests/Feature/BookingResourceTest.php index 23281fa..83d99a6 100644 --- a/app-modules/booking/tests/Feature/BookingResourceTest.php +++ b/app-modules/booking/tests/Feature/BookingResourceTest.php @@ -7,12 +7,46 @@ use Modules\Booking\Filament\Resources\Bookings\Pages\ListBookings; use Modules\Booking\Filament\Resources\Bookings\Pages\ViewBooking; use Modules\Booking\Models\Booking; use Modules\Booking\Models\BookingVehicleOption; +use Modules\Payment\Contracts\PaymentGatewayInterface; +use Modules\Payment\Data\PaymentRequestData; +use Modules\Payment\Data\PaymentResultData; +use Modules\Payment\Data\RefundResultData; use Modules\Payment\Enums\PaymentMethod; use Modules\Payment\Enums\PaymentStatus; +use Modules\Payment\Enums\RefundStatus; +use Modules\Payment\Factories\PaymentGatewayFactory; use Modules\Payment\Models\Payment; +use Modules\Payment\Models\Refund; use Modules\Shared\Enums\VehicleOption; use Spatie\Permission\Models\Permission; +/** + * Never calls the real KBZ refund API in tests (mirrors RefundResourceTest's + * fake for the Refunds resource's own process action). + */ +class FakeBookingResourceRefundGateway implements PaymentGatewayInterface +{ + public function initiate(PaymentRequestData $data): PaymentResultData + { + throw new RuntimeException('not needed for this test'); + } + + public function verify(string $gatewayTransactionId): PaymentResultData + { + throw new RuntimeException('not needed for this test'); + } + + public function refund(string $gatewayTransactionId, string $amount, string $reason): RefundResultData + { + return new RefundResultData(status: RefundStatus::Completed, gatewayRefundId: 'REFUND123', gatewayPayload: []); + } + + public function handleWebhook(array $payload): PaymentResultData + { + throw new RuntimeException('not needed for this test'); + } +} + beforeEach(function () { foreach (['view_bookings', 'manage_bookings', 'process_refunds'] as $permission) { Permission::findOrCreate($permission, 'web'); @@ -384,3 +418,100 @@ test('the restore action is hidden from a user without manage_bookings', functio ->filterTable('trashed', true) ->assertTableActionHidden('restore', $booking); }); + +test('the refund action is visible and enabled for a confirmed booking with process_refunds', function () { + $booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]); + + Livewire::test(ListBookings::class) + ->assertTableActionVisible('refund', $booking) + ->assertTableActionEnabled('refund', $booking); +}); + +test('the refund action is visible but disabled for a pending_payment booking', function () { + $booking = Booking::factory()->create(['status' => BookingStatus::PendingPayment]); + + Livewire::test(ListBookings::class) + ->assertTableActionVisible('refund', $booking) + ->assertTableActionDisabled('refund', $booking); +}); + +test('the refund action is hidden from a user without process_refunds', function () { + $viewer = User::factory()->create()->givePermissionTo('view_bookings'); + $this->actingAs($viewer); + + $booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]); + + Livewire::test(ListBookings::class) + ->assertTableActionHidden('refund', $booking); +}); + +test('calling the refund action from the bookings list with full refund toggled on refunds the whole balance', function () { + app(PaymentGatewayFactory::class)->register(PaymentMethod::KbzMiniApp, FakeBookingResourceRefundGateway::class); + + $booking = Booking::factory()->create(['status' => BookingStatus::Confirmed, 'price' => 15000]); + $payment = Payment::factory()->completed()->create([ + 'booking_id' => $booking->id, + 'gateway' => PaymentMethod::KbzMiniApp, + 'amount' => 15000, + 'gateway_transaction_id' => 'EVB-BOOKING-REFUND-1', + ]); + + Livewire::test(ListBookings::class) + ->callTableAction('refund', $booking, data: [ + 'full_refund' => true, + 'reason' => 'customer requested cancellation', + ]) + ->assertNotified(); + + expect(Refund::where('payment_id', $payment->id)->where('status', RefundStatus::Completed)->where('amount', 15000)->exists())->toBeTrue(); +}); + +test('calling the refund action with full refund toggled off refunds only the given amount', function () { + app(PaymentGatewayFactory::class)->register(PaymentMethod::KbzMiniApp, FakeBookingResourceRefundGateway::class); + + $booking = Booking::factory()->create(['status' => BookingStatus::Confirmed, 'price' => 15000]); + $payment = Payment::factory()->completed()->create([ + 'booking_id' => $booking->id, + 'gateway' => PaymentMethod::KbzMiniApp, + 'amount' => 15000, + 'gateway_transaction_id' => 'EVB-BOOKING-PARTIAL-1', + ]); + + Livewire::test(ListBookings::class) + ->callTableAction('refund', $booking, data: [ + 'full_refund' => false, + 'amount' => 5000, + 'reason' => 'customer requested cancellation', + ]) + ->assertNotified(); + + expect(Refund::where('payment_id', $payment->id)->where('status', RefundStatus::Completed)->where('amount', 5000)->exists())->toBeTrue(); +}); + +test('the refund action\'s amount field is capped at the booking\'s payment\'s refundable balance', function () { + $booking = Booking::factory()->create(['status' => BookingStatus::Confirmed, 'price' => 15000]); + Payment::factory()->completed()->create([ + 'booking_id' => $booking->id, + 'gateway' => PaymentMethod::KbzMiniApp, + 'amount' => 15000, + 'gateway_transaction_id' => 'EVB-BOOKING-MAX-1', + ]); + + Livewire::test(ListBookings::class) + ->callTableAction('refund', $booking, data: [ + 'full_refund' => false, + 'amount' => 15000.01, + 'reason' => 'reason', + ]) + ->assertHasTableActionErrors(['amount' => 'max']); + + expect(Refund::where('booking_id', $booking->id)->exists())->toBeFalse(); +}); + +test('the detail page also has a refund action, shared with the table', function () { + $confirmed = Booking::factory()->create(['status' => BookingStatus::Confirmed]); + + Livewire::test(ViewBooking::class, ['record' => $confirmed->getRouteKey()]) + ->assertActionVisible('refund') + ->assertActionEnabled('refund'); +}); diff --git a/app-modules/payment/src/Actions/RefundBookingAction.php b/app-modules/payment/src/Actions/RefundBookingAction.php index a738e92..4ab3ed8 100644 --- a/app-modules/payment/src/Actions/RefundBookingAction.php +++ b/app-modules/payment/src/Actions/RefundBookingAction.php @@ -35,14 +35,7 @@ class RefundBookingAction throw RefundNotAllowedException::notConfirmed($booking); } - // Round trip: payment is combined on the outbound leg, so a return - // leg has no Payment of its own — refund against its linked leg's - // Payment instead (domain.md §2b). The Confirmed check above still - // applies to $booking itself, not the payment holder, so each leg - // remains independently cancellable/refundable. - $paymentBooking = $booking->is_return_leg ? ($booking->linkedBooking ?? $booking) : $booking; - - $payment = $paymentBooking->payments()->where('status', PaymentStatus::Completed->value)->latest()->first(); + $payment = $this->resolveRefundablePayment($booking); if ($payment === null) { throw RefundNotAllowedException::noCompletedPayment($booking); @@ -80,10 +73,27 @@ class RefundBookingAction return $refund; } + /** + * The Completed Payment a refund against $booking would apply to. + * Public so the Filament refund forms can look up the same Payment to + * surface its refundable balance before staff submit an amount. + * + * Round trip: payment is combined on the outbound leg, so a return leg + * has no Payment of its own — resolve against its linked leg's Payment + * instead (domain.md §2b). The Confirmed check in handle() still applies + * to $booking itself, not the payment holder, so each leg remains + * independently cancellable/refundable. + */ + public function resolveRefundablePayment(Booking $booking): ?Payment + { + $paymentBooking = $booking->is_return_leg ? ($booking->linkedBooking ?? $booking) : $booking; + + return $paymentBooking->payments()->where('status', PaymentStatus::Completed->value)->latest()->first(); + } + private function assertWithinRefundableBalance(Payment $payment, string $amount): void { - $alreadyRefunded = (string) $payment->refunds()->where('status', RefundStatus::Completed->value)->sum('amount'); - $remaining = bcsub((string) $payment->amount, $alreadyRefunded, 2); + $remaining = $payment->refundableBalance(); if (bccomp($amount, $remaining, 2) === 1) { throw RefundNotAllowedException::exceedsRefundableBalance($payment, $amount, $remaining); diff --git a/app-modules/payment/src/Filament/Actions/RefundBookingTableAction.php b/app-modules/payment/src/Filament/Actions/RefundBookingTableAction.php new file mode 100644 index 0000000..6f2d34b --- /dev/null +++ b/app-modules/payment/src/Filament/Actions/RefundBookingTableAction.php @@ -0,0 +1,77 @@ +label('Refund') + ->icon(Heroicon::OutlinedReceiptRefund) + ->color('danger') + ->visible(fn (): bool => auth()->user()?->can('process_refunds') ?? false) + ->disabled(fn (Booking $record): bool => $record->status !== BookingStatus::Confirmed) + ->schema([ + Toggle::make('full_refund') + ->label('Full refund') + ->live() + ->default(true) + ->helperText(fn (Booking $record): string => 'Refundable balance: '.(app(RefundBookingAction::class) + ->resolveRefundablePayment($record)?->refundableBalance() ?? '0.00')), + TextInput::make('amount') + ->numeric() + ->minValue(0.01) + ->visible(fn (Get $get): bool => ! $get('full_refund')) + ->required(fn (Get $get): bool => ! $get('full_refund')) + ->maxValue(fn (Booking $record): ?string => app(RefundBookingAction::class) + ->resolveRefundablePayment($record)?->refundableBalance()), + Textarea::make('reason') + ->required(), + ]) + ->action(function (Booking $record, array $data): void { + $amount = $data['full_refund'] + ? app(RefundBookingAction::class)->resolveRefundablePayment($record)?->refundableBalance() ?? '0.00' + : (string) $data['amount']; + + try { + app(RefundBookingAction::class)->handle( + $record, + $amount, + $data['reason'], + auth()->id(), + ); + + Notification::make() + ->title('Refund processed') + ->success() + ->send(); + } catch (RefundNotAllowedException|RefundFailedException $exception) { + Notification::make() + ->title('Refund failed') + ->body($exception->getMessage()) + ->danger() + ->send(); + } + }); + } +} diff --git a/app-modules/payment/src/Filament/Resources/Refunds/Actions/ProcessRefundAction.php b/app-modules/payment/src/Filament/Resources/Refunds/Actions/ProcessRefundAction.php index 56950bc..787a914 100644 --- a/app-modules/payment/src/Filament/Resources/Refunds/Actions/ProcessRefundAction.php +++ b/app-modules/payment/src/Filament/Resources/Refunds/Actions/ProcessRefundAction.php @@ -6,7 +6,9 @@ use Filament\Actions\Action; use Filament\Forms\Components\Select; use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\Toggle; use Filament\Notifications\Notification; +use Filament\Schemas\Components\Utilities\Get; use Filament\Support\Icons\Heroicon; use Modules\Payment\Actions\RefundBookingAction; use Modules\Payment\Enums\PaymentStatus; @@ -43,11 +45,21 @@ class ProcessRefundAction $payment->id => "{$payment->booking?->booking_ref} — {$payment->amount} {$payment->currency} (#{$payment->id})", ])) ->searchable() + ->live() ->required(), + Toggle::make('full_refund') + ->label('Full refund') + ->live() + ->default(true) + ->helperText(fn (Get $get): string => $get('payment_id') + ? 'Refundable balance: '.(Payment::find($get('payment_id'))?->refundableBalance() ?? '0.00') + : 'Select a payment to see its refundable balance.'), TextInput::make('amount') ->numeric() ->minValue(0.01) - ->required(), + ->visible(fn (Get $get): bool => ! $get('full_refund')) + ->required(fn (Get $get): bool => ! $get('full_refund')) + ->maxValue(fn (Get $get): ?string => Payment::find($get('payment_id'))?->refundableBalance()), Textarea::make('reason') ->required(), ]) @@ -68,10 +80,12 @@ class ProcessRefundAction return; } + $amount = $data['full_refund'] ? $payment->refundableBalance() : (string) $data['amount']; + try { app(RefundBookingAction::class)->handle( $payment->booking, - (string) $data['amount'], + $amount, $data['reason'], auth()->id(), ); diff --git a/app-modules/payment/src/Models/Payment.php b/app-modules/payment/src/Models/Payment.php index 1a4a025..e0f47f1 100644 --- a/app-modules/payment/src/Models/Payment.php +++ b/app-modules/payment/src/Models/Payment.php @@ -10,6 +10,7 @@ use Modules\Booking\Models\Booking; use Modules\Payment\Database\Factories\PaymentFactory; use Modules\Payment\Enums\PaymentMethod; use Modules\Payment\Enums\PaymentStatus; +use Modules\Payment\Enums\RefundStatus; use Spatie\Activitylog\Models\Concerns\LogsActivity; use Spatie\Activitylog\Support\LogOptions; @@ -74,4 +75,17 @@ class Payment extends Model { return $this->hasMany(Refund::class); } + + /** + * What's left to refund on this Payment — its total minus whatever has + * already been completed-refunded (partial refunds supported, domain.md + * §6). Shared by RefundBookingAction's own guard and the Filament refund + * forms, which surface it to staff before they submit. + */ + public function refundableBalance(): string + { + $alreadyRefunded = (string) $this->refunds()->where('status', RefundStatus::Completed->value)->sum('amount'); + + return bcsub((string) $this->amount, $alreadyRefunded, 2); + } } diff --git a/app-modules/payment/tests/Feature/RefundResourceTest.php b/app-modules/payment/tests/Feature/RefundResourceTest.php index 645b09e..b724132 100644 --- a/app-modules/payment/tests/Feature/RefundResourceTest.php +++ b/app-modules/payment/tests/Feature/RefundResourceTest.php @@ -77,7 +77,7 @@ test('the process action is visible to a user with process_refunds', function () ->assertActionVisible('process'); }); -test('processing a refund via the action calls RefundBookingAction and cancels the booking', function () { +test('processing a partial refund via the action calls RefundBookingAction and cancels the booking', function () { $admin = User::factory()->create()->givePermissionTo(['view_payments', 'process_refunds']); $this->actingAs($admin); @@ -92,13 +92,64 @@ test('processing a refund via the action calls RefundBookingAction and cancels t Livewire::test(ListRefunds::class) ->callAction('process', data: [ 'payment_id' => $payment->id, - 'amount' => 15000, + 'full_refund' => false, + 'amount' => 5000, 'reason' => 'customer requested cancellation', ]) ->assertNotified(); expect($booking->refresh()->status)->toBe(BookingStatus::Cancelled) - ->and(Refund::where('payment_id', $payment->id)->where('status', RefundStatus::Completed)->exists())->toBeTrue(); + ->and(Refund::where('payment_id', $payment->id)->where('status', RefundStatus::Completed)->where('amount', 5000)->exists())->toBeTrue(); +}); + +test('the full refund toggle refunds the payment\'s whole refundable balance without an amount input', function () { + $admin = User::factory()->create()->givePermissionTo(['view_payments', 'process_refunds']); + $this->actingAs($admin); + + $booking = Booking::factory()->create(['status' => BookingStatus::Confirmed, 'price' => 15000]); + $payment = Payment::factory()->completed()->create([ + 'booking_id' => $booking->id, + 'gateway' => PaymentMethod::KbzMiniApp, + 'amount' => 15000, + 'gateway_transaction_id' => 'EVB-FILAMENT-FULL-1', + ]); + + Livewire::test(ListRefunds::class) + ->callAction('process', data: [ + 'payment_id' => $payment->id, + 'full_refund' => true, + 'reason' => 'customer requested cancellation', + ]) + ->assertNotified(); + + expect(Refund::where('payment_id', $payment->id)->where('status', RefundStatus::Completed)->where('amount', 15000)->exists())->toBeTrue(); +}); + +test('the full refund toggle defaults to on', function () { + $admin = User::factory()->create()->givePermissionTo(['view_payments', 'process_refunds']); + $this->actingAs($admin); + + Livewire::test(ListRefunds::class) + ->mountAction('process') + ->assertActionDataSet(['full_refund' => true]); +}); + +test('turning the full refund toggle off requires an amount', function () { + $admin = User::factory()->create()->givePermissionTo(['view_payments', 'process_refunds']); + $this->actingAs($admin); + + $payment = Payment::factory()->completed()->create([ + 'gateway' => PaymentMethod::KbzMiniApp, + 'amount' => 15000, + ]); + + Livewire::test(ListRefunds::class) + ->callAction('process', data: [ + 'payment_id' => $payment->id, + 'full_refund' => false, + 'reason' => 'reason', + ]) + ->assertHasFormErrors(['amount' => 'required']); }); test('a non-completed payment is not offered in the process action\'s payment select', function () { @@ -151,3 +202,27 @@ test('a payment whose booking has been soft-deleted is not offered in the proces expect(Refund::where('payment_id', $payment->id)->exists())->toBeFalse(); }); + +test('the process action\'s amount field is capped at the selected payment\'s refundable balance', function () { + $admin = User::factory()->create()->givePermissionTo(['view_payments', 'process_refunds']); + $this->actingAs($admin); + + $booking = Booking::factory()->create(['status' => BookingStatus::Confirmed, 'price' => 15000]); + $payment = Payment::factory()->completed()->create([ + 'booking_id' => $booking->id, + 'gateway' => PaymentMethod::KbzMiniApp, + 'amount' => 15000, + 'gateway_transaction_id' => 'EVB-FILAMENT-MAX-1', + ]); + + Livewire::test(ListRefunds::class) + ->callAction('process', data: [ + 'payment_id' => $payment->id, + 'full_refund' => false, + 'amount' => 15000.01, + 'reason' => 'reason', + ]) + ->assertHasFormErrors(['amount' => 'max']); + + expect(Refund::where('payment_id', $payment->id)->exists())->toBeFalse(); +}); diff --git a/app-modules/payment/tests/Unit/PaymentRefundableBalanceTest.php b/app-modules/payment/tests/Unit/PaymentRefundableBalanceTest.php new file mode 100644 index 0000000..bd4372e --- /dev/null +++ b/app-modules/payment/tests/Unit/PaymentRefundableBalanceTest.php @@ -0,0 +1,28 @@ +completed()->create(['amount' => 15000]); + + expect($payment->refundableBalance())->toBe('15000.00'); +}); + +test('refundable balance subtracts only completed refunds', function () { + $payment = Payment::factory()->completed()->create(['amount' => 15000]); + + Refund::factory()->completed()->for($payment)->create(['amount' => 5000]); + Refund::factory()->failed()->for($payment)->create(['amount' => 3000]); + Refund::factory()->for($payment)->create(['amount' => 2000]); // default state is Pending + + expect($payment->refundableBalance())->toBe('10000.00'); +}); + +test('refundable balance reaches zero once fully refunded', function () { + $payment = Payment::factory()->completed()->create(['amount' => 15000]); + + Refund::factory()->completed()->for($payment)->create(['amount' => 15000]); + + expect($payment->refundableBalance())->toBe('0.00'); +});