From fa908cdcaf29de8817093002af2109bfb083bca0 Mon Sep 17 00:00:00 2001 From: Nyan Lin Paing <117423022+LinPaing21@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:43:41 +0700 Subject: [PATCH] add notes/remark and refactor round-trip --- .../database/factories/BookingFactory.php | 4 +- ...add_notes_and_remark_to_bookings_table.php | 32 +++ ...00_redesign_round_trip_booking_linking.php | 40 ++++ .../src/Actions/CreateBookingAction.php | 129 +++++++--- .../booking/src/Actions/SetRemarkAction.php | 21 ++ .../booking/src/Data/CreateBookingData.php | 8 +- .../InvalidReturnRouteException.php | 32 +++ .../Bookings/Actions/SetRemarkTableAction.php | 40 ++++ .../Resources/Bookings/Pages/ViewBooking.php | 2 + .../Bookings/Schemas/BookingInfolist.php | 39 ++- .../Bookings/Tables/BookingsTable.php | 27 +++ .../Http/Controllers/BookingController.php | 23 +- .../src/Http/Requests/StoreBookingRequest.php | 12 +- .../src/Http/Resources/BookingResource.php | 32 ++- app-modules/booking/src/Models/Booking.php | 33 ++- .../tests/Feature/BookingCreateApiTest.php | 146 +++++++++++ .../tests/Feature/BookingResourceTest.php | 39 +++ .../tests/Feature/CreateBookingActionTest.php | 174 +++++++++++++- .../tests/Unit/AssignDriverActionTest.php | 22 ++ .../tests/Feature/AgentAbilityAuditTest.php | 21 +- ...000200_add_booking_id_to_refunds_table.php | 42 ++++ .../src/Actions/InitiatePaymentAction.php | 23 +- .../src/Actions/RefundBookingAction.php | 12 +- .../PaymentInitiationNotAllowedException.php | 12 + .../payment/src/Listeners/MarkBookingPaid.php | 9 + .../src/Listeners/MarkBookingRefunded.php | 6 +- app-modules/payment/src/Models/Refund.php | 12 + .../tests/Feature/InitiatePaymentApiTest.php | 41 ++++ .../tests/Feature/MarkBookingPaidTest.php | 17 ++ .../tests/Feature/RefundBookingActionTest.php | 54 +++++ .../database/factories/EvRouteFactory.php | 1 - ...0000_add_is_popular_to_ev_routes_table.php | 29 +++ ...rop_is_round_trip_from_ev_routes_table.php | 31 +++ ...0_drop_is_popular_from_ev_routes_table.php | 31 +++ app-modules/routing/routes/routing-routes.php | 2 +- .../EvRoutes/Schemas/EvRouteForm.php | 146 ++++++----- .../EvRoutes/Tables/EvRoutesTable.php | 22 +- .../Http/Controllers/EvRouteController.php | 115 ++++++++- .../src/Http/Requests/SearchRoutesRequest.php | 41 ++++ .../src/Http/Resources/EvRouteResource.php | 1 - app-modules/routing/src/Models/EvRoute.php | 14 +- .../tests/Feature/EvRouteResourceTest.php | 34 ++- .../routing/tests/Feature/EvRouteTest.php | 20 +- .../tests/Feature/RoutesCachingTest.php | 22 +- .../tests/Feature/RoutesReadApiTest.php | 226 ++++++++++++++++-- domain.md | 22 +- 46 files changed, 1679 insertions(+), 182 deletions(-) create mode 100644 app-modules/booking/database/migrations/2026_08_20_000000_add_notes_and_remark_to_bookings_table.php create mode 100644 app-modules/booking/database/migrations/2026_08_21_000000_redesign_round_trip_booking_linking.php create mode 100644 app-modules/booking/src/Actions/SetRemarkAction.php create mode 100644 app-modules/booking/src/Exceptions/InvalidReturnRouteException.php create mode 100644 app-modules/booking/src/Filament/Resources/Bookings/Actions/SetRemarkTableAction.php create mode 100644 app-modules/payment/database/migrations/2026_08_21_000200_add_booking_id_to_refunds_table.php create mode 100644 app-modules/routing/database/migrations/2026_08_20_010000_add_is_popular_to_ev_routes_table.php create mode 100644 app-modules/routing/database/migrations/2026_08_21_000100_drop_is_round_trip_from_ev_routes_table.php create mode 100644 app-modules/routing/database/migrations/2026_08_22_000000_drop_is_popular_from_ev_routes_table.php create mode 100644 app-modules/routing/src/Http/Requests/SearchRoutesRequest.php diff --git a/app-modules/booking/database/factories/BookingFactory.php b/app-modules/booking/database/factories/BookingFactory.php index 5a10a84..f792af3 100644 --- a/app-modules/booking/database/factories/BookingFactory.php +++ b/app-modules/booking/database/factories/BookingFactory.php @@ -29,6 +29,8 @@ class BookingFactory extends Factory 'user_id' => null, 'openid' => null, 'ev_route_id' => EvRoute::factory(), + 'linked_booking_id' => null, + 'is_return_leg' => false, 'departure_time_slot_id' => DepartureTimeSlot::factory(), 'travel_date' => now()->addDay()->toDateString(), 'passenger_name' => $this->faker->name(), @@ -41,8 +43,6 @@ class BookingFactory extends Factory 'dropoff_lng' => null, 'price' => $this->faker->randomFloat(2, 5000, 50000), 'status' => BookingStatus::PendingPayment, - 'is_round_trip' => false, - 'return_travel_date' => null, 'created_by_channel' => BookingChannel::MiniApp, 'driver_name' => null, 'driver_phone' => null, diff --git a/app-modules/booking/database/migrations/2026_08_20_000000_add_notes_and_remark_to_bookings_table.php b/app-modules/booking/database/migrations/2026_08_20_000000_add_notes_and_remark_to_bookings_table.php new file mode 100644 index 0000000..82e5b44 --- /dev/null +++ b/app-modules/booking/database/migrations/2026_08_20_000000_add_notes_and_remark_to_bookings_table.php @@ -0,0 +1,32 @@ +text('notes')->nullable(); + $table->text('remark')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('bookings', function (Blueprint $table) { + $table->dropColumn(['notes', 'remark']); + }); + } +}; diff --git a/app-modules/booking/database/migrations/2026_08_21_000000_redesign_round_trip_booking_linking.php b/app-modules/booking/database/migrations/2026_08_21_000000_redesign_round_trip_booking_linking.php new file mode 100644 index 0000000..9c54f00 --- /dev/null +++ b/app-modules/booking/database/migrations/2026_08_21_000000_redesign_round_trip_booking_linking.php @@ -0,0 +1,40 @@ +dropColumn(['is_round_trip', 'return_travel_date']); + $table->foreignId('linked_booking_id')->nullable()->after('ev_route_id') + ->constrained('bookings')->nullOnDelete(); + $table->boolean('is_return_leg')->default(false)->after('linked_booking_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('bookings', function (Blueprint $table) { + $table->dropConstrainedForeignId('linked_booking_id'); + $table->dropColumn('is_return_leg'); + $table->boolean('is_round_trip')->default(false); + $table->date('return_travel_date')->nullable(); + }); + } +}; diff --git a/app-modules/booking/src/Actions/CreateBookingAction.php b/app-modules/booking/src/Actions/CreateBookingAction.php index 9df37c5..0a983e2 100644 --- a/app-modules/booking/src/Actions/CreateBookingAction.php +++ b/app-modules/booking/src/Actions/CreateBookingAction.php @@ -7,6 +7,7 @@ use Modules\Booking\Data\CreateBookingData; use Modules\Booking\Data\VehicleSelectionData; use Modules\Booking\Enums\BookingStatus; use Modules\Booking\Events\BookingCreated; +use Modules\Booking\Exceptions\InvalidReturnRouteException; use Modules\Booking\Models\Booking; use Modules\Booking\Services\BookingRefGenerator; use Modules\Booking\Services\BookingService; @@ -26,50 +27,110 @@ class CreateBookingAction { $this->bookingService->validateSelections($data->selections); - return DB::transaction(function () use ($data) { - $route = EvRoute::findOrFail($data->evRouteId); + $isRoundTrip = $data->returnEvRouteId !== null; - $lines = array_map( - fn (VehicleSelectionData $selection) => $this->priceSelection($route, $selection), - $data->selections, + if ($isRoundTrip) { + $this->bookingService->validateSelections($data->returnSelections); + } + + return DB::transaction(function () use ($data, $isRoundTrip) { + $outboundRoute = EvRoute::findOrFail($data->evRouteId); + + $outboundBooking = $this->createLeg( + data: $data, + route: $outboundRoute, + selections: $data->selections, + travelDate: $data->travelDate, + timeSlotId: $data->departureTimeSlotId, + isReturnLeg: false, ); - $totalPrice = array_reduce( - $lines, - fn (string $carry, array $line) => bcadd($carry, $line['line_total'], 2), - '0.00', + if (! $isRoundTrip) { + BookingCreated::dispatch($outboundBooking); + + return $outboundBooking; + } + + $returnRoute = EvRoute::findOrFail($data->returnEvRouteId); + + if (! $returnRoute->isReverseOf($outboundRoute)) { + throw InvalidReturnRouteException::notReverseOfOutbound($returnRoute, $outboundRoute); + } + + $returnBooking = $this->createLeg( + data: $data, + route: $returnRoute, + selections: $data->returnSelections, + travelDate: $data->returnTravelDate, + timeSlotId: $data->returnDepartureTimeSlotId, + isReturnLeg: true, ); - $booking = Booking::create([ - 'booking_ref' => $this->bookingRefGenerator->generate(), - 'user_id' => $data->userId, - 'openid' => $data->openid, - 'ev_route_id' => $data->evRouteId, - 'departure_time_slot_id' => $data->departureTimeSlotId, - 'travel_date' => $data->travelDate, - 'passenger_name' => $data->passengerName, - 'passenger_phone' => $data->passengerPhone, - 'pickup_address' => $data->pickupAddress, - 'pickup_lat' => $data->pickupLat, - 'pickup_lng' => $data->pickupLng, - 'dropoff_address' => $data->dropoffAddress, - 'dropoff_lat' => $data->dropoffLat, - 'dropoff_lng' => $data->dropoffLng, - 'price' => $totalPrice, - 'status' => BookingStatus::PendingPayment, - 'is_round_trip' => $data->isRoundTrip, - 'return_travel_date' => $data->returnTravelDate, - 'created_by_channel' => $data->createdByChannel, - ]); + // Linked bidirectionally after both rows exist — a single + // `linked_booking_id` FK can't be set on either row at create + // time since the other side doesn't have an id yet. + $returnBooking->update(['linked_booking_id' => $outboundBooking->id]); + $outboundBooking->update(['linked_booking_id' => $returnBooking->id]); - $booking->vehicleOptions()->createMany($lines); + // No registered listeners on BookingCreated today, so firing it + // twice per round-trip creation has no side effects — flagged + // here for whoever adds the first listener. + BookingCreated::dispatch($outboundBooking); + BookingCreated::dispatch($returnBooking); - BookingCreated::dispatch($booking); - - return $booking; + return $outboundBooking->refresh(); }); } + /** + * @param list $selections + */ + private function createLeg( + CreateBookingData $data, + EvRoute $route, + array $selections, + string $travelDate, + int $timeSlotId, + bool $isReturnLeg, + ): Booking { + $lines = array_map( + fn (VehicleSelectionData $selection) => $this->priceSelection($route, $selection), + $selections, + ); + + $totalPrice = array_reduce( + $lines, + fn (string $carry, array $line) => bcadd($carry, $line['line_total'], 2), + '0.00', + ); + + $booking = Booking::create([ + 'booking_ref' => $this->bookingRefGenerator->generate(), + 'user_id' => $data->userId, + 'openid' => $data->openid, + 'ev_route_id' => $route->id, + 'is_return_leg' => $isReturnLeg, + 'departure_time_slot_id' => $timeSlotId, + 'travel_date' => $travelDate, + 'passenger_name' => $data->passengerName, + 'passenger_phone' => $data->passengerPhone, + 'notes' => $data->notes, + 'pickup_address' => $data->pickupAddress, + 'pickup_lat' => $data->pickupLat, + 'pickup_lng' => $data->pickupLng, + 'dropoff_address' => $data->dropoffAddress, + 'dropoff_lat' => $data->dropoffLat, + 'dropoff_lng' => $data->dropoffLng, + 'price' => $totalPrice, + 'status' => BookingStatus::PendingPayment, + 'created_by_channel' => $data->createdByChannel, + ]); + + $booking->vehicleOptions()->createMany($lines); + + return $booking; + } + /** * @return array{vehicle_option: VehicleOption, passenger_count: int, unit_price: string, line_total: string} */ diff --git a/app-modules/booking/src/Actions/SetRemarkAction.php b/app-modules/booking/src/Actions/SetRemarkAction.php new file mode 100644 index 0000000..52eb4cf --- /dev/null +++ b/app-modules/booking/src/Actions/SetRemarkAction.php @@ -0,0 +1,21 @@ +update(['remark' => $remark]); + + return $booking; + } +} diff --git a/app-modules/booking/src/Data/CreateBookingData.php b/app-modules/booking/src/Data/CreateBookingData.php index 03efb31..6f1bdfb 100644 --- a/app-modules/booking/src/Data/CreateBookingData.php +++ b/app-modules/booking/src/Data/CreateBookingData.php @@ -9,6 +9,9 @@ readonly class CreateBookingData /** * @param list $selections One or more Vehicle Option * selections (e.g. front_seat + back_seat) — domain.md §2. + * @param list|null $returnSelections Same shape as $selections, + * priced independently against $returnEvRouteId. Presence of + * $returnEvRouteId is the round-trip signal (domain.md §2b). */ public function __construct( public int $evRouteId, @@ -22,11 +25,14 @@ readonly class CreateBookingData public BookingChannel $createdByChannel, public ?int $userId = null, public ?string $openid = null, + public ?string $notes = null, public ?float $pickupLat = null, public ?float $pickupLng = null, public ?float $dropoffLat = null, public ?float $dropoffLng = null, - public bool $isRoundTrip = false, + public ?int $returnEvRouteId = null, + public ?int $returnDepartureTimeSlotId = null, public ?string $returnTravelDate = null, + public ?array $returnSelections = null, ) {} } diff --git a/app-modules/booking/src/Exceptions/InvalidReturnRouteException.php b/app-modules/booking/src/Exceptions/InvalidReturnRouteException.php new file mode 100644 index 0000000..2b8d6e3 --- /dev/null +++ b/app-modules/booking/src/Exceptions/InvalidReturnRouteException.php @@ -0,0 +1,32 @@ +id}] is not the reverse of outbound route [{$outboundRoute->id}] — ". + 'from/to destinations must be swapped.' + ); + } + + /** + * A rejected return route is a client input problem, not a server + * error — surface it as 422, matching InvalidVehicleSelectionException. + */ + public function render(Request $request): ?JsonResponse + { + if ($request->expectsJson()) { + return response()->json(['message' => $this->getMessage()], 422); + } + + return null; + } +} diff --git a/app-modules/booking/src/Filament/Resources/Bookings/Actions/SetRemarkTableAction.php b/app-modules/booking/src/Filament/Resources/Bookings/Actions/SetRemarkTableAction.php new file mode 100644 index 0000000..75d75a3 --- /dev/null +++ b/app-modules/booking/src/Filament/Resources/Bookings/Actions/SetRemarkTableAction.php @@ -0,0 +1,40 @@ +label('Remark') + ->icon(Heroicon::OutlinedPencilSquare) + ->color('gray') + ->visible(fn (): bool => auth()->user()?->can('manage_bookings') ?? false) + ->schema([ + Textarea::make('remark')->maxLength(1000), + ]) + ->fillForm(fn (Booking $record): array => [ + 'remark' => $record->remark, + ]) + ->action(function (array $data, Booking $record, SetRemarkAction $setRemarkAction) { + $setRemarkAction->handle($record, $data['remark'] ?: null); + + Notification::make() + ->title('Remark saved') + ->success() + ->send(); + }); + } +} diff --git a/app-modules/booking/src/Filament/Resources/Bookings/Pages/ViewBooking.php b/app-modules/booking/src/Filament/Resources/Bookings/Pages/ViewBooking.php index f41941e..ad496a5 100644 --- a/app-modules/booking/src/Filament/Resources/Bookings/Pages/ViewBooking.php +++ b/app-modules/booking/src/Filament/Resources/Bookings/Pages/ViewBooking.php @@ -5,6 +5,7 @@ namespace Modules\Booking\Filament\Resources\Bookings\Pages; use Filament\Resources\Pages\ViewRecord; use Modules\Booking\Filament\Resources\Bookings\Actions\AssignDriverTableAction; use Modules\Booking\Filament\Resources\Bookings\Actions\CancelBookingTableAction; +use Modules\Booking\Filament\Resources\Bookings\Actions\SetRemarkTableAction; use Modules\Booking\Filament\Resources\Bookings\BookingResource; class ViewBooking extends ViewRecord @@ -15,6 +16,7 @@ class ViewBooking extends ViewRecord { return [ AssignDriverTableAction::make(), + SetRemarkTableAction::make(), CancelBookingTableAction::make(), ]; } diff --git a/app-modules/booking/src/Filament/Resources/Bookings/Schemas/BookingInfolist.php b/app-modules/booking/src/Filament/Resources/Bookings/Schemas/BookingInfolist.php index 4ef4846..fe6d250 100644 --- a/app-modules/booking/src/Filament/Resources/Bookings/Schemas/BookingInfolist.php +++ b/app-modules/booking/src/Filament/Resources/Bookings/Schemas/BookingInfolist.php @@ -8,6 +8,7 @@ use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Section; use Filament\Schemas\Schema; use Modules\Booking\Enums\BookingStatus; +use Modules\Booking\Filament\Resources\Bookings\BookingResource; use Modules\Payment\Enums\PaymentStatus; class BookingInfolist @@ -32,7 +33,8 @@ class BookingInfolist TextEntry::make('created_by_channel')->badge(), TextEntry::make('created_at')->dateTime(), ]), - ]), + ]) + ->columnSpanFull(), Section::make('Trip') ->schema([ Grid::make(3) @@ -43,10 +45,17 @@ class BookingInfolist TextEntry::make('timeSlot.label')->label('Time Slot'), TextEntry::make('travel_date')->date(), TextEntry::make('is_round_trip')->label('Round Trip')->badge(), - TextEntry::make('return_travel_date')->date() + TextEntry::make('is_return_leg')->label('Leg')->badge() + ->formatStateUsing(fn (bool $state) => $state ? 'Return' : 'Outbound') ->visible(fn ($record) => $record->is_round_trip), + TextEntry::make('linkedBooking.booking_ref')->label('Linked Leg') + ->visible(fn ($record) => $record->is_round_trip) + ->url(fn ($record) => $record->linked_booking_id + ? BookingResource::getUrl('view', ['record' => $record->linked_booking_id]) + : null), ]), - ]), + ]) + ->columnSpanFull(), Section::make('Vehicle Options') ->schema([ RepeatableEntry::make('vehicleOptions') @@ -61,15 +70,29 @@ class BookingInfolist ]), ]), TextEntry::make('price')->label('Total Price')->numeric(2), - ]), + ]) + ->columnSpanFull(), Section::make('Passenger') ->schema([ Grid::make(2) ->schema([ TextEntry::make('passenger_name'), TextEntry::make('passenger_phone'), + TextEntry::make('notes') + ->label('Customer Notes') + ->placeholder('—') + ->columnSpanFull(), ]), - ]), + ]) + ->columnSpanFull(), + Section::make('Staff Remark') + ->description('Internal only — never shown to the customer. Set via the Remark action.') + ->schema([ + TextEntry::make('remark') + ->label('') + ->placeholder('No remark yet.'), + ]) + ->columnSpanFull(), Section::make('Pickup & Dropoff') ->schema([ Grid::make(2) @@ -81,7 +104,8 @@ class BookingInfolist TextEntry::make('pickup_lng')->label('Pickup Lng')->placeholder('—'), TextEntry::make('dropoff_lng')->label('Dropoff Lng')->placeholder('—'), ]), - ]), + ]) + ->columnSpanFull(), Section::make('Driver & Vehicle') ->description('Filled in by staff once the booking is confirmed — see the Assign Driver action.') ->schema([ @@ -92,7 +116,8 @@ class BookingInfolist TextEntry::make('car_plate_number')->label('Car Plate')->placeholder('Not yet assigned'), TextEntry::make('car_model')->label('Car Model')->placeholder('—'), ]), - ]), + ]) + ->columnSpanFull(), // A booking can have more than one payment attempt if an // earlier one failed and the customer retried (domain.md §1) // — full detail (gateway response, refunds) lives on the diff --git a/app-modules/booking/src/Filament/Resources/Bookings/Tables/BookingsTable.php b/app-modules/booking/src/Filament/Resources/Bookings/Tables/BookingsTable.php index 9868b6c..f764d24 100644 --- a/app-modules/booking/src/Filament/Resources/Bookings/Tables/BookingsTable.php +++ b/app-modules/booking/src/Filament/Resources/Bookings/Tables/BookingsTable.php @@ -4,6 +4,8 @@ namespace Modules\Booking\Filament\Resources\Bookings\Tables; use Filament\Actions\ViewAction; use Filament\Forms\Components\DatePicker; +use Filament\Forms\Components\Toggle; +use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Filters\Filter; use Filament\Tables\Filters\SelectFilter; @@ -15,6 +17,7 @@ use Modules\Booking\Filament\Resources\Bookings\Actions\AssignDriverTableAction; use Modules\Booking\Filament\Resources\Bookings\Actions\CancelBookingTableAction; use Modules\Booking\Filament\Resources\Bookings\Actions\DeleteBookingTableAction; use Modules\Booking\Filament\Resources\Bookings\Actions\RestoreBookingTableAction; +use Modules\Booking\Filament\Resources\Bookings\Actions\SetRemarkTableAction; use Modules\Booking\Models\Booking; use Modules\Catalog\Models\EvCompany; use Modules\Routing\Models\EvRoute; @@ -54,6 +57,10 @@ class BookingsTable ->sortable(), TextColumn::make('timeSlot.label') ->label('Time Slot'), + IconColumn::make('is_round_trip') + ->label('Round Trip') + ->boolean() + ->toggleable(), TextColumn::make('vehicleOptions') ->label('Vehicle Options') ->state(fn (Booking $record) => $record->vehicleOptions @@ -79,6 +86,16 @@ class BookingsTable ->join(' • ') ?: null) ->searchable(['driver_name', 'driver_phone', 'car_plate_number', 'car_model']) ->toggleable(), + TextColumn::make('notes') + ->label('Customer Notes') + ->placeholder('—') + ->limit(50) + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('remark') + ->label('Staff Remark') + ->placeholder('—') + ->limit(50) + ->toggleable(isToggledHiddenByDefault: true), TextColumn::make('created_at') ->dateTime() ->sortable() @@ -112,6 +129,15 @@ class BookingsTable $data['value'] ?? null, fn (Builder $q, $companyId) => $q->whereHas('route', fn (Builder $rq) => $rq->where('ev_company_id', $companyId)), )), + // is_round_trip is a computed accessor (linked_booking_id + // !== null), not a DB column — TernaryFilter builds a raw + // where() on it, which breaks now that the column is gone. + Filter::make('is_round_trip') + ->schema([Toggle::make('is_round_trip')]) + ->query(fn (Builder $query, array $data) => $query->when( + $data['is_round_trip'] ?? null, + fn (Builder $q) => $q->whereNotNull('linked_booking_id'), + )), // Deleted bookings are soft-deleted, not hard-removed // (domain.md; T7.x follow-up) — this is the only place they // become visible again, off by default. @@ -120,6 +146,7 @@ class BookingsTable ->recordActions([ ViewAction::make(), AssignDriverTableAction::make(), + SetRemarkTableAction::make(), CancelBookingTableAction::make(), DeleteBookingTableAction::make(), RestoreBookingTableAction::make(), diff --git a/app-modules/booking/src/Http/Controllers/BookingController.php b/app-modules/booking/src/Http/Controllers/BookingController.php index 3e58d92..1ca27cb 100644 --- a/app-modules/booking/src/Http/Controllers/BookingController.php +++ b/app-modules/booking/src/Http/Controllers/BookingController.php @@ -22,7 +22,11 @@ class BookingController extends Controller /** * @var list */ - private const EAGER_LOADS = ['route', 'timeSlot', 'vehicleOptions']; + private const EAGER_LOADS = [ + 'route', 'timeSlot', 'vehicleOptions', + 'linkedBooking.route.company', 'linkedBooking.route.fromDestination', 'linkedBooking.route.toDestination', + 'linkedBooking.timeSlot', 'linkedBooking.vehicleOptions', + ]; public function __construct( private CreateBookingAction $createBookingAction, @@ -83,6 +87,18 @@ class BookingController extends Controller $validated['selections'], ); + $isRoundTrip = $validated['is_round_trip'] ?? false; + + $returnSelections = $isRoundTrip + ? array_map( + fn (array $selection) => new VehicleSelectionData( + vehicleOption: VehicleOption::from($selection['vehicle_option']), + passengerCount: $selection['passenger_count'], + ), + $validated['return_selections'], + ) + : null; + // The agent's own auth path always wins over anything a header could // claim; customer channels come from Device-Type, not a // client-supplied body field (BookingChannel::fromDeviceTypeHeader @@ -98,6 +114,7 @@ class BookingController extends Controller selections: $selections, passengerName: $validated['passenger_name'], passengerPhone: $validated['passenger_phone'], + notes: $validated['notes'] ?? null, pickupAddress: $validated['pickup_address'], dropoffAddress: $validated['dropoff_address'], createdByChannel: $channel, @@ -110,8 +127,10 @@ class BookingController extends Controller pickupLng: $validated['pickup_lng'] ?? null, dropoffLat: $validated['dropoff_lat'] ?? null, dropoffLng: $validated['dropoff_lng'] ?? null, - isRoundTrip: $validated['is_round_trip'] ?? false, + returnEvRouteId: $isRoundTrip ? $validated['return_ev_route_id'] : null, + returnDepartureTimeSlotId: $isRoundTrip ? $validated['return_departure_time_slot_id'] : null, returnTravelDate: $validated['return_travel_date'] ?? null, + returnSelections: $returnSelections, )); return (new BookingResource($booking->load(self::EAGER_LOADS))) diff --git a/app-modules/booking/src/Http/Requests/StoreBookingRequest.php b/app-modules/booking/src/Http/Requests/StoreBookingRequest.php index 98e33b7..98895cd 100644 --- a/app-modules/booking/src/Http/Requests/StoreBookingRequest.php +++ b/app-modules/booking/src/Http/Requests/StoreBookingRequest.php @@ -34,14 +34,24 @@ class StoreBookingRequest extends FormRequest 'selections.*.passenger_count' => ['required', 'integer', 'min:1'], 'passenger_name' => ['required', 'string', 'max:255'], 'passenger_phone' => ['required', 'string', 'max:50'], + 'notes' => ['nullable', 'string', 'max:1000'], 'pickup_address' => ['required', 'string', 'max:500'], 'pickup_lat' => ['nullable', 'numeric', 'between:-90,90'], 'pickup_lng' => ['nullable', 'numeric', 'between:-180,180'], 'dropoff_address' => ['required', 'string', 'max:500'], 'dropoff_lat' => ['nullable', 'numeric', 'between:-90,90'], 'dropoff_lng' => ['nullable', 'numeric', 'between:-180,180'], + // Round trip = a second, independently-priced leg on its own + // route/time-slot/date — the return route must already exist as + // a catalog EvRoute and is validated server-side as the true + // reverse of ev_route_id (EvRoute::isReverseOf, domain.md §2b). 'is_round_trip' => ['sometimes', 'boolean'], - 'return_travel_date' => ['nullable', 'date', 'required_if:is_round_trip,true'], + 'return_ev_route_id' => ['required_if:is_round_trip,true', 'integer', 'exists:ev_routes,id'], + 'return_departure_time_slot_id' => ['required_if:is_round_trip,true', 'integer', 'exists:departure_time_slots,id'], + 'return_travel_date' => ['required_if:is_round_trip,true', 'date', 'after_or_equal:travel_date'], + 'return_selections' => ['required_if:is_round_trip,true', 'array', 'min:1'], + 'return_selections.*.vehicle_option' => ['required_if:is_round_trip,true', Rule::enum(VehicleOption::class)], + 'return_selections.*.passenger_count' => ['required_if:is_round_trip,true', 'integer', 'min:1'], ]; } } diff --git a/app-modules/booking/src/Http/Resources/BookingResource.php b/app-modules/booking/src/Http/Resources/BookingResource.php index 1fd9c8f..926e4ee 100644 --- a/app-modules/booking/src/Http/Resources/BookingResource.php +++ b/app-modules/booking/src/Http/Resources/BookingResource.php @@ -20,9 +20,10 @@ class BookingResource extends JsonResource 'status' => $this->status, 'travel_date' => $this->travel_date?->toDateString(), 'is_round_trip' => $this->is_round_trip, - 'return_travel_date' => $this->return_travel_date?->toDateString(), + 'is_return_leg' => $this->is_return_leg, 'passenger_name' => $this->passenger_name, 'passenger_phone' => $this->passenger_phone, + 'notes' => $this->notes, 'pickup_address' => $this->pickup_address, 'pickup_lat' => $this->pickup_lat, 'pickup_lng' => $this->pickup_lng, @@ -53,6 +54,35 @@ class BookingResource extends JsonResource 'label' => $this->timeSlot->label, 'time' => $this->timeSlot->time?->format('H:i'), ]), + // 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). + 'linked_booking' => $this->whenLoaded('linkedBooking', fn () => [ + 'id' => $this->linkedBooking->id, + 'booking_ref' => $this->linkedBooking->booking_ref, + 'status' => $this->linkedBooking->status, + 'travel_date' => $this->linkedBooking->travel_date?->toDateString(), + 'is_return_leg' => $this->linkedBooking->is_return_leg, + 'route' => $this->linkedBooking->relationLoaded('route') ? [ + 'id' => $this->linkedBooking->route->id, + 'ev_company_id' => $this->linkedBooking->route->ev_company_id, + 'from_destination_id' => $this->linkedBooking->route->from_destination_id, + 'to_destination_id' => $this->linkedBooking->route->to_destination_id, + ] : null, + 'time_slot' => $this->linkedBooking->relationLoaded('timeSlot') ? [ + 'id' => $this->linkedBooking->timeSlot->id, + 'label' => $this->linkedBooking->timeSlot->label, + 'time' => $this->linkedBooking->timeSlot->time?->format('H:i'), + ] : null, + 'vehicle_options' => $this->linkedBooking->relationLoaded('vehicleOptions') + ? $this->linkedBooking->vehicleOptions->map(fn ($selection) => [ + 'vehicle_option' => $selection->vehicle_option, + 'passenger_count' => $selection->passenger_count, + 'unit_price' => $selection->unit_price, + 'line_total' => $selection->line_total, + ]) + : null, + ]), 'created_at' => $this->created_at, ]; } diff --git a/app-modules/booking/src/Models/Booking.php b/app-modules/booking/src/Models/Booking.php index f1da7cb..610a9a6 100644 --- a/app-modules/booking/src/Models/Booking.php +++ b/app-modules/booking/src/Models/Booking.php @@ -3,6 +3,7 @@ namespace Modules\Booking\Models; use App\Models\User; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -43,10 +44,14 @@ class Booking extends Model 'user_id', 'openid', 'ev_route_id', + 'linked_booking_id', + 'is_return_leg', 'departure_time_slot_id', 'travel_date', 'passenger_name', 'passenger_phone', + 'notes', + 'remark', 'pickup_address', 'pickup_lat', 'pickup_lng', @@ -55,8 +60,6 @@ class Booking extends Model 'dropoff_lng', 'price', 'status', - 'is_round_trip', - 'return_travel_date', 'created_by_channel', 'driver_name', 'driver_phone', @@ -77,8 +80,7 @@ class Booking extends Model 'dropoff_lng' => 'decimal:7', 'price' => 'decimal:2', 'status' => BookingStatus::class, - 'is_round_trip' => 'boolean', - 'return_travel_date' => 'date', + 'is_return_leg' => 'boolean', 'created_by_channel' => BookingChannel::class, ]; } @@ -93,6 +95,16 @@ class Booking extends Model return $this->belongsTo(EvRoute::class, 'ev_route_id'); } + /** + * The other leg of a round trip (outbound <-> return), linked + * bidirectionally by CreateBookingAction. Null for a plain one-way + * booking — see the `isRoundTrip()` accessor (domain.md §2b). + */ + public function linkedBooking(): BelongsTo + { + return $this->belongsTo(Booking::class, 'linked_booking_id'); + } + public function timeSlot(): BelongsTo { return $this->belongsTo(DepartureTimeSlot::class, 'departure_time_slot_id'); @@ -107,4 +119,17 @@ class Booking extends Model { return $this->hasMany(Payment::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 + * the single source of truth, so it can't drift out of sync the way a + * separate flag column could (domain.md §2b). + */ + public function isRoundTrip(): Attribute + { + return Attribute::make( + get: fn (): bool => $this->linked_booking_id !== null, + ); + } } diff --git a/app-modules/booking/tests/Feature/BookingCreateApiTest.php b/app-modules/booking/tests/Feature/BookingCreateApiTest.php index 5461685..30618e7 100644 --- a/app-modules/booking/tests/Feature/BookingCreateApiTest.php +++ b/app-modules/booking/tests/Feature/BookingCreateApiTest.php @@ -206,6 +206,33 @@ test('created_by_channel is taken from the Device-Type header', function (string 'kbz_miniapp' => ['kbz_miniapp', BookingChannel::MiniApp], ]); +test('customer-supplied notes are stored and returned', function () { + [$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]); + + $payload = bookingPayload($route, $timeSlot, [ + ['vehicle_option' => 'back_seat', 'passenger_count' => 1], + ]); + $payload['notes'] = 'Please call before arriving.'; + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->postJson('/api/v1/bookings', $payload) + ->assertCreated() + ->assertJsonPath('data.notes', 'Please call before arriving.'); + + expect(Booking::first()->notes)->toBe('Please call before arriving.'); +}); + +test('notes is optional and defaults to null', function () { + [$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [ + ['vehicle_option' => 'back_seat', 'passenger_count' => 1], + ])) + ->assertCreated() + ->assertJsonPath('data.notes', null); +}); + test('a Device-Type header cannot spoof the agent or admin channel', function (string $deviceType) { [$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]); @@ -221,3 +248,122 @@ test('a Device-Type header cannot spoof the agent or admin channel', function (s 'admin' => ['admin'], 'unrecognized value' => ['smart-fridge'], ]); + +/** + * Same company as $outbound, from/to swapped — the true reverse route. + * + * @param array $pricedOptions + */ +function reverseRouteAndSlot(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, + 'is_active' => true, + ]); + $timeSlot = DepartureTimeSlot::factory()->create(); + $route->timeSlots()->attach($timeSlot->id, ['is_active' => true]); + + foreach ($pricedOptions as [$vehicleOption, $price]) { + RoutePricing::factory()->create([ + 'ev_route_id' => $route->id, + 'vehicle_option' => $vehicleOption, + 'price' => $price, + ]); + } + + return [$route, $timeSlot]; +} + +test('round trip: creates two linked bookings, each priced against its own route', function () { + config(['booking.back_seat_enabled' => true]); + + [$outboundRoute, $outboundSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '9000.00']]); + [$returnRoute, $returnSlot] = reverseRouteAndSlot($outboundRoute, [[VehicleOption::BackSeat, '11000.00']]); + + $payload = bookingPayload($outboundRoute, $outboundSlot, [ + ['vehicle_option' => 'back_seat', 'passenger_count' => 1], + ]); + $payload['is_round_trip'] = true; + $payload['return_ev_route_id'] = $returnRoute->id; + $payload['return_departure_time_slot_id'] = $returnSlot->id; + $payload['return_travel_date'] = now()->addDays(3)->toDateString(); + $payload['return_selections'] = [['vehicle_option' => 'back_seat', 'passenger_count' => 1]]; + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->postJson('/api/v1/bookings', $payload) + ->assertCreated() + ->assertJsonPath('data.is_round_trip', true) + ->assertJsonPath('data.is_return_leg', false) + ->assertJsonPath('data.price', '9000.00') + ->assertJsonPath('data.linked_booking.is_return_leg', true) + ->assertJsonPath('data.linked_booking.route.id', $returnRoute->id) + ->assertJsonPath('data.linked_booking.vehicle_options.0.vehicle_option', 'back_seat') + ->assertJsonPath('data.linked_booking.vehicle_options.0.unit_price', '11000.00'); + + expect(Booking::count())->toBe(2); + + $return = Booking::where('is_return_leg', true)->firstOrFail(); + expect($return->price)->toEqual('11000.00') + ->and($return->ev_route_id)->toBe($returnRoute->id); +}); + +test('round trip: a return route that is not the reverse of the outbound route surfaces as 422', function () { + config(['booking.back_seat_enabled' => true]); + + [$outboundRoute, $outboundSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '9000.00']]); + [$unrelatedRoute, $unrelatedSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '9000.00']]); + + $payload = bookingPayload($outboundRoute, $outboundSlot, [ + ['vehicle_option' => 'back_seat', 'passenger_count' => 1], + ]); + $payload['is_round_trip'] = true; + $payload['return_ev_route_id'] = $unrelatedRoute->id; + $payload['return_departure_time_slot_id'] = $unrelatedSlot->id; + $payload['return_travel_date'] = now()->addDays(3)->toDateString(); + $payload['return_selections'] = [['vehicle_option' => 'back_seat', 'passenger_count' => 1]]; + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->postJson('/api/v1/bookings', $payload) + ->assertStatus(422); + + expect(Booking::count())->toBe(0); +}); + +test('round trip: return fields are required when is_round_trip is true', function () { + [$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]); + + $payload = bookingPayload($route, $timeSlot, [ + ['vehicle_option' => 'back_seat', 'passenger_count' => 1], + ]); + $payload['is_round_trip'] = true; + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->postJson('/api/v1/bookings', $payload) + ->assertStatus(422) + ->assertJsonValidationErrors([ + 'return_ev_route_id', 'return_departure_time_slot_id', 'return_travel_date', 'return_selections', + ]); +}); + +test('round trip: return_travel_date before travel_date is rejected', function () { + config(['booking.back_seat_enabled' => true]); + + [$outboundRoute, $outboundSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '9000.00']]); + [$returnRoute, $returnSlot] = reverseRouteAndSlot($outboundRoute, [[VehicleOption::BackSeat, '9000.00']]); + + $payload = bookingPayload($outboundRoute, $outboundSlot, [ + ['vehicle_option' => 'back_seat', 'passenger_count' => 1], + ]); + $payload['is_round_trip'] = true; + $payload['return_ev_route_id'] = $returnRoute->id; + $payload['return_departure_time_slot_id'] = $returnSlot->id; + $payload['return_travel_date'] = now()->toDateString(); // before travel_date (addDay()) + $payload['return_selections'] = [['vehicle_option' => 'back_seat', 'passenger_count' => 1]]; + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->postJson('/api/v1/bookings', $payload) + ->assertStatus(422) + ->assertJsonValidationErrors(['return_travel_date']); +}); diff --git a/app-modules/booking/tests/Feature/BookingResourceTest.php b/app-modules/booking/tests/Feature/BookingResourceTest.php index 7484141..b8b08f4 100644 --- a/app-modules/booking/tests/Feature/BookingResourceTest.php +++ b/app-modules/booking/tests/Feature/BookingResourceTest.php @@ -325,6 +325,45 @@ test('restoring a deleted booking brings it back', function () { expect(Booking::find($booking->id)->trashed())->toBeFalse(); }); +test('the remark action is visible for a user with manage_bookings', function () { + $booking = Booking::factory()->create(); + + Livewire::test(ListBookings::class) + ->assertTableActionVisible('setRemark', $booking); +}); + +test('the remark action is hidden from a user without manage_bookings', function () { + $viewer = User::factory()->create()->givePermissionTo('view_bookings'); + $this->actingAs($viewer); + + $booking = Booking::factory()->create(); + + Livewire::test(ListBookings::class) + ->assertTableActionHidden('setRemark', $booking); +}); + +test('calling the remark action sets the staff remark on a booking', function () { + $booking = Booking::factory()->create(); + + Livewire::test(ListBookings::class) + ->callTableAction('setRemark', $booking, data: [ + 'remark' => 'Passenger requested a child seat.', + ]) + ->assertNotified(); + + expect($booking->refresh()->remark)->toBe('Passenger requested a child seat.'); +}); + +test('the remark form is pre-filled with the booking\'s existing remark', function () { + $booking = Booking::factory()->create(['remark' => 'Existing remark.']); + + Livewire::test(ListBookings::class) + ->mountTableAction('setRemark', $booking) + ->assertTableActionDataSet([ + 'remark' => 'Existing remark.', + ]); +}); + test('the restore action is hidden from a user without manage_bookings', function () { $stranger = User::factory()->create(); $booking = Booking::factory()->create(); diff --git a/app-modules/booking/tests/Feature/CreateBookingActionTest.php b/app-modules/booking/tests/Feature/CreateBookingActionTest.php index e82e2d9..598ccb9 100644 --- a/app-modules/booking/tests/Feature/CreateBookingActionTest.php +++ b/app-modules/booking/tests/Feature/CreateBookingActionTest.php @@ -7,9 +7,11 @@ use Modules\Booking\Data\VehicleSelectionData; use Modules\Booking\Enums\BookingChannel; use Modules\Booking\Enums\BookingStatus; use Modules\Booking\Events\BookingCreated; +use Modules\Booking\Exceptions\InvalidReturnRouteException; use Modules\Booking\Exceptions\InvalidVehicleSelectionException; use Modules\Booking\Models\Booking; use Modules\Catalog\Models\DepartureTimeSlot; +use Modules\Routing\Exceptions\RoutePricingNotFoundException; use Modules\Routing\Models\EvRoute; use Modules\Routing\Models\RoutePricing; use Modules\Shared\Enums\VehicleOption; @@ -33,7 +35,7 @@ function makeBookableRoute(array $pricedOptions): array return [$route, $timeSlot]; } -function bookingData(EvRoute $route, DepartureTimeSlot $timeSlot, array $selections): CreateBookingData +function bookingData(EvRoute $route, DepartureTimeSlot $timeSlot, array $selections, array $roundTrip = []): CreateBookingData { return new CreateBookingData( evRouteId: $route->id, @@ -46,9 +48,38 @@ function bookingData(EvRoute $route, DepartureTimeSlot $timeSlot, array $selecti 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]); @@ -150,3 +181,144 @@ test('each booking created gets a unique, sequential booking_ref', function () { 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); +}); diff --git a/app-modules/booking/tests/Unit/AssignDriverActionTest.php b/app-modules/booking/tests/Unit/AssignDriverActionTest.php index 65aa2d4..7289f4c 100644 --- a/app-modules/booking/tests/Unit/AssignDriverActionTest.php +++ b/app-modules/booking/tests/Unit/AssignDriverActionTest.php @@ -74,3 +74,25 @@ test('reassigning a different driver on a still-confirmed booking overwrites the expect($booking->refresh()->driver_name)->toBe('Daw Hla') ->and($booking->car_plate_number)->toBe('YGN-5678'); }); + +test('a round trip: assigning a driver to the outbound leg does not touch the linked return leg', function () { + $outbound = Booking::factory()->create(['status' => BookingStatus::Confirmed]); + $return = Booking::factory()->create([ + 'status' => BookingStatus::Confirmed, + 'is_return_leg' => true, + 'linked_booking_id' => $outbound->id, + ]); + $outbound->update(['linked_booking_id' => $return->id]); + + (new AssignDriverAction)->handle($outbound, new AssignDriverData( + driverName: 'U Aung', + driverPhone: '+959111222333', + carPlateNumber: 'YGN-1234', + )); + + // Each leg has its own independent driver/vehicle slot — the return leg + // can get a completely different (or no-yet-assigned) vehicle, per the + // "next available vehicle" business rule (domain.md §2b). + expect($outbound->refresh()->driver_name)->toBe('U Aung') + ->and($return->refresh()->driver_name)->toBeNull(); +}); diff --git a/app-modules/identity/tests/Feature/AgentAbilityAuditTest.php b/app-modules/identity/tests/Feature/AgentAbilityAuditTest.php index 2ca19d2..51739f2 100644 --- a/app-modules/identity/tests/Feature/AgentAbilityAuditTest.php +++ b/app-modules/identity/tests/Feature/AgentAbilityAuditTest.php @@ -6,6 +6,7 @@ use Modules\Booking\Models\Booking; use Modules\Identity\Enums\TokenAbility; use Modules\Payment\Enums\PaymentMethod; use Modules\Payment\Models\Payment; +use Modules\Routing\Models\EvRoute; /** * T6.4 — full policy + agent-ability audit (domain.md §8). The FastAPI @@ -63,14 +64,30 @@ test('catalog writes have no customer-facing route at all', function () { }); test('routing/pricing writes have no customer-facing route at all', function () { + // Only a read-only search endpoint exists for EvRoute — no create/update/ + // delete route was ever registered, and the search endpoint itself + // never creates records regardless of payload (it's POST because + // round_trip returns two result sets, not because it writes anything). + // {route} only has a GET (show) handler registered, so PUT/DELETE hit + // that same URI pattern and are rejected as 405 (method not allowed). $this->withHeader('Authorization', "Bearer {$this->agentToken}") - ->postJson('/api/v1/routes', ['ev_company_id' => 1]) + ->putJson('/api/v1/routes/1', ['ev_company_id' => 1]) ->assertStatus(405); + + $this->withHeader('Authorization', "Bearer {$this->agentToken}") + ->deleteJson('/api/v1/routes/1') + ->assertStatus(405); + + $this->withHeader('Authorization', "Bearer {$this->agentToken}") + ->postJson('/api/v1/routes/search', ['ev_company_id' => 1]) + ->assertSuccessful(); + + expect(EvRoute::count())->toBe(0); }); test('the agent token can still read routes and create/read bookings', function () { $this->withHeader('Authorization', "Bearer {$this->agentToken}") - ->getJson('/api/v1/routes') + ->postJson('/api/v1/routes/search') ->assertSuccessful(); $booking = Booking::factory()->create(['user_id' => $this->agent->id]); diff --git a/app-modules/payment/database/migrations/2026_08_21_000200_add_booking_id_to_refunds_table.php b/app-modules/payment/database/migrations/2026_08_21_000200_add_booking_id_to_refunds_table.php new file mode 100644 index 0000000..bd20feb --- /dev/null +++ b/app-modules/payment/database/migrations/2026_08_21_000200_add_booking_id_to_refunds_table.php @@ -0,0 +1,42 @@ +payment->booking` is no longer reliable + * for identifying which leg a refund actually cancels — a refund + * against the return leg still hangs off the primary's Payment. + * `booking_id` records the actual leg RefundBookingAction was asked to + * refund, so MarkBookingRefunded flips the right booking to cancelled. + */ + public function up(): void + { + Schema::table('refunds', function (Blueprint $table) { + $table->foreignId('booking_id')->nullable()->after('payment_id') + ->constrained('bookings')->nullOnDelete(); + }); + + // Backfill existing rows from their Payment's booking — correct for + // every pre-existing refund, since round trip didn't exist yet. + DB::statement( + 'update refunds set booking_id = payments.booking_id '. + 'from payments where payments.id = refunds.payment_id' + ); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('refunds', function (Blueprint $table) { + $table->dropConstrainedForeignId('booking_id'); + }); + } +}; diff --git a/app-modules/payment/src/Actions/InitiatePaymentAction.php b/app-modules/payment/src/Actions/InitiatePaymentAction.php index a1a9238..3bb6930 100644 --- a/app-modules/payment/src/Actions/InitiatePaymentAction.php +++ b/app-modules/payment/src/Actions/InitiatePaymentAction.php @@ -43,6 +43,10 @@ class InitiatePaymentAction throw PaymentInitiationNotAllowedException::notPendingPayment($booking); } + if ($booking->is_return_leg) { + throw PaymentInitiationNotAllowedException::isReturnLeg($booking); + } + return DB::transaction(function () use ($booking, $method) { $booking = Booking::whereKey($booking->id)->lockForUpdate()->first(); @@ -62,11 +66,12 @@ class InitiatePaymentAction } $merchantOrderId = $this->merchantOrderId($booking); + $amount = $this->amount($booking); $result = $this->paymentService->initiate(new PaymentRequestData( bookingId: $booking->id, merchantOrderId: $merchantOrderId, - amount: (string) $booking->price, + amount: $amount, currency: self::CURRENCY, method: $method, notifyUrl: $this->notifyUrl($booking, $method), @@ -76,7 +81,7 @@ class InitiatePaymentAction 'booking_id' => $booking->id, 'gateway' => $method, 'status' => $result->status, - 'amount' => $booking->price, + 'amount' => $amount, 'currency' => self::CURRENCY, 'gateway_transaction_id' => $result->gatewayTransactionId ?? $merchantOrderId, 'gateway_payload' => $result->gatewayPayload, @@ -85,6 +90,20 @@ class InitiatePaymentAction }); } + /** + * A round trip's payment is combined on the outbound leg — covers both + * legs' price, since the return leg never gets its own Payment + * (domain.md §2b). A plain one-way booking just pays its own price. + */ + private function amount(Booking $booking): string + { + if ($booking->linked_booking_id === null) { + return (string) $booking->price; + } + + return bcadd((string) $booking->price, (string) $booking->linkedBooking->price, 2); + } + /** * A booking can have more than one payment attempt (retry after * failure), so the merchant order id must be unique per attempt, not diff --git a/app-modules/payment/src/Actions/RefundBookingAction.php b/app-modules/payment/src/Actions/RefundBookingAction.php index e93d0d0..a738e92 100644 --- a/app-modules/payment/src/Actions/RefundBookingAction.php +++ b/app-modules/payment/src/Actions/RefundBookingAction.php @@ -35,7 +35,14 @@ class RefundBookingAction throw RefundNotAllowedException::notConfirmed($booking); } - $payment = $booking->payments()->where('status', PaymentStatus::Completed->value)->latest()->first(); + // Round trip: payment is combined on the outbound leg, so a return + // leg has no Payment of its own — refund against its linked leg's + // Payment instead (domain.md §2b). The Confirmed check above still + // applies to $booking itself, not the payment holder, so each leg + // remains independently cancellable/refundable. + $paymentBooking = $booking->is_return_leg ? ($booking->linkedBooking ?? $booking) : $booking; + + $payment = $paymentBooking->payments()->where('status', PaymentStatus::Completed->value)->latest()->first(); if ($payment === null) { throw RefundNotAllowedException::noCompletedPayment($booking); @@ -45,9 +52,10 @@ class RefundBookingAction $result = $this->paymentService->refund($payment->gateway, $payment->gateway_transaction_id, $amount, $reason); - $refund = DB::transaction(function () use ($payment, $amount, $reason, $result, $requestedBy) { + $refund = DB::transaction(function () use ($booking, $payment, $amount, $reason, $result, $requestedBy) { $refund = Refund::create([ 'payment_id' => $payment->id, + 'booking_id' => $booking->id, 'status' => $result->status, 'amount' => $amount, 'reason' => $reason, diff --git a/app-modules/payment/src/Exceptions/PaymentInitiationNotAllowedException.php b/app-modules/payment/src/Exceptions/PaymentInitiationNotAllowedException.php index 00bda2e..88afaf9 100644 --- a/app-modules/payment/src/Exceptions/PaymentInitiationNotAllowedException.php +++ b/app-modules/payment/src/Exceptions/PaymentInitiationNotAllowedException.php @@ -16,6 +16,18 @@ class PaymentInitiationNotAllowedException extends RuntimeException ); } + /** + * A round trip's payment is combined on the outbound leg — the return + * leg is marked paid when the outbound leg's payment succeeds + * (MarkBookingPaid), never via its own Payment (domain.md §2b). + */ + public static function isReturnLeg(Booking $booking): self + { + return new self( + "Booking [{$booking->booking_ref}] is a round trip's return leg — initiate payment on its linked outbound booking instead." + ); + } + public function render(Request $request): ?JsonResponse { if ($request->expectsJson()) { diff --git a/app-modules/payment/src/Listeners/MarkBookingPaid.php b/app-modules/payment/src/Listeners/MarkBookingPaid.php index 1dc7824..73ae0d4 100644 --- a/app-modules/payment/src/Listeners/MarkBookingPaid.php +++ b/app-modules/payment/src/Listeners/MarkBookingPaid.php @@ -29,5 +29,14 @@ class MarkBookingPaid implements ShouldQueue if ($booking->status === BookingStatus::PendingPayment) { $booking->update(['status' => BookingStatus::Confirmed]); } + + // Round trip: payment is combined on the outbound leg, so its + // success also confirms the linked return leg — the return leg + // never gets its own Payment (domain.md §2b). + $linkedBooking = $booking->linkedBooking; + + if ($linkedBooking !== null && $linkedBooking->status === BookingStatus::PendingPayment) { + $linkedBooking->update(['status' => BookingStatus::Confirmed]); + } } } diff --git a/app-modules/payment/src/Listeners/MarkBookingRefunded.php b/app-modules/payment/src/Listeners/MarkBookingRefunded.php index 44f12e3..e58ecf1 100644 --- a/app-modules/payment/src/Listeners/MarkBookingRefunded.php +++ b/app-modules/payment/src/Listeners/MarkBookingRefunded.php @@ -16,7 +16,11 @@ class MarkBookingRefunded implements ShouldQueue { public function handle(RefundProcessed $event): void { - $booking = $event->refund->payment->booking; + // The leg actually refunded — not payment->booking, since a round + // trip's return leg refunds against the primary leg's shared + // Payment (domain.md §2b). Falls back to payment->booking for + // pre-redesign rows where booking_id wasn't yet recorded. + $booking = $event->refund->booking ?? $event->refund->payment->booking; // Booking uses SoftDeletes — normally unreachable here (a confirmed // booking is never deletable, BookingPolicy::delete), but this diff --git a/app-modules/payment/src/Models/Refund.php b/app-modules/payment/src/Models/Refund.php index 9e3e4a4..20c0804 100644 --- a/app-modules/payment/src/Models/Refund.php +++ b/app-modules/payment/src/Models/Refund.php @@ -6,6 +6,7 @@ use App\Models\User; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Modules\Booking\Models\Booking; use Modules\Payment\Database\Factories\RefundFactory; use Modules\Payment\Enums\RefundStatus; use Spatie\Activitylog\Models\Concerns\LogsActivity; @@ -38,6 +39,7 @@ class Refund extends Model */ protected $fillable = [ 'payment_id', + 'booking_id', 'status', 'amount', 'reason', @@ -67,6 +69,16 @@ class Refund extends Model return $this->belongsTo(Payment::class); } + /** + * The leg actually being refunded/cancelled — not necessarily + * payment->booking, since a round trip's return leg refunds against the + * primary leg's shared Payment (domain.md §2b). + */ + public function booking(): BelongsTo + { + return $this->belongsTo(Booking::class); + } + public function requestedBy(): BelongsTo { return $this->belongsTo(User::class, 'requested_by'); diff --git a/app-modules/payment/tests/Feature/InitiatePaymentApiTest.php b/app-modules/payment/tests/Feature/InitiatePaymentApiTest.php index 8a237c2..e6acbc7 100644 --- a/app-modules/payment/tests/Feature/InitiatePaymentApiTest.php +++ b/app-modules/payment/tests/Feature/InitiatePaymentApiTest.php @@ -191,3 +191,44 @@ test('404s for a booking that does not exist', function () { ->postJson('/api/v1/payments/EVB-DOES-NOT-EXIST/initiate') ->assertNotFound(); }); + +test('round trip: initiating payment on the primary leg charges the combined total of both legs', function () { + $outbound = Booking::factory()->create([ + 'user_id' => $this->owner->id, + 'status' => BookingStatus::PendingPayment, + 'price' => 9000, + ]); + $return = Booking::factory()->create([ + 'status' => BookingStatus::PendingPayment, + 'price' => 11000, + 'is_return_leg' => true, + 'linked_booking_id' => $outbound->id, + ]); + $outbound->update(['linked_booking_id' => $return->id]); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->postJson("/api/v1/payments/{$outbound->booking_ref}/initiate") + ->assertCreated(); + + $payment = Payment::where('booking_id', $outbound->id)->sole(); + + expect((float) $payment->amount)->toBe(20000.0) + ->and(Payment::where('booking_id', $return->id)->count())->toBe(0); +}); + +test('round trip: initiating payment on the return leg directly surfaces as 422', function () { + $outbound = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::PendingPayment]); + $return = Booking::factory()->create([ + 'user_id' => $this->owner->id, + 'status' => BookingStatus::PendingPayment, + 'is_return_leg' => true, + 'linked_booking_id' => $outbound->id, + ]); + $outbound->update(['linked_booking_id' => $return->id]); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->postJson("/api/v1/payments/{$return->booking_ref}/initiate") + ->assertStatus(422); + + expect(Payment::where('booking_id', $return->id)->count())->toBe(0); +}); diff --git a/app-modules/payment/tests/Feature/MarkBookingPaidTest.php b/app-modules/payment/tests/Feature/MarkBookingPaidTest.php index 690d833..abc9bf8 100644 --- a/app-modules/payment/tests/Feature/MarkBookingPaidTest.php +++ b/app-modules/payment/tests/Feature/MarkBookingPaidTest.php @@ -33,3 +33,20 @@ test('does not crash if the booking was soft-deleted before this queued listener expect(fn () => (new MarkBookingPaid)->handle(new PaymentCompleted($payment->fresh()))) ->not->toThrow(Throwable::class); }); + +test('a round trip: paying the primary leg also confirms its linked return leg', function () { + $outbound = Booking::factory()->create(['status' => BookingStatus::PendingPayment]); + $return = Booking::factory()->create([ + 'status' => BookingStatus::PendingPayment, + 'is_return_leg' => true, + 'linked_booking_id' => $outbound->id, + ]); + $outbound->update(['linked_booking_id' => $return->id]); + + $payment = Payment::factory()->completed()->create(['booking_id' => $outbound->id]); + + (new MarkBookingPaid)->handle(new PaymentCompleted($payment)); + + expect($outbound->refresh()->status)->toBe(BookingStatus::Confirmed) + ->and($return->refresh()->status)->toBe(BookingStatus::Confirmed); +}); diff --git a/app-modules/payment/tests/Feature/RefundBookingActionTest.php b/app-modules/payment/tests/Feature/RefundBookingActionTest.php index ef59361..18a6919 100644 --- a/app-modules/payment/tests/Feature/RefundBookingActionTest.php +++ b/app-modules/payment/tests/Feature/RefundBookingActionTest.php @@ -152,3 +152,57 @@ test('a failed gateway refund is persisted as failed, leaves the booking untouch Event::assertNotDispatched(RefundProcessed::class); }); + +/** + * Round trip: payment is combined on the outbound ("primary") leg — the + * return leg has no Payment of its own (domain.md §2b). + */ +function confirmedRoundTripWithCombinedPayment(string $outboundPrice, string $returnPrice): array +{ + $outbound = Booking::factory()->create(['status' => BookingStatus::Confirmed, 'price' => $outboundPrice]); + $return = Booking::factory()->create([ + 'status' => BookingStatus::Confirmed, + 'price' => $returnPrice, + 'is_return_leg' => true, + 'linked_booking_id' => $outbound->id, + ]); + $outbound->update(['linked_booking_id' => $return->id]); + + $combined = bcadd($outboundPrice, $returnPrice, 2); + + Payment::factory()->completed()->create([ + 'booking_id' => $outbound->id, + 'gateway' => PaymentMethod::KbzMiniApp, + 'amount' => $combined, + 'gateway_transaction_id' => 'EVB-ROUNDTRIP-REFUND-1', + ]); + + return [$outbound->fresh(), $return->fresh()]; +} + +test('refunding a return leg draws a partial refund against the primary leg\'s combined payment', function () { + [$outbound, $return] = confirmedRoundTripWithCombinedPayment('9000.00', '11000.00'); + + $refund = app(RefundBookingAction::class)->handle($return, '11000', 'return leg cancelled'); + + expect($refund->status)->toBe(RefundStatus::Completed) + ->and($refund->payment_id)->toBe($outbound->payments()->first()->id) + ->and($return->refresh()->status)->toBe(BookingStatus::Cancelled) + ->and($outbound->refresh()->status)->toBe(BookingStatus::Confirmed); +}); + +test('each leg of a round trip can be cancelled/refunded independently without exceeding the combined payment', function () { + [$outbound, $return] = confirmedRoundTripWithCombinedPayment('9000.00', '11000.00'); + + app(RefundBookingAction::class)->handle($return, '11000', 'return leg cancelled'); + $second = app(RefundBookingAction::class)->handle($outbound, '9000', 'outbound leg cancelled too'); + + expect($second->status)->toBe(RefundStatus::Completed) + ->and($outbound->refresh()->status)->toBe(BookingStatus::Cancelled) + ->and($return->refresh()->status)->toBe(BookingStatus::Cancelled); + + // Cumulative refunds (20000) exactly match the combined payment total — + // a third refund attempt on either leg must now fail. + expect(fn () => app(RefundBookingAction::class)->handle($outbound, '1', 'over the limit')) + ->toThrow(RefundNotAllowedException::class); +}); diff --git a/app-modules/routing/database/factories/EvRouteFactory.php b/app-modules/routing/database/factories/EvRouteFactory.php index f31f966..0b31d48 100644 --- a/app-modules/routing/database/factories/EvRouteFactory.php +++ b/app-modules/routing/database/factories/EvRouteFactory.php @@ -23,7 +23,6 @@ class EvRouteFactory extends Factory 'ev_company_id' => EvCompany::factory(), 'from_destination_id' => Destination::factory(), 'to_destination_id' => Destination::factory(), - 'is_round_trip' => false, 'is_active' => true, ]; } diff --git a/app-modules/routing/database/migrations/2026_08_20_010000_add_is_popular_to_ev_routes_table.php b/app-modules/routing/database/migrations/2026_08_20_010000_add_is_popular_to_ev_routes_table.php new file mode 100644 index 0000000..76a42d1 --- /dev/null +++ b/app-modules/routing/database/migrations/2026_08_20_010000_add_is_popular_to_ev_routes_table.php @@ -0,0 +1,29 @@ +boolean('is_popular')->default(false)->after('is_active'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('ev_routes', function (Blueprint $table) { + $table->dropColumn('is_popular'); + }); + } +}; diff --git a/app-modules/routing/database/migrations/2026_08_21_000100_drop_is_round_trip_from_ev_routes_table.php b/app-modules/routing/database/migrations/2026_08_21_000100_drop_is_round_trip_from_ev_routes_table.php new file mode 100644 index 0000000..40d36e2 --- /dev/null +++ b/app-modules/routing/database/migrations/2026_08_21_000100_drop_is_round_trip_from_ev_routes_table.php @@ -0,0 +1,31 @@ +dropColumn('is_round_trip'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('ev_routes', function (Blueprint $table) { + $table->boolean('is_round_trip')->default(false); + }); + } +}; diff --git a/app-modules/routing/database/migrations/2026_08_22_000000_drop_is_popular_from_ev_routes_table.php b/app-modules/routing/database/migrations/2026_08_22_000000_drop_is_popular_from_ev_routes_table.php new file mode 100644 index 0000000..11d9c76 --- /dev/null +++ b/app-modules/routing/database/migrations/2026_08_22_000000_drop_is_popular_from_ev_routes_table.php @@ -0,0 +1,31 @@ +dropColumn('is_popular'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('ev_routes', function (Blueprint $table) { + $table->boolean('is_popular')->default(false); + }); + } +}; diff --git a/app-modules/routing/routes/routing-routes.php b/app-modules/routing/routes/routing-routes.php index 6037588..05c09c3 100644 --- a/app-modules/routing/routes/routing-routes.php +++ b/app-modules/routing/routes/routing-routes.php @@ -4,7 +4,7 @@ use Illuminate\Support\Facades\Route; use Modules\Routing\Http\Controllers\EvRouteController; Route::prefix('api/v1')->middleware(['api', 'api.auth', 'throttle:api-read'])->group(function () { - Route::get('/routes', [EvRouteController::class, 'index'])->name('routing.routes.index'); + Route::post('/routes/search', [EvRouteController::class, 'search'])->name('routing.routes.search'); Route::get('/routes/{route}', [EvRouteController::class, 'show'])->name('routing.routes.show'); Route::get('/routes/{route}/pricing', [EvRouteController::class, 'pricing'])->name('routing.routes.pricing'); Route::get('/routes/{route}/time-slots', [EvRouteController::class, 'timeSlots'])->name('routing.routes.time-slots'); diff --git a/app-modules/routing/src/Filament/Resources/EvRoutes/Schemas/EvRouteForm.php b/app-modules/routing/src/Filament/Resources/EvRoutes/Schemas/EvRouteForm.php index 3b6cf34..90409c2 100644 --- a/app-modules/routing/src/Filament/Resources/EvRoutes/Schemas/EvRouteForm.php +++ b/app-modules/routing/src/Filament/Resources/EvRoutes/Schemas/EvRouteForm.php @@ -6,6 +6,8 @@ use Filament\Forms\Components\Repeater; use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; +use Filament\Schemas\Components\Grid; +use Filament\Schemas\Components\Section; use Filament\Schemas\Schema; use Modules\Shared\Enums\VehicleOption; @@ -15,74 +17,88 @@ class EvRouteForm { return $schema ->components([ - Select::make('ev_company_id') - ->label('EV Company') - ->relationship('company', 'name') - ->required() - ->searchable() - ->preload(), - Select::make('from_destination_id') - ->label('From') - ->relationship('fromDestination', 'name') - ->required() - ->searchable() - ->preload(), - Select::make('to_destination_id') - ->label('To') - ->relationship('toDestination', 'name') - ->required() - ->searchable() - ->preload() - ->different('from_destination_id') - ->validationMessages([ - 'different' => 'The destination must be different from the origin.', - ]), - Select::make('timeSlots') - ->label('Departure Time Slots') - ->relationship('timeSlots', 'label') - ->multiple() - ->searchable() - ->preload(), - Toggle::make('is_round_trip') - ->required() - ->default(false), - Toggle::make('is_active') - ->required() - ->default(false) - ->helperText('Every non-blocked vehicle option must have a price above 0 before a route can be activated.'), - Repeater::make('pricing') - ->relationship() - ->label('Pricing') + Section::make('Route') ->schema([ - Select::make('vehicle_option') - ->options(array_combine( - array_map(fn (VehicleOption $option) => $option->value, VehicleOption::cases()), - array_map(fn (VehicleOption $option) => str($option->value)->headline()->toString(), VehicleOption::cases()), - )) - ->disabled() - ->dehydrated() - ->required(), - TextInput::make('price') - ->numeric() - ->minValue(0) - ->required(), - Toggle::make('is_blocked') - ->label('Blocked') - ->helperText('Hidden from booking regardless of price.'), + Grid::make(2) + ->schema([ + Select::make('ev_company_id') + ->label('EV Company') + ->relationship('company', 'name') + ->required() + ->searchable() + ->preload(), + Select::make('from_destination_id') + ->label('From') + ->relationship('fromDestination', 'name') + ->required() + ->searchable() + ->preload(), + Select::make('to_destination_id') + ->label('To') + ->relationship('toDestination', 'name') + ->required() + ->searchable() + ->preload() + ->different('from_destination_id') + ->validationMessages([ + 'different' => 'The destination must be different from the origin.', + ]), + Select::make('timeSlots') + ->label('Departure Time Slots') + ->relationship('timeSlots', 'label') + ->multiple() + ->searchable() + ->preload(), + ]), ]) - ->columns(3) - ->default( - collect(VehicleOption::cases()) - ->map(fn (VehicleOption $option) => [ - 'vehicle_option' => $option->value, - 'price' => 0, - 'is_blocked' => false, + ->columnSpanFull(), + Section::make('Options') + ->schema([ + Grid::make(2) + ->schema([ + Toggle::make('is_active') + ->required() + ->default(false) + ->helperText('Every non-blocked vehicle option must have a price above 0 before a route can be activated.'), + ]), + ]) + ->columnSpanFull(), + Section::make('Pricing') + ->schema([ + Repeater::make('pricing') + ->relationship() + ->hiddenLabel() + ->schema([ + Select::make('vehicle_option') + ->options(array_combine( + array_map(fn (VehicleOption $option) => $option->value, VehicleOption::cases()), + array_map(fn (VehicleOption $option) => str($option->value)->headline()->toString(), VehicleOption::cases()), + )) + ->disabled() + ->dehydrated() + ->required(), + TextInput::make('price') + ->numeric() + ->minValue(0) + ->required(), + Toggle::make('is_blocked') + ->label('Blocked') + ->helperText('Hidden from booking regardless of price.'), ]) - ->all() - ) - ->addable(false) - ->deletable(false) - ->reorderable(false) + ->columns(3) + ->default( + collect(VehicleOption::cases()) + ->map(fn (VehicleOption $option) => [ + 'vehicle_option' => $option->value, + 'price' => 0, + 'is_blocked' => false, + ]) + ->all() + ) + ->addable(false) + ->deletable(false) + ->reorderable(false), + ]) ->columnSpanFull(), ]); } diff --git a/app-modules/routing/src/Filament/Resources/EvRoutes/Tables/EvRoutesTable.php b/app-modules/routing/src/Filament/Resources/EvRoutes/Tables/EvRoutesTable.php index 70bdcdb..f7f895b 100644 --- a/app-modules/routing/src/Filament/Resources/EvRoutes/Tables/EvRoutesTable.php +++ b/app-modules/routing/src/Filament/Resources/EvRoutes/Tables/EvRoutesTable.php @@ -5,8 +5,10 @@ namespace Modules\Routing\Filament\Resources\EvRoutes\Tables; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; +use Filament\Support\Enums\Width; use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\TernaryFilter; use Filament\Tables\Table; use Modules\Routing\Models\EvRoute; @@ -40,8 +42,6 @@ class EvRoutesTable .($pricing->is_blocked ? 'Blocked' : number_format($pricing->price, 0))) ->all()) ->listWithLineBreaks(), - IconColumn::make('is_round_trip') - ->boolean(), IconColumn::make('is_active') ->boolean(), TextColumn::make('created_at') @@ -50,9 +50,25 @@ class EvRoutesTable ->toggleable(isToggledHiddenByDefault: true), ]) ->filters([ + SelectFilter::make('ev_company_id') + ->label('Company') + ->relationship('company', 'name') + ->searchable() + ->preload(), + SelectFilter::make('from_destination_id') + ->label('From') + ->relationship('fromDestination', 'name') + ->searchable() + ->preload(), + SelectFilter::make('to_destination_id') + ->label('To') + ->relationship('toDestination', 'name') + ->searchable() + ->preload(), TernaryFilter::make('is_active'), - TernaryFilter::make('is_round_trip'), ]) + ->filtersFormColumns(2) + ->filtersFormWidth(Width::Large) ->recordActions([ EditAction::make(), ]) diff --git a/app-modules/routing/src/Http/Controllers/EvRouteController.php b/app-modules/routing/src/Http/Controllers/EvRouteController.php index 7ab7bb3..4ebeabd 100644 --- a/app-modules/routing/src/Http/Controllers/EvRouteController.php +++ b/app-modules/routing/src/Http/Controllers/EvRouteController.php @@ -2,10 +2,16 @@ namespace Modules\Routing\Http\Controllers; -use Illuminate\Http\Request; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; +use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Routing\Controller; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Cache; +use Modules\Catalog\Models\EvCompany; +use Modules\Routing\Http\Requests\SearchRoutesRequest; use Modules\Routing\Http\Resources\EvRouteResource; use Modules\Routing\Http\Resources\RoutePricingResource; use Modules\Routing\Http\Resources\RouteTimeSlotResource; @@ -22,26 +28,111 @@ class EvRouteController extends Controller private const CACHE_TTL_MINUTES = 5; - public function index(Request $request): AnonymousResourceCollection + /** + * POST, not GET: round_trip=true returns two independent result sets + * (routes + return_routes) in one response, which doesn't fit a plain + * GET-with-query-params search shape as cleanly (domain.md §2b). + */ + public function search(SearchRoutesRequest $request): JsonResponse { - $filters = $request->only(['company', 'from', 'to', 'date']); - $page = $request->integer('page', 1); + $filters = $request->only(['company', 'from', 'to', 'date', 'time_slot']); + $isRoundTrip = $request->boolean('round_trip'); - $routes = Cache::tags(self::CACHE_TAG)->remember( - 'routes:index:'.md5(json_encode($filters + ['page' => $page])), + // Separate page params: routes and return_routes almost always have + // different totals, so paging one must never slice the other at the + // same offset (e.g. return_routes with only 3 rows would come back + // empty on page=2 while routes still has real data there). + $routes = $this->searchRoutes($filters, fromKey: 'from', toKey: 'to', pageName: 'page', page: $request->integer('page', 1)); + + $returnRoutes = $isRoundTrip + ? $this->searchRoutes($filters, fromKey: 'to', toKey: 'from', pageName: 'return_page', page: $request->integer('return_page', 1)) + : new LengthAwarePaginator([], 0, 15); + + return response()->json([ + 'routes' => EvRouteResource::collection($routes)->response()->getData(true), + 'return_routes' => EvRouteResource::collection($returnRoutes)->response()->getData(true), + // The company/time_slot options actually available for this + // from->to pair — computed from from/to alone, ignoring any + // company/time_slot already applied, so the client can offer + // switching between them rather than guessing a static list. + 'filters' => $this->filterOptions($filters['from'] ?? null, $filters['to'] ?? null), + 'return_filters' => $isRoundTrip + ? $this->filterOptions($filters['to'] ?? null, $filters['from'] ?? null) + : ['companies' => [], 'time_slots' => []], + ]); + } + + /** + * @return array{companies: array>, time_slots: array>} + */ + private function filterOptions(mixed $from, mixed $to): array + { + if (blank($from) || blank($to)) { + return ['companies' => [], 'time_slots' => []]; + } + + $cacheKey = "routes:filter-options:{$from}:{$to}"; + + return Cache::tags(self::CACHE_TAG)->remember( + $cacheKey, + now()->addMinutes(self::CACHE_TTL_MINUTES), + function () use ($from, $to) { + $routes = EvRoute::query() + ->where('is_active', true) + ->where('from_destination_id', $from) + ->where('to_destination_id', $to) + ->with(['company', 'timeSlots' => fn (BelongsToMany $query) => $query->wherePivot('is_active', true)]) + ->get(); + + $companies = $routes->pluck('company')->filter()->unique('id')->sortBy('name')->values(); + $timeSlots = $routes->flatMap(fn (EvRoute $route) => $route->timeSlots)->unique('id')->sortBy('time')->values(); + + return [ + // Facet purposes only — not the full EvCompanyResource + // (no slug/description/contact/logo needed just to + // populate a filter option). + 'companies' => $companies->map(fn (EvCompany $company) => [ + 'id' => $company->id, + 'name' => $company->name, + 'mm_name' => $company->mm_name, + ])->all(), + 'time_slots' => $timeSlots->map(fn ($slot) => [ + 'id' => $slot->id, + 'label' => $slot->label, + 'time' => $slot->time?->format('H:i'), + ])->all(), + ]; + }, + ); + } + + /** + * @param array $filters Keyed by 'from'/'to' regardless of + * $fromKey/$toKey — swapped for the return leg of a round trip. + */ + private function searchRoutes(array $filters, string $fromKey, string $toKey, string $pageName, int $page): LengthAwarePaginator + { + $cacheKey = 'routes:search:'.md5(json_encode($filters + ['fromKey' => $fromKey, 'page' => $page])); + + return Cache::tags(self::CACHE_TAG)->remember( + $cacheKey, now()->addMinutes(self::CACHE_TTL_MINUTES), fn () => EvRoute::query() ->where('is_active', true) - ->when($request->filled('company'), fn ($query) => $query->where('ev_company_id', $request->integer('company'))) - ->when($request->filled('from'), fn ($query) => $query->where('from_destination_id', $request->integer('from'))) - ->when($request->filled('to'), fn ($query) => $query->where('to_destination_id', $request->integer('to'))) + ->when(filled($filters['company'] ?? null), fn (Builder $query) => $query->where('ev_company_id', $filters['company'])) + ->when(filled($filters[$fromKey] ?? null), fn (Builder $query) => $query->where('from_destination_id', $filters[$fromKey])) + ->when(filled($filters[$toKey] ?? null), fn (Builder $query) => $query->where('to_destination_id', $filters[$toKey])) + ->when(filled($filters['time_slot'] ?? null), fn (Builder $query) => $query->whereHas( + 'timeSlots', + fn (Builder $timeSlotQuery) => $timeSlotQuery + ->where('departure_time_slots.time', Carbon::createFromFormat('H:i', $filters['time_slot'])->format('H:i:s')) + ->where('ev_route_time_slots.is_active', true), + )) // `date` is accepted for forward-compatibility with future per-date capacity // checks (domain.md §7), but v1 has no route-level calendar to filter against. ->with(self::EAGER_LOADS) - ->paginate(), + ->paginate(perPage: 15, pageName: $pageName, page: $page), ); - - return EvRouteResource::collection($routes); } public function show(EvRoute $route): EvRouteResource diff --git a/app-modules/routing/src/Http/Requests/SearchRoutesRequest.php b/app-modules/routing/src/Http/Requests/SearchRoutesRequest.php new file mode 100644 index 0000000..5082bdb --- /dev/null +++ b/app-modules/routing/src/Http/Requests/SearchRoutesRequest.php @@ -0,0 +1,41 @@ +> + */ + public function rules(): array + { + return [ + 'company' => ['nullable', 'integer', 'exists:ev_companies,id'], + 'from' => ['nullable', 'integer', 'exists:destinations,id', 'required_if:round_trip,true'], + 'to' => ['nullable', 'integer', 'exists:destinations,id', 'different:from', 'required_if:round_trip,true'], + 'date' => ['nullable', 'date'], + // The catalog's shared time value (e.g. "06:00"), not a + // DepartureTimeSlot id — matches how customers think about + // departure times (domain.md §1). + 'time_slot' => ['nullable', 'date_format:H:i'], + 'round_trip' => ['sometimes', 'boolean'], + // Independent page cursors — routes and return_routes almost + // always have different totals, so they can't share one `page` + // without one side silently paginating the other's offset. + 'page' => ['nullable', 'integer', 'min:1'], + 'return_page' => ['nullable', 'integer', 'min:1'], + ]; + } +} diff --git a/app-modules/routing/src/Http/Resources/EvRouteResource.php b/app-modules/routing/src/Http/Resources/EvRouteResource.php index 6b8d7db..f85a783 100644 --- a/app-modules/routing/src/Http/Resources/EvRouteResource.php +++ b/app-modules/routing/src/Http/Resources/EvRouteResource.php @@ -18,7 +18,6 @@ class EvRouteResource extends JsonResource { return [ 'id' => $this->id, - 'is_round_trip' => $this->is_round_trip, 'is_active' => $this->is_active, 'company' => new EvCompanyResource($this->whenLoaded('company')), 'from_destination' => new DestinationResource($this->whenLoaded('fromDestination')), diff --git a/app-modules/routing/src/Models/EvRoute.php b/app-modules/routing/src/Models/EvRoute.php index 8e08350..ee96dfd 100644 --- a/app-modules/routing/src/Models/EvRoute.php +++ b/app-modules/routing/src/Models/EvRoute.php @@ -42,7 +42,6 @@ class EvRoute extends Model 'ev_company_id', 'from_destination_id', 'to_destination_id', - 'is_round_trip', 'is_active', ]; @@ -52,7 +51,6 @@ class EvRoute extends Model protected function casts(): array { return [ - 'is_round_trip' => 'boolean', 'is_active' => 'boolean', ]; } @@ -99,4 +97,16 @@ class EvRoute extends Model get: fn (): string => $this->fromDestination->name.' → '.$this->toDestination->name, ); } + + /** + * True when this route is the exact reverse direction of $other (from + * and to swapped) — used to validate a booking's `return_ev_route_id` + * is genuinely the return leg of its outbound route, not an unrelated + * pair (domain.md §2b). + */ + public function isReverseOf(EvRoute $other): bool + { + return $this->from_destination_id === $other->to_destination_id + && $this->to_destination_id === $other->from_destination_id; + } } diff --git a/app-modules/routing/tests/Feature/EvRouteResourceTest.php b/app-modules/routing/tests/Feature/EvRouteResourceTest.php index 2cd8c66..12f4d57 100644 --- a/app-modules/routing/tests/Feature/EvRouteResourceTest.php +++ b/app-modules/routing/tests/Feature/EvRouteResourceTest.php @@ -41,6 +41,39 @@ test('can list ev routes', function () { ->assertCanSeeTableRecords($routes); }); +test('can filter ev routes by company, from, and to', function () { + $companyA = EvCompany::factory()->create(); + $companyB = EvCompany::factory()->create(); + $yangon = Destination::factory()->create(); + $mandalay = Destination::factory()->create(); + $bagan = Destination::factory()->create(); + + $matching = EvRoute::factory()->create([ + 'ev_company_id' => $companyA->id, + 'from_destination_id' => $yangon->id, + 'to_destination_id' => $mandalay->id, + ]); + + $wrongCompany = EvRoute::factory()->create([ + 'ev_company_id' => $companyB->id, + 'from_destination_id' => $yangon->id, + 'to_destination_id' => $mandalay->id, + ]); + + $wrongDestination = EvRoute::factory()->create([ + 'ev_company_id' => $companyA->id, + 'from_destination_id' => $yangon->id, + 'to_destination_id' => $bagan->id, + ]); + + Livewire::test(ListEvRoutes::class) + ->filterTable('ev_company_id', $companyA->id) + ->filterTable('from_destination_id', $yangon->id) + ->filterTable('to_destination_id', $mandalay->id) + ->assertCanSeeTableRecords([$matching]) + ->assertCanNotSeeTableRecords([$wrongCompany, $wrongDestination]); +}); + test('list shows each vehicle option price stacked, and blocked options instead of a price', function () { $route = EvRoute::factory()->create(); @@ -63,7 +96,6 @@ test('creating a route also creates all three vehicle option pricing rows, defau 'ev_company_id' => $company->id, 'from_destination_id' => $from->id, 'to_destination_id' => $to->id, - 'is_round_trip' => false, 'is_active' => false, 'pricing' => pricingPayload(), ]) diff --git a/app-modules/routing/tests/Feature/EvRouteTest.php b/app-modules/routing/tests/Feature/EvRouteTest.php index 4cfdab2..eadd273 100644 --- a/app-modules/routing/tests/Feature/EvRouteTest.php +++ b/app-modules/routing/tests/Feature/EvRouteTest.php @@ -25,14 +25,26 @@ test('an ev route belongs to a company and two destinations', function () { ->and($route->toDestination->is($to))->toBeTrue(); }); -test('is_round_trip and is_active cast to boolean', function () { +test('is_active casts to boolean', function () { $route = EvRoute::factory()->create([ - 'is_round_trip' => 1, 'is_active' => 0, ]); - expect($route->is_round_trip)->toBeTrue() - ->and($route->is_active)->toBeFalse(); + expect($route->is_active)->toBeFalse(); +}); + +test('isReverseOf detects a route with from/to swapped', function () { + $a = Destination::factory()->create(); + $b = Destination::factory()->create(); + + $outbound = EvRoute::factory()->create(['from_destination_id' => $a->id, 'to_destination_id' => $b->id]); + $return = EvRoute::factory()->create(['from_destination_id' => $b->id, 'to_destination_id' => $a->id]); + $unrelated = EvRoute::factory()->create(); + + expect($return->isReverseOf($outbound))->toBeTrue() + ->and($outbound->isReverseOf($return))->toBeTrue() + ->and($unrelated->isReverseOf($outbound))->toBeFalse() + ->and($outbound->isReverseOf($outbound))->toBeFalse(); }); test('a route can be attached to time slots via the pivot, carrying its own is_active flag', function () { diff --git a/app-modules/routing/tests/Feature/RoutesCachingTest.php b/app-modules/routing/tests/Feature/RoutesCachingTest.php index f24a06d..dfd528d 100644 --- a/app-modules/routing/tests/Feature/RoutesCachingTest.php +++ b/app-modules/routing/tests/Feature/RoutesCachingTest.php @@ -41,42 +41,42 @@ test('saving an ev route invalidates the routes cache tag', function () { $route = EvRoute::factory()->create(['is_active' => true]); $this->withHeader('Authorization', "Bearer {$this->token}") - ->getJson('/api/v1/routes') - ->assertJsonCount(1, 'data'); + ->postJson('/api/v1/routes/search') + ->assertJsonCount(1, 'routes.data'); // Bypass Eloquent so the change wouldn't be visible without invalidation. EvRoute::query()->where('id', $route->id)->update(['is_active' => false]); $this->withHeader('Authorization', "Bearer {$this->token}") - ->getJson('/api/v1/routes') - ->assertJsonCount(1, 'data'); + ->postJson('/api/v1/routes/search') + ->assertJsonCount(1, 'routes.data'); $route->refresh()->save(); $this->withHeader('Authorization', "Bearer {$this->token}") - ->getJson('/api/v1/routes') - ->assertJsonCount(0, 'data'); + ->postJson('/api/v1/routes/search') + ->assertJsonCount(0, 'routes.data'); }); test('deleting an ev route invalidates the routes cache tag', function () { $route = EvRoute::factory()->create(['is_active' => true]); $this->withHeader('Authorization', "Bearer {$this->token}") - ->getJson('/api/v1/routes') - ->assertJsonCount(1, 'data'); + ->postJson('/api/v1/routes/search') + ->assertJsonCount(1, 'routes.data'); $route->delete(); $this->withHeader('Authorization', "Bearer {$this->token}") - ->getJson('/api/v1/routes') - ->assertJsonCount(0, 'data'); + ->postJson('/api/v1/routes/search') + ->assertJsonCount(0, 'routes.data'); }); test('flushing the routes cache tag does not affect other cached data', function () { Cache::put('unrelated-key', 'still here', now()->addMinutes(5)); $route = EvRoute::factory()->create(['is_active' => true]); - $route->update(['is_round_trip' => true]); + $route->update(['is_active' => false]); expect(Cache::get('unrelated-key'))->toBe('still here'); }); diff --git a/app-modules/routing/tests/Feature/RoutesReadApiTest.php b/app-modules/routing/tests/Feature/RoutesReadApiTest.php index 806921c..49a1c18 100644 --- a/app-modules/routing/tests/Feature/RoutesReadApiTest.php +++ b/app-modules/routing/tests/Feature/RoutesReadApiTest.php @@ -12,7 +12,7 @@ beforeEach(function () { $this->token = User::factory()->create()->createToken('test-token')->plainTextToken; }); -test('lists active routes with nested company, destinations, time slots and pricing', function () { +test('searches active routes with nested company, destinations, time slots and pricing', function () { $route = EvRoute::factory()->create(['is_active' => true]); EvRoute::factory()->create(['is_active' => false]); @@ -26,17 +26,18 @@ test('lists active routes with nested company, destinations, time slots and pric ]); $this->withHeader('Authorization', "Bearer {$this->token}") - ->getJson('/api/v1/routes') + ->postJson('/api/v1/routes/search') ->assertSuccessful() - ->assertJsonCount(1, 'data') - ->assertJsonPath('data.0.id', $route->id) - ->assertJsonPath('data.0.company.id', $route->ev_company_id) - ->assertJsonPath('data.0.from_destination.id', $route->from_destination_id) - ->assertJsonPath('data.0.to_destination.id', $route->to_destination_id) - ->assertJsonPath('data.0.time_slots.0.id', $slot->id) - ->assertJsonPath('data.0.time_slots.0.is_active', true) - ->assertJsonPath('data.0.pricing.0.vehicle_option', 'front_seat') - ->assertJsonPath('data.0.pricing.0.price', '12000.00'); + ->assertJsonCount(1, 'routes.data') + ->assertJsonPath('routes.data.0.id', $route->id) + ->assertJsonPath('routes.data.0.company.id', $route->ev_company_id) + ->assertJsonPath('routes.data.0.from_destination.id', $route->from_destination_id) + ->assertJsonPath('routes.data.0.to_destination.id', $route->to_destination_id) + ->assertJsonPath('routes.data.0.time_slots.0.id', $slot->id) + ->assertJsonPath('routes.data.0.time_slots.0.is_active', true) + ->assertJsonPath('routes.data.0.pricing.0.vehicle_option', 'front_seat') + ->assertJsonPath('routes.data.0.pricing.0.price', '12000.00') + ->assertJsonCount(0, 'return_routes.data'); }); test('filters routes by company, from, and to', function () { @@ -68,24 +69,211 @@ test('filters routes by company, from, and to', function () { ]); $this->withHeader('Authorization', "Bearer {$this->token}") - ->getJson('/api/v1/routes?'.http_build_query([ + ->postJson('/api/v1/routes/search', [ 'company' => $companyA->id, 'from' => $yangon->id, 'to' => $mandalay->id, - ])) + ]) ->assertSuccessful() - ->assertJsonCount(1, 'data') - ->assertJsonPath('data.0.id', $matching->id); + ->assertJsonCount(1, 'routes.data') + ->assertJsonPath('routes.data.0.id', $matching->id); +}); + +test('filters routes by time_slot', function () { + $morning = DepartureTimeSlot::factory()->create(['time' => '06:00']); + $evening = DepartureTimeSlot::factory()->create(['time' => '18:00']); + + $morningRoute = EvRoute::factory()->create(['is_active' => true]); + $morningRoute->timeSlots()->attach($morning->id, ['is_active' => true]); + + $eveningRoute = EvRoute::factory()->create(['is_active' => true]); + $eveningRoute->timeSlots()->attach($evening->id, ['is_active' => true]); + + // Attached but inactive on this route — must not match. + $inactivePivotRoute = EvRoute::factory()->create(['is_active' => true]); + $inactivePivotRoute->timeSlots()->attach($morning->id, ['is_active' => false]); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->postJson('/api/v1/routes/search', ['time_slot' => '06:00']) + ->assertSuccessful() + ->assertJsonCount(1, 'routes.data') + ->assertJsonPath('routes.data.0.id', $morningRoute->id); +}); + +test('round trip: returns both routes and return_routes, swapped from/to', function () { + $company = EvCompany::factory()->create(); + $yangon = Destination::factory()->create(); + $mandalay = Destination::factory()->create(); + + $outbound = EvRoute::factory()->create([ + 'ev_company_id' => $company->id, + 'from_destination_id' => $yangon->id, + 'to_destination_id' => $mandalay->id, + 'is_active' => true, + ]); + $return = EvRoute::factory()->create([ + 'ev_company_id' => $company->id, + 'from_destination_id' => $mandalay->id, + 'to_destination_id' => $yangon->id, + 'is_active' => true, + ]); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->postJson('/api/v1/routes/search', [ + 'round_trip' => true, + 'from' => $yangon->id, + 'to' => $mandalay->id, + ]) + ->assertSuccessful() + ->assertJsonCount(1, 'routes.data') + ->assertJsonPath('routes.data.0.id', $outbound->id) + ->assertJsonCount(1, 'return_routes.data') + ->assertJsonPath('return_routes.data.0.id', $return->id); +}); + +test('response includes the distinct companies and time_slots actually available for the from-to pair', function () { + $yangon = Destination::factory()->create(); + $mandalay = Destination::factory()->create(); + + $companyA = EvCompany::factory()->create(['name' => 'Alpha EV']); + $companyB = EvCompany::factory()->create(['name' => 'Beta EV']); + $morning = DepartureTimeSlot::factory()->create(['time' => '06:00']); + $evening = DepartureTimeSlot::factory()->create(['time' => '18:00']); + $inactiveSlot = DepartureTimeSlot::factory()->create(['time' => '12:00']); + + $routeA = EvRoute::factory()->create([ + 'ev_company_id' => $companyA->id, + 'from_destination_id' => $yangon->id, + 'to_destination_id' => $mandalay->id, + 'is_active' => true, + ]); + $routeA->timeSlots()->attach([$morning->id => ['is_active' => true], $inactiveSlot->id => ['is_active' => false]]); + + $routeB = EvRoute::factory()->create([ + 'ev_company_id' => $companyB->id, + 'from_destination_id' => $yangon->id, + 'to_destination_id' => $mandalay->id, + 'is_active' => true, + ]); + $routeB->timeSlots()->attach($evening->id, ['is_active' => true]); + + // Unrelated pair — must not leak into the facets. + $bagan = Destination::factory()->create(); + $unrelated = EvRoute::factory()->create([ + 'from_destination_id' => $yangon->id, + 'to_destination_id' => $bagan->id, + 'is_active' => true, + ]); + $unrelated->timeSlots()->attach(DepartureTimeSlot::factory()->create(['time' => '09:00'])->id, ['is_active' => true]); + + // Applying a company filter narrows `routes.data` but must not narrow + // the facets themselves — facets always reflect the full from-to pair. + $response = $this->withHeader('Authorization', "Bearer {$this->token}") + ->postJson('/api/v1/routes/search', [ + 'from' => $yangon->id, + 'to' => $mandalay->id, + 'company' => $companyA->id, + ]) + ->assertSuccessful() + ->assertJsonCount(1, 'routes.data') + ->json(); + + expect(collect($response['filters']['companies'])->pluck('id')->sort()->values()->all()) + ->toBe([$companyA->id, $companyB->id]); + // Facet shape is trimmed to id/name/mm_name — not the full company resource. + expect(array_keys($response['filters']['companies'][0]))->toBe(['id', 'name', 'mm_name']); + expect(collect($response['filters']['time_slots'])->pluck('time')->all()) + ->toBe(['06:00', '18:00']); // sorted by time, inactive pivot and unrelated pair excluded +}); + +test('filter options are empty when from/to are not both given', function () { + $this->withHeader('Authorization', "Bearer {$this->token}") + ->postJson('/api/v1/routes/search') + ->assertSuccessful() + ->assertJson(['filters' => ['companies' => [], 'time_slots' => []]]); +}); + +test('round trip: return_filters reflect the swapped to-from pair', function () { + $yangon = Destination::factory()->create(); + $mandalay = Destination::factory()->create(); + $returnCompany = EvCompany::factory()->create(); + + EvRoute::factory()->create([ + 'from_destination_id' => $yangon->id, + 'to_destination_id' => $mandalay->id, + 'is_active' => true, + ]); + EvRoute::factory()->create([ + 'ev_company_id' => $returnCompany->id, + 'from_destination_id' => $mandalay->id, + 'to_destination_id' => $yangon->id, + 'is_active' => true, + ]); + + $response = $this->withHeader('Authorization', "Bearer {$this->token}") + ->postJson('/api/v1/routes/search', [ + 'round_trip' => true, + 'from' => $yangon->id, + 'to' => $mandalay->id, + ]) + ->assertSuccessful() + ->json(); + + expect(collect($response['return_filters']['companies'])->pluck('id')->all()) + ->toBe([$returnCompany->id]); +}); + +test('round trip: routes and return_routes paginate independently via page and return_page', function () { + $company = EvCompany::factory()->create(); + $yangon = Destination::factory()->create(); + $mandalay = Destination::factory()->create(); + + // 20 outbound routes (2 pages of 15), only 3 return routes (1 page). + EvRoute::factory()->count(20)->create([ + 'ev_company_id' => $company->id, + 'from_destination_id' => $yangon->id, + 'to_destination_id' => $mandalay->id, + 'is_active' => true, + ]); + EvRoute::factory()->count(3)->create([ + 'ev_company_id' => $company->id, + 'from_destination_id' => $mandalay->id, + 'to_destination_id' => $yangon->id, + 'is_active' => true, + ]); + + // page=2 must give the 2nd page of routes (5 remaining), while + // return_routes — with no return_page given — must still return its own + // full page 1 (all 3), not an empty slice at offset 2. + $this->withHeader('Authorization', "Bearer {$this->token}") + ->postJson('/api/v1/routes/search', [ + 'round_trip' => true, + 'from' => $yangon->id, + 'to' => $mandalay->id, + 'page' => 2, + ]) + ->assertSuccessful() + ->assertJsonCount(5, 'routes.data') + ->assertJsonPath('routes.meta.current_page', 2) + ->assertJsonCount(3, 'return_routes.data') + ->assertJsonPath('return_routes.meta.current_page', 1); +}); + +test('round_trip without from and to is rejected', function () { + $this->withHeader('Authorization', "Bearer {$this->token}") + ->postJson('/api/v1/routes/search', ['round_trip' => true]) + ->assertStatus(422) + ->assertJsonValidationErrors(['from', 'to']); }); test('paginates routes', function () { EvRoute::factory()->count(20)->create(['is_active' => true]); $this->withHeader('Authorization', "Bearer {$this->token}") - ->getJson('/api/v1/routes') + ->postJson('/api/v1/routes/search') ->assertSuccessful() - ->assertJsonCount(15, 'data') - ->assertJsonPath('meta.total', 20); + ->assertJsonCount(15, 'routes.data') + ->assertJsonPath('routes.meta.total', 20); }); test('shows a single active route', function () { @@ -148,7 +336,7 @@ test('lists a route\'s time slots with the pivot active flag', function () { test('routes endpoints reject unauthenticated requests', function () { $route = EvRoute::factory()->create(['is_active' => true]); - $this->getJson('/api/v1/routes')->assertUnauthorized(); + $this->postJson('/api/v1/routes/search')->assertUnauthorized(); $this->getJson("/api/v1/routes/{$route->id}")->assertUnauthorized(); $this->getJson("/api/v1/routes/{$route->id}/pricing")->assertUnauthorized(); $this->getJson("/api/v1/routes/{$route->id}/time-slots")->assertUnauthorized(); diff --git a/domain.md b/domain.md index 0d9826d..443e091 100644 --- a/domain.md +++ b/domain.md @@ -11,10 +11,10 @@ Reference doc for business rules and domain vocabulary. Pull this up alongside ` | **EV Company** | A vehicle operator/fleet owner. Plain reference data — not a tenant (see §4). | | **Destination** | A city/town served by routes. Used as both origin and endpoint. | | **Departure Time Slot** | A shared catalog of times (e.g. "06:00 AM"); attached to routes via a pivot, not owned by one route. | -| **EV Route** | Company + From Destination + To Destination + round-trip flag + one or more Time Slots + pricing per Vehicle Option. | +| **EV Route** | Company + From Destination + To Destination + one or more Time Slots + pricing per Vehicle Option. One row is one direction only — round trip is not a flag on the route, see §2b. | | **Vehicle Option** | What the customer books: `front_seat`, `back_seat`, or `whole_vehicle`. Not a numbered seat — see §2. | | **Pickup/Dropoff Address** | Free-text address (+ optional lat/lng) the customer supplies when booking — where the EV meets/drops them. Captured per Booking, not a catalog entity — see §2a. | -| **Booking** | A customer's reservation on one Route + Date + Time Slot, with customer-supplied pickup/dropoff addresses. Covers one or more Vehicle Option selections (e.g. `front_seat` + `back_seat` together), each with its own passenger count — see `booking_vehicle_options` in §2. Once `confirmed`, staff assign a driver/vehicle to it — see §5a. | +| **Booking** | A customer's reservation on one Route + Date + Time Slot, with customer-supplied pickup/dropoff addresses. Covers one or more Vehicle Option selections (e.g. `front_seat` + `back_seat` together), each with its own passenger count — see `booking_vehicle_options` in §2. Once `confirmed`, staff assign a driver/vehicle to it — see §5a. A round trip is **two** linked Bookings (outbound + return), not one — see §2b. | | **Payment** | One attempt to pay for a Booking through a gateway (may retry after failure). | | **Refund** | A reversal against a specific successful Payment (not against the Booking directly). | @@ -53,6 +53,21 @@ The real-world business model is **door-to-door**: the EV drives to wherever the --- +## 2b. Round Trips + +Client-confirmed business rule: a round trip's return leg is driven by **whichever vehicle/driver is next available**, never guaranteed to be the same car that did the outbound leg. This is why round trip is **not** a flag on a single `EvRoute`/`Booking` row — it's modeled as **two independent, linked one-way `Booking` rows** (outbound + return), each with its own route, time slot, price, status, and driver/vehicle assignment slot (a single `Booking` only has one set of `driver_name`/`car_plate_number`/etc. columns, which can't represent two different vehicles). + +- **Linking**: `bookings.linked_booking_id` — a nullable, self-referencing FK, set bidirectionally once both legs exist. `bookings.is_return_leg` distinguishes which half is which. `Booking::isRoundTrip` is a computed accessor (`linked_booking_id !== null`), not a stored column. +- **Return route**: the client explicitly supplies `return_ev_route_id` (mirroring `ev_route_id` for the outbound leg) — it must already exist as a real catalog `EvRoute` (admin-created, e.g. B→A). The server validates it's genuinely the reverse of the outbound route (`EvRoute::isReverseOf` — from/to swapped), rejecting with 422 otherwise. There is no auto-derivation of a reverse route, since multiple companies could plausibly run the same pair. +- `EvRoute.is_round_trip` was removed — it was never load-bearing, and with the return route now explicit + validated it has no remaining purpose. +- **Discovering the return route**: `POST /api/v1/routes/search` (not GET — see below) accepts `round_trip=true` alongside `from`/`to` (both required when round trip) and returns **two** result sets in one response: `routes` (from→to) and `return_routes` (to→from, swapped), each a normal paginated collection with its own nested `data`/`links`/`meta` — not a single shared pagination block, since the two sides almost always have different totals. Paging them is likewise independent: `page` pages `routes`, `return_page` pages `return_routes`, each defaulting to 1 and generating links under its own param name. This is how a client finds the `return_ev_route_id` to submit with the booking. It's POST rather than GET because the response shape genuinely branches (two independent collections) rather than being a single filtered list — a query-string GET stays a better fit for the plain `show`/`pricing`/`time-slots` single-route endpoints, which are unchanged. The search endpoint also accepts `time_slot` (a catalog time value like `"06:00"`, not a `DepartureTimeSlot` id) to filter to routes offering that departure time, applied identically to both `routes` and `return_routes`. +- **Filter facets**: the response also carries `filters` (and `return_filters` when round trip) — the distinct companies and active time slots actually available for that specific from→to pair, computed independently of any `company`/`time_slot` already applied (so narrowing by one doesn't collapse the options shown for the other). Empty when `from`/`to` aren't both given. Company facet entries are trimmed to `id`/`name`/`mm_name` — not the full company resource (no slug/description/contact/logo needed just to populate a filter dropdown). +- **"Popular routes"** (`EvRoute.is_popular`) was removed (2026-08-22) — the blunt boolean flag didn't match the client's actual popularity logic. Revisit once that logic is specified; don't re-add a plain boolean without it. +- **Cancellation/refund**: each leg cancels and refunds **independently** — cancelling the return leg does not touch the outbound leg and vice versa. +- **Payment**: **combined** on the outbound ("primary") leg — one `Payment` row covers both legs' total (`InitiatePaymentAction` sums `outbound.price + return.price`). The return leg is marked `confirmed` when the primary's payment succeeds (`MarkBookingPaid` confirms both). Since the return leg has no `Payment` of its own, `RefundBookingAction` resolves the payment-holder via `linkedBooking` when refunding a return leg — partial refunds (already supported, §6) keep the running total correctly bounded to the combined `Payment.amount` regardless of which leg is cancelled first. + +--- + ## 3. Pricing - `RoutePricing` holds one price per (Route, Vehicle Option) pair. @@ -90,6 +105,7 @@ Once a Booking is `confirmed` (paid), dispatch assigns who's actually doing the - Filled in via `AssignDriverAction`, gated to `confirmed` bookings only — assigning a driver to a `pending_payment`/`cancelled`/`expired` booking is rejected (`DriverAssignmentNotAllowedException`). Staff can re-run it to reassign a different driver/vehicle as long as the booking is still `confirmed`. - Filament-only for now: the "Assign Driver" action on the admin Booking list/detail page (`manage_bookings` permission), no customer-facing write path. The values are exposed read-only on the booking API response (`GET /api/v1/bookings*`) so a confirmed customer can see who's picking them up. - **Deliberate v1 simplification**: no `drivers`/`vehicles` catalog, no driver scheduling/availability, no linking a driver to an `EvCompany`. If driver roster management becomes a real need, this is the natural point to introduce a `Driver`/`Vehicle` catalog and swap these free-text columns for FKs — not scoped now. +- Round trip needed no schema change here: since a round trip is two independent `Booking` rows (§2b), each leg already has its own independent set of these columns — the outbound and return leg can be assigned different drivers/vehicles with zero extra modeling. --- @@ -114,6 +130,8 @@ The existing KBZ Mini App payment code at `/home/marcspecta/company_projects/bnf **Webhook idempotency**: KBZ may retry the notify webhook. The handler must check the Payment's current status before transitioning it — never assume a webhook call is the first/only delivery. +**Round trips**: payment is combined on the outbound leg (§2b) — `MarkBookingPaid` confirms both the primary booking and its linked return leg when payment succeeds, and `RefundBookingAction` resolves the payment-holder via `linkedBooking` when refunding a return leg (it has no `Payment` of its own). + --- ## 7. Deferred / Future (do not build yet)