modify booking response data

This commit is contained in:
Nyan Lin Paing
2026-08-23 14:51:10 +07:00
parent fa908cdcaf
commit 41c9454334
5 changed files with 166 additions and 3 deletions
@@ -15,6 +15,7 @@ use Modules\Booking\Enums\BookingChannel;
use Modules\Booking\Http\Requests\StoreBookingRequest; use Modules\Booking\Http\Requests\StoreBookingRequest;
use Modules\Booking\Http\Resources\BookingResource; use Modules\Booking\Http\Resources\BookingResource;
use Modules\Booking\Models\Booking; use Modules\Booking\Models\Booking;
use Modules\Payment\Enums\PaymentStatus;
use Modules\Shared\Enums\VehicleOption; use Modules\Shared\Enums\VehicleOption;
class BookingController extends Controller class BookingController extends Controller
@@ -50,6 +51,17 @@ class BookingController extends Controller
} }
$bookings = $query $bookings = $query
// Only bookings that actually have a completed payment — a
// pending_payment booking never had money move, so it's noise
// in a booking list, not a real reservation to show.
->whereHas('payments', fn ($paymentQuery) => $paymentQuery->where('status', PaymentStatus::Completed))
// A round trip is two Booking rows (outbound + return leg,
// linked via linked_booking_id — domain.md §2b), but it should
// still surface once here, not as two separate list entries.
// The outbound row's `linked_booking` already carries the
// 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(self::EAGER_LOADS)
->latest() ->latest()
->paginate(); ->paginate();
@@ -31,6 +31,15 @@ class BookingResource extends JsonResource
'dropoff_lat' => $this->dropoff_lat, 'dropoff_lat' => $this->dropoff_lat,
'dropoff_lng' => $this->dropoff_lng, 'dropoff_lng' => $this->dropoff_lng,
'price' => $this->price, 'price' => $this->price,
// This leg's own price, same value CancelBookingAction/
// RefundBookingAction use for this specific leg. total_price is
// the round-trip total (this leg + linked leg) — computed here,
// not left to the client to sum, since it must always match what
// InitiatePaymentAction actually charges (bcadd, same as there).
// Equal to `price` for a plain one-way booking.
'total_price' => $this->relationLoaded('linkedBooking') && $this->linkedBooking !== null
? bcadd((string) $this->price, (string) $this->linkedBooking->price, 2)
: $this->price,
'created_by_channel' => $this->created_by_channel, 'created_by_channel' => $this->created_by_channel,
// Only ever populated once status is confirmed — see AssignDriverAction. // Only ever populated once status is confirmed — see AssignDriverAction.
'driver_name' => $this->driver_name, 'driver_name' => $this->driver_name,
@@ -82,6 +91,15 @@ class BookingResource extends JsonResource
'line_total' => $selection->line_total, 'line_total' => $selection->line_total,
]) ])
: null, : null,
// Each leg gets its own independent driver/vehicle
// assignment — the return leg is never guaranteed the same
// car as the outbound leg (domain.md §2b). Only ever
// populated once that leg's own status is confirmed — see
// AssignDriverAction.
'driver_name' => $this->linkedBooking->driver_name,
'driver_phone' => $this->linkedBooking->driver_phone,
'car_plate_number' => $this->linkedBooking->car_plate_number,
'car_model' => $this->linkedBooking->car_model,
]), ]),
'created_at' => $this->created_at, 'created_at' => $this->created_at,
]; ];
@@ -1,7 +1,10 @@
<?php <?php
use App\Models\User; use App\Models\User;
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Models\Booking; use Modules\Booking\Models\Booking;
use Modules\Payment\Enums\PaymentMethod;
use Modules\Payment\Models\Payment;
use Spatie\Permission\Models\Permission; use Spatie\Permission\Models\Permission;
beforeEach(function () { beforeEach(function () {
@@ -11,10 +14,27 @@ beforeEach(function () {
$this->token = $this->owner->createToken('test-token')->plainTextToken; $this->token = $this->owner->createToken('test-token')->plainTextToken;
}); });
/**
* Index only ever shows bookings with a completed payment give the
* booking a completed Payment row so it's not silently excluded.
*/
function paidBooking(array $attributes = []): Booking
{
$booking = Booking::factory()->create($attributes);
Payment::factory()->completed()->create([
'booking_id' => $booking->id,
'gateway' => PaymentMethod::KbzMiniApp,
'amount' => $booking->price,
]);
return $booking;
}
test('index lists only the authenticated user\'s own bookings, latest first', function () { test('index lists only the authenticated user\'s own bookings, latest first', function () {
$mine = Booking::factory()->create(['user_id' => $this->owner->id, 'created_at' => now()->subMinute()]); $mine = paidBooking(['user_id' => $this->owner->id, 'created_at' => now()->subMinute()]);
$mineNewer = Booking::factory()->create(['user_id' => $this->owner->id]); $mineNewer = paidBooking(['user_id' => $this->owner->id]);
Booking::factory()->create(['user_id' => User::factory()->create()->id]); paidBooking(['user_id' => User::factory()->create()->id]);
$this->withHeader('Authorization', "Bearer {$this->token}") $this->withHeader('Authorization', "Bearer {$this->token}")
->getJson('/api/v1/bookings') ->getJson('/api/v1/bookings')
@@ -24,6 +44,115 @@ test('index lists only the authenticated user\'s own bookings, latest first', fu
->assertJsonPath('data.1.id', $mine->id); ->assertJsonPath('data.1.id', $mine->id);
}); });
test('index excludes bookings with no completed payment', function () {
// pending_payment, never paid.
Booking::factory()->create(['user_id' => $this->owner->id]);
// Has a payment attempt, but it failed — still not "complete".
$failedPayment = Booking::factory()->create(['user_id' => $this->owner->id]);
Payment::factory()->failed()->create(['booking_id' => $failedPayment->id, 'gateway' => PaymentMethod::KbzMiniApp]);
$paid = paidBooking(['user_id' => $this->owner->id]);
$this->withHeader('Authorization', "Bearer {$this->token}")
->getJson('/api/v1/bookings')
->assertSuccessful()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.id', $paid->id);
});
test('index surfaces a round trip once, not as two separate rows, with a combined total_price', function () {
$outbound = paidBooking(['user_id' => $this->owner->id, 'price' => '9000.00']);
$return = Booking::factory()->create([
'user_id' => $this->owner->id,
'price' => '11000.00',
'is_return_leg' => true,
'linked_booking_id' => $outbound->id,
]);
$outbound->update(['linked_booking_id' => $return->id]);
$this->withHeader('Authorization', "Bearer {$this->token}")
->getJson('/api/v1/bookings')
->assertSuccessful()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.id', $outbound->id)
->assertJsonPath('data.0.price', '9000.00')
->assertJsonPath('data.0.total_price', '20000.00')
->assertJsonPath('data.0.linked_booking.id', $return->id);
});
test('linked_booking carries the return leg\'s own driver/vehicle assignment, independent of the outbound leg\'s', function () {
$outbound = paidBooking([
'user_id' => $this->owner->id,
'status' => BookingStatus::Confirmed,
'driver_name' => 'U Aung',
'driver_phone' => '+959111222333',
'car_plate_number' => 'YGN-1234',
'car_model' => 'Tesla Model Y',
]);
$return = Booking::factory()->create([
'user_id' => $this->owner->id,
'is_return_leg' => true,
'linked_booking_id' => $outbound->id,
'status' => BookingStatus::Confirmed,
'driver_name' => 'Daw Hla',
'driver_phone' => '+959444555666',
'car_plate_number' => 'MDY-5678',
'car_model' => null,
]);
$outbound->update(['linked_booking_id' => $return->id]);
$this->withHeader('Authorization', "Bearer {$this->token}")
->getJson("/api/v1/bookings/{$outbound->booking_ref}")
->assertSuccessful()
->assertJsonPath('data.driver_name', 'U Aung')
->assertJsonPath('data.car_plate_number', 'YGN-1234')
->assertJsonPath('data.linked_booking.driver_name', 'Daw Hla')
->assertJsonPath('data.linked_booking.driver_phone', '+959444555666')
->assertJsonPath('data.linked_booking.car_plate_number', 'MDY-5678')
->assertJsonPath('data.linked_booking.car_model', null);
});
test('total_price equals price for a plain one-way booking, on both index and show', function () {
$booking = paidBooking(['user_id' => $this->owner->id, 'price' => '15000.00']);
$this->withHeader('Authorization', "Bearer {$this->token}")
->getJson('/api/v1/bookings')
->assertJsonPath('data.0.total_price', '15000.00');
$this->withHeader('Authorization', "Bearer {$this->token}")
->getJson("/api/v1/bookings/{$booking->booking_ref}")
->assertJsonPath('data.total_price', '15000.00');
});
test('show returns the combined total_price for a round trip', function () {
$outbound = Booking::factory()->create(['user_id' => $this->owner->id, 'price' => '9000.00']);
$return = Booking::factory()->create([
'user_id' => $this->owner->id,
'price' => '11000.00',
'is_return_leg' => true,
'linked_booking_id' => $outbound->id,
]);
$outbound->update(['linked_booking_id' => $return->id]);
$this->withHeader('Authorization', "Bearer {$this->token}")
->getJson("/api/v1/bookings/{$outbound->booking_ref}")
->assertSuccessful()
->assertJsonPath('data.price', '9000.00')
->assertJsonPath('data.total_price', '20000.00');
});
test('index filters by booking_ref, partial and case-insensitive', function () {
$match = paidBooking(['user_id' => $this->owner->id, 'booking_ref' => 'EVB-FINDME1']);
paidBooking(['user_id' => $this->owner->id, 'booking_ref' => 'EVB-OTHER01']);
$this->withHeader('Authorization', "Bearer {$this->token}")
->getJson('/api/v1/bookings?booking_ref=findme')
->assertSuccessful()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.id', $match->id);
});
test('index rejects unauthenticated requests', function () { test('index rejects unauthenticated requests', function () {
$this->getJson('/api/v1/bookings')->assertUnauthorized(); $this->getJson('/api/v1/bookings')->assertUnauthorized();
}); });
@@ -4,6 +4,8 @@ use Firebase\JWT\JWT;
use Modules\Booking\Enums\BookingChannel; use Modules\Booking\Enums\BookingChannel;
use Modules\Booking\Models\Booking; use Modules\Booking\Models\Booking;
use Modules\Catalog\Models\DepartureTimeSlot; use Modules\Catalog\Models\DepartureTimeSlot;
use Modules\Payment\Enums\PaymentMethod;
use Modules\Payment\Models\Payment;
use Modules\Routing\Models\EvRoute; use Modules\Routing\Models\EvRoute;
use Modules\Routing\Models\RoutePricing; use Modules\Routing\Models\RoutePricing;
use Modules\Shared\Enums\VehicleOption; use Modules\Shared\Enums\VehicleOption;
@@ -59,6 +61,7 @@ test('a FastAPI JWT booking is stored against the verified openid, ignoring a sp
test('a FastAPI JWT can list and show only its own openid\'s bookings', function () { test('a FastAPI JWT can list and show only its own openid\'s bookings', function () {
$mine = Booking::factory()->create(['openid' => 'agent-openid-mine']); $mine = Booking::factory()->create(['openid' => 'agent-openid-mine']);
Payment::factory()->completed()->create(['booking_id' => $mine->id, 'gateway' => PaymentMethod::KbzMiniApp]);
Booking::factory()->create(['openid' => 'agent-openid-someone-else']); Booking::factory()->create(['openid' => 'agent-openid-someone-else']);
$token = fastApiAgentToken('agent-openid-mine'); $token = fastApiAgentToken('agent-openid-mine');
@@ -91,6 +91,7 @@ test('the agent token can still read routes and create/read bookings', function
->assertSuccessful(); ->assertSuccessful();
$booking = Booking::factory()->create(['user_id' => $this->agent->id]); $booking = Booking::factory()->create(['user_id' => $this->agent->id]);
Payment::factory()->completed()->create(['booking_id' => $booking->id, 'gateway' => PaymentMethod::KbzMiniApp]);
$this->withHeader('Authorization', "Bearer {$this->agentToken}") $this->withHeader('Authorization', "Bearer {$this->agentToken}")
->getJson('/api/v1/bookings') ->getJson('/api/v1/bookings')