Only expose completed refunds in booking API responses
PHP Tests / php-tests (push) Waiting to run

BookingController's eager load now constrains the refunds relation to
status completed only (a new eagerLoads() layering that onto the
existing EAGER_LOADS list, applied uniformly across
index/show/store/cancel). A pending or failed refund attempt isn't
customer-facing — staff still track those via Filament's Refunds
resource, which loads the relation unconstrained.
This commit is contained in:
Nyan Lin Paing
2026-09-03 21:48:22 +07:00
parent 47be71c2cf
commit 37f9dc1905
3 changed files with 45 additions and 9 deletions
@@ -16,6 +16,7 @@ use Modules\Booking\Http\Requests\StoreBookingRequest;
use Modules\Booking\Http\Resources\BookingResource;
use Modules\Booking\Models\Booking;
use Modules\Payment\Enums\PaymentStatus;
use Modules\Payment\Enums\RefundStatus;
use Modules\Shared\Enums\VehicleOption;
class BookingController extends Controller
@@ -24,7 +25,7 @@ class BookingController extends Controller
* @var list<string>
*/
private const EAGER_LOADS = [
'route', 'timeSlot', 'vehicleOptions', 'refunds',
'route', 'timeSlot', 'vehicleOptions',
'linkedBooking.route.company', 'linkedBooking.route.fromDestination', 'linkedBooking.route.toDestination',
'linkedBooking.timeSlot', 'linkedBooking.vehicleOptions',
];
@@ -34,6 +35,21 @@ class BookingController extends Controller
private CancelBookingAction $cancelBookingAction,
) {}
/**
* Same relations as EAGER_LOADS, plus refunds constrained to completed
* only a pending/failed attempt isn't something a customer/agent needs
* to see here; Filament's Refunds resource is where staff track those.
*
* @return array<int|string, string|\Closure>
*/
private function eagerLoads(): array
{
return [
...self::EAGER_LOADS,
'refunds' => fn ($query) => $query->where('status', RefundStatus::Completed),
];
}
public function index(Request $request): AnonymousResourceCollection
{
$openid = $request->attributes->get('fastapi_openid');
@@ -62,7 +78,7 @@ class BookingController extends Controller
// return leg's full detail (including vehicle_options).
->where('is_return_leg', false)
->when($request->filled('booking_ref'), fn ($q) => $q->where('booking_ref', 'ilike', '%'.$request->string('booking_ref').'%'))
->with(self::EAGER_LOADS)
->with($this->eagerLoads())
->latest()
->paginate();
@@ -79,7 +95,7 @@ class BookingController extends Controller
Gate::authorize('view', $booking);
}
return new BookingResource($booking->load(self::EAGER_LOADS));
return new BookingResource($booking->load($this->eagerLoads()));
}
public function store(StoreBookingRequest $request): JsonResponse
@@ -145,7 +161,7 @@ class BookingController extends Controller
returnSelections: $returnSelections,
));
return (new BookingResource($booking->load(self::EAGER_LOADS)))
return (new BookingResource($booking->load($this->eagerLoads())))
->response()
->setStatusCode(201);
}
@@ -156,6 +172,6 @@ class BookingController extends Controller
$this->cancelBookingAction->handle($booking, $request->user()?->id);
return new BookingResource($booking->load(self::EAGER_LOADS));
return new BookingResource($booking->load($this->eagerLoads()));
}
}
@@ -63,11 +63,15 @@ 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
// This leg's own settled refunds (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.
// needs to see: amount, reason, and when it was requested/completed.
// BookingController's eager load constrains this to status
// completed only — a pending/failed attempt isn't customer-facing
// (Filament's Refunds resource is where staff track those), so
// `status` here is always "completed" in practice. Cancelling a
// pending_payment booking never creates a refund at all (no money
// moved yet — CancelBookingAction), so this stays empty too.
'refunds' => $this->whenLoaded('refunds', fn () => $this->refunds->map(fn ($refund) => [
'id' => $refund->id,
'status' => $refund->status,
@@ -115,6 +115,22 @@ test('cancelling a confirmed booking returns the refund it created', function ()
expect($response->json('data.refunds.0.completed_at'))->not->toBeNull();
});
test('a pending or failed refund is not returned — only completed refunds are exposed', function () {
$booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::Cancelled, 'price' => 15000]);
$payment = Payment::factory()->completed()->create([
'booking_id' => $booking->id,
'gateway' => PaymentMethod::KbzMiniApp,
'amount' => 15000,
]);
$booking->refunds()->create(['payment_id' => $payment->id, 'status' => RefundStatus::Pending, 'amount' => 15000, 'reason' => 'Booking cancellation']);
$booking->refunds()->create(['payment_id' => $payment->id, 'status' => RefundStatus::Failed, 'amount' => 15000, 'reason' => 'Booking cancellation']);
$this->withHeader('Authorization', "Bearer {$this->token}")
->getJson("/api/v1/bookings/{$booking->booking_ref}")
->assertSuccessful()
->assertJsonPath('data.refunds', []);
});
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]);