From bebcab88facfd85e70544165bf1e50dba2cc0136 Mon Sep 17 00:00:00 2001 From: Nyan Lin Paing <117423022+LinPaing21@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:45:27 +0700 Subject: [PATCH] Generalize per-vehicle-option booking rules to Front Seat too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Front Seat previously had a hardcoded max-per-booking check with no enable toggle; now every Vehicle Option (front_seat/back_seat/whole_vehicle) gets the same config-driven pair — an on/off toggle and a max passenger_count — surfaced via new BOOKING_*_ENABLED/BOOKING_*_MAX_PER_BOOKING env vars, editable from ManageAppSettings, and exposed on route pricing as max_per_booking. --- .env.example | 7 +- .../InvalidVehicleSelectionException.php | 4 +- .../booking/src/Services/BookingService.php | 39 +++++------ .../tests/Feature/BookingCreateApiTest.php | 32 ++++++++- .../booking/tests/Unit/BookingServiceTest.php | 70 +++++++++++-------- .../src/Filament/Pages/ManageAppSettings.php | 44 +++++++++--- .../tests/Feature/ManageAppSettingsTest.php | 26 ++++--- .../Http/Resources/RoutePricingResource.php | 1 + .../tests/Feature/RoutesReadApiTest.php | 14 +++- config/booking.php | 26 +++---- domain.md | 9 ++- 11 files changed, 175 insertions(+), 97 deletions(-) diff --git a/.env.example b/.env.example index cdbaac3..7fc683c 100644 --- a/.env.example +++ b/.env.example @@ -53,9 +53,12 @@ REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379 -BOOKING_BACK_SEAT_ENABLED=true -BOOKING_WHOLE_VEHICLE_ENABLED=true +BOOKING_FRONT_SEAT_ENABLED=true BOOKING_FRONT_SEAT_MAX_PER_BOOKING=1 +BOOKING_BACK_SEAT_ENABLED=true +BOOKING_BACK_SEAT_MAX_PER_BOOKING=3 +BOOKING_WHOLE_VEHICLE_ENABLED=true +BOOKING_WHOLE_VEHICLE_MAX_PER_BOOKING=4 BOOKING_ADMIN_EMAILS="example@gmail.com" diff --git a/app-modules/booking/src/Exceptions/InvalidVehicleSelectionException.php b/app-modules/booking/src/Exceptions/InvalidVehicleSelectionException.php index e357c8a..ae6f00f 100644 --- a/app-modules/booking/src/Exceptions/InvalidVehicleSelectionException.php +++ b/app-modules/booking/src/Exceptions/InvalidVehicleSelectionException.php @@ -9,9 +9,9 @@ use RuntimeException; class InvalidVehicleSelectionException extends RuntimeException { - public static function frontSeatLimitExceeded(int $requested, int $max): self + public static function passengerLimitExceeded(VehicleOption $vehicleOption, int $requested, int $max): self { - return new self("Front seat request [{$requested}] exceeds the max of [{$max}] per booking."); + return new self("Vehicle option [{$vehicleOption->value}] passenger count [{$requested}] exceeds the max of [{$max}] per booking."); } public static function optionDisabled(VehicleOption $vehicleOption): self diff --git a/app-modules/booking/src/Services/BookingService.php b/app-modules/booking/src/Services/BookingService.php index 3c4bb2e..3428fd5 100644 --- a/app-modules/booking/src/Services/BookingService.php +++ b/app-modules/booking/src/Services/BookingService.php @@ -9,11 +9,11 @@ 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). + * Enforces the same two blunt, config-driven rules for every Vehicle + * Option — an on/off toggle and a max passenger_count per booking (see + * domain.md §2) — plus 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). @@ -43,26 +43,23 @@ class BookingService 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'), - }; + $this->validateEnabled($vehicleOption); + $this->validateMax($vehicleOption, $passengerCount); } - private function validateFrontSeat(int $passengerCount): void + private function validateEnabled(VehicleOption $vehicleOption): 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)) { + if (! config("booking.{$vehicleOption->value}_enabled")) { throw InvalidVehicleSelectionException::optionDisabled($vehicleOption); } } + + private function validateMax(VehicleOption $vehicleOption, int $passengerCount): void + { + $max = config("booking.{$vehicleOption->value}_max_per_booking"); + + if ($passengerCount > $max) { + throw InvalidVehicleSelectionException::passengerLimitExceeded($vehicleOption, $passengerCount, $max); + } + } } diff --git a/app-modules/booking/tests/Feature/BookingCreateApiTest.php b/app-modules/booking/tests/Feature/BookingCreateApiTest.php index 30618e7..05362d6 100644 --- a/app-modules/booking/tests/Feature/BookingCreateApiTest.php +++ b/app-modules/booking/tests/Feature/BookingCreateApiTest.php @@ -97,7 +97,37 @@ test('front-seat-limit rejection surfaces as 422', function () { ['vehicle_option' => 'front_seat', 'passenger_count' => 2], ])) ->assertStatus(422) - ->assertJsonPath('message', 'Front seat request [2] exceeds the max of [1] per booking.'); + ->assertJsonPath('message', 'Vehicle option [front_seat] passenger count [2] exceeds the max of [1] per booking.'); + + expect(Booking::count())->toBe(0); +}); + +test('back-seat-limit rejection surfaces as 422', function () { + config(['booking.back_seat_max_per_booking' => 1]); + + [$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '9000.00']]); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [ + ['vehicle_option' => 'back_seat', 'passenger_count' => 2], + ])) + ->assertStatus(422) + ->assertJsonPath('message', 'Vehicle option [back_seat] passenger count [2] exceeds the max of [1] per booking.'); + + expect(Booking::count())->toBe(0); +}); + +test('whole-vehicle-limit rejection surfaces as 422', function () { + config(['booking.whole_vehicle_max_per_booking' => 1]); + + [$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' => 2], + ])) + ->assertStatus(422) + ->assertJsonPath('message', 'Vehicle option [whole_vehicle] passenger count [2] exceeds the max of [1] per booking.'); expect(Booking::count())->toBe(0); }); diff --git a/app-modules/booking/tests/Unit/BookingServiceTest.php b/app-modules/booking/tests/Unit/BookingServiceTest.php index 68fd556..4a8fea0 100644 --- a/app-modules/booking/tests/Unit/BookingServiceTest.php +++ b/app-modules/booking/tests/Unit/BookingServiceTest.php @@ -30,40 +30,52 @@ test('front seat and back seat can be selected together in one booking', functio ]))->not->toThrow(InvalidVehicleSelectionException::class); }); -test('requesting more front seats than the configured max is rejected', function () { +test('requesting more passengers than the configured max is rejected', function (VehicleOption $option) { + config(["booking.{$option->value}_max_per_booking" => 1]); + + $service = new BookingService; + + expect(fn () => $service->validateSelections([new VehicleSelectionData($option, 2)])) + ->toThrow(InvalidVehicleSelectionException::class); +})->with([ + 'front_seat' => [VehicleOption::FrontSeat], + 'back_seat' => [VehicleOption::BackSeat], + 'whole_vehicle' => [VehicleOption::WholeVehicle], +]); + +test('requesting passengers up to the configured max passes', function (VehicleOption $option) { + config(["booking.{$option->value}_max_per_booking" => 2]); + + $service = new BookingService; + + expect(fn () => $service->validateSelections([new VehicleSelectionData($option, 2)])) + ->not->toThrow(InvalidVehicleSelectionException::class); +})->with([ + 'front_seat' => [VehicleOption::FrontSeat], + 'back_seat' => [VehicleOption::BackSeat], + 'whole_vehicle' => [VehicleOption::WholeVehicle], +]); + +test('an option is rejected when disabled via config', function (VehicleOption $option) { + config(["booking.{$option->value}_enabled" => false]); + + $service = new BookingService; + + expect(fn () => $service->validateSelections([new VehicleSelectionData($option)])) + ->toThrow(InvalidVehicleSelectionException::class, "Vehicle option [{$option->value}] is not currently available for booking."); +})->with([ + 'front_seat' => [VehicleOption::FrontSeat], + 'back_seat' => [VehicleOption::BackSeat], + 'whole_vehicle' => [VehicleOption::WholeVehicle], +]); + +test('exceeding the max produces the expected message', 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); + ->toThrow(InvalidVehicleSelectionException::class, 'Vehicle option [front_seat] passenger count [2] exceeds the max of [1] per booking.'); }); test('the same vehicle option cannot be selected twice in one booking', function () { diff --git a/app-modules/identity/src/Filament/Pages/ManageAppSettings.php b/app-modules/identity/src/Filament/Pages/ManageAppSettings.php index a25febf..06617f0 100644 --- a/app-modules/identity/src/Filament/Pages/ManageAppSettings.php +++ b/app-modules/identity/src/Filament/Pages/ManageAppSettings.php @@ -60,9 +60,12 @@ class ManageAppSettings extends Page 'support_phone' => config('app.support_phone'), 'timezone' => config('app.timezone'), 'currency' => config('app.currency'), - 'back_seat_enabled' => (bool) config('booking.back_seat_enabled'), - 'whole_vehicle_enabled' => (bool) config('booking.whole_vehicle_enabled'), + 'front_seat_enabled' => (bool) config('booking.front_seat_enabled'), 'front_seat_max_per_booking' => config('booking.front_seat_max_per_booking'), + 'back_seat_enabled' => (bool) config('booking.back_seat_enabled'), + 'back_seat_max_per_booking' => config('booking.back_seat_max_per_booking'), + 'whole_vehicle_enabled' => (bool) config('booking.whole_vehicle_enabled'), + 'whole_vehicle_max_per_booking' => config('booking.whole_vehicle_max_per_booking'), 'booking_admin_emails' => config('booking.admin_emails'), 'sms_enabled' => (bool) config('services.sms.enabled'), 'sms_server' => config('services.sms.sms_poh.server'), @@ -106,23 +109,39 @@ class ManageAppSettings extends Page ->columns(2), Tab::make('Booking') ->schema([ - Toggle::make('back_seat_enabled') - ->label('Back Seat Enabled') - ->helperText('Whether customers can select Back Seat at all right now.'), - Toggle::make('whole_vehicle_enabled') - ->label('Whole Vehicle Enabled') - ->helperText('Whether customers can select Whole Vehicle at all right now.'), + Toggle::make('front_seat_enabled') + ->label('Front Seat Enabled') + ->helperText('Whether customers can select Front Seat at all right now.'), TextInput::make('front_seat_max_per_booking') ->label('Front Seat Max Per Booking') ->numeric() ->minValue(1) ->required() ->helperText('Max Front Seats a single booking may request.'), + Toggle::make('back_seat_enabled') + ->label('Back Seat Enabled') + ->helperText('Whether customers can select Back Seat at all right now.'), + TextInput::make('back_seat_max_per_booking') + ->label('Back Seat Max Per Booking') + ->numeric() + ->minValue(1) + ->required() + ->helperText('Max Back Seats a single booking may request.'), + Toggle::make('whole_vehicle_enabled') + ->label('Whole Vehicle Enabled') + ->helperText('Whether customers can select Whole Vehicle at all right now.'), + TextInput::make('whole_vehicle_max_per_booking') + ->label('Whole Vehicle Max Per Booking') + ->numeric() + ->minValue(1) + ->required() + ->helperText('Max Whole Vehicle passenger count a single booking may request.'), TagsInput::make('booking_admin_emails') ->label('Admin Emails') ->required() ->helperText('Notified on booking events. Press enter after each address.'), - ]), + ]) + ->columns(2), Tab::make('SMS') ->schema([ Toggle::make('sms_enabled') @@ -170,9 +189,12 @@ class ManageAppSettings extends Page 'SUPPORT_PHONE' => $state['support_phone'], 'APP_TIMEZONE' => $state['timezone'], 'APP_CURRENCY' => $state['currency'], - 'BOOKING_BACK_SEAT_ENABLED' => (bool) $state['back_seat_enabled'], - 'BOOKING_WHOLE_VEHICLE_ENABLED' => (bool) $state['whole_vehicle_enabled'], + 'BOOKING_FRONT_SEAT_ENABLED' => (bool) $state['front_seat_enabled'], 'BOOKING_FRONT_SEAT_MAX_PER_BOOKING' => (int) $state['front_seat_max_per_booking'], + 'BOOKING_BACK_SEAT_ENABLED' => (bool) $state['back_seat_enabled'], + 'BOOKING_BACK_SEAT_MAX_PER_BOOKING' => (int) $state['back_seat_max_per_booking'], + 'BOOKING_WHOLE_VEHICLE_ENABLED' => (bool) $state['whole_vehicle_enabled'], + 'BOOKING_WHOLE_VEHICLE_MAX_PER_BOOKING' => (int) $state['whole_vehicle_max_per_booking'], 'BOOKING_ADMIN_EMAILS' => implode(',', $state['booking_admin_emails'] ?? []), 'SMS_ENABLED' => (bool) $state['sms_enabled'], 'SMS_SERVER' => $state['sms_server'], diff --git a/app-modules/identity/tests/Feature/ManageAppSettingsTest.php b/app-modules/identity/tests/Feature/ManageAppSettingsTest.php index 0163305..2c1bb11 100644 --- a/app-modules/identity/tests/Feature/ManageAppSettingsTest.php +++ b/app-modules/identity/tests/Feature/ManageAppSettingsTest.php @@ -40,9 +40,12 @@ test('a super_admin can view and save app settings, writing them to .env', funct 'support_phone' => '+95912345678', 'timezone' => 'Asia/Yangon', 'currency' => 'MMK', - 'back_seat_enabled' => false, - 'whole_vehicle_enabled' => true, + 'front_seat_enabled' => false, 'front_seat_max_per_booking' => 2, + 'back_seat_enabled' => false, + 'back_seat_max_per_booking' => 5, + 'whole_vehicle_enabled' => true, + 'whole_vehicle_max_per_booking' => 6, 'booking_admin_emails' => ['ops@evbooking.test', 'dispatch@evbooking.test'], 'sms_enabled' => true, 'sms_server' => 'https://sms.example.test/send', @@ -59,9 +62,12 @@ test('a super_admin can view and save app settings, writing them to .env', funct ->toContain('SUPPORT_EMAIL=help@evbooking.test') ->toContain('APP_TIMEZONE=Asia/Yangon') ->toContain('APP_CURRENCY=MMK') - ->toContain('BOOKING_BACK_SEAT_ENABLED=false') - ->toContain('BOOKING_WHOLE_VEHICLE_ENABLED=true') + ->toContain('BOOKING_FRONT_SEAT_ENABLED=false') ->toContain('BOOKING_FRONT_SEAT_MAX_PER_BOOKING=2') + ->toContain('BOOKING_BACK_SEAT_ENABLED=false') + ->toContain('BOOKING_BACK_SEAT_MAX_PER_BOOKING=5') + ->toContain('BOOKING_WHOLE_VEHICLE_ENABLED=true') + ->toContain('BOOKING_WHOLE_VEHICLE_MAX_PER_BOOKING=6') ->toContain('BOOKING_ADMIN_EMAILS=ops@evbooking.test,dispatch@evbooking.test') ->toContain('SMS_ENABLED=true') ->toContain('SMS_SERVER=https://sms.example.test/send') @@ -80,13 +86,17 @@ test('sms server and token are required once sms is enabled', function () { ->assertHasFormErrors(['sms_server', 'sms_token']); }); -test('front seat max per booking must be at least 1', function () { +test('max per booking fields must be at least 1', function (string $field) { $superAdmin = User::factory()->create(); $superAdmin->assignRole('super_admin'); $this->actingAs($superAdmin); Livewire::test(ManageAppSettings::class) - ->fillForm(['front_seat_max_per_booking' => 0]) + ->fillForm([$field => 0]) ->call('save') - ->assertHasFormErrors(['front_seat_max_per_booking']); -}); + ->assertHasFormErrors([$field]); +})->with([ + 'front_seat_max_per_booking', + 'back_seat_max_per_booking', + 'whole_vehicle_max_per_booking', +]); diff --git a/app-modules/routing/src/Http/Resources/RoutePricingResource.php b/app-modules/routing/src/Http/Resources/RoutePricingResource.php index 515d30a..2b44564 100644 --- a/app-modules/routing/src/Http/Resources/RoutePricingResource.php +++ b/app-modules/routing/src/Http/Resources/RoutePricingResource.php @@ -18,6 +18,7 @@ class RoutePricingResource extends JsonResource 'vehicle_option' => $this->vehicle_option->value, 'price' => (string) $this->price, 'is_blocked' => $this->is_blocked, + 'max_per_booking' => config("booking.{$this->vehicle_option->value}_max_per_booking"), ]; } } diff --git a/app-modules/routing/tests/Feature/RoutesReadApiTest.php b/app-modules/routing/tests/Feature/RoutesReadApiTest.php index 49a1c18..509358e 100644 --- a/app-modules/routing/tests/Feature/RoutesReadApiTest.php +++ b/app-modules/routing/tests/Feature/RoutesReadApiTest.php @@ -37,6 +37,7 @@ test('searches active routes with nested company, destinations, time slots and p ->assertJsonPath('routes.data.0.time_slots.0.is_active', true) ->assertJsonPath('routes.data.0.pricing.0.vehicle_option', 'front_seat') ->assertJsonPath('routes.data.0.pricing.0.price', '12000.00') + ->assertJsonPath('routes.data.0.pricing.0.max_per_booking', config('booking.front_seat_max_per_booking')) ->assertJsonCount(0, 'return_routes.data'); }); @@ -311,8 +312,17 @@ test('lists a route\'s pricing including blocked options', function () { ->getJson("/api/v1/routes/{$route->id}/pricing") ->assertSuccessful() ->assertJsonCount(2, 'data') - ->assertJsonFragment(['vehicle_option' => 'front_seat', 'price' => '12000.00', 'is_blocked' => false]) - ->assertJsonFragment(['vehicle_option' => 'whole_vehicle', 'is_blocked' => true]); + ->assertJsonFragment([ + 'vehicle_option' => 'front_seat', + 'price' => '12000.00', + 'is_blocked' => false, + 'max_per_booking' => config('booking.front_seat_max_per_booking'), + ]) + ->assertJsonFragment([ + 'vehicle_option' => 'whole_vehicle', + 'is_blocked' => true, + 'max_per_booking' => config('booking.whole_vehicle_max_per_booking'), + ]); }); test('lists a route\'s time slots with the pivot active flag', function () { diff --git a/config/booking.php b/config/booking.php index 6129ba4..3a4e8ed 100644 --- a/config/booking.php +++ b/config/booking.php @@ -4,30 +4,24 @@ return [ /* |-------------------------------------------------------------------------- - | Vehicle Option Toggles + | Vehicle Option Rules |-------------------------------------------------------------------------- | - | Blunt on/off switches for Vehicle Options that have no real inventory - | tracking in v1 (see domain.md §2). Front Seat has no toggle — it is - | always selectable, only capped per-booking by the max below. + | Every Vehicle Option gets the same two blunt, config-driven rules — no + | real inventory tracking in v1 (see domain.md §2): an on/off switch for + | whether it can be selected at all right now, and a max passenger_count + | a single booking may request for it. | */ + 'front_seat_enabled' => env('BOOKING_FRONT_SEAT_ENABLED', true), + 'front_seat_max_per_booking' => env('BOOKING_FRONT_SEAT_MAX_PER_BOOKING', 1), + 'back_seat_enabled' => env('BOOKING_BACK_SEAT_ENABLED', true), + 'back_seat_max_per_booking' => env('BOOKING_BACK_SEAT_MAX_PER_BOOKING', 3), 'whole_vehicle_enabled' => env('BOOKING_WHOLE_VEHICLE_ENABLED', true), - - /* - |-------------------------------------------------------------------------- - | Front Seat Limit - |-------------------------------------------------------------------------- - | - | The only inventory rule enforced in v1: max Front Seats a single - | booking may request. - | - */ - - 'front_seat_max_per_booking' => env('BOOKING_FRONT_SEAT_MAX_PER_BOOKING', 1), + 'whole_vehicle_max_per_booking' => env('BOOKING_WHOLE_VEHICLE_MAX_PER_BOOKING', 4), // comma-separated list of admin emails to notify on booking events 'admin_emails' => explode(',', env('BOOKING_ADMIN_EMAILS', 'admin@example.com')), diff --git a/domain.md b/domain.md index 443e091..8b17239 100644 --- a/domain.md +++ b/domain.md @@ -27,11 +27,10 @@ Unlike a bus-booking system, **there is no seat map and no capacity tracking in - A Booking can select **more than one Vehicle Option** in the same booking (e.g. `front_seat` + `back_seat` for a customer traveling with a companion) — stored as one row per selected option in `booking_vehicle_options` (`booking_id`, `vehicle_option`, `passenger_count`, `unit_price`, `line_total`), not a single column on `bookings`. `bookings.price` is the sum of every line's `line_total`. - Each Vehicle Option can appear **at most once per booking** (no two separate `front_seat` lines — bump `passenger_count` instead). `whole_vehicle` cannot be combined with any other option in the same booking, since it already covers the entire vehicle. - Any number of *different bookings* can book the same Route + Date + Time Slot. The system does not check whether a "Whole Vehicle" or "Back Seat" is already taken by someone else. -- The **only inventory rule enforced in code** is: **max Front Seats per booking**, checked against `passenger_count` on the `front_seat` line (`BOOKING_FRONT_SEAT_MAX_PER_BOOKING`, currently `1` — a per-booking constraint, not a per-trip inventory check). -- Back Seat and Whole Vehicle availability are controlled by **blunt config toggles**, not database rows: - - `BOOKING_BACK_SEAT_ENABLED` — whether Back Seat can be selected at all right now. - - `BOOKING_WHOLE_VEHICLE_ENABLED` — whether Whole Vehicle can be selected at all right now. - - `BOOKING_FRONT_SEAT_MAX_PER_BOOKING` — currently `1`, expressed as config in case it ever needs to change. +- Every Vehicle Option is governed by the same two **blunt config-driven rules**, not database rows: an on/off toggle for whether it can be selected at all right now, and a max `passenger_count` a single booking may request for it (a per-booking constraint, not a per-trip inventory check): + - `BOOKING_FRONT_SEAT_ENABLED` / `BOOKING_FRONT_SEAT_MAX_PER_BOOKING` (currently `1`) + - `BOOKING_BACK_SEAT_ENABLED` / `BOOKING_BACK_SEAT_MAX_PER_BOOKING` (currently `3`) + - `BOOKING_WHOLE_VEHICLE_ENABLED` / `BOOKING_WHOLE_VEHICLE_MAX_PER_BOOKING` (currently `4`) - 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).