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>
|
* @var list<string>
|
||||||
*/
|
*/
|
||||||
private const EAGER_LOADS = [
|
private const EAGER_LOADS = [
|
||||||
'route', 'timeSlot', 'vehicleOptions',
|
'route', 'timeSlot', 'vehicleOptions', 'refunds',
|
||||||
'linkedBooking.route.company', 'linkedBooking.route.fromDestination', 'linkedBooking.route.toDestination',
|
'linkedBooking.route.company', 'linkedBooking.route.fromDestination', 'linkedBooking.route.toDestination',
|
||||||
'linkedBooking.timeSlot', 'linkedBooking.vehicleOptions',
|
'linkedBooking.timeSlot', 'linkedBooking.vehicleOptions',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -63,6 +63,19 @@ class BookingResource extends JsonResource
|
|||||||
'label' => $this->timeSlot->label,
|
'label' => $this->timeSlot->label,
|
||||||
'time' => $this->timeSlot->time?->format('H:i'),
|
'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
|
// Hand-built, not a nested BookingResource — the linked leg's
|
||||||
// own linked_booking points right back here, so nesting the
|
// own linked_booking points right back here, so nesting the
|
||||||
// full resource would recurse forever (domain.md §2b).
|
// full resource would recurse forever (domain.md §2b).
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use Modules\Booking\Enums\BookingChannel;
|
|||||||
use Modules\Booking\Enums\BookingStatus;
|
use Modules\Booking\Enums\BookingStatus;
|
||||||
use Modules\Catalog\Models\DepartureTimeSlot;
|
use Modules\Catalog\Models\DepartureTimeSlot;
|
||||||
use Modules\Payment\Models\Payment;
|
use Modules\Payment\Models\Payment;
|
||||||
|
use Modules\Payment\Models\Refund;
|
||||||
use Modules\Routing\Models\EvRoute;
|
use Modules\Routing\Models\EvRoute;
|
||||||
use Spatie\Activitylog\Models\Concerns\LogsActivity;
|
use Spatie\Activitylog\Models\Concerns\LogsActivity;
|
||||||
use Spatie\Activitylog\Support\LogOptions;
|
use Spatie\Activitylog\Support\LogOptions;
|
||||||
@@ -120,6 +121,16 @@ class Booking extends Model
|
|||||||
return $this->hasMany(Payment::class);
|
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
|
* 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
|
* 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);
|
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 () {
|
test('cancelling a confirmed booking with no completed payment surfaces as 422 and leaves it untouched', function () {
|
||||||
$staff = User::factory()->create()->givePermissionTo('process_refunds');
|
$staff = User::factory()->create()->givePermissionTo('process_refunds');
|
||||||
$staffToken = $staff->createToken('staff-token')->plainTextToken;
|
$staffToken = $staff->createToken('staff-token')->plainTextToken;
|
||||||
|
|||||||
Reference in New Issue
Block a user