diff --git a/CLAUDE.md b/CLAUDE.md index 37bcf67..d669edf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -163,3 +163,8 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac - This application runs inside Docker via Laravel Sail. Use `./vendor/bin/sail artisan ...` instead of `php artisan ...`, and `./vendor/bin/sail composer ...` instead of `composer ...`. - For binaries not wrapped by Sail's own commands (e.g. Pint), run them inside the container: `./vendor/bin/sail exec laravel.test vendor/bin/pint --dirty --format agent`. - Check containers are up first with `./vendor/bin/sail ps` before running commands; start them with `./vendor/bin/sail up -d` if they aren't. + +## Architecture / ERD Diagram + +- The canonical tldraw board for this project's architecture and ERD lives at `/home/marcspecta/Documents/EV Booking System Architecture.tldraw` (outside the repo — not committed). Use this path when opening/updating the board with the tldraw-offline skill/agent. +- Do not copy or save this file into the project directory; a stray copy there was previously deleted. diff --git a/app-modules/booking/database/factories/BookingFactory.php b/app-modules/booking/database/factories/BookingFactory.php new file mode 100644 index 0000000..5a10a84 --- /dev/null +++ b/app-modules/booking/database/factories/BookingFactory.php @@ -0,0 +1,53 @@ + + */ +class BookingFactory extends Factory +{ + protected $model = Booking::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'booking_ref' => 'EVB-'.strtoupper(Str::random(6)), + 'user_id' => null, + 'openid' => null, + 'ev_route_id' => EvRoute::factory(), + 'departure_time_slot_id' => DepartureTimeSlot::factory(), + 'travel_date' => now()->addDay()->toDateString(), + 'passenger_name' => $this->faker->name(), + 'passenger_phone' => $this->faker->phoneNumber(), + 'pickup_address' => $this->faker->address(), + 'pickup_lat' => null, + 'pickup_lng' => null, + 'dropoff_address' => $this->faker->address(), + 'dropoff_lat' => null, + '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, + 'car_plate_number' => null, + 'car_model' => null, + ]; + } +} diff --git a/app-modules/booking/database/factories/BookingVehicleOptionFactory.php b/app-modules/booking/database/factories/BookingVehicleOptionFactory.php new file mode 100644 index 0000000..78b6b6d --- /dev/null +++ b/app-modules/booking/database/factories/BookingVehicleOptionFactory.php @@ -0,0 +1,32 @@ + + */ +class BookingVehicleOptionFactory extends Factory +{ + protected $model = BookingVehicleOption::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'booking_id' => Booking::factory(), + 'vehicle_option' => VehicleOption::BackSeat, + 'passenger_count' => 1, + 'unit_price' => 9000.00, + 'line_total' => 9000.00, + ]; + } +} diff --git a/app-modules/booking/database/migrations/2026_08_07_090000_create_bookings_table.php b/app-modules/booking/database/migrations/2026_08_07_090000_create_bookings_table.php new file mode 100644 index 0000000..0168e4d --- /dev/null +++ b/app-modules/booking/database/migrations/2026_08_07_090000_create_bookings_table.php @@ -0,0 +1,50 @@ +id(); + $table->string('booking_ref')->unique(); + $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('openid')->nullable()->index(); + $table->foreignId('ev_route_id')->constrained('ev_routes')->cascadeOnDelete(); + $table->foreignId('departure_time_slot_id')->constrained('departure_time_slots')->cascadeOnDelete(); + $table->date('travel_date'); + $table->string('passenger_name'); + $table->string('passenger_phone'); + $table->string('pickup_address'); + $table->decimal('pickup_lat', 10, 7)->nullable(); + $table->decimal('pickup_lng', 10, 7)->nullable(); + $table->string('dropoff_address'); + $table->decimal('dropoff_lat', 10, 7)->nullable(); + $table->decimal('dropoff_lng', 10, 7)->nullable(); + // Total across all booking_vehicle_options lines — see that table for the + // per-vehicle-option breakdown (a booking can mix e.g. front_seat + back_seat). + $table->decimal('price', 10, 2); + $table->string('status')->default('pending_payment'); + $table->boolean('is_round_trip')->default(false); + $table->date('return_travel_date')->nullable(); + $table->string('created_by_channel'); + $table->timestamps(); + + $table->index(['ev_route_id', 'travel_date', 'departure_time_slot_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('bookings'); + } +}; diff --git a/app-modules/booking/database/migrations/2026_08_07_090600_create_booking_vehicle_options_table.php b/app-modules/booking/database/migrations/2026_08_07_090600_create_booking_vehicle_options_table.php new file mode 100644 index 0000000..ff11061 --- /dev/null +++ b/app-modules/booking/database/migrations/2026_08_07_090600_create_booking_vehicle_options_table.php @@ -0,0 +1,46 @@ +id(); + $table->foreignId('booking_id')->constrained('bookings')->cascadeOnDelete(); + $table->string('vehicle_option'); + $table->unsignedInteger('passenger_count')->default(1); + // Snapshotted at booking time, same as bookings.price — never re-read + // from route_pricing later (domain.md §3). + $table->decimal('unit_price', 10, 2); + $table->decimal('line_total', 10, 2); + $table->timestamps(); + + $table->unique(['booking_id', 'vehicle_option']); + $table->index('vehicle_option'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('booking_vehicle_options'); + } +}; diff --git a/app-modules/booking/database/migrations/2026_08_08_090000_add_driver_and_car_to_bookings_table.php b/app-modules/booking/database/migrations/2026_08_08_090000_add_driver_and_car_to_bookings_table.php new file mode 100644 index 0000000..c696a65 --- /dev/null +++ b/app-modules/booking/database/migrations/2026_08_08_090000_add_driver_and_car_to_bookings_table.php @@ -0,0 +1,34 @@ +string('driver_name')->nullable(); + $table->string('driver_phone')->nullable(); + $table->string('car_plate_number')->nullable(); + $table->string('car_model')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('bookings', function (Blueprint $table) { + $table->dropColumn(['driver_name', 'driver_phone', 'car_plate_number', 'car_model']); + }); + } +}; diff --git a/app-modules/booking/routes/booking-routes.php b/app-modules/booking/routes/booking-routes.php index b3d9bbc..79af426 100644 --- a/app-modules/booking/routes/booking-routes.php +++ b/app-modules/booking/routes/booking-routes.php @@ -1 +1,11 @@ middleware(['api', 'auth:sanctum', 'throttle:60,1'])->group(function () { + Route::get('/bookings', [BookingController::class, 'index'])->name('booking.bookings.index'); + Route::get('/bookings/{booking:booking_ref}', [BookingController::class, 'show'])->name('booking.bookings.show'); + Route::post('/bookings', [BookingController::class, 'store'])->name('booking.bookings.store'); + Route::post('/bookings/{booking:booking_ref}/cancel', [BookingController::class, 'cancel'])->name('booking.bookings.cancel'); +}); diff --git a/app-modules/booking/src/Actions/AssignDriverAction.php b/app-modules/booking/src/Actions/AssignDriverAction.php new file mode 100644 index 0000000..ba331de --- /dev/null +++ b/app-modules/booking/src/Actions/AssignDriverAction.php @@ -0,0 +1,33 @@ +status !== BookingStatus::Confirmed) { + throw DriverAssignmentNotAllowedException::notConfirmed($booking); + } + + $booking->update([ + 'driver_name' => $data->driverName, + 'driver_phone' => $data->driverPhone, + 'car_plate_number' => $data->carPlateNumber, + 'car_model' => $data->carModel, + ]); + + return $booking; + } +} diff --git a/app-modules/booking/src/Actions/CancelBookingAction.php b/app-modules/booking/src/Actions/CancelBookingAction.php new file mode 100644 index 0000000..231be9f --- /dev/null +++ b/app-modules/booking/src/Actions/CancelBookingAction.php @@ -0,0 +1,27 @@ +status !== BookingStatus::PendingPayment) { + throw BookingCannotBeCancelledException::notPendingPayment($booking); + } + + $booking->update(['status' => BookingStatus::Cancelled]); + + return $booking; + } +} diff --git a/app-modules/booking/src/Actions/CreateBookingAction.php b/app-modules/booking/src/Actions/CreateBookingAction.php new file mode 100644 index 0000000..9df37c5 --- /dev/null +++ b/app-modules/booking/src/Actions/CreateBookingAction.php @@ -0,0 +1,87 @@ +bookingService->validateSelections($data->selections); + + return DB::transaction(function () use ($data) { + $route = EvRoute::findOrFail($data->evRouteId); + + $lines = array_map( + fn (VehicleSelectionData $selection) => $this->priceSelection($route, $selection), + $data->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' => $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, + ]); + + $booking->vehicleOptions()->createMany($lines); + + BookingCreated::dispatch($booking); + + return $booking; + }); + } + + /** + * @return array{vehicle_option: VehicleOption, passenger_count: int, unit_price: string, line_total: string} + */ + private function priceSelection(EvRoute $route, VehicleSelectionData $selection): array + { + $quote = $this->pricingService->quote($route, $selection->vehicleOption); + + return [ + 'vehicle_option' => $selection->vehicleOption, + 'passenger_count' => $selection->passengerCount, + 'unit_price' => $quote->price, + 'line_total' => bcmul($quote->price, (string) $selection->passengerCount, 2), + ]; + } +} diff --git a/app-modules/booking/src/BookingPlugin.php b/app-modules/booking/src/BookingPlugin.php new file mode 100644 index 0000000..7c0c0be --- /dev/null +++ b/app-modules/booking/src/BookingPlugin.php @@ -0,0 +1,38 @@ +discoverResources( + in: __DIR__.'/Filament/Resources', + for: 'Modules\Booking\Filament\Resources', + ) + ->discoverPages( + in: __DIR__.'/Filament/Pages', + for: 'Modules\Booking\Filament\Pages', + ) + ->discoverWidgets( + in: __DIR__.'/Filament/Widgets', + for: 'Modules\Booking\Filament\Widgets', + ); + } + + public function boot(Panel $panel): void {} + + public static function make(): static + { + return app(static::class); + } +} diff --git a/app-modules/booking/src/Data/AssignDriverData.php b/app-modules/booking/src/Data/AssignDriverData.php new file mode 100644 index 0000000..a0bc26a --- /dev/null +++ b/app-modules/booking/src/Data/AssignDriverData.php @@ -0,0 +1,13 @@ + $selections One or more Vehicle Option + * selections (e.g. front_seat + back_seat) — domain.md §2. + */ + public function __construct( + public int $evRouteId, + public int $departureTimeSlotId, + public string $travelDate, + public array $selections, + public string $passengerName, + public string $passengerPhone, + public string $pickupAddress, + public string $dropoffAddress, + public BookingChannel $createdByChannel, + public ?int $userId = null, + public ?string $openid = null, + public ?float $pickupLat = null, + public ?float $pickupLng = null, + public ?float $dropoffLat = null, + public ?float $dropoffLng = null, + public bool $isRoundTrip = false, + public ?string $returnTravelDate = null, + ) {} +} diff --git a/app-modules/booking/src/Data/VehicleSelectionData.php b/app-modules/booking/src/Data/VehicleSelectionData.php new file mode 100644 index 0000000..a54d920 --- /dev/null +++ b/app-modules/booking/src/Data/VehicleSelectionData.php @@ -0,0 +1,13 @@ +booking_ref}] cannot be cancelled directly because its status is [{$booking->status->value}]." + .($booking->status === BookingStatus::Confirmed + ? ' A confirmed (paid) booking must go through a refund first.' + : '') + ); + } + + /** + * A rejected cancel attempt is a client input problem, not a server + * error — surface it as 422 rather than the default 500. + */ + 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/Exceptions/DriverAssignmentNotAllowedException.php b/app-modules/booking/src/Exceptions/DriverAssignmentNotAllowedException.php new file mode 100644 index 0000000..0d0adba --- /dev/null +++ b/app-modules/booking/src/Exceptions/DriverAssignmentNotAllowedException.php @@ -0,0 +1,27 @@ +booking_ref}] cannot have a driver assigned because its status is [{$booking->status->value}], not confirmed." + ); + } + + 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/Exceptions/InvalidVehicleSelectionException.php b/app-modules/booking/src/Exceptions/InvalidVehicleSelectionException.php new file mode 100644 index 0000000..e357c8a --- /dev/null +++ b/app-modules/booking/src/Exceptions/InvalidVehicleSelectionException.php @@ -0,0 +1,46 @@ +value}] is not currently available for booking."); + } + + public static function duplicateOption(VehicleOption $vehicleOption): self + { + return new self("Vehicle option [{$vehicleOption->value}] was selected more than once — combine it into a single selection."); + } + + public static function wholeVehicleCannotBeCombined(): self + { + return new self('Whole Vehicle cannot be combined with other vehicle options in the same booking.'); + } + + /** + * A rejected vehicle selection is a client input problem, not a server + * error — surface it as 422 rather than the default 500. A broader JSON + * error envelope for all of api/* is Phase 6 (T6.3); this keeps the + * mapping local until that lands. + */ + 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/Pages/.gitkeep b/app-modules/booking/src/Filament/Pages/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app-modules/booking/src/Filament/Resources/Bookings/Actions/AssignDriverTableAction.php b/app-modules/booking/src/Filament/Resources/Bookings/Actions/AssignDriverTableAction.php new file mode 100644 index 0000000..ef345a6 --- /dev/null +++ b/app-modules/booking/src/Filament/Resources/Bookings/Actions/AssignDriverTableAction.php @@ -0,0 +1,63 @@ +label('Assign Driver') + ->icon(Heroicon::OutlinedTruck) + ->color('primary') + ->visible(fn (Booking $record): bool => $record->status === BookingStatus::Confirmed + && (auth()->user()?->can('manage_bookings') ?? false)) + ->schema([ + TextInput::make('driver_name')->required(), + TextInput::make('driver_phone')->required(), + TextInput::make('car_plate_number')->required(), + TextInput::make('car_model'), + ]) + ->fillForm(fn (Booking $record): array => [ + 'driver_name' => $record->driver_name, + 'driver_phone' => $record->driver_phone, + 'car_plate_number' => $record->car_plate_number, + 'car_model' => $record->car_model, + ]) + ->action(function (array $data, Booking $record, AssignDriverAction $assignDriverAction) { + try { + $assignDriverAction->handle($record, new AssignDriverData( + driverName: $data['driver_name'], + driverPhone: $data['driver_phone'], + carPlateNumber: $data['car_plate_number'], + carModel: $data['car_model'] ?: null, + )); + + Notification::make() + ->title('Driver assigned') + ->success() + ->send(); + } catch (DriverAssignmentNotAllowedException $exception) { + Notification::make() + ->title('Cannot assign driver') + ->body($exception->getMessage()) + ->danger() + ->send(); + } + }); + } +} diff --git a/app-modules/booking/src/Filament/Resources/Bookings/Actions/CancelBookingTableAction.php b/app-modules/booking/src/Filament/Resources/Bookings/Actions/CancelBookingTableAction.php new file mode 100644 index 0000000..add08f8 --- /dev/null +++ b/app-modules/booking/src/Filament/Resources/Bookings/Actions/CancelBookingTableAction.php @@ -0,0 +1,46 @@ +label('Cancel') + ->icon(Heroicon::OutlinedXCircle) + ->color('danger') + ->requiresConfirmation() + ->visible(fn (Booking $record): bool => Gate::allows('cancel', $record)) + ->disabled(fn (Booking $record): bool => $record->status !== BookingStatus::PendingPayment) + ->action(function (Booking $record, CancelBookingAction $cancelBookingAction) { + try { + $cancelBookingAction->handle($record); + + Notification::make() + ->title('Booking cancelled') + ->success() + ->send(); + } catch (BookingCannotBeCancelledException $exception) { + Notification::make() + ->title('Cannot cancel booking') + ->body($exception->getMessage()) + ->danger() + ->send(); + } + }); + } +} diff --git a/app-modules/booking/src/Filament/Resources/Bookings/BookingResource.php b/app-modules/booking/src/Filament/Resources/Bookings/BookingResource.php new file mode 100644 index 0000000..082a483 --- /dev/null +++ b/app-modules/booking/src/Filament/Resources/Bookings/BookingResource.php @@ -0,0 +1,47 @@ + ListBookings::route('/'), + 'view' => ViewBooking::route('/{record}'), + ]; + } +} diff --git a/app-modules/booking/src/Filament/Resources/Bookings/Pages/ListBookings.php b/app-modules/booking/src/Filament/Resources/Bookings/Pages/ListBookings.php new file mode 100644 index 0000000..3127e01 --- /dev/null +++ b/app-modules/booking/src/Filament/Resources/Bookings/Pages/ListBookings.php @@ -0,0 +1,18 @@ +components([ + Section::make('Booking') + ->schema([ + Grid::make(4) + ->schema([ + TextEntry::make('booking_ref')->label('Ref'), + TextEntry::make('status') + ->badge() + ->color(fn (BookingStatus $state) => match ($state) { + BookingStatus::PendingPayment => 'warning', + BookingStatus::Confirmed => 'success', + BookingStatus::Cancelled => 'gray', + BookingStatus::Expired => 'danger', + }), + TextEntry::make('created_by_channel')->badge(), + TextEntry::make('created_at')->dateTime(), + ]), + ]), + Section::make('Trip') + ->schema([ + Grid::make(3) + ->schema([ + TextEntry::make('route.company.name')->label('Company'), + TextEntry::make('route.fromDestination.name')->label('From'), + TextEntry::make('route.toDestination.name')->label('To'), + 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() + ->visible(fn ($record) => $record->is_round_trip), + ]), + ]), + Section::make('Vehicle Options') + ->schema([ + RepeatableEntry::make('vehicleOptions') + ->label('') + ->schema([ + Grid::make(4) + ->schema([ + TextEntry::make('vehicle_option')->badge(), + TextEntry::make('passenger_count'), + TextEntry::make('unit_price')->numeric(2), + TextEntry::make('line_total')->numeric(2), + ]), + ]), + TextEntry::make('price')->label('Total Price')->numeric(2), + ]), + Section::make('Passenger') + ->schema([ + Grid::make(2) + ->schema([ + TextEntry::make('passenger_name'), + TextEntry::make('passenger_phone'), + ]), + ]), + Section::make('Pickup & Dropoff') + ->schema([ + Grid::make(2) + ->schema([ + TextEntry::make('pickup_address'), + TextEntry::make('dropoff_address'), + TextEntry::make('pickup_lat')->label('Pickup Lat')->placeholder('—'), + TextEntry::make('dropoff_lat')->label('Dropoff Lat')->placeholder('—'), + TextEntry::make('pickup_lng')->label('Pickup Lng')->placeholder('—'), + TextEntry::make('dropoff_lng')->label('Dropoff Lng')->placeholder('—'), + ]), + ]), + Section::make('Driver & Vehicle') + ->description('Filled in by staff once the booking is confirmed — see the Assign Driver action.') + ->schema([ + Grid::make(4) + ->schema([ + TextEntry::make('driver_name')->label('Driver')->placeholder('Not yet assigned'), + TextEntry::make('driver_phone')->label('Driver Phone')->placeholder('Not yet assigned'), + TextEntry::make('car_plate_number')->label('Car Plate')->placeholder('Not yet assigned'), + TextEntry::make('car_model')->label('Car Model')->placeholder('—'), + ]), + ]), + ]); + } +} diff --git a/app-modules/booking/src/Filament/Resources/Bookings/Tables/BookingsTable.php b/app-modules/booking/src/Filament/Resources/Bookings/Tables/BookingsTable.php new file mode 100644 index 0000000..6b028c3 --- /dev/null +++ b/app-modules/booking/src/Filament/Resources/Bookings/Tables/BookingsTable.php @@ -0,0 +1,119 @@ +modifyQueryUsing(fn (Builder $query) => $query->with([ + 'route.company', 'route.fromDestination', 'route.toDestination', 'timeSlot', 'vehicleOptions', + ])) + ->defaultSort('created_at', 'desc') + ->columns([ + TextColumn::make('booking_ref') + ->label('Ref') + ->searchable() + ->sortable(), + TextColumn::make('status') + ->badge() + ->color(fn (BookingStatus $state) => match ($state) { + BookingStatus::PendingPayment => 'warning', + BookingStatus::Confirmed => 'success', + BookingStatus::Cancelled => 'gray', + BookingStatus::Expired => 'danger', + }), + TextColumn::make('route.company.name') + ->label('Company') + ->searchable() + ->sortable(), + TextColumn::make('route.fromDestination.name') + ->label('From'), + TextColumn::make('route.toDestination.name') + ->label('To'), + TextColumn::make('travel_date') + ->date() + ->sortable(), + TextColumn::make('timeSlot.label') + ->label('Time Slot'), + TextColumn::make('vehicleOptions') + ->label('Vehicle Options') + ->state(fn (Booking $record) => $record->vehicleOptions + ->map(fn ($line) => str($line->vehicle_option->value)->headline().' x'.$line->passenger_count) + ->all()) + ->listWithLineBreaks(), + TextColumn::make('price') + ->numeric(2) + ->sortable(), + TextColumn::make('passenger_name') + ->label('Passenger') + ->description(fn (Booking $record) => $record->passenger_phone) + ->searchable(['passenger_name', 'passenger_phone']) + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('created_by_channel') + ->badge() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('driver_name') + ->label('Driver') + ->placeholder('Not yet assigned') + ->description(fn (Booking $record) => collect([$record->driver_phone, $record->car_plate_number, $record->car_model]) + ->filter() + ->join(' • ') ?: null) + ->searchable(['driver_name', 'driver_phone', 'car_plate_number', 'car_model']) + ->toggleable(), + TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + SelectFilter::make('status') + ->options(array_combine( + array_map(fn (BookingStatus $status) => $status->value, BookingStatus::cases()), + array_map(fn (BookingStatus $status) => str($status->value)->headline()->toString(), BookingStatus::cases()), + )), + Filter::make('travel_date') + ->schema([ + DatePicker::make('travel_date'), + ]) + ->query(fn (Builder $query, array $data) => $query->when( + $data['travel_date'] ?? null, + fn (Builder $q, $date) => $q->whereDate('travel_date', $date), + )), + SelectFilter::make('ev_route_id') + ->label('Route') + ->options(fn () => EvRoute::with(['fromDestination', 'toDestination'])->get() + ->mapWithKeys(fn (EvRoute $route) => [ + $route->id => "{$route->fromDestination?->name} → {$route->toDestination?->name}", + ])) + ->searchable(), + SelectFilter::make('company') + ->options(fn () => EvCompany::pluck('name', 'id')) + ->searchable() + ->query(fn (Builder $query, array $data) => $query->when( + $data['value'] ?? null, + fn (Builder $q, $companyId) => $q->whereHas('route', fn (Builder $rq) => $rq->where('ev_company_id', $companyId)), + )), + ]) + ->recordActions([ + ViewAction::make(), + AssignDriverTableAction::make(), + CancelBookingTableAction::make(), + ]); + } +} diff --git a/app-modules/booking/src/Filament/Widgets/.gitkeep b/app-modules/booking/src/Filament/Widgets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app-modules/booking/src/Http/Controllers/BookingController.php b/app-modules/booking/src/Http/Controllers/BookingController.php new file mode 100644 index 0000000..f19e0ba --- /dev/null +++ b/app-modules/booking/src/Http/Controllers/BookingController.php @@ -0,0 +1,99 @@ + + */ + private const EAGER_LOADS = ['route', 'timeSlot', 'vehicleOptions']; + + public function __construct( + private CreateBookingAction $createBookingAction, + private CancelBookingAction $cancelBookingAction, + ) {} + + public function index(Request $request): AnonymousResourceCollection + { + Gate::authorize('viewAny', Booking::class); + + $bookings = Booking::query() + ->where('user_id', $request->user()->id) + ->with(self::EAGER_LOADS) + ->latest() + ->paginate(); + + return BookingResource::collection($bookings); + } + + public function show(Booking $booking): BookingResource + { + Gate::authorize('view', $booking); + + return new BookingResource($booking->load(self::EAGER_LOADS)); + } + + public function store(StoreBookingRequest $request): JsonResponse + { + $validated = $request->validated(); + + $selections = array_map( + fn (array $selection) => new VehicleSelectionData( + vehicleOption: VehicleOption::from($selection['vehicle_option']), + passengerCount: $selection['passenger_count'], + ), + $validated['selections'], + ); + + $booking = $this->createBookingAction->handle(new CreateBookingData( + evRouteId: $validated['ev_route_id'], + departureTimeSlotId: $validated['departure_time_slot_id'], + travelDate: $validated['travel_date'], + selections: $selections, + passengerName: $validated['passenger_name'], + passengerPhone: $validated['passenger_phone'], + pickupAddress: $validated['pickup_address'], + dropoffAddress: $validated['dropoff_address'], + createdByChannel: isset($validated['created_by_channel']) + ? BookingChannel::from($validated['created_by_channel']) + : BookingChannel::MiniApp, + userId: $request->user()?->id, + openid: $validated['openid'] ?? null, + pickupLat: $validated['pickup_lat'] ?? null, + pickupLng: $validated['pickup_lng'] ?? null, + dropoffLat: $validated['dropoff_lat'] ?? null, + dropoffLng: $validated['dropoff_lng'] ?? null, + isRoundTrip: $validated['is_round_trip'] ?? false, + returnTravelDate: $validated['return_travel_date'] ?? null, + )); + + return (new BookingResource($booking->load(self::EAGER_LOADS))) + ->response() + ->setStatusCode(201); + } + + public function cancel(Booking $booking): BookingResource + { + Gate::authorize('cancel', $booking); + + $this->cancelBookingAction->handle($booking); + + 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 new file mode 100644 index 0000000..f1b94b1 --- /dev/null +++ b/app-modules/booking/src/Http/Requests/StoreBookingRequest.php @@ -0,0 +1,51 @@ +> + */ + public function rules(): array + { + return [ + 'ev_route_id' => ['required', 'integer', 'exists:ev_routes,id'], + 'departure_time_slot_id' => ['required', 'integer', 'exists:departure_time_slots,id'], + 'travel_date' => ['required', 'date'], + // One or more Vehicle Option lines — e.g. front_seat + back_seat together + // (domain.md §2). Duplicate-option/whole-vehicle-exclusivity rules stay in + // BookingService, not here. + 'selections' => ['required', 'array', 'min:1'], + 'selections.*.vehicle_option' => ['required', Rule::enum(VehicleOption::class)], + 'selections.*.passenger_count' => ['required', 'integer', 'min:1'], + 'passenger_name' => ['required', 'string', 'max:255'], + 'passenger_phone' => ['required', 'string', 'max:50'], + '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'], + 'openid' => ['nullable', 'string', 'max:255'], + 'is_round_trip' => ['sometimes', 'boolean'], + 'return_travel_date' => ['nullable', 'date', 'required_if:is_round_trip,true'], + // Admin-created bookings go through the Filament resource (T4.7), not this API. + 'created_by_channel' => ['sometimes', Rule::enum(BookingChannel::class)->except(BookingChannel::Admin)], + ]; + } +} diff --git a/app-modules/booking/src/Http/Resources/BookingResource.php b/app-modules/booking/src/Http/Resources/BookingResource.php new file mode 100644 index 0000000..1fd9c8f --- /dev/null +++ b/app-modules/booking/src/Http/Resources/BookingResource.php @@ -0,0 +1,59 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'booking_ref' => $this->booking_ref, + 'status' => $this->status, + 'travel_date' => $this->travel_date?->toDateString(), + 'is_round_trip' => $this->is_round_trip, + 'return_travel_date' => $this->return_travel_date?->toDateString(), + 'passenger_name' => $this->passenger_name, + 'passenger_phone' => $this->passenger_phone, + 'pickup_address' => $this->pickup_address, + 'pickup_lat' => $this->pickup_lat, + 'pickup_lng' => $this->pickup_lng, + 'dropoff_address' => $this->dropoff_address, + 'dropoff_lat' => $this->dropoff_lat, + 'dropoff_lng' => $this->dropoff_lng, + 'price' => $this->price, + 'created_by_channel' => $this->created_by_channel, + // Only ever populated once status is confirmed — see AssignDriverAction. + 'driver_name' => $this->driver_name, + 'driver_phone' => $this->driver_phone, + 'car_plate_number' => $this->car_plate_number, + 'car_model' => $this->car_model, + 'vehicle_options' => $this->whenLoaded('vehicleOptions', fn () => $this->vehicleOptions->map(fn ($selection) => [ + 'vehicle_option' => $selection->vehicle_option, + 'passenger_count' => $selection->passenger_count, + 'unit_price' => $selection->unit_price, + 'line_total' => $selection->line_total, + ])), + 'route' => $this->whenLoaded('route', fn () => [ + 'id' => $this->route->id, + 'ev_company_id' => $this->route->ev_company_id, + 'from_destination_id' => $this->route->from_destination_id, + 'to_destination_id' => $this->route->to_destination_id, + ]), + 'time_slot' => $this->whenLoaded('timeSlot', fn () => [ + 'id' => $this->timeSlot->id, + 'label' => $this->timeSlot->label, + 'time' => $this->timeSlot->time?->format('H:i'), + ]), + 'created_at' => $this->created_at, + ]; + } +} diff --git a/app-modules/booking/src/Models/Booking.php b/app-modules/booking/src/Models/Booking.php new file mode 100644 index 0000000..aaaca06 --- /dev/null +++ b/app-modules/booking/src/Models/Booking.php @@ -0,0 +1,88 @@ + */ + use HasFactory; + + /** + * @var list + */ + protected $fillable = [ + 'booking_ref', + 'user_id', + 'openid', + 'ev_route_id', + 'departure_time_slot_id', + 'travel_date', + 'passenger_name', + 'passenger_phone', + 'pickup_address', + 'pickup_lat', + 'pickup_lng', + 'dropoff_address', + 'dropoff_lat', + 'dropoff_lng', + 'price', + 'status', + 'is_round_trip', + 'return_travel_date', + 'created_by_channel', + 'driver_name', + 'driver_phone', + 'car_plate_number', + 'car_model', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'travel_date' => 'date', + 'pickup_lat' => 'decimal:7', + 'pickup_lng' => 'decimal:7', + 'dropoff_lat' => 'decimal:7', + 'dropoff_lng' => 'decimal:7', + 'price' => 'decimal:2', + 'status' => BookingStatus::class, + 'is_round_trip' => 'boolean', + 'return_travel_date' => 'date', + 'created_by_channel' => BookingChannel::class, + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function route(): BelongsTo + { + return $this->belongsTo(EvRoute::class, 'ev_route_id'); + } + + public function timeSlot(): BelongsTo + { + return $this->belongsTo(DepartureTimeSlot::class, 'departure_time_slot_id'); + } + + public function vehicleOptions(): HasMany + { + return $this->hasMany(BookingVehicleOption::class); + } +} diff --git a/app-modules/booking/src/Models/BookingVehicleOption.php b/app-modules/booking/src/Models/BookingVehicleOption.php new file mode 100644 index 0000000..79c883e --- /dev/null +++ b/app-modules/booking/src/Models/BookingVehicleOption.php @@ -0,0 +1,48 @@ + */ + use HasFactory; + + /** + * @var list + */ + protected $fillable = [ + 'booking_id', + 'vehicle_option', + 'passenger_count', + 'unit_price', + 'line_total', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'vehicle_option' => VehicleOption::class, + 'passenger_count' => 'integer', + 'unit_price' => 'decimal:2', + 'line_total' => 'decimal:2', + ]; + } + + public function booking(): BelongsTo + { + return $this->belongsTo(Booking::class); + } +} diff --git a/app-modules/booking/src/Policies/BookingPolicy.php b/app-modules/booking/src/Policies/BookingPolicy.php index 08a928d..372f95c 100644 --- a/app-modules/booking/src/Policies/BookingPolicy.php +++ b/app-modules/booking/src/Policies/BookingPolicy.php @@ -3,22 +3,28 @@ namespace Modules\Booking\Policies; use App\Models\User; +use Modules\Booking\Models\Booking; -/** - * Skeleton only — role/permission gates for now. Per-booking ownership - * checks (e.g. a customer may only view/cancel their own booking) are - * filled in against the real Booking model once it exists (Phase 4). - */ class BookingPolicy { + /** + * Listing is always scoped to the caller's own bookings at the query + * level (BookingController::index) — any authenticated user may look at + * their own list. Staff get the full, unscoped list via the Filament + * BookingResource (T4.7), not this gate. + */ public function viewAny(User $user): bool { - return $user->can('view_bookings'); + return true; } - public function view(User $user, mixed $booking): bool + /** + * A booking's owner may always view it; anyone else needs the + * view_bookings permission (admin/support roles). + */ + public function view(User $user, Booking $booking): bool { - return $user->can('view_bookings'); + return $user->id === $booking->user_id || $user->can('view_bookings'); } public function create(User $user): bool @@ -26,9 +32,14 @@ class BookingPolicy return true; } - public function cancel(User $user, mixed $booking): bool + /** + * A booking's owner may cancel their own (still pending_payment only — + * enforced by CancelBookingAction, not here); staff can cancel any + * booking via manage_bookings (domain.md §8). + */ + public function cancel(User $user, Booking $booking): bool { - return $user->can('manage_bookings'); + return $user->id === $booking->user_id || $user->can('manage_bookings'); } public function refund(User $user, mixed $booking): bool diff --git a/app-modules/booking/src/Services/BookingRefGenerator.php b/app-modules/booking/src/Services/BookingRefGenerator.php new file mode 100644 index 0000000..06bbafd --- /dev/null +++ b/app-modules/booking/src/Services/BookingRefGenerator.php @@ -0,0 +1,60 @@ +orderByDesc('id')->value('booking_ref'); + + // If the latest ref doesn't match the expected format, start the sequence fresh. + if ($latest && preg_match('/^[A-Z]+-[A-Z0-9]+$/', $latest)) { + return $this->incrementRef($latest); + } + + return self::PREFIX.'-AAAAA1'; + } + + private function incrementRef(string $ref): string + { + preg_match('/^(.*)-([A-Z0-9]+)$/', $ref, $matches); + + $prefix = $matches[1]; + $suffix = str_split($matches[2]); + $base = strlen(self::CHARS); + $i = count($suffix) - 1; + $carry = true; + + while ($i >= 0 && $carry) { + $idx = strpos(self::CHARS, $suffix[$i]); + + if ($idx + 1 < $base) { + $suffix[$i] = self::CHARS[$idx + 1]; + $carry = false; + } else { + $suffix[$i] = self::CHARS[0]; + } + $i--; + } + + if ($carry) { + array_unshift($suffix, self::CHARS[0]); + } + + return $prefix.'-'.implode('', $suffix); + } +} diff --git a/app-modules/booking/src/Services/BookingService.php b/app-modules/booking/src/Services/BookingService.php new file mode 100644 index 0000000..3c4bb2e --- /dev/null +++ b/app-modules/booking/src/Services/BookingService.php @@ -0,0 +1,68 @@ + $selections + * + * @throws InvalidVehicleSelectionException + */ + public function validateSelections(array $selections): void + { + $seen = []; + + foreach ($selections as $selection) { + if (isset($seen[$selection->vehicleOption->value])) { + throw InvalidVehicleSelectionException::duplicateOption($selection->vehicleOption); + } + + $seen[$selection->vehicleOption->value] = true; + + $this->validateOption($selection->vehicleOption, $selection->passengerCount); + } + + if (isset($seen[VehicleOption::WholeVehicle->value]) && count($seen) > 1) { + throw InvalidVehicleSelectionException::wholeVehicleCannotBeCombined(); + } + } + + private function validateOption(VehicleOption $vehicleOption, int $passengerCount): void + { + match ($vehicleOption) { + VehicleOption::FrontSeat => $this->validateFrontSeat($passengerCount), + VehicleOption::BackSeat => $this->validateEnabled($vehicleOption, 'booking.back_seat_enabled'), + VehicleOption::WholeVehicle => $this->validateEnabled($vehicleOption, 'booking.whole_vehicle_enabled'), + }; + } + + private function validateFrontSeat(int $passengerCount): void + { + $max = config('booking.front_seat_max_per_booking'); + + if ($passengerCount > $max) { + throw InvalidVehicleSelectionException::frontSeatLimitExceeded($passengerCount, $max); + } + } + + private function validateEnabled(VehicleOption $vehicleOption, string $configKey): void + { + if (! config($configKey)) { + throw InvalidVehicleSelectionException::optionDisabled($vehicleOption); + } + } +} diff --git a/app-modules/booking/tests/Feature/BookingCancelApiTest.php b/app-modules/booking/tests/Feature/BookingCancelApiTest.php new file mode 100644 index 0000000..77e62ae --- /dev/null +++ b/app-modules/booking/tests/Feature/BookingCancelApiTest.php @@ -0,0 +1,72 @@ +owner = User::factory()->create(); + $this->token = $this->owner->createToken('test-token')->plainTextToken; +}); + +test('the owner can cancel their own pending_payment booking', function () { + $booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::PendingPayment]); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->postJson("/api/v1/bookings/{$booking->booking_ref}/cancel") + ->assertSuccessful() + ->assertJsonPath('data.status', BookingStatus::Cancelled->value); + + expect($booking->refresh()->status)->toBe(BookingStatus::Cancelled); +}); + +test('cancelling a confirmed booking surfaces as 422 and leaves it untouched', function () { + $booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::Confirmed]); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->postJson("/api/v1/bookings/{$booking->booking_ref}/cancel") + ->assertStatus(422); + + expect($booking->refresh()->status)->toBe(BookingStatus::Confirmed); +}); + +test('a non-owner without manage_bookings cannot cancel someone else\'s booking', function () { + $booking = Booking::factory()->create([ + 'user_id' => User::factory()->create()->id, + 'status' => BookingStatus::PendingPayment, + ]); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->postJson("/api/v1/bookings/{$booking->booking_ref}/cancel") + ->assertForbidden(); + + expect($booking->refresh()->status)->toBe(BookingStatus::PendingPayment); +}); + +test('staff with manage_bookings can cancel someone else\'s pending_payment booking', function () { + $staff = User::factory()->create()->givePermissionTo('manage_bookings'); + $staffToken = $staff->createToken('staff-token')->plainTextToken; + + $booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::PendingPayment]); + + $this->withHeader('Authorization', "Bearer {$staffToken}") + ->postJson("/api/v1/bookings/{$booking->booking_ref}/cancel") + ->assertSuccessful(); + + expect($booking->refresh()->status)->toBe(BookingStatus::Cancelled); +}); + +test('unauthenticated requests are rejected', function () { + $booking = Booking::factory()->create(['status' => BookingStatus::PendingPayment]); + + $this->postJson("/api/v1/bookings/{$booking->booking_ref}/cancel")->assertUnauthorized(); +}); + +test('404s for a booking that does not exist', function () { + $this->withHeader('Authorization', "Bearer {$this->token}") + ->postJson('/api/v1/bookings/EVB-DOES-NOT-EXIST/cancel') + ->assertNotFound(); +}); diff --git a/app-modules/booking/tests/Feature/BookingCreateApiTest.php b/app-modules/booking/tests/Feature/BookingCreateApiTest.php new file mode 100644 index 0000000..e85384d --- /dev/null +++ b/app-modules/booking/tests/Feature/BookingCreateApiTest.php @@ -0,0 +1,178 @@ +token = User::factory()->create()->createToken('test-token')->plainTextToken; +}); + +/** + * @param array $pricedOptions + */ +function bookableRouteAndSlot(array $pricedOptions): array +{ + $route = EvRoute::factory()->create(['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]; +} + +/** + * @param array $selections + */ +function bookingPayload(EvRoute $route, DepartureTimeSlot $timeSlot, array $selections): array +{ + return [ + 'ev_route_id' => $route->id, + 'departure_time_slot_id' => $timeSlot->id, + 'travel_date' => now()->addDay()->toDateString(), + 'selections' => $selections, + 'passenger_name' => 'Jane Doe', + 'passenger_phone' => '+959123456789', + 'pickup_address' => '123 Pickup St', + 'dropoff_address' => '456 Dropoff Ave', + ]; +} + +test('happy path: it creates a pending_payment booking with a snapshotted price', function () { + config(['booking.back_seat_enabled' => true]); + + [$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.status', BookingStatus::PendingPayment->value) + ->assertJsonPath('data.price', '15000.00') + ->assertJsonPath('data.vehicle_options.0.vehicle_option', VehicleOption::BackSeat->value) + ->assertJsonPath('data.route.id', $route->id) + ->assertJsonPath('data.time_slot.id', $timeSlot->id); + + expect(Booking::count())->toBe(1); +}); + +test('happy path: front seat and back seat can be booked together', function () { + config(['booking.back_seat_enabled' => true]); + + [$route, $timeSlot] = bookableRouteAndSlot([ + [VehicleOption::FrontSeat, '12000.00'], + [VehicleOption::BackSeat, '9000.00'], + ]); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [ + ['vehicle_option' => 'front_seat', 'passenger_count' => 1], + ['vehicle_option' => 'back_seat', 'passenger_count' => 2], + ])) + ->assertCreated() + ->assertJsonPath('data.price', '30000.00') + ->assertJsonCount(2, 'data.vehicle_options'); +}); + +test('front-seat-limit rejection surfaces as 422', function () { + config(['booking.front_seat_max_per_booking' => 1]); + + [$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::FrontSeat, '12000.00']]); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [ + ['vehicle_option' => 'front_seat', 'passenger_count' => 2], + ])) + ->assertStatus(422) + ->assertJsonPath('message', 'Front seat request [2] exceeds the max of [1] per booking.'); + + expect(Booking::count())->toBe(0); +}); + +test('disabled-vehicle-option rejection surfaces as 422', function () { + config(['booking.whole_vehicle_enabled' => false]); + + [$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::WholeVehicle, '30000.00']]); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [ + ['vehicle_option' => 'whole_vehicle', 'passenger_count' => 1], + ])) + ->assertStatus(422) + ->assertJsonPath('message', 'Vehicle option [whole_vehicle] is not currently available for booking.'); + + expect(Booking::count())->toBe(0); +}); + +test('mixing whole vehicle with another option surfaces as 422', function () { + config([ + 'booking.back_seat_enabled' => true, + 'booking.whole_vehicle_enabled' => true, + ]); + + [$route, $timeSlot] = bookableRouteAndSlot([ + [VehicleOption::WholeVehicle, '30000.00'], + [VehicleOption::BackSeat, '9000.00'], + ]); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [ + ['vehicle_option' => 'whole_vehicle', 'passenger_count' => 1], + ['vehicle_option' => 'back_seat', 'passenger_count' => 1], + ])) + ->assertStatus(422); + + expect(Booking::count())->toBe(0); +}); + +test('unauthenticated requests are rejected', function () { + [$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]); + + $this->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [ + ['vehicle_option' => 'back_seat', 'passenger_count' => 1], + ]))->assertUnauthorized(); +}); + +test('shape validation rejects a missing required field', function () { + $this->withHeader('Authorization', "Bearer {$this->token}") + ->postJson('/api/v1/bookings', []) + ->assertStatus(422) + ->assertJsonValidationErrors([ + 'ev_route_id', 'departure_time_slot_id', 'travel_date', 'selections', + 'passenger_name', 'passenger_phone', 'pickup_address', 'dropoff_address', + ]); +}); + +test('shape validation rejects an invalid vehicle_option value', function () { + [$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]); + + $payload = bookingPayload($route, $timeSlot, [ + ['vehicle_option' => 'business_class', 'passenger_count' => 1], + ]); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->postJson('/api/v1/bookings', $payload) + ->assertStatus(422) + ->assertJsonValidationErrors(['selections.0.vehicle_option']); +}); + +test('shape validation rejects an empty selections array', function () { + [$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [])) + ->assertStatus(422) + ->assertJsonValidationErrors(['selections']); +}); diff --git a/app-modules/booking/tests/Feature/BookingPolicyTest.php b/app-modules/booking/tests/Feature/BookingPolicyTest.php index d2bdcff..27c214f 100644 --- a/app-modules/booking/tests/Feature/BookingPolicyTest.php +++ b/app-modules/booking/tests/Feature/BookingPolicyTest.php @@ -1,6 +1,7 @@ create()->givePermissionTo('view_bookings'); - $withoutPermission = User::factory()->create(); + expect($policy->viewAny(User::factory()->create()))->toBeTrue(); +}); - expect($policy->viewAny($withPermission))->toBeTrue() - ->and($policy->view($withPermission, null))->toBeTrue() - ->and($policy->viewAny($withoutPermission))->toBeFalse() - ->and($policy->view($withoutPermission, null))->toBeFalse(); +test('view allows the booking\'s owner', function () { + $policy = new BookingPolicy; + + $owner = User::factory()->create(); + $booking = Booking::factory()->create(['user_id' => $owner->id]); + + expect($policy->view($owner, $booking))->toBeTrue(); +}); + +test('view rejects a non-owner without the view_bookings permission', function () { + $policy = new BookingPolicy; + + $stranger = User::factory()->create(); + $booking = Booking::factory()->create(['user_id' => User::factory()->create()->id]); + + expect($policy->view($stranger, $booking))->toBeFalse(); +}); + +test('view allows a non-owner with the view_bookings permission (admin/support)', function () { + $policy = new BookingPolicy; + + $admin = User::factory()->create()->givePermissionTo('view_bookings'); + $booking = Booking::factory()->create(['user_id' => User::factory()->create()->id]); + + expect($policy->view($admin, $booking))->toBeTrue(); }); test('create is open to any authenticated user', function () { @@ -28,14 +50,31 @@ test('create is open to any authenticated user', function () { expect($policy->create(User::factory()->create()))->toBeTrue(); }); -test('cancel requires the manage_bookings permission', function () { +test('cancel allows the booking\'s owner', function () { $policy = new BookingPolicy; - $withPermission = User::factory()->create()->givePermissionTo('manage_bookings'); - $withoutPermission = User::factory()->create(); + $owner = User::factory()->create(); + $booking = Booking::factory()->create(['user_id' => $owner->id]); - expect($policy->cancel($withPermission, null))->toBeTrue() - ->and($policy->cancel($withoutPermission, null))->toBeFalse(); + expect($policy->cancel($owner, $booking))->toBeTrue(); +}); + +test('cancel allows staff with the manage_bookings permission on someone else\'s booking', function () { + $policy = new BookingPolicy; + + $staff = User::factory()->create()->givePermissionTo('manage_bookings'); + $booking = Booking::factory()->create(['user_id' => User::factory()->create()->id]); + + expect($policy->cancel($staff, $booking))->toBeTrue(); +}); + +test('cancel rejects a non-owner without the manage_bookings permission', function () { + $policy = new BookingPolicy; + + $stranger = User::factory()->create(); + $booking = Booking::factory()->create(['user_id' => User::factory()->create()->id]); + + expect($policy->cancel($stranger, $booking))->toBeFalse(); }); test('refund requires the process_refunds permission', function () { diff --git a/app-modules/booking/tests/Feature/BookingReadApiTest.php b/app-modules/booking/tests/Feature/BookingReadApiTest.php new file mode 100644 index 0000000..1229481 --- /dev/null +++ b/app-modules/booking/tests/Feature/BookingReadApiTest.php @@ -0,0 +1,71 @@ +owner = User::factory()->create(); + $this->token = $this->owner->createToken('test-token')->plainTextToken; +}); + +test('index lists only the authenticated user\'s own bookings, latest first', function () { + $mine = Booking::factory()->create(['user_id' => $this->owner->id, 'created_at' => now()->subMinute()]); + $mineNewer = Booking::factory()->create(['user_id' => $this->owner->id]); + Booking::factory()->create(['user_id' => User::factory()->create()->id]); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->getJson('/api/v1/bookings') + ->assertSuccessful() + ->assertJsonCount(2, 'data') + ->assertJsonPath('data.0.id', $mineNewer->id) + ->assertJsonPath('data.1.id', $mine->id); +}); + +test('index rejects unauthenticated requests', function () { + $this->getJson('/api/v1/bookings')->assertUnauthorized(); +}); + +test('show allows the owner to view their own booking', function () { + $booking = Booking::factory()->create(['user_id' => $this->owner->id]); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->getJson("/api/v1/bookings/{$booking->booking_ref}") + ->assertSuccessful() + ->assertJsonPath('data.id', $booking->id); +}); + +test('show rejects a non-owner without the view_bookings permission', function () { + $booking = Booking::factory()->create(['user_id' => User::factory()->create()->id]); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->getJson("/api/v1/bookings/{$booking->booking_ref}") + ->assertForbidden(); +}); + +test('show allows an admin/support user (view_bookings permission) to view someone else\'s booking', function () { + $admin = User::factory()->create(); + $admin->givePermissionTo('view_bookings'); + $adminToken = $admin->createToken('admin-token')->plainTextToken; + + $booking = Booking::factory()->create(['user_id' => $this->owner->id]); + + $this->withHeader('Authorization', "Bearer {$adminToken}") + ->getJson("/api/v1/bookings/{$booking->booking_ref}") + ->assertSuccessful() + ->assertJsonPath('data.id', $booking->id); +}); + +test('show rejects unauthenticated requests', function () { + $booking = Booking::factory()->create(); + + $this->getJson("/api/v1/bookings/{$booking->id}")->assertUnauthorized(); +}); + +test('show 404s for a booking that does not exist', function () { + $this->withHeader('Authorization', "Bearer {$this->token}") + ->getJson('/api/v1/bookings/EVB-DOES-NOT-EXIST') + ->assertNotFound(); +}); diff --git a/app-modules/booking/tests/Feature/BookingResourceTest.php b/app-modules/booking/tests/Feature/BookingResourceTest.php new file mode 100644 index 0000000..bfcdafd --- /dev/null +++ b/app-modules/booking/tests/Feature/BookingResourceTest.php @@ -0,0 +1,219 @@ +admin = User::factory()->create()->givePermissionTo(['view_bookings', 'manage_bookings']); + $this->actingAs($this->admin); +}); + +test('can list bookings', function () { + $bookings = Booking::factory()->count(3)->create(); + + Livewire::test(ListBookings::class) + ->assertOk() + ->assertCanSeeTableRecords($bookings); +}); + +test('can filter bookings by status', function () { + $pending = Booking::factory()->create(['status' => BookingStatus::PendingPayment]); + $confirmed = Booking::factory()->create(['status' => BookingStatus::Confirmed]); + + Livewire::test(ListBookings::class) + ->filterTable('status', BookingStatus::PendingPayment->value) + ->assertCanSeeTableRecords([$pending]) + ->assertCanNotSeeTableRecords([$confirmed]); +}); + +test('the cancel action is visible and enabled for a pending_payment booking', function () { + $booking = Booking::factory()->create(['status' => BookingStatus::PendingPayment]); + + Livewire::test(ListBookings::class) + ->assertTableActionVisible('cancel', $booking) + ->assertTableActionEnabled('cancel', $booking); +}); + +test('the cancel action is visible but disabled for a confirmed booking', function () { + $booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]); + + Livewire::test(ListBookings::class) + ->assertTableActionVisible('cancel', $booking) + ->assertTableActionDisabled('cancel', $booking); +}); + +test('the cancel action is hidden from a user without manage_bookings and not the owner', function () { + $stranger = User::factory()->create(); + $this->actingAs($stranger); + + $booking = Booking::factory()->create(['user_id' => User::factory()->create()->id, 'status' => BookingStatus::PendingPayment]); + + Livewire::test(ListBookings::class) + ->assertTableActionHidden('cancel', $booking); +}); + +test('calling the cancel action cancels a pending_payment booking', function () { + $booking = Booking::factory()->create(['status' => BookingStatus::PendingPayment]); + + Livewire::test(ListBookings::class) + ->callTableAction('cancel', $booking) + ->assertNotified(); + + expect($booking->refresh()->status)->toBe(BookingStatus::Cancelled); +}); + +test('the view action is visible for a user with view_bookings', function () { + $booking = Booking::factory()->create(); + + Livewire::test(ListBookings::class) + ->assertTableActionVisible('view', $booking); +}); + +test('the view action is hidden from a non-owner without view_bookings', function () { + $stranger = User::factory()->create(); + $this->actingAs($stranger); + + $booking = Booking::factory()->create(['user_id' => User::factory()->create()->id]); + + Livewire::test(ListBookings::class) + ->assertTableActionHidden('view', $booking); +}); + +test('can view a booking\'s detail page', function () { + $booking = Booking::factory()->create([ + 'passenger_name' => 'Jane Doe', + 'passenger_phone' => '+959123456789', + ]); + + BookingVehicleOption::factory()->create([ + 'booking_id' => $booking->id, + 'vehicle_option' => VehicleOption::BackSeat, + 'passenger_count' => 2, + 'unit_price' => 9000, + 'line_total' => 18000, + ]); + + Livewire::test(ViewBooking::class, ['record' => $booking->getRouteKey()]) + ->assertOk() + ->assertSee($booking->booking_ref) + ->assertSee('Jane Doe') + ->assertSee('+959123456789') + ->assertSee($booking->route->company->name) + ->assertSee($booking->pickup_address) + ->assertSee($booking->dropoff_address); +}); + +test('the assign driver action is visible for a confirmed booking and hidden otherwise', function () { + $confirmed = Booking::factory()->create(['status' => BookingStatus::Confirmed]); + $pending = Booking::factory()->create(['status' => BookingStatus::PendingPayment]); + + Livewire::test(ListBookings::class) + ->assertTableActionVisible('assignDriver', $confirmed) + ->assertTableActionHidden('assignDriver', $pending); +}); + +test('the assign driver action is hidden from a user without manage_bookings', function () { + $viewer = User::factory()->create()->givePermissionTo('view_bookings'); + $this->actingAs($viewer); + + $booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]); + + Livewire::test(ListBookings::class) + ->assertTableActionHidden('assignDriver', $booking); +}); + +test('calling the assign driver action sets driver and car details on a confirmed booking', function () { + $booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]); + + Livewire::test(ListBookings::class) + ->callTableAction('assignDriver', $booking, data: [ + 'driver_name' => 'U Aung', + 'driver_phone' => '+959111222333', + 'car_plate_number' => 'YGN-1234', + 'car_model' => 'Tesla Model Y', + ]) + ->assertNotified(); + + $booking->refresh(); + + expect($booking->driver_name)->toBe('U Aung') + ->and($booking->driver_phone)->toBe('+959111222333') + ->and($booking->car_plate_number)->toBe('YGN-1234') + ->and($booking->car_model)->toBe('Tesla Model Y'); +}); + +test('the assign driver form requires driver_name, driver_phone, and car_plate_number', function () { + $booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]); + + Livewire::test(ListBookings::class) + ->callTableAction('assignDriver', $booking, data: [ + 'driver_name' => '', + 'driver_phone' => '', + 'car_plate_number' => '', + ]) + ->assertHasTableActionErrors(['driver_name' => 'required', 'driver_phone' => 'required', 'car_plate_number' => 'required']); + + expect($booking->refresh()->driver_name)->toBeNull(); +}); + +test('the assign driver form is pre-filled with the booking\'s existing driver/car details', function () { + $booking = Booking::factory()->create([ + 'status' => BookingStatus::Confirmed, + 'driver_name' => 'U Aung', + 'driver_phone' => '+959111222333', + 'car_plate_number' => 'YGN-1234', + 'car_model' => 'Tesla Model Y', + ]); + + Livewire::test(ListBookings::class) + ->mountTableAction('assignDriver', $booking) + ->assertTableActionDataSet([ + 'driver_name' => 'U Aung', + 'driver_phone' => '+959111222333', + 'car_plate_number' => 'YGN-1234', + 'car_model' => 'Tesla Model Y', + ]); +}); + +test('the detail page also has assign driver and cancel actions, shared with the table', function () { + $confirmed = Booking::factory()->create(['status' => BookingStatus::Confirmed]); + + Livewire::test(ViewBooking::class, ['record' => $confirmed->getRouteKey()]) + ->assertActionVisible('assignDriver') + ->assertActionVisible('cancel') + ->assertActionDisabled('cancel'); +}); + +test('calling assign driver from the detail page sets driver and car details', function () { + $booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]); + + Livewire::test(ViewBooking::class, ['record' => $booking->getRouteKey()]) + ->callAction('assignDriver', data: [ + 'driver_name' => 'U Aung', + 'driver_phone' => '+959111222333', + 'car_plate_number' => 'YGN-1234', + 'car_model' => 'Tesla Model Y', + ]) + ->assertNotified(); + + expect($booking->refresh()->driver_name)->toBe('U Aung'); +}); + +test('the detail page\'s assign driver action is hidden for a pending_payment booking', function () { + $pending = Booking::factory()->create(['status' => BookingStatus::PendingPayment]); + + Livewire::test(ViewBooking::class, ['record' => $pending->getRouteKey()]) + ->assertActionHidden('assignDriver') + ->assertActionEnabled('cancel'); +}); diff --git a/app-modules/booking/tests/Feature/BookingTest.php b/app-modules/booking/tests/Feature/BookingTest.php new file mode 100644 index 0000000..0d3c3b2 --- /dev/null +++ b/app-modules/booking/tests/Feature/BookingTest.php @@ -0,0 +1,124 @@ +create(); + $timeSlot = DepartureTimeSlot::factory()->create(); + + $booking = Booking::factory()->create([ + 'ev_route_id' => $route->id, + 'departure_time_slot_id' => $timeSlot->id, + ]); + + expect($booking->route)->toBeInstanceOf(EvRoute::class) + ->and($booking->route->is($route))->toBeTrue() + ->and($booking->timeSlot)->toBeInstanceOf(DepartureTimeSlot::class) + ->and($booking->timeSlot->is($timeSlot))->toBeTrue(); +}); + +test('booking_ref is unique', function () { + Booking::factory()->create(['booking_ref' => 'EVB-DUPLICATE']); + + expect(fn () => Booking::factory()->create(['booking_ref' => 'EVB-DUPLICATE'])) + ->toThrow(QueryException::class); +}); + +test('status and created_by_channel cast to their enums', function () { + $booking = Booking::factory()->create([ + 'status' => BookingStatus::Confirmed, + 'created_by_channel' => BookingChannel::Android, + ]); + + expect($booking->status)->toBe(BookingStatus::Confirmed) + ->and($booking->created_by_channel)->toBe(BookingChannel::Android); +}); + +test('a booking defaults to pending_payment', function () { + $booking = Booking::factory()->create(); + + expect($booking->status)->toBe(BookingStatus::PendingPayment); +}); + +test('a booking can have multiple vehicle option lines, e.g. front seat and back seat together', function () { + $booking = Booking::factory()->create(); + + BookingVehicleOption::factory()->create([ + 'booking_id' => $booking->id, + 'vehicle_option' => VehicleOption::FrontSeat, + 'passenger_count' => 1, + 'unit_price' => 12000, + 'line_total' => 12000, + ]); + BookingVehicleOption::factory()->create([ + 'booking_id' => $booking->id, + 'vehicle_option' => VehicleOption::BackSeat, + 'passenger_count' => 2, + 'unit_price' => 9000, + 'line_total' => 18000, + ]); + + expect($booking->vehicleOptions)->toHaveCount(2) + ->and($booking->vehicleOptions->pluck('vehicle_option')->map(fn ($option) => $option->value)->sort()->values()->all()) + ->toEqual(['back_seat', 'front_seat']); +}); + +test('a vehicle option line cannot be duplicated on the same booking', function () { + $booking = Booking::factory()->create(); + + BookingVehicleOption::factory()->create([ + 'booking_id' => $booking->id, + 'vehicle_option' => VehicleOption::BackSeat, + ]); + + expect(fn () => BookingVehicleOption::factory()->create([ + 'booking_id' => $booking->id, + 'vehicle_option' => VehicleOption::BackSeat, + ]))->toThrow(QueryException::class); +}); + +test('price is snapshotted onto the booking and does not change when RoutePricing is edited later', function () { + $route = EvRoute::factory()->create(); + + $pricing = RoutePricing::factory()->create([ + 'ev_route_id' => $route->id, + 'vehicle_option' => VehicleOption::BackSeat, + 'price' => 15000, + ]); + + $booking = Booking::factory()->create([ + 'ev_route_id' => $route->id, + 'price' => $pricing->price, + ]); + + BookingVehicleOption::factory()->create([ + 'booking_id' => $booking->id, + 'vehicle_option' => VehicleOption::BackSeat, + 'unit_price' => $pricing->price, + 'line_total' => $pricing->price, + ]); + + $pricing->update(['price' => 25000]); + + expect($booking->refresh()->price)->toEqual('15000.00') + ->and($booking->vehicleOptions()->first()->unit_price)->toEqual('15000.00') + ->and($pricing->refresh()->price)->toEqual('25000.00'); +}); + +test('a booking can have a user or be guest-checked-out via mini app openid', function () { + $guestBooking = Booking::factory()->create([ + 'user_id' => null, + 'openid' => 'mini-app-openid-123', + ]); + + expect($guestBooking->user_id)->toBeNull() + ->and($guestBooking->openid)->toBe('mini-app-openid-123'); +}); diff --git a/app-modules/booking/tests/Feature/CreateBookingActionTest.php b/app-modules/booking/tests/Feature/CreateBookingActionTest.php new file mode 100644 index 0000000..e82e2d9 --- /dev/null +++ b/app-modules/booking/tests/Feature/CreateBookingActionTest.php @@ -0,0 +1,152 @@ + $pricedOptions + */ +function makeBookableRoute(array $pricedOptions): array +{ + $route = EvRoute::factory()->create(); + $timeSlot = DepartureTimeSlot::factory()->create(); + + foreach ($pricedOptions as [$vehicleOption, $price]) { + RoutePricing::factory()->create([ + 'ev_route_id' => $route->id, + 'vehicle_option' => $vehicleOption, + 'price' => $price, + ]); + } + + return [$route, $timeSlot]; +} + +function bookingData(EvRoute $route, DepartureTimeSlot $timeSlot, array $selections): CreateBookingData +{ + return new CreateBookingData( + evRouteId: $route->id, + departureTimeSlotId: $timeSlot->id, + travelDate: now()->addDay()->toDateString(), + selections: $selections, + passengerName: 'Jane Doe', + passengerPhone: '+959123456789', + pickupAddress: '123 Pickup St', + dropoffAddress: '456 Dropoff Ave', + createdByChannel: BookingChannel::MiniApp, + openid: 'mini-app-openid-123', + ); +} + +test('it persists a pending_payment booking with the price snapshotted from PricingService', function () { + config(['booking.back_seat_enabled' => true]); + + [$route, $timeSlot] = makeBookableRoute([[VehicleOption::BackSeat, '15000.00']]); + + $booking = app(CreateBookingAction::class)->handle( + bookingData($route, $timeSlot, [new VehicleSelectionData(VehicleOption::BackSeat)]) + ); + + expect($booking->exists)->toBeTrue() + ->and($booking->booking_ref)->toBe('EVB-AAAAA1') + ->and($booking->status)->toBe(BookingStatus::PendingPayment) + ->and($booking->price)->toEqual('15000.00') + ->and($booking->ev_route_id)->toBe($route->id) + ->and($booking->departure_time_slot_id)->toBe($timeSlot->id) + ->and($booking->openid)->toBe('mini-app-openid-123') + ->and($booking->vehicleOptions)->toHaveCount(1) + ->and($booking->vehicleOptions->first()->vehicle_option)->toBe(VehicleOption::BackSeat) + ->and($booking->vehicleOptions->first()->unit_price)->toEqual('15000.00'); +}); + +test('it books front seat and back seat together and sums the price across both lines', function () { + config(['booking.back_seat_enabled' => true]); + + [$route, $timeSlot] = makeBookableRoute([ + [VehicleOption::FrontSeat, '12000.00'], + [VehicleOption::BackSeat, '9000.00'], + ]); + + $booking = app(CreateBookingAction::class)->handle(bookingData($route, $timeSlot, [ + new VehicleSelectionData(VehicleOption::FrontSeat, 1), + new VehicleSelectionData(VehicleOption::BackSeat, 2), + ])); + + expect($booking->price)->toEqual('30000.00') // 12000 + (9000 * 2) + ->and($booking->vehicleOptions)->toHaveCount(2); + + $frontSeatLine = $booking->vehicleOptions->firstWhere('vehicle_option', VehicleOption::FrontSeat); + $backSeatLine = $booking->vehicleOptions->firstWhere('vehicle_option', VehicleOption::BackSeat); + + expect($frontSeatLine->passenger_count)->toBe(1) + ->and($frontSeatLine->line_total)->toEqual('12000.00') + ->and($backSeatLine->passenger_count)->toBe(2) + ->and($backSeatLine->line_total)->toEqual('18000.00'); +}); + +test('it dispatches BookingCreated', function () { + Event::fake([BookingCreated::class]); + config(['booking.whole_vehicle_enabled' => true]); + + [$route, $timeSlot] = makeBookableRoute([[VehicleOption::WholeVehicle, '30000.00']]); + + $booking = app(CreateBookingAction::class)->handle( + bookingData($route, $timeSlot, [new VehicleSelectionData(VehicleOption::WholeVehicle)]) + ); + + Event::assertDispatched(BookingCreated::class, fn (BookingCreated $event) => $event->booking->is($booking)); +}); + +test('it rejects a disabled vehicle option before touching the database', function () { + config(['booking.whole_vehicle_enabled' => false]); + + [$route, $timeSlot] = makeBookableRoute([[VehicleOption::WholeVehicle, '30000.00']]); + + expect(fn () => app(CreateBookingAction::class)->handle( + bookingData($route, $timeSlot, [new VehicleSelectionData(VehicleOption::WholeVehicle)]) + ))->toThrow(InvalidVehicleSelectionException::class); + + expect(Booking::count())->toBe(0); +}); + +test('it rejects mixing whole vehicle with another option before touching the database', function () { + config([ + 'booking.back_seat_enabled' => true, + 'booking.whole_vehicle_enabled' => true, + ]); + + [$route, $timeSlot] = makeBookableRoute([ + [VehicleOption::WholeVehicle, '30000.00'], + [VehicleOption::BackSeat, '9000.00'], + ]); + + expect(fn () => app(CreateBookingAction::class)->handle(bookingData($route, $timeSlot, [ + new VehicleSelectionData(VehicleOption::WholeVehicle), + new VehicleSelectionData(VehicleOption::BackSeat), + ])))->toThrow(InvalidVehicleSelectionException::class); + + expect(Booking::count())->toBe(0); +}); + +test('each booking created gets a unique, sequential booking_ref', function () { + config(['booking.back_seat_enabled' => true]); + + [$route, $timeSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]); + + $first = app(CreateBookingAction::class)->handle(bookingData($route, $timeSlot, [new VehicleSelectionData(VehicleOption::BackSeat)])); + $second = app(CreateBookingAction::class)->handle(bookingData($route, $timeSlot, [new VehicleSelectionData(VehicleOption::BackSeat)])); + + expect($first->booking_ref)->toBe('EVB-AAAAA1') + ->and($second->booking_ref)->toBe('EVB-AAAAA2'); +}); diff --git a/app-modules/booking/tests/Unit/AssignDriverActionTest.php b/app-modules/booking/tests/Unit/AssignDriverActionTest.php new file mode 100644 index 0000000..65aa2d4 --- /dev/null +++ b/app-modules/booking/tests/Unit/AssignDriverActionTest.php @@ -0,0 +1,76 @@ +create(['status' => BookingStatus::Confirmed]); + + $updated = (new AssignDriverAction)->handle($booking, new AssignDriverData( + driverName: 'U Aung', + driverPhone: '+959111222333', + carPlateNumber: 'YGN-1234', + carModel: 'Tesla Model Y', + )); + + expect($updated->driver_name)->toBe('U Aung') + ->and($updated->driver_phone)->toBe('+959111222333') + ->and($updated->car_plate_number)->toBe('YGN-1234') + ->and($updated->car_model)->toBe('Tesla Model Y') + ->and($booking->refresh()->driver_name)->toBe('U Aung'); +}); + +test('car_model is optional', function () { + $booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]); + + $updated = (new AssignDriverAction)->handle($booking, new AssignDriverData( + driverName: 'U Aung', + driverPhone: '+959111222333', + carPlateNumber: 'YGN-1234', + )); + + expect($updated->car_model)->toBeNull(); +}); + +test('it guards against assigning a driver to a pending_payment booking', function () { + $booking = Booking::factory()->create(['status' => BookingStatus::PendingPayment]); + + expect(fn () => (new AssignDriverAction)->handle($booking, new AssignDriverData( + driverName: 'U Aung', + driverPhone: '+959111222333', + carPlateNumber: 'YGN-1234', + )))->toThrow(DriverAssignmentNotAllowedException::class); + + expect($booking->refresh()->driver_name)->toBeNull(); +}); + +test('it guards against assigning a driver to a cancelled booking', function () { + $booking = Booking::factory()->create(['status' => BookingStatus::Cancelled]); + + expect(fn () => (new AssignDriverAction)->handle($booking, new AssignDriverData( + driverName: 'U Aung', + driverPhone: '+959111222333', + carPlateNumber: 'YGN-1234', + )))->toThrow(DriverAssignmentNotAllowedException::class); +}); + +test('reassigning a different driver on a still-confirmed booking overwrites the previous values', function () { + $booking = Booking::factory()->create([ + 'status' => BookingStatus::Confirmed, + 'driver_name' => 'U Aung', + 'driver_phone' => '+959111222333', + 'car_plate_number' => 'YGN-1234', + ]); + + (new AssignDriverAction)->handle($booking, new AssignDriverData( + driverName: 'Daw Hla', + driverPhone: '+959444555666', + carPlateNumber: 'YGN-5678', + )); + + expect($booking->refresh()->driver_name)->toBe('Daw Hla') + ->and($booking->car_plate_number)->toBe('YGN-5678'); +}); diff --git a/app-modules/booking/tests/Unit/BookingRefGeneratorTest.php b/app-modules/booking/tests/Unit/BookingRefGeneratorTest.php new file mode 100644 index 0000000..bc67608 --- /dev/null +++ b/app-modules/booking/tests/Unit/BookingRefGeneratorTest.php @@ -0,0 +1,40 @@ +generate(); + + expect($ref)->toBe('EVB-AAAAA1'); +}); + +test('the ref increments digit by digit through the alphabet', function () { + Booking::factory()->create(['booking_ref' => 'EVB-AAAAA9']); + + expect((new BookingRefGenerator)->generate())->toBe('EVB-AAAAAA'); +}); + +test('the ref carries over into the next position once the alphabet is exhausted', function () { + Booking::factory()->create(['booking_ref' => 'EVB-AAAAZZ']); + + expect((new BookingRefGenerator)->generate())->toBe('EVB-AAAB11'); +}); + +test('generated refs are unique across repeated calls', function () { + $refs = []; + + for ($i = 0; $i < 20; $i++) { + $ref = (new BookingRefGenerator)->generate(); + Booking::factory()->create(['booking_ref' => $ref]); + $refs[] = $ref; + } + + expect($refs)->toEqual(array_unique($refs)); +}); + +test('an unrecognised existing ref format resets the sequence rather than throwing', function () { + Booking::factory()->create(['booking_ref' => 'LEGACY-2024-0001']); + + expect((new BookingRefGenerator)->generate())->toBe('EVB-AAAAA1'); +}); diff --git a/app-modules/booking/tests/Unit/BookingServiceTest.php b/app-modules/booking/tests/Unit/BookingServiceTest.php new file mode 100644 index 0000000..68fd556 --- /dev/null +++ b/app-modules/booking/tests/Unit/BookingServiceTest.php @@ -0,0 +1,92 @@ + true, + 'booking.whole_vehicle_enabled' => true, + ]); + + $service = new BookingService; + + foreach (VehicleOption::cases() as $option) { + expect(fn () => $service->validateSelections([new VehicleSelectionData($option)])) + ->not->toThrow(InvalidVehicleSelectionException::class); + } +}); + +test('front seat and back seat can be selected together in one booking', function () { + config(['booking.back_seat_enabled' => true]); + + $service = new BookingService; + + expect(fn () => $service->validateSelections([ + new VehicleSelectionData(VehicleOption::FrontSeat, 1), + new VehicleSelectionData(VehicleOption::BackSeat, 2), + ]))->not->toThrow(InvalidVehicleSelectionException::class); +}); + +test('requesting more front seats than the configured max is rejected', function () { + config(['booking.front_seat_max_per_booking' => 1]); + + $service = new BookingService; + + expect(fn () => $service->validateSelections([new VehicleSelectionData(VehicleOption::FrontSeat, 2)])) + ->toThrow(InvalidVehicleSelectionException::class); +}); + +test('requesting front seats up to the configured max passes', function () { + config(['booking.front_seat_max_per_booking' => 2]); + + $service = new BookingService; + + expect(fn () => $service->validateSelections([new VehicleSelectionData(VehicleOption::FrontSeat, 2)])) + ->not->toThrow(InvalidVehicleSelectionException::class); +}); + +test('back seat is rejected when disabled via config', function () { + config(['booking.back_seat_enabled' => false]); + + $service = new BookingService; + + expect(fn () => $service->validateSelections([new VehicleSelectionData(VehicleOption::BackSeat)])) + ->toThrow(InvalidVehicleSelectionException::class); +}); + +test('whole vehicle is rejected when disabled via config', function () { + config(['booking.whole_vehicle_enabled' => false]); + + $service = new BookingService; + + expect(fn () => $service->validateSelections([new VehicleSelectionData(VehicleOption::WholeVehicle)])) + ->toThrow(InvalidVehicleSelectionException::class); +}); + +test('the same vehicle option cannot be selected twice in one booking', function () { + config(['booking.back_seat_enabled' => true]); + + $service = new BookingService; + + expect(fn () => $service->validateSelections([ + new VehicleSelectionData(VehicleOption::BackSeat, 1), + new VehicleSelectionData(VehicleOption::BackSeat, 1), + ]))->toThrow(InvalidVehicleSelectionException::class); +}); + +test('whole vehicle cannot be combined with another vehicle option', function () { + config([ + 'booking.back_seat_enabled' => true, + 'booking.whole_vehicle_enabled' => true, + ]); + + $service = new BookingService; + + expect(fn () => $service->validateSelections([ + new VehicleSelectionData(VehicleOption::WholeVehicle), + new VehicleSelectionData(VehicleOption::BackSeat), + ]))->toThrow(InvalidVehicleSelectionException::class); +}); diff --git a/app-modules/booking/tests/Unit/CancelBookingActionTest.php b/app-modules/booking/tests/Unit/CancelBookingActionTest.php new file mode 100644 index 0000000..1422beb --- /dev/null +++ b/app-modules/booking/tests/Unit/CancelBookingActionTest.php @@ -0,0 +1,38 @@ +create(['status' => BookingStatus::PendingPayment]); + + $cancelled = (new CancelBookingAction)->handle($booking); + + expect($cancelled->status)->toBe(BookingStatus::Cancelled) + ->and($booking->refresh()->status)->toBe(BookingStatus::Cancelled); +}); + +test('it guards against cancelling a confirmed booking', function () { + $booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]); + + expect(fn () => (new CancelBookingAction)->handle($booking)) + ->toThrow(BookingCannotBeCancelledException::class); + + expect($booking->refresh()->status)->toBe(BookingStatus::Confirmed); +}); + +test('it guards against cancelling an already cancelled booking', function () { + $booking = Booking::factory()->create(['status' => BookingStatus::Cancelled]); + + expect(fn () => (new CancelBookingAction)->handle($booking)) + ->toThrow(BookingCannotBeCancelledException::class); +}); + +test('it guards against cancelling an expired booking', function () { + $booking = Booking::factory()->create(['status' => BookingStatus::Expired]); + + expect(fn () => (new CancelBookingAction)->handle($booking)) + ->toThrow(BookingCannotBeCancelledException::class); +}); diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index fd31349..ec078bc 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -19,6 +19,7 @@ use Illuminate\Foundation\Http\Middleware\PreventRequestForgery; use Illuminate\Routing\Middleware\SubstituteBindings; use Illuminate\Session\Middleware\StartSession; use Illuminate\View\Middleware\ShareErrorsFromSession; +use Modules\Booking\BookingPlugin; use Modules\Catalog\CatalogPlugin; use Modules\Routing\RoutingPlugin; @@ -43,6 +44,7 @@ class AdminPanelProvider extends PanelProvider ->plugins([ CatalogPlugin::make(), RoutingPlugin::make(), + BookingPlugin::make(), ]) ->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources') ->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages') diff --git a/domain.md b/domain.md index 8493552..0d9826d 100644 --- a/domain.md +++ b/domain.md @@ -10,12 +10,11 @@ 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. | -| **Pickup Location** | A physical point within a Destination where customers board. | -| **Dropoff Location** | A physical point within a Destination where customers alight. | | **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 + Pickup + Dropoff + round-trip flag + one or more Time Slots + pricing per Vehicle Option. | +| **EV Route** | Company + From Destination + To Destination + round-trip flag + one or more Time Slots + pricing per Vehicle Option. | | **Vehicle Option** | What the customer books: `front_seat`, `back_seat`, or `whole_vehicle`. Not a numbered seat — see §2. | -| **Booking** | A customer's reservation of one Vehicle Option on one Route + Date + Time Slot. | +| **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. | | **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). | @@ -25,19 +24,35 @@ Reference doc for business rules and domain vocabulary. Pull this up alongside ` Unlike a bus-booking system, **there is no seat map and no capacity tracking in v1**: -- Any number of customers can book the same Route + Date + Time Slot. The system does not check whether a "Whole Vehicle" or "Back Seat" is already taken by someone else. -- The **only rule enforced in code** is: **max 1 Front Seat per booking** (a single booking cannot request more than one front seat — this is a per-booking constraint, not a per-trip inventory check). +- A Booking can select **more than one Vehicle Option** in the same booking (e.g. `front_seat` + `back_seat` for a customer traveling with a companion) — stored as one row per selected option in `booking_vehicle_options` (`booking_id`, `vehicle_option`, `passenger_count`, `unit_price`, `line_total`), not a single column on `bookings`. `bookings.price` is the sum of every line's `line_total`. +- Each Vehicle Option can appear **at most once per booking** (no two separate `front_seat` lines — bump `passenger_count` instead). `whole_vehicle` cannot be combined with any other option in the same booking, since it already covers the entire vehicle. +- Any number of *different bookings* can book the same Route + Date + Time Slot. The system does not check whether a "Whole Vehicle" or "Back Seat" is already taken by someone else. +- The **only inventory rule enforced in code** is: **max Front Seats per booking**, checked against `passenger_count` on the `front_seat` line (`BOOKING_FRONT_SEAT_MAX_PER_BOOKING`, currently `1` — a per-booking constraint, not a per-trip inventory check). - Back Seat and Whole Vehicle availability are controlled by **blunt config toggles**, not database rows: - `BOOKING_BACK_SEAT_ENABLED` — whether Back Seat can be selected at all right now. - `BOOKING_WHOLE_VEHICLE_ENABLED` — whether Whole Vehicle can be selected at all right now. - `BOOKING_FRONT_SEAT_MAX_PER_BOOKING` — currently `1`, expressed as config in case it ever needs to change. -- This is a **deliberate v1 simplification**, not an oversight. Real per-route/date/time-slot capacity holding (e.g. "only 1 Whole Vehicle booking allowed per trip") is an explicitly deferred future phase — see §7. +- This is a **deliberate v1 simplification**, not an oversight. Real per-route/date/time-slot capacity holding (e.g. "only 1 Whole Vehicle booking allowed per trip") is an explicitly deferred future phase — see §7. `booking_vehicle_options` is deliberately shaped so that phase can be built as a new query against it (`sum(passenger_count) group by vehicle_option` for a route/date/time-slot) rather than a schema rework. - Consequence: double-booking of "Whole Vehicle" is possible by design until that future phase ships. Admins reconcile manually via the Filament Booking list (filterable by route + date + time). **Do not build seat inventory, seat locking, or availability-checking logic against real capacity in current-phase tickets** unless a ticket explicitly says so — it's out of scope until the "Future Scalability" phase. --- +## 2a. Pickup & Dropoff (door-to-door, v1) + +The real-world business model is **door-to-door**: the EV drives to wherever the customer requests, not a fixed depot. So, unlike a bus system, `EvRoute` does **not** carry a pickup/dropoff location — those live on `Booking` itself: + +- `pickup_address` (free text) + optional `pickup_lat`/`pickup_lng`. +- `dropoff_address` (free text) + optional `dropoff_lat`/`dropoff_lng`. +- Captured at booking time (customer types/pins it), not selected from a catalog. + +**Deliberate v1 simplification**: there is no fixed "meet-up checkpoint" catalog and no automatic "customer is too far" detection (no geofencing/service-radius check). In reality, dispatch sometimes asks a too-far customer to meet at a fixed checkpoint instead of door-to-door — that checkpoint catalog (`pickup_locations`/`dropoff_locations` tied to `EvRoute`) is **deferred**, see §7. Until then, "meet at a checkpoint" is handled operationally (dispatch calls the customer), not in the schema. + +**Do not build a `pickup_locations`/`dropoff_locations` catalog or attach pickup/dropoff FKs to `EvRoute`** in current-phase tickets — `Booking` carries the address directly instead. + +--- + ## 3. Pricing - `RoutePricing` holds one price per (Route, Vehicle Option) pair. @@ -67,6 +82,17 @@ pending_payment ──(TTL expiry, future phase)──▶ expired --- +## 5a. Driver & Vehicle Assignment + +Once a Booking is `confirmed` (paid), dispatch assigns who's actually doing the trip — a real driver and a real EV, not a catalog lookup: + +- `bookings.driver_name`, `driver_phone`, `car_plate_number`, `car_model` — plain nullable columns directly on `Booking`, not a separate `drivers`/`vehicles` catalog. Null until assigned; `car_model` stays nullable even after assignment (optional detail). +- 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. + +--- + ## 6. Payment Domain (ported from `bnf_event`, refined) The existing KBZ Mini App payment code at `/home/marcspecta/company_projects/bnf_event` (`app/Strategies/Payments/KBZMiniApp.php`, `KBZPay.php`, `BasePayment.php`, `app/Services/PaymentService.php`) is the reference implementation being ported and refined — **read it before starting any Payment-module ticket.** @@ -92,6 +118,7 @@ The existing KBZ Mini App payment code at `/home/marcspecta/company_projects/bnf ## 7. Deferred / Future (do not build yet) +- Fixed pickup/dropoff checkpoint catalog (`pickup_locations`/`dropoff_locations`, FK'd from `EvRoute`) for when a customer is too far for door-to-door — v1 is pure free-text `pickup_address`/`dropoff_address` on `Booking` (see §2a). Revisit if checkpoint meet-ups become common enough to need a curated, reusable list instead of ad-hoc dispatch calls. - Real per-route/date/time-slot capacity holding + availability checks (see §2). - DB row locking (`SELECT ... FOR UPDATE`) for booking concurrency — only needed once real inventory exists. - Multi-tenant admin isolation (Spatie Permission "teams"). diff --git a/tickets.md b/tickets.md index d0e8413..47e6c9d 100644 --- a/tickets.md +++ b/tickets.md @@ -10,7 +10,7 @@ app-modules/ composer.json # requires filament/filament, registers provider src/ Providers/CatalogServiceProvider.php # repository bindings, event/listener registration - Models/ (EvCompany, Destination, PickupLocation, DropoffLocation, DepartureTimeSlot) + Models/ (EvCompany, Destination, DepartureTimeSlot — PickupLocation/DropoffLocation deferred, see "Deferred Tickets") Http/Controllers/ Http/Requests/ Http/Resources/ Filament/ Resources/ Pages/ Widgets/ @@ -110,11 +110,9 @@ Cross-module domain code (e.g. Booking module calling Payment module's `RefundBo - **Description**: Migration, model, factory for `destinations` (`name`, `region`, `is_active`). `DestinationResource` in `app-modules/catalog/src/Filament/Resources/`. - **Domain reference**: domain.md §1 -### T2.3 — Pickup & Dropoff Locations -- **Module**: Catalog -- **Depends on**: T2.2 -- **Description**: Migrations, models, factories for `pickup_locations` / `dropoff_locations` (`destination_id` FK, `name`, `address`, `lat`, `lng`, `is_active`). Two Filament resources (or one resource with a type toggle — prefer two for clarity given the architecture plan's separate tables), both under `app-modules/catalog/src/Filament/Resources/`. -- **Domain reference**: domain.md §1 +### T2.3 — Deferred (see "Deferred Tickets" at bottom) + +Pickup & Dropoff Locations was originally scoped here. The real business model is door-to-door (customer supplies a free-text pickup/dropoff address on `Booking`, see domain.md §2a), so a fixed checkpoint catalog isn't needed for v1. The original ticket is kept at the bottom of this file for when checkpoint meet-ups get built. ### T2.4 — Departure Time Slot - **Module**: Catalog @@ -134,9 +132,9 @@ Cross-module domain code (e.g. Booking module calling Payment module's `RefundBo ### T3.1 — EvRoute - **Module**: Routing -- **Depends on**: T2.1, T2.2, T2.3 -- **Description**: Migration, model, factory for `ev_routes` (`ev_company_id`, `from_destination_id`, `to_destination_id`, `pickup_location_id`, `dropoff_location_id`, `is_round_trip`, `is_active`). Eloquent relations: `belongsTo` company/fromDestination/toDestination/pickup/dropoff. -- **Domain reference**: domain.md §1 +- **Depends on**: T2.1, T2.2 +- **Description**: Migration, model, factory for `ev_routes` (`ev_company_id`, `from_destination_id`, `to_destination_id`, `is_round_trip`, `is_active`). Eloquent relations: `belongsTo` company/fromDestination/toDestination. No pickup/dropoff FK on the route — those are captured per-booking as free-text addresses (domain.md §2a). +- **Domain reference**: domain.md §1, §2a ### T3.2 — Route ↔ Time Slot pivot - **Module**: Routing @@ -159,13 +157,13 @@ Cross-module domain code (e.g. Booking module calling Payment module's `RefundBo ### T3.5 — RoutingPlugin + Filament EvRouteResource - **Module**: Routing/Filament - **Depends on**: T3.1–T3.3, T1.3 -- **Description**: `app-modules/routing/src/RoutingPlugin.php` (same `Plugin` contract shape as `CatalogPlugin`, T2.0), added to `AdminPanelProvider`'s `->plugins([...])`. `EvRouteResource` in `app-modules/routing/src/Filament/Resources/` — form with relation selects (company, from/to destination, pickup/dropoff), multi-select for time slots, nested `RoutePricing` relation manager (one row per vehicle option, enforce all 3 present before route can be activated — validation, not a DB constraint). +- **Description**: `app-modules/routing/src/RoutingPlugin.php` (same `Plugin` contract shape as `CatalogPlugin`, T2.0), added to `AdminPanelProvider`'s `->plugins([...])`. `EvRouteResource` in `app-modules/routing/src/Filament/Resources/` — form with relation selects (company, from/to destination), multi-select for time slots, nested `RoutePricing` relation manager (one row per vehicle option, enforce all 3 present before route can be activated — validation, not a DB constraint). - **Domain reference**: domain.md §1, §3 ### T3.6 — Routes read API - **Module**: Routing - **Depends on**: T3.1–T3.4 -- **Description**: `GET /api/v1/routes` (filters: `from`, `to`, `date`, `company`), `GET /api/v1/routes/{route}`, `GET /api/v1/routes/{route}/pricing`, `GET /api/v1/routes/{route}/time-slots`. `EvRouteResource` includes nested pickup/dropoff/company/timeSlots/pricing per architecture plan §11 (AI-agent-friendly shape). Feature tests including the filter combinations. +- **Description**: `GET /api/v1/routes` (filters: `from`, `to`, `date`, `company`), `GET /api/v1/routes/{route}`, `GET /api/v1/routes/{route}/pricing`, `GET /api/v1/routes/{route}/time-slots`. `EvRouteResource` includes nested company/timeSlots/pricing per architecture plan §11 (AI-agent-friendly shape). Feature tests including the filter combinations. - **Domain reference**: domain.md §8 (this is what the AI agent's `route:read` ability consumes) ### T3.7 — Route/pricing caching @@ -181,8 +179,8 @@ Cross-module domain code (e.g. Booking module calling Payment module's `RefundBo ### T4.1 — Booking model - **Module**: Booking - **Depends on**: T3.1, T3.2, T3.3 -- **Description**: Migration, model, factory for `bookings` (`booking_code` unique, `user_id` nullable FK, `ev_route_id`, `departure_time_slot_id`, `travel_date`, `vehicle_option` enum, `passenger_name`, `passenger_phone`, `price` decimal snapshot, `status` enum, `is_round_trip`, `return_travel_date` nullable, `created_by_channel` enum). `BookingStatus` enum (`pending_payment`, `confirmed`, `cancelled`, `expired`). -- **Domain reference**: domain.md §3 (price snapshot — critical, write a test asserting price doesn't change after a later `RoutePricing` edit), §5 (status machine) +- **Description**: Migration, model, factory for `bookings` (`booking_code` unique, `user_id` nullable FK, `ev_route_id`, `departure_time_slot_id`, `travel_date`, `vehicle_option` enum, `passenger_name`, `passenger_phone`, `pickup_address`, `pickup_lat` nullable, `pickup_lng` nullable, `dropoff_address`, `dropoff_lat` nullable, `dropoff_lng` nullable, `price` decimal snapshot, `status` enum, `is_round_trip`, `return_travel_date` nullable, `created_by_channel` enum). `BookingStatus` enum (`pending_payment`, `confirmed`, `cancelled`, `expired`). +- **Domain reference**: domain.md §2a (door-to-door pickup/dropoff addresses live here, not on the route), §3 (price snapshot — critical, write a test asserting price doesn't change after a later `RoutePricing` edit), §5 (status machine) ### T4.2 — BookingService::validateSelection - **Module**: Booking @@ -347,3 +345,15 @@ Cross-module domain code (e.g. Booking module calling Payment module's `RefundBo - **Depends on**: all above - **Description**: Production compose file (queue worker service, scheduler cron), `config:cache`/`route:cache`/`view:cache` in deploy steps, mTLS certs delivered via deployment secrets (not committed). - **Domain reference**: domain.md §6 (cert handling) + +--- + +## Deferred Tickets (not scheduled — pick up if the business need reappears) + +### T2.3 (deferred) — Pickup & Dropoff Checkpoint Locations +- **Module**: Catalog +- **Depends on**: T2.2 +- **Description**: Migrations, models, factories for `pickup_locations` / `dropoff_locations` (`destination_id` FK, `name`, `address`, `lat`, `lng`, `is_active`). Two Filament resources (or one resource with a type toggle — prefer two for clarity), both under `app-modules/catalog/src/Filament/Resources/`. +- **Why deferred**: the actual business model is door-to-door — the EV goes to whatever address the customer gives at booking time (see domain.md §2a), not a fixed catalog point. This ticket models the *exception* case (customer too far, so they and the car meet at a fixed checkpoint instead), which isn't built for v1. +- **To revive this later**: re-add `pickup_location_id`/`dropoff_location_id` nullable FKs (route-level default checkpoint) or put them directly on `Booking` (per-booking checkpoint choice — more likely, since door-to-door is also per-booking) alongside the existing `pickup_address`/`dropoff_address` free-text fields from T4.1, so a booking can be *either* a free-text address *or* a checkpoint reference. Update `EvRouteResource` (T3.5) and the routes read API (T3.6) if checkpoints end up route-scoped. +- **Domain reference**: domain.md §2a, §7