diff --git a/app-modules/booking/src/Http/Controllers/BookingController.php b/app-modules/booking/src/Http/Controllers/BookingController.php index 1ca27cb..567b72a 100644 --- a/app-modules/booking/src/Http/Controllers/BookingController.php +++ b/app-modules/booking/src/Http/Controllers/BookingController.php @@ -15,6 +15,7 @@ use Modules\Booking\Enums\BookingChannel; use Modules\Booking\Http\Requests\StoreBookingRequest; use Modules\Booking\Http\Resources\BookingResource; use Modules\Booking\Models\Booking; +use Modules\Payment\Enums\PaymentStatus; use Modules\Shared\Enums\VehicleOption; class BookingController extends Controller @@ -50,6 +51,17 @@ class BookingController extends Controller } $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) ->latest() ->paginate(); diff --git a/app-modules/booking/src/Http/Resources/BookingResource.php b/app-modules/booking/src/Http/Resources/BookingResource.php index 926e4ee..a0b1194 100644 --- a/app-modules/booking/src/Http/Resources/BookingResource.php +++ b/app-modules/booking/src/Http/Resources/BookingResource.php @@ -31,6 +31,15 @@ class BookingResource extends JsonResource 'dropoff_lat' => $this->dropoff_lat, 'dropoff_lng' => $this->dropoff_lng, '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, // Only ever populated once status is confirmed — see AssignDriverAction. 'driver_name' => $this->driver_name, @@ -82,6 +91,15 @@ class BookingResource extends JsonResource 'line_total' => $selection->line_total, ]) : 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, ]; diff --git a/app-modules/booking/tests/Feature/BookingReadApiTest.php b/app-modules/booking/tests/Feature/BookingReadApiTest.php index 1229481..c6be7ab 100644 --- a/app-modules/booking/tests/Feature/BookingReadApiTest.php +++ b/app-modules/booking/tests/Feature/BookingReadApiTest.php @@ -1,7 +1,10 @@ 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 () { - $mine = Booking::factory()->create(['user_id' => $this->owner->id, 'created_at' => now()->subMinute()]); - $mineNewer = Booking::factory()->create(['user_id' => $this->owner->id]); - Booking::factory()->create(['user_id' => User::factory()->create()->id]); + $mine = paidBooking(['user_id' => $this->owner->id, 'created_at' => now()->subMinute()]); + $mineNewer = paidBooking(['user_id' => $this->owner->id]); + paidBooking(['user_id' => User::factory()->create()->id]); $this->withHeader('Authorization', "Bearer {$this->token}") ->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); }); +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 () { $this->getJson('/api/v1/bookings')->assertUnauthorized(); }); diff --git a/app-modules/booking/tests/Feature/FastApiAgentBookingTest.php b/app-modules/booking/tests/Feature/FastApiAgentBookingTest.php index 1e24c35..1d6af2b 100644 --- a/app-modules/booking/tests/Feature/FastApiAgentBookingTest.php +++ b/app-modules/booking/tests/Feature/FastApiAgentBookingTest.php @@ -4,6 +4,8 @@ use Firebase\JWT\JWT; use Modules\Booking\Enums\BookingChannel; use Modules\Booking\Models\Booking; use Modules\Catalog\Models\DepartureTimeSlot; +use Modules\Payment\Enums\PaymentMethod; +use Modules\Payment\Models\Payment; use Modules\Routing\Models\EvRoute; use Modules\Routing\Models\RoutePricing; 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 () { $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']); $token = fastApiAgentToken('agent-openid-mine'); diff --git a/app-modules/identity/tests/Feature/AgentAbilityAuditTest.php b/app-modules/identity/tests/Feature/AgentAbilityAuditTest.php index 51739f2..e8b7510 100644 --- a/app-modules/identity/tests/Feature/AgentAbilityAuditTest.php +++ b/app-modules/identity/tests/Feature/AgentAbilityAuditTest.php @@ -91,6 +91,7 @@ test('the agent token can still read routes and create/read bookings', function ->assertSuccessful(); $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}") ->getJson('/api/v1/bookings')