Include refund/cancel info in booking API responses
PHP Tests / php-tests (push) Waiting to run

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: [].
This commit is contained in:
Nyan Lin Paing
2026-09-03 20:43:02 +07:00
parent 051b091ef8
commit 47be71c2cf
4 changed files with 58 additions and 1 deletions
@@ -24,7 +24,7 @@ class BookingController extends Controller
* @var list<string>
*/
private const EAGER_LOADS = [
'route', 'timeSlot', 'vehicleOptions',
'route', 'timeSlot', 'vehicleOptions', 'refunds',
'linkedBooking.route.company', 'linkedBooking.route.fromDestination', 'linkedBooking.route.toDestination',
'linkedBooking.timeSlot', 'linkedBooking.vehicleOptions',
];
@@ -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).
@@ -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
@@ -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;