$pricedOptions */ function makeBookableRoute(array $pricedOptions): array { $route = EvRoute::factory()->create(); $timeSlot = DepartureTimeSlot::factory()->create(); foreach ($pricedOptions as [$vehicleOption, $price]) { RoutePricing::factory()->create([ 'ev_route_id' => $route->id, 'vehicle_option' => $vehicleOption, 'price' => $price, ]); } return [$route, $timeSlot]; } function bookingData(EvRoute $route, DepartureTimeSlot $timeSlot, array $selections, array $roundTrip = []): CreateBookingData { return new CreateBookingData( evRouteId: $route->id, departureTimeSlotId: $timeSlot->id, travelDate: now()->addDay()->toDateString(), selections: $selections, passengerName: 'Jane Doe', passengerPhone: '+959123456789', pickupAddress: '123 Pickup St', dropoffAddress: '456 Dropoff Ave', createdByChannel: BookingChannel::MiniApp, openid: 'mini-app-openid-123', returnEvRouteId: $roundTrip['route']->id ?? null, returnDepartureTimeSlotId: $roundTrip['timeSlot']->id ?? null, returnTravelDate: $roundTrip['travelDate'] ?? (isset($roundTrip['route']) ? now()->addDays(3)->toDateString() : null), returnSelections: $roundTrip['selections'] ?? null, ); } /** * Same company as $outbound, from/to swapped — the true reverse route. * * @param array $pricedOptions */ function makeReverseRoute(EvRoute $outbound, array $pricedOptions): array { $route = EvRoute::factory()->create([ 'ev_company_id' => $outbound->ev_company_id, 'from_destination_id' => $outbound->to_destination_id, 'to_destination_id' => $outbound->from_destination_id, ]); $timeSlot = DepartureTimeSlot::factory()->create(); foreach ($pricedOptions as [$vehicleOption, $price]) { RoutePricing::factory()->create([ 'ev_route_id' => $route->id, 'vehicle_option' => $vehicleOption, 'price' => $price, ]); } return [$route, $timeSlot]; } test('it persists a pending_payment booking with the price snapshotted from PricingService', function () { config(['booking.back_seat_enabled' => true]); [$route, $timeSlot] = makeBookableRoute([[VehicleOption::BackSeat, '15000.00']]); $booking = app(CreateBookingAction::class)->handle( bookingData($route, $timeSlot, [new VehicleSelectionData(VehicleOption::BackSeat)]) ); expect($booking->exists)->toBeTrue() ->and($booking->booking_ref)->toBe('EVB-AAAAA1') ->and($booking->status)->toBe(BookingStatus::PendingPayment) ->and($booking->price)->toEqual('15000.00') ->and($booking->ev_route_id)->toBe($route->id) ->and($booking->departure_time_slot_id)->toBe($timeSlot->id) ->and($booking->openid)->toBe('mini-app-openid-123') ->and($booking->vehicleOptions)->toHaveCount(1) ->and($booking->vehicleOptions->first()->vehicle_option)->toBe(VehicleOption::BackSeat) ->and($booking->vehicleOptions->first()->unit_price)->toEqual('15000.00'); }); test('it books front seat and back seat together and sums the price across both lines', function () { config(['booking.back_seat_enabled' => true]); [$route, $timeSlot] = makeBookableRoute([ [VehicleOption::FrontSeat, '12000.00'], [VehicleOption::BackSeat, '9000.00'], ]); $booking = app(CreateBookingAction::class)->handle(bookingData($route, $timeSlot, [ new VehicleSelectionData(VehicleOption::FrontSeat, 1), new VehicleSelectionData(VehicleOption::BackSeat, 2), ])); expect($booking->price)->toEqual('30000.00') // 12000 + (9000 * 2) ->and($booking->vehicleOptions)->toHaveCount(2); $frontSeatLine = $booking->vehicleOptions->firstWhere('vehicle_option', VehicleOption::FrontSeat); $backSeatLine = $booking->vehicleOptions->firstWhere('vehicle_option', VehicleOption::BackSeat); expect($frontSeatLine->passenger_count)->toBe(1) ->and($frontSeatLine->line_total)->toEqual('12000.00') ->and($backSeatLine->passenger_count)->toBe(2) ->and($backSeatLine->line_total)->toEqual('18000.00'); }); test('it dispatches BookingCreated', function () { Event::fake([BookingCreated::class]); config(['booking.whole_vehicle_enabled' => true]); [$route, $timeSlot] = makeBookableRoute([[VehicleOption::WholeVehicle, '30000.00']]); $booking = app(CreateBookingAction::class)->handle( bookingData($route, $timeSlot, [new VehicleSelectionData(VehicleOption::WholeVehicle)]) ); Event::assertDispatched(BookingCreated::class, fn (BookingCreated $event) => $event->booking->is($booking)); }); test('it rejects a disabled vehicle option before touching the database', function () { config(['booking.whole_vehicle_enabled' => false]); [$route, $timeSlot] = makeBookableRoute([[VehicleOption::WholeVehicle, '30000.00']]); expect(fn () => app(CreateBookingAction::class)->handle( bookingData($route, $timeSlot, [new VehicleSelectionData(VehicleOption::WholeVehicle)]) ))->toThrow(InvalidVehicleSelectionException::class); expect(Booking::count())->toBe(0); }); test('it rejects mixing whole vehicle with another option before touching the database', function () { config([ 'booking.back_seat_enabled' => true, 'booking.whole_vehicle_enabled' => true, ]); [$route, $timeSlot] = makeBookableRoute([ [VehicleOption::WholeVehicle, '30000.00'], [VehicleOption::BackSeat, '9000.00'], ]); expect(fn () => app(CreateBookingAction::class)->handle(bookingData($route, $timeSlot, [ new VehicleSelectionData(VehicleOption::WholeVehicle), new VehicleSelectionData(VehicleOption::BackSeat), ])))->toThrow(InvalidVehicleSelectionException::class); expect(Booking::count())->toBe(0); }); test('each booking created gets a unique, sequential booking_ref', function () { config(['booking.back_seat_enabled' => true]); [$route, $timeSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]); $first = app(CreateBookingAction::class)->handle(bookingData($route, $timeSlot, [new VehicleSelectionData(VehicleOption::BackSeat)])); $second = app(CreateBookingAction::class)->handle(bookingData($route, $timeSlot, [new VehicleSelectionData(VehicleOption::BackSeat)])); expect($first->booking_ref)->toBe('EVB-AAAAA1') ->and($second->booking_ref)->toBe('EVB-AAAAA2'); }); test('a plain one-way booking has no linked leg', function () { config(['booking.back_seat_enabled' => true]); [$route, $timeSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]); $booking = app(CreateBookingAction::class)->handle( bookingData($route, $timeSlot, [new VehicleSelectionData(VehicleOption::BackSeat)]) ); expect($booking->linked_booking_id)->toBeNull() ->and($booking->is_round_trip)->toBeFalse() ->and($booking->is_return_leg)->toBeFalse() ->and(Booking::count())->toBe(1); }); test('a round trip creates two bookings linked bidirectionally, each priced independently', function () { config(['booking.back_seat_enabled' => true]); [$outboundRoute, $outboundSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]); [$returnRoute, $returnSlot] = makeReverseRoute($outboundRoute, [[VehicleOption::BackSeat, '11000.00']]); $outbound = app(CreateBookingAction::class)->handle(bookingData( $outboundRoute, $outboundSlot, [new VehicleSelectionData(VehicleOption::BackSeat)], roundTrip: [ 'route' => $returnRoute, 'timeSlot' => $returnSlot, 'selections' => [new VehicleSelectionData(VehicleOption::BackSeat)], ], )); expect(Booking::count())->toBe(2) ->and($outbound->is_return_leg)->toBeFalse() ->and($outbound->is_round_trip)->toBeTrue() ->and($outbound->price)->toEqual('9000.00'); $return = $outbound->linkedBooking; expect($return)->not->toBeNull() ->and($return->is_return_leg)->toBeTrue() ->and($return->is_round_trip)->toBeTrue() ->and($return->linked_booking_id)->toBe($outbound->id) ->and($return->ev_route_id)->toBe($returnRoute->id) ->and($return->departure_time_slot_id)->toBe($returnSlot->id) ->and($return->price)->toEqual('11000.00'); }); test('a round trip dispatches BookingCreated for both legs', function () { Event::fake([BookingCreated::class]); config(['booking.back_seat_enabled' => true]); [$outboundRoute, $outboundSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]); [$returnRoute, $returnSlot] = makeReverseRoute($outboundRoute, [[VehicleOption::BackSeat, '9000.00']]); $outbound = app(CreateBookingAction::class)->handle(bookingData( $outboundRoute, $outboundSlot, [new VehicleSelectionData(VehicleOption::BackSeat)], roundTrip: [ 'route' => $returnRoute, 'timeSlot' => $returnSlot, 'selections' => [new VehicleSelectionData(VehicleOption::BackSeat)], ], )); Event::assertDispatched(BookingCreated::class, 2); Event::assertDispatched(BookingCreated::class, fn (BookingCreated $event) => $event->booking->is($outbound)); Event::assertDispatched(BookingCreated::class, fn (BookingCreated $event) => $event->booking->is($outbound->linkedBooking)); }); test('it rejects a return route that is not the reverse of the outbound route', function () { config(['booking.back_seat_enabled' => true]); [$outboundRoute, $outboundSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]); // Unrelated route — not from/to swapped. [$unrelatedRoute, $unrelatedSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]); expect(fn () => app(CreateBookingAction::class)->handle(bookingData( $outboundRoute, $outboundSlot, [new VehicleSelectionData(VehicleOption::BackSeat)], roundTrip: [ 'route' => $unrelatedRoute, 'timeSlot' => $unrelatedSlot, 'selections' => [new VehicleSelectionData(VehicleOption::BackSeat)], ], )))->toThrow(InvalidReturnRouteException::class); // The whole transaction rolls back — no orphan outbound-only booking. expect(Booking::count())->toBe(0); }); test('return leg selections are validated independently of the outbound leg', function () { config(['booking.back_seat_enabled' => true]); [$outboundRoute, $outboundSlot] = makeBookableRoute([[VehicleOption::FrontSeat, '12000.00']]); [$returnRoute, $returnSlot] = makeReverseRoute($outboundRoute, [[VehicleOption::FrontSeat, '12000.00']]); expect(fn () => app(CreateBookingAction::class)->handle(bookingData( $outboundRoute, $outboundSlot, [new VehicleSelectionData(VehicleOption::FrontSeat, 1)], roundTrip: [ 'route' => $returnRoute, 'timeSlot' => $returnSlot, // Front seat max per booking is 1 — this should fail validation // for the return leg even though the outbound leg is valid. 'selections' => [new VehicleSelectionData(VehicleOption::FrontSeat, 2)], ], )))->toThrow(InvalidVehicleSelectionException::class); expect(Booking::count())->toBe(0); }); test('a failed return-leg price lookup rolls back the outbound leg too', function () { config(['booking.back_seat_enabled' => true]); [$outboundRoute, $outboundSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]); // Return route exists (true reverse) but has no pricing rows at all. $returnRoute = EvRoute::factory()->create([ 'ev_company_id' => $outboundRoute->ev_company_id, 'from_destination_id' => $outboundRoute->to_destination_id, 'to_destination_id' => $outboundRoute->from_destination_id, ]); $returnSlot = DepartureTimeSlot::factory()->create(); expect(fn () => app(CreateBookingAction::class)->handle(bookingData( $outboundRoute, $outboundSlot, [new VehicleSelectionData(VehicleOption::BackSeat)], roundTrip: [ 'route' => $returnRoute, 'timeSlot' => $returnSlot, 'selections' => [new VehicleSelectionData(VehicleOption::BackSeat)], ], )))->toThrow(RoutePricingNotFoundException::class); expect(Booking::count())->toBe(0); });