Add Booking module: model, create/read/cancel API, Filament resource (T4.1-T4.7)

- Booking model with booking_vehicle_options line items (supports mixing
  vehicle options like front_seat + back_seat in one booking), price
  snapshot, status machine, and driver/car assignment fields
- BookingService: front-seat max, disabled-option toggles, duplicate-option
  and whole-vehicle-exclusivity guards
- CreateBookingAction, CancelBookingAction, AssignDriverAction
- BookingRefGenerator: sequential EVB-AAAAA1-style refs via row lock
- POST/GET/cancel booking API endpoints (Sanctum, ownership + admin policy)
- BookingPlugin + Filament BookingResource: list, detail view, Cancel and
  Assign Driver actions (shared between table and detail page)
- domain.md updated for multi-vehicle-option bookings (§2) and driver/
  vehicle assignment (§5a)
This commit is contained in:
Nyan Lin Paing
2026-08-08 21:43:15 +07:00
parent 4da9ecfe7d
commit 5b68f4fa38
51 changed files with 2698 additions and 42 deletions
+5
View File
@@ -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 ...`. - 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`. - 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. - 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.
@@ -0,0 +1,53 @@
<?php
namespace Modules\Booking\Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
use Modules\Booking\Enums\BookingChannel;
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Models\Booking;
use Modules\Catalog\Models\DepartureTimeSlot;
use Modules\Routing\Models\EvRoute;
/**
* @extends Factory<Booking>
*/
class BookingFactory extends Factory
{
protected $model = Booking::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
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,
];
}
}
@@ -0,0 +1,32 @@
<?php
namespace Modules\Booking\Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Modules\Booking\Models\Booking;
use Modules\Booking\Models\BookingVehicleOption;
use Modules\Shared\Enums\VehicleOption;
/**
* @extends Factory<BookingVehicleOption>
*/
class BookingVehicleOptionFactory extends Factory
{
protected $model = BookingVehicleOption::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'booking_id' => Booking::factory(),
'vehicle_option' => VehicleOption::BackSeat,
'passenger_count' => 1,
'unit_price' => 9000.00,
'line_total' => 9000.00,
];
}
}
@@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('bookings', function (Blueprint $table) {
$table->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');
}
};
@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* One row per Vehicle Option selected on a booking a booking can mix
* e.g. front_seat + back_seat in one go (domain.md §2). The unique
* constraint keeps each option to a single line per booking (no
* duplicate front_seat rows); BookingService still enforces the
* business rules (front-seat max, disabled options, whole-vehicle
* exclusivity) on top of this shape.
*
* Also the natural source table for the deferred real-capacity-check
* phase (domain.md §7): summing passenger_count per vehicle_option for
* a route/date/time-slot is exactly what that future check needs.
*/
public function up(): void
{
Schema::create('booking_vehicle_options', function (Blueprint $table) {
$table->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');
}
};
@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Driver/vehicle details, filled in by admin staff once a booking is
* confirmed (paid) and dispatch assigns who's actually doing the trip.
* Nullable unknown until assignment happens, and never required for
* pending_payment/cancelled/expired bookings.
*/
public function up(): void
{
Schema::table('bookings', function (Blueprint $table) {
$table->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']);
});
}
};
@@ -1 +1,11 @@
<?php <?php
use Illuminate\Support\Facades\Route;
use Modules\Booking\Http\Controllers\BookingController;
Route::prefix('api/v1')->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');
});
@@ -0,0 +1,33 @@
<?php
namespace Modules\Booking\Actions;
use Modules\Booking\Data\AssignDriverData;
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Exceptions\DriverAssignmentNotAllowedException;
use Modules\Booking\Models\Booking;
/**
* Driver/vehicle details only make sense once a booking is confirmed
* (paid) dispatch assigns who's actually doing the trip at that point,
* not before. Re-running this (e.g. reassigning a different driver) is
* allowed as long as the booking is still confirmed.
*/
class AssignDriverAction
{
public function handle(Booking $booking, AssignDriverData $data): Booking
{
if ($booking->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;
}
}
@@ -0,0 +1,27 @@
<?php
namespace Modules\Booking\Actions;
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Exceptions\BookingCannotBeCancelledException;
use Modules\Booking\Models\Booking;
/**
* Unpaid path only a pending_payment booking has no money moved yet, so
* it can be cancelled directly. A confirmed (paid) booking must go through
* a refund first; this action explicitly guards against bypassing that
* (domain.md §5). Wired into that refund path in T5.12.
*/
class CancelBookingAction
{
public function handle(Booking $booking): Booking
{
if ($booking->status !== BookingStatus::PendingPayment) {
throw BookingCannotBeCancelledException::notPendingPayment($booking);
}
$booking->update(['status' => BookingStatus::Cancelled]);
return $booking;
}
}
@@ -0,0 +1,87 @@
<?php
namespace Modules\Booking\Actions;
use Illuminate\Support\Facades\DB;
use Modules\Booking\Data\CreateBookingData;
use Modules\Booking\Data\VehicleSelectionData;
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Events\BookingCreated;
use Modules\Booking\Models\Booking;
use Modules\Booking\Services\BookingRefGenerator;
use Modules\Booking\Services\BookingService;
use Modules\Routing\Models\EvRoute;
use Modules\Routing\Services\PricingService;
use Modules\Shared\Enums\VehicleOption;
class CreateBookingAction
{
public function __construct(
private BookingService $bookingService,
private PricingService $pricingService,
private BookingRefGenerator $bookingRefGenerator,
) {}
public function handle(CreateBookingData $data): Booking
{
$this->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),
];
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace Modules\Booking;
use Filament\Contracts\Plugin;
use Filament\Panel;
class BookingPlugin implements Plugin
{
public function getId(): string
{
return 'booking';
}
public function register(Panel $panel): void
{
$panel
->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);
}
}
@@ -0,0 +1,13 @@
<?php
namespace Modules\Booking\Data;
readonly class AssignDriverData
{
public function __construct(
public string $driverName,
public string $driverPhone,
public string $carPlateNumber,
public ?string $carModel = null,
) {}
}
@@ -0,0 +1,32 @@
<?php
namespace Modules\Booking\Data;
use Modules\Booking\Enums\BookingChannel;
readonly class CreateBookingData
{
/**
* @param list<VehicleSelectionData> $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,
) {}
}
@@ -0,0 +1,13 @@
<?php
namespace Modules\Booking\Data;
use Modules\Shared\Enums\VehicleOption;
readonly class VehicleSelectionData
{
public function __construct(
public VehicleOption $vehicleOption,
public int $passengerCount = 1,
) {}
}
@@ -0,0 +1,16 @@
<?php
namespace Modules\Booking\Enums;
/**
* Which external actor created the booking (domain.md §8).
*/
enum BookingChannel: string
{
case MiniApp = 'mini_app';
case Android = 'android';
case Ios = 'ios';
case Web = 'web';
case Agent = 'agent';
case Admin = 'admin';
}
@@ -0,0 +1,11 @@
<?php
namespace Modules\Booking\Enums;
enum BookingStatus: string
{
case PendingPayment = 'pending_payment';
case Confirmed = 'confirmed';
case Cancelled = 'cancelled';
case Expired = 'expired';
}
@@ -0,0 +1,13 @@
<?php
namespace Modules\Booking\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Modules\Booking\Models\Booking;
class BookingCreated
{
use Dispatchable;
public function __construct(public Booking $booking) {}
}
@@ -0,0 +1,35 @@
<?php
namespace Modules\Booking\Exceptions;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Models\Booking;
use RuntimeException;
class BookingCannotBeCancelledException extends RuntimeException
{
public static function notPendingPayment(Booking $booking): self
{
return new self(
"Booking [{$booking->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;
}
}
@@ -0,0 +1,27 @@
<?php
namespace Modules\Booking\Exceptions;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\Booking\Models\Booking;
use RuntimeException;
class DriverAssignmentNotAllowedException extends RuntimeException
{
public static function notConfirmed(Booking $booking): self
{
return new self(
"Booking [{$booking->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;
}
}
@@ -0,0 +1,46 @@
<?php
namespace Modules\Booking\Exceptions;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\Shared\Enums\VehicleOption;
use RuntimeException;
class InvalidVehicleSelectionException extends RuntimeException
{
public static function frontSeatLimitExceeded(int $requested, int $max): self
{
return new self("Front seat request [{$requested}] exceeds the max of [{$max}] per booking.");
}
public static function optionDisabled(VehicleOption $vehicleOption): self
{
return new self("Vehicle option [{$vehicleOption->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;
}
}
@@ -0,0 +1,63 @@
<?php
namespace Modules\Booking\Filament\Resources\Bookings\Actions;
use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Support\Icons\Heroicon;
use Modules\Booking\Actions\AssignDriverAction;
use Modules\Booking\Data\AssignDriverData;
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Exceptions\DriverAssignmentNotAllowedException;
use Modules\Booking\Models\Booking;
/**
* Shared between BookingsTable (row action) and ViewBooking (header action)
* so both surfaces stay in sync one definition, not two.
*/
class AssignDriverTableAction
{
public static function make(): Action
{
return Action::make('assignDriver')
->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();
}
});
}
}
@@ -0,0 +1,46 @@
<?php
namespace Modules\Booking\Filament\Resources\Bookings\Actions;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Support\Icons\Heroicon;
use Illuminate\Support\Facades\Gate;
use Modules\Booking\Actions\CancelBookingAction;
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Exceptions\BookingCannotBeCancelledException;
use Modules\Booking\Models\Booking;
/**
* Shared between BookingsTable (row action) and ViewBooking (header action)
* so both surfaces stay in sync one definition, not two.
*/
class CancelBookingTableAction
{
public static function make(): Action
{
return Action::make('cancel')
->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();
}
});
}
}
@@ -0,0 +1,47 @@
<?php
namespace Modules\Booking\Filament\Resources\Bookings;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Modules\Booking\Filament\Resources\Bookings\Pages\ListBookings;
use Modules\Booking\Filament\Resources\Bookings\Pages\ViewBooking;
use Modules\Booking\Filament\Resources\Bookings\Schemas\BookingInfolist;
use Modules\Booking\Filament\Resources\Bookings\Tables\BookingsTable;
use Modules\Booking\Models\Booking;
use UnitEnum;
/**
* Read-mostly by design: bookings are created through the API (T4.4), not
* hand-entered in the admin so this resource has no create/edit form, just
* a list with filters and a status-gated Cancel action (T4.6).
*/
class BookingResource extends Resource
{
protected static ?string $model = Booking::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedTicket;
protected static string|UnitEnum|null $navigationGroup = 'Operations';
public static function table(Table $table): Table
{
return BookingsTable::configure($table);
}
public static function infolist(Schema $schema): Schema
{
return BookingInfolist::configure($schema);
}
public static function getPages(): array
{
return [
'index' => ListBookings::route('/'),
'view' => ViewBooking::route('/{record}'),
];
}
}
@@ -0,0 +1,18 @@
<?php
namespace Modules\Booking\Filament\Resources\Bookings\Pages;
use Filament\Resources\Pages\ListRecords;
use Modules\Booking\Filament\Resources\Bookings\BookingResource;
class ListBookings extends ListRecords
{
protected static string $resource = BookingResource::class;
protected function getHeaderActions(): array
{
// No CreateAction — bookings are created through the API (T4.4), not
// hand-entered here.
return [];
}
}
@@ -0,0 +1,21 @@
<?php
namespace Modules\Booking\Filament\Resources\Bookings\Pages;
use Filament\Resources\Pages\ViewRecord;
use Modules\Booking\Filament\Resources\Bookings\Actions\AssignDriverTableAction;
use Modules\Booking\Filament\Resources\Bookings\Actions\CancelBookingTableAction;
use Modules\Booking\Filament\Resources\Bookings\BookingResource;
class ViewBooking extends ViewRecord
{
protected static string $resource = BookingResource::class;
protected function getHeaderActions(): array
{
return [
AssignDriverTableAction::make(),
CancelBookingTableAction::make(),
];
}
}
@@ -0,0 +1,97 @@
<?php
namespace Modules\Booking\Filament\Resources\Bookings\Schemas;
use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Modules\Booking\Enums\BookingStatus;
class BookingInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema
->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('—'),
]),
]),
]);
}
}
@@ -0,0 +1,119 @@
<?php
namespace Modules\Booking\Filament\Resources\Bookings\Tables;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\DatePicker;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Filament\Resources\Bookings\Actions\AssignDriverTableAction;
use Modules\Booking\Filament\Resources\Bookings\Actions\CancelBookingTableAction;
use Modules\Booking\Models\Booking;
use Modules\Catalog\Models\EvCompany;
use Modules\Routing\Models\EvRoute;
class BookingsTable
{
public static function configure(Table $table): Table
{
return $table
->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(),
]);
}
}
@@ -0,0 +1,99 @@
<?php
namespace Modules\Booking\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Gate;
use Modules\Booking\Actions\CancelBookingAction;
use Modules\Booking\Actions\CreateBookingAction;
use Modules\Booking\Data\CreateBookingData;
use Modules\Booking\Data\VehicleSelectionData;
use Modules\Booking\Enums\BookingChannel;
use Modules\Booking\Http\Requests\StoreBookingRequest;
use Modules\Booking\Http\Resources\BookingResource;
use Modules\Booking\Models\Booking;
use Modules\Shared\Enums\VehicleOption;
class BookingController extends Controller
{
/**
* @var list<string>
*/
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));
}
}
@@ -0,0 +1,51 @@
<?php
namespace Modules\Booking\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Modules\Booking\Enums\BookingChannel;
use Modules\Shared\Enums\VehicleOption;
/**
* Shape validation only business rules (front-seat limit, disabled vehicle
* options, pricing) stay in BookingService/PricingService, not here.
*/
class StoreBookingRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, array<int, mixed>>
*/
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)],
];
}
}
@@ -0,0 +1,59 @@
<?php
namespace Modules\Booking\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class BookingResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
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,
];
}
}
@@ -0,0 +1,88 @@
<?php
namespace Modules\Booking\Models;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Modules\Booking\Database\Factories\BookingFactory;
use Modules\Booking\Enums\BookingChannel;
use Modules\Booking\Enums\BookingStatus;
use Modules\Catalog\Models\DepartureTimeSlot;
use Modules\Routing\Models\EvRoute;
class Booking extends Model
{
/** @use HasFactory<BookingFactory> */
use HasFactory;
/**
* @var list<string>
*/
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<string, string>
*/
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);
}
}
@@ -0,0 +1,48 @@
<?php
namespace Modules\Booking\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Modules\Booking\Database\Factories\BookingVehicleOptionFactory;
use Modules\Shared\Enums\VehicleOption;
/**
* One Vehicle Option line on a Booking (e.g. "back_seat x2"). A booking can
* have more than one of these see domain.md §2.
*/
class BookingVehicleOption extends Model
{
/** @use HasFactory<BookingVehicleOptionFactory> */
use HasFactory;
/**
* @var list<string>
*/
protected $fillable = [
'booking_id',
'vehicle_option',
'passenger_count',
'unit_price',
'line_total',
];
/**
* @return array<string, string>
*/
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);
}
}
@@ -3,22 +3,28 @@
namespace Modules\Booking\Policies; namespace Modules\Booking\Policies;
use App\Models\User; 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 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 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 public function create(User $user): bool
@@ -26,9 +32,14 @@ class BookingPolicy
return true; 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 public function refund(User $user, mixed $booking): bool
@@ -0,0 +1,60 @@
<?php
namespace Modules\Booking\Services;
use Modules\Booking\Models\Booking;
class BookingRefGenerator
{
private const PREFIX = 'EVB';
private const CHARS = '123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
/**
* Must be called inside the same DB::transaction() as the booking insert
* the row lock on the latest booking is what keeps concurrent callers
* from generating the same ref, and it only holds for the transaction's
* lifetime.
*/
public function generate(): string
{
// Lock the latest row so concurrent transactions can't read the same ref.
$latest = Booking::lockForUpdate()->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);
}
}
@@ -0,0 +1,68 @@
<?php
namespace Modules\Booking\Services;
use Modules\Booking\Data\VehicleSelectionData;
use Modules\Booking\Exceptions\InvalidVehicleSelectionException;
use Modules\Shared\Enums\VehicleOption;
class BookingService
{
/**
* Enforces the only v1 inventory rule (max Front Seats per booking), the
* blunt config toggles for Back Seat / Whole Vehicle availability, and
* shape rules around combining options in one booking (no duplicate
* option lines, Whole Vehicle can't be mixed with anything else since it
* already covers the whole car).
*
* Deliberately does not check real capacity/availability that's an
* explicitly deferred future phase (domain.md §2, §7).
*
* @param list<VehicleSelectionData> $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);
}
}
}
@@ -0,0 +1,72 @@
<?php
use App\Models\User;
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Models\Booking;
use Spatie\Permission\Models\Permission;
beforeEach(function () {
Permission::findOrCreate('manage_bookings', 'web');
$this->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();
});
@@ -0,0 +1,178 @@
<?php
use App\Models\User;
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Models\Booking;
use Modules\Catalog\Models\DepartureTimeSlot;
use Modules\Routing\Models\EvRoute;
use Modules\Routing\Models\RoutePricing;
use Modules\Shared\Enums\VehicleOption;
beforeEach(function () {
$this->token = User::factory()->create()->createToken('test-token')->plainTextToken;
});
/**
* @param array<int, array{0: VehicleOption, 1: string}> $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<int, array{vehicle_option: string, passenger_count: int}> $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']);
});
@@ -1,6 +1,7 @@
<?php <?php
use App\Models\User; use App\Models\User;
use Modules\Booking\Models\Booking;
use Modules\Booking\Policies\BookingPolicy; use Modules\Booking\Policies\BookingPolicy;
use Spatie\Permission\Models\Permission; use Spatie\Permission\Models\Permission;
@@ -10,16 +11,37 @@ beforeEach(function () {
} }
}); });
test('viewAny and view require the view_bookings permission', function () { test('viewAny is open to any authenticated user — listing is scoped to their own bookings at the query level', function () {
$policy = new BookingPolicy; $policy = new BookingPolicy;
$withPermission = User::factory()->create()->givePermissionTo('view_bookings'); expect($policy->viewAny(User::factory()->create()))->toBeTrue();
$withoutPermission = User::factory()->create(); });
expect($policy->viewAny($withPermission))->toBeTrue() test('view allows the booking\'s owner', function () {
->and($policy->view($withPermission, null))->toBeTrue() $policy = new BookingPolicy;
->and($policy->viewAny($withoutPermission))->toBeFalse()
->and($policy->view($withoutPermission, null))->toBeFalse(); $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 () { 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(); 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; $policy = new BookingPolicy;
$withPermission = User::factory()->create()->givePermissionTo('manage_bookings'); $owner = User::factory()->create();
$withoutPermission = User::factory()->create(); $booking = Booking::factory()->create(['user_id' => $owner->id]);
expect($policy->cancel($withPermission, null))->toBeTrue() expect($policy->cancel($owner, $booking))->toBeTrue();
->and($policy->cancel($withoutPermission, null))->toBeFalse(); });
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 () { test('refund requires the process_refunds permission', function () {
@@ -0,0 +1,71 @@
<?php
use App\Models\User;
use Modules\Booking\Models\Booking;
use Spatie\Permission\Models\Permission;
beforeEach(function () {
Permission::findOrCreate('view_bookings', 'web');
$this->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();
});
@@ -0,0 +1,219 @@
<?php
use App\Models\User;
use Livewire\Livewire;
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Filament\Resources\Bookings\Pages\ListBookings;
use Modules\Booking\Filament\Resources\Bookings\Pages\ViewBooking;
use Modules\Booking\Models\Booking;
use Modules\Booking\Models\BookingVehicleOption;
use Modules\Shared\Enums\VehicleOption;
use Spatie\Permission\Models\Permission;
beforeEach(function () {
foreach (['view_bookings', 'manage_bookings'] as $permission) {
Permission::findOrCreate($permission, 'web');
}
$this->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');
});
@@ -0,0 +1,124 @@
<?php
use Illuminate\Database\QueryException;
use Modules\Booking\Enums\BookingChannel;
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Models\Booking;
use Modules\Booking\Models\BookingVehicleOption;
use Modules\Catalog\Models\DepartureTimeSlot;
use Modules\Routing\Models\EvRoute;
use Modules\Routing\Models\RoutePricing;
use Modules\Shared\Enums\VehicleOption;
test('a booking belongs to a route and a time slot', function () {
$route = EvRoute::factory()->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');
});
@@ -0,0 +1,152 @@
<?php
use Illuminate\Support\Facades\Event;
use Modules\Booking\Actions\CreateBookingAction;
use Modules\Booking\Data\CreateBookingData;
use Modules\Booking\Data\VehicleSelectionData;
use Modules\Booking\Enums\BookingChannel;
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Events\BookingCreated;
use Modules\Booking\Exceptions\InvalidVehicleSelectionException;
use Modules\Booking\Models\Booking;
use Modules\Catalog\Models\DepartureTimeSlot;
use Modules\Routing\Models\EvRoute;
use Modules\Routing\Models\RoutePricing;
use Modules\Shared\Enums\VehicleOption;
/**
* @param array<int, array{0: VehicleOption, 1: string}> $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');
});
@@ -0,0 +1,76 @@
<?php
use Modules\Booking\Actions\AssignDriverAction;
use Modules\Booking\Data\AssignDriverData;
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Exceptions\DriverAssignmentNotAllowedException;
use Modules\Booking\Models\Booking;
test('it assigns driver and car details to a confirmed booking', function () {
$booking = Booking::factory()->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');
});
@@ -0,0 +1,40 @@
<?php
use Modules\Booking\Models\Booking;
use Modules\Booking\Services\BookingRefGenerator;
test('the first booking ref starts the sequence at AAAAA1', function () {
$ref = (new BookingRefGenerator)->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');
});
@@ -0,0 +1,92 @@
<?php
use Modules\Booking\Data\VehicleSelectionData;
use Modules\Booking\Exceptions\InvalidVehicleSelectionException;
use Modules\Booking\Services\BookingService;
use Modules\Shared\Enums\VehicleOption;
test('a normal single-option selection of each vehicle option passes', function () {
config([
'booking.back_seat_enabled' => 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);
});
@@ -0,0 +1,38 @@
<?php
use Modules\Booking\Actions\CancelBookingAction;
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Exceptions\BookingCannotBeCancelledException;
use Modules\Booking\Models\Booking;
test('it cancels a pending_payment booking', function () {
$booking = Booking::factory()->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);
});
@@ -19,6 +19,7 @@ use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
use Illuminate\Routing\Middleware\SubstituteBindings; use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\StartSession; use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession; use Illuminate\View\Middleware\ShareErrorsFromSession;
use Modules\Booking\BookingPlugin;
use Modules\Catalog\CatalogPlugin; use Modules\Catalog\CatalogPlugin;
use Modules\Routing\RoutingPlugin; use Modules\Routing\RoutingPlugin;
@@ -43,6 +44,7 @@ class AdminPanelProvider extends PanelProvider
->plugins([ ->plugins([
CatalogPlugin::make(), CatalogPlugin::make(),
RoutingPlugin::make(), RoutingPlugin::make(),
BookingPlugin::make(),
]) ])
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources') ->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages') ->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
+34 -7
View File
@@ -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). | | **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. | | **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. | | **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. | | **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). | | **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). | | **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**: 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. - 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`.
- 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). - 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: - 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_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_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. - `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). - 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. **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 ## 3. Pricing
- `RoutePricing` holds one price per (Route, Vehicle Option) pair. - `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) ## 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.** 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) ## 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). - 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. - DB row locking (`SELECT ... FOR UPDATE`) for booking concurrency — only needed once real inventory exists.
- Multi-tenant admin isolation (Spatie Permission "teams"). - Multi-tenant admin isolation (Spatie Permission "teams").
+23 -13
View File
@@ -10,7 +10,7 @@ app-modules/
composer.json # requires filament/filament, registers provider composer.json # requires filament/filament, registers provider
src/ src/
Providers/CatalogServiceProvider.php # repository bindings, event/listener registration 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/ Http/Controllers/ Http/Requests/ Http/Resources/
Filament/ Filament/
Resources/ Pages/ Widgets/ 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/`. - **Description**: Migration, model, factory for `destinations` (`name`, `region`, `is_active`). `DestinationResource` in `app-modules/catalog/src/Filament/Resources/`.
- **Domain reference**: domain.md §1 - **Domain reference**: domain.md §1
### T2.3 — Pickup & Dropoff Locations ### T2.3 — Deferred (see "Deferred Tickets" at bottom)
- **Module**: Catalog
- **Depends on**: T2.2 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.
- **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.4 — Departure Time Slot ### T2.4 — Departure Time Slot
- **Module**: Catalog - **Module**: Catalog
@@ -134,9 +132,9 @@ Cross-module domain code (e.g. Booking module calling Payment module's `RefundBo
### T3.1 — EvRoute ### T3.1 — EvRoute
- **Module**: Routing - **Module**: Routing
- **Depends on**: T2.1, T2.2, T2.3 - **Depends on**: T2.1, T2.2
- **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. - **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 - **Domain reference**: domain.md §1, §2a
### T3.2 — Route ↔ Time Slot pivot ### T3.2 — Route ↔ Time Slot pivot
- **Module**: Routing - **Module**: Routing
@@ -159,13 +157,13 @@ Cross-module domain code (e.g. Booking module calling Payment module's `RefundBo
### T3.5 — RoutingPlugin + Filament EvRouteResource ### T3.5 — RoutingPlugin + Filament EvRouteResource
- **Module**: Routing/Filament - **Module**: Routing/Filament
- **Depends on**: T3.1T3.3, T1.3 - **Depends on**: T3.1T3.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 - **Domain reference**: domain.md §1, §3
### T3.6 — Routes read API ### T3.6 — Routes read API
- **Module**: Routing - **Module**: Routing
- **Depends on**: T3.1T3.4 - **Depends on**: T3.1T3.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) - **Domain reference**: domain.md §8 (this is what the AI agent's `route:read` ability consumes)
### T3.7 — Route/pricing caching ### 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 ### T4.1 — Booking model
- **Module**: Booking - **Module**: Booking
- **Depends on**: T3.1, T3.2, T3.3 - **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`). - **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 §3 (price snapshot — critical, write a test asserting price doesn't change after a later `RoutePricing` edit), §5 (status machine) - **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 ### T4.2 — BookingService::validateSelection
- **Module**: Booking - **Module**: Booking
@@ -347,3 +345,15 @@ Cross-module domain code (e.g. Booking module calling Payment module's `RefundBo
- **Depends on**: all above - **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). - **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) - **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