From 47be71c2cf654254fcfa63400488cf5a66bcc4b9 Mon Sep 17 00:00:00 2001 From: Nyan Lin Paing <117423022+LinPaing21@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:43:02 +0700 Subject: [PATCH] Include refund/cancel info in booking API responses Adds Booking::refunds() (hasMany Refund, scoped to this leg per Refund::booking()) and eager-loads it alongside the other booking relations, so index/show/store/cancel all expose a refunds array (status, amount, reason, requested_at, completed_at). A booking that was cancelled before any payment completed still returns refunds: []. --- .../Http/Controllers/BookingController.php | 2 +- .../src/Http/Resources/BookingResource.php | 13 ++++++++ app-modules/booking/src/Models/Booking.php | 11 +++++++ .../tests/Feature/BookingCancelApiTest.php | 33 +++++++++++++++++++ 4 files changed, 58 insertions(+), 1 deletion(-) diff --git a/app-modules/booking/src/Http/Controllers/BookingController.php b/app-modules/booking/src/Http/Controllers/BookingController.php index 567b72a..50488b4 100644 --- a/app-modules/booking/src/Http/Controllers/BookingController.php +++ b/app-modules/booking/src/Http/Controllers/BookingController.php @@ -24,7 +24,7 @@ class BookingController extends Controller * @var list */ private const EAGER_LOADS = [ - 'route', 'timeSlot', 'vehicleOptions', + 'route', 'timeSlot', 'vehicleOptions', 'refunds', 'linkedBooking.route.company', 'linkedBooking.route.fromDestination', 'linkedBooking.route.toDestination', 'linkedBooking.timeSlot', 'linkedBooking.vehicleOptions', ]; diff --git a/app-modules/booking/src/Http/Resources/BookingResource.php b/app-modules/booking/src/Http/Resources/BookingResource.php index a0b1194..990e23f 100644 --- a/app-modules/booking/src/Http/Resources/BookingResource.php +++ b/app-modules/booking/src/Http/Resources/BookingResource.php @@ -63,6 +63,19 @@ class BookingResource extends JsonResource 'label' => $this->timeSlot->label, 'time' => $this->timeSlot->time?->format('H:i'), ]), + // This leg's own refund attempts (cancellation, or any partial + // refund) — not payment-level detail, just what a customer/agent + // needs to see: status, amount, and when it was requested/settled. + // Cancelling a pending_payment booking never creates one (no + // money moved yet — CancelBookingAction), so this stays empty. + 'refunds' => $this->whenLoaded('refunds', fn () => $this->refunds->map(fn ($refund) => [ + 'id' => $refund->id, + 'status' => $refund->status, + 'amount' => $refund->amount, + 'reason' => $refund->reason, + 'requested_at' => $refund->requested_at, + 'completed_at' => $refund->completed_at, + ])), // Hand-built, not a nested BookingResource — the linked leg's // own linked_booking points right back here, so nesting the // full resource would recurse forever (domain.md §2b). diff --git a/app-modules/booking/src/Models/Booking.php b/app-modules/booking/src/Models/Booking.php index 610a9a6..196c955 100644 --- a/app-modules/booking/src/Models/Booking.php +++ b/app-modules/booking/src/Models/Booking.php @@ -14,6 +14,7 @@ use Modules\Booking\Enums\BookingChannel; use Modules\Booking\Enums\BookingStatus; use Modules\Catalog\Models\DepartureTimeSlot; use Modules\Payment\Models\Payment; +use Modules\Payment\Models\Refund; use Modules\Routing\Models\EvRoute; use Spatie\Activitylog\Models\Concerns\LogsActivity; use Spatie\Activitylog\Support\LogOptions; @@ -120,6 +121,16 @@ class Booking extends Model return $this->hasMany(Payment::class); } + /** + * Refunds against *this* leg specifically — Refund::booking() points at + * the leg actually being refunded/cancelled, not necessarily the leg + * that owns the shared Payment (domain.md §2b). + */ + public function refunds(): HasMany + { + return $this->hasMany(Refund::class); + } + /** * True when this booking has a linked leg — i.e. it's one half of a * round trip. Computed, not stored: presence of `linked_booking_id` is diff --git a/app-modules/booking/tests/Feature/BookingCancelApiTest.php b/app-modules/booking/tests/Feature/BookingCancelApiTest.php index 9a3985f..e916bab 100644 --- a/app-modules/booking/tests/Feature/BookingCancelApiTest.php +++ b/app-modules/booking/tests/Feature/BookingCancelApiTest.php @@ -91,6 +91,39 @@ test('staff with process_refunds can cancel a confirmed booking, which refunds i expect($booking->refresh()->status)->toBe(BookingStatus::Cancelled); }); +test('cancelling a confirmed booking returns the refund it created', function () { + $staff = User::factory()->create()->givePermissionTo('process_refunds'); + $staffToken = $staff->createToken('staff-token')->plainTextToken; + + $booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::Confirmed, 'price' => 15000]); + Payment::factory()->completed()->create([ + 'booking_id' => $booking->id, + 'gateway' => PaymentMethod::KbzMiniApp, + 'amount' => 15000, + 'gateway_transaction_id' => 'EVB-CANCEL-API-2', + ]); + + $response = $this->withHeader('Authorization', "Bearer {$staffToken}") + ->postJson("/api/v1/bookings/{$booking->booking_ref}/cancel") + ->assertSuccessful() + ->assertJsonPath('data.status', BookingStatus::Cancelled->value) + ->assertJsonCount(1, 'data.refunds') + ->assertJsonPath('data.refunds.0.status', RefundStatus::Completed->value) + ->assertJsonPath('data.refunds.0.amount', '15000.00') + ->assertJsonPath('data.refunds.0.reason', 'Booking cancellation'); + + expect($response->json('data.refunds.0.completed_at'))->not->toBeNull(); +}); + +test('cancelling a pending_payment booking returns an empty refunds list — no money moved yet', function () { + $booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::PendingPayment]); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->postJson("/api/v1/bookings/{$booking->booking_ref}/cancel") + ->assertSuccessful() + ->assertJsonPath('data.refunds', []); +}); + test('cancelling a confirmed booking with no completed payment surfaces as 422 and leaves it untouched', function () { $staff = User::factory()->create()->givePermissionTo('process_refunds'); $staffToken = $staff->createToken('staff-token')->plainTextToken;