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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user