Generalize per-vehicle-option booking rules to Front Seat too

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.
This commit is contained in:
Nyan Lin Paing
2026-08-30 15:45:27 +07:00
parent 914b7f97f3
commit bebcab88fa
11 changed files with 175 additions and 97 deletions
+5 -2
View File
@@ -53,9 +53,12 @@ REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null REDIS_PASSWORD=null
REDIS_PORT=6379 REDIS_PORT=6379
BOOKING_BACK_SEAT_ENABLED=true BOOKING_FRONT_SEAT_ENABLED=true
BOOKING_WHOLE_VEHICLE_ENABLED=true
BOOKING_FRONT_SEAT_MAX_PER_BOOKING=1 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" BOOKING_ADMIN_EMAILS="example@gmail.com"
@@ -9,9 +9,9 @@ use RuntimeException;
class InvalidVehicleSelectionException extends 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 public static function optionDisabled(VehicleOption $vehicleOption): self
@@ -9,11 +9,11 @@ use Modules\Shared\Enums\VehicleOption;
class BookingService class BookingService
{ {
/** /**
* Enforces the only v1 inventory rule (max Front Seats per booking), the * Enforces the same two blunt, config-driven rules for every Vehicle
* blunt config toggles for Back Seat / Whole Vehicle availability, and * Option an on/off toggle and a max passenger_count per booking (see
* shape rules around combining options in one booking (no duplicate * domain.md §2) plus shape rules around combining options in one
* option lines, Whole Vehicle can't be mixed with anything else since it * booking (no duplicate option lines, Whole Vehicle can't be mixed with
* already covers the whole car). * anything else since it already covers the whole car).
* *
* Deliberately does not check real capacity/availability that's an * Deliberately does not check real capacity/availability that's an
* explicitly deferred future phase (domain.md §2, §7). * explicitly deferred future phase (domain.md §2, §7).
@@ -43,26 +43,23 @@ class BookingService
private function validateOption(VehicleOption $vehicleOption, int $passengerCount): void private function validateOption(VehicleOption $vehicleOption, int $passengerCount): void
{ {
match ($vehicleOption) { $this->validateEnabled($vehicleOption);
VehicleOption::FrontSeat => $this->validateFrontSeat($passengerCount), $this->validateMax($vehicleOption, $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 private function validateEnabled(VehicleOption $vehicleOption): void
{ {
$max = config('booking.front_seat_max_per_booking'); if (! config("booking.{$vehicleOption->value}_enabled")) {
if ($passengerCount > $max) {
throw InvalidVehicleSelectionException::frontSeatLimitExceeded($passengerCount, $max);
}
}
private function validateEnabled(VehicleOption $vehicleOption, string $configKey): void
{
if (! config($configKey)) {
throw InvalidVehicleSelectionException::optionDisabled($vehicleOption); 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);
}
}
} }
@@ -97,7 +97,37 @@ test('front-seat-limit rejection surfaces as 422', function () {
['vehicle_option' => 'front_seat', 'passenger_count' => 2], ['vehicle_option' => 'front_seat', 'passenger_count' => 2],
])) ]))
->assertStatus(422) ->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); expect(Booking::count())->toBe(0);
}); });
@@ -30,40 +30,52 @@ test('front seat and back seat can be selected together in one booking', functio
]))->not->toThrow(InvalidVehicleSelectionException::class); ]))->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]); config(['booking.front_seat_max_per_booking' => 1]);
$service = new BookingService; $service = new BookingService;
expect(fn () => $service->validateSelections([new VehicleSelectionData(VehicleOption::FrontSeat, 2)])) expect(fn () => $service->validateSelections([new VehicleSelectionData(VehicleOption::FrontSeat, 2)]))
->toThrow(InvalidVehicleSelectionException::class); ->toThrow(InvalidVehicleSelectionException::class, 'Vehicle option [front_seat] passenger count [2] exceeds the max of [1] per booking.');
});
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 () { test('the same vehicle option cannot be selected twice in one booking', function () {
@@ -60,9 +60,12 @@ class ManageAppSettings extends Page
'support_phone' => config('app.support_phone'), 'support_phone' => config('app.support_phone'),
'timezone' => config('app.timezone'), 'timezone' => config('app.timezone'),
'currency' => config('app.currency'), 'currency' => config('app.currency'),
'back_seat_enabled' => (bool) config('booking.back_seat_enabled'), 'front_seat_enabled' => (bool) config('booking.front_seat_enabled'),
'whole_vehicle_enabled' => (bool) config('booking.whole_vehicle_enabled'),
'front_seat_max_per_booking' => config('booking.front_seat_max_per_booking'), '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'), 'booking_admin_emails' => config('booking.admin_emails'),
'sms_enabled' => (bool) config('services.sms.enabled'), 'sms_enabled' => (bool) config('services.sms.enabled'),
'sms_server' => config('services.sms.sms_poh.server'), 'sms_server' => config('services.sms.sms_poh.server'),
@@ -106,23 +109,39 @@ class ManageAppSettings extends Page
->columns(2), ->columns(2),
Tab::make('Booking') Tab::make('Booking')
->schema([ ->schema([
Toggle::make('back_seat_enabled') Toggle::make('front_seat_enabled')
->label('Back Seat Enabled') ->label('Front Seat Enabled')
->helperText('Whether customers can select Back Seat at all right now.'), ->helperText('Whether customers can select Front 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.'),
TextInput::make('front_seat_max_per_booking') TextInput::make('front_seat_max_per_booking')
->label('Front Seat Max Per Booking') ->label('Front Seat Max Per Booking')
->numeric() ->numeric()
->minValue(1) ->minValue(1)
->required() ->required()
->helperText('Max Front Seats a single booking may request.'), ->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') TagsInput::make('booking_admin_emails')
->label('Admin Emails') ->label('Admin Emails')
->required() ->required()
->helperText('Notified on booking events. Press enter after each address.'), ->helperText('Notified on booking events. Press enter after each address.'),
]), ])
->columns(2),
Tab::make('SMS') Tab::make('SMS')
->schema([ ->schema([
Toggle::make('sms_enabled') Toggle::make('sms_enabled')
@@ -170,9 +189,12 @@ class ManageAppSettings extends Page
'SUPPORT_PHONE' => $state['support_phone'], 'SUPPORT_PHONE' => $state['support_phone'],
'APP_TIMEZONE' => $state['timezone'], 'APP_TIMEZONE' => $state['timezone'],
'APP_CURRENCY' => $state['currency'], 'APP_CURRENCY' => $state['currency'],
'BOOKING_BACK_SEAT_ENABLED' => (bool) $state['back_seat_enabled'], 'BOOKING_FRONT_SEAT_ENABLED' => (bool) $state['front_seat_enabled'],
'BOOKING_WHOLE_VEHICLE_ENABLED' => (bool) $state['whole_vehicle_enabled'],
'BOOKING_FRONT_SEAT_MAX_PER_BOOKING' => (int) $state['front_seat_max_per_booking'], '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'] ?? []), 'BOOKING_ADMIN_EMAILS' => implode(',', $state['booking_admin_emails'] ?? []),
'SMS_ENABLED' => (bool) $state['sms_enabled'], 'SMS_ENABLED' => (bool) $state['sms_enabled'],
'SMS_SERVER' => $state['sms_server'], 'SMS_SERVER' => $state['sms_server'],
@@ -40,9 +40,12 @@ test('a super_admin can view and save app settings, writing them to .env', funct
'support_phone' => '+95912345678', 'support_phone' => '+95912345678',
'timezone' => 'Asia/Yangon', 'timezone' => 'Asia/Yangon',
'currency' => 'MMK', 'currency' => 'MMK',
'back_seat_enabled' => false, 'front_seat_enabled' => false,
'whole_vehicle_enabled' => true,
'front_seat_max_per_booking' => 2, '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'], 'booking_admin_emails' => ['ops@evbooking.test', 'dispatch@evbooking.test'],
'sms_enabled' => true, 'sms_enabled' => true,
'sms_server' => 'https://sms.example.test/send', '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('SUPPORT_EMAIL=help@evbooking.test')
->toContain('APP_TIMEZONE=Asia/Yangon') ->toContain('APP_TIMEZONE=Asia/Yangon')
->toContain('APP_CURRENCY=MMK') ->toContain('APP_CURRENCY=MMK')
->toContain('BOOKING_BACK_SEAT_ENABLED=false') ->toContain('BOOKING_FRONT_SEAT_ENABLED=false')
->toContain('BOOKING_WHOLE_VEHICLE_ENABLED=true')
->toContain('BOOKING_FRONT_SEAT_MAX_PER_BOOKING=2') ->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('BOOKING_ADMIN_EMAILS=ops@evbooking.test,dispatch@evbooking.test')
->toContain('SMS_ENABLED=true') ->toContain('SMS_ENABLED=true')
->toContain('SMS_SERVER=https://sms.example.test/send') ->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']); ->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 = User::factory()->create();
$superAdmin->assignRole('super_admin'); $superAdmin->assignRole('super_admin');
$this->actingAs($superAdmin); $this->actingAs($superAdmin);
Livewire::test(ManageAppSettings::class) Livewire::test(ManageAppSettings::class)
->fillForm(['front_seat_max_per_booking' => 0]) ->fillForm([$field => 0])
->call('save') ->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',
]);
@@ -18,6 +18,7 @@ class RoutePricingResource extends JsonResource
'vehicle_option' => $this->vehicle_option->value, 'vehicle_option' => $this->vehicle_option->value,
'price' => (string) $this->price, 'price' => (string) $this->price,
'is_blocked' => $this->is_blocked, 'is_blocked' => $this->is_blocked,
'max_per_booking' => config("booking.{$this->vehicle_option->value}_max_per_booking"),
]; ];
} }
} }
@@ -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.time_slots.0.is_active', true)
->assertJsonPath('routes.data.0.pricing.0.vehicle_option', 'front_seat') ->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.price', '12000.00')
->assertJsonPath('routes.data.0.pricing.0.max_per_booking', config('booking.front_seat_max_per_booking'))
->assertJsonCount(0, 'return_routes.data'); ->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") ->getJson("/api/v1/routes/{$route->id}/pricing")
->assertSuccessful() ->assertSuccessful()
->assertJsonCount(2, 'data') ->assertJsonCount(2, 'data')
->assertJsonFragment(['vehicle_option' => 'front_seat', 'price' => '12000.00', 'is_blocked' => false]) ->assertJsonFragment([
->assertJsonFragment(['vehicle_option' => 'whole_vehicle', 'is_blocked' => true]); '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 () { test('lists a route\'s time slots with the pivot active flag', function () {
+10 -16
View File
@@ -4,30 +4,24 @@ return [
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Vehicle Option Toggles | Vehicle Option Rules
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| Blunt on/off switches for Vehicle Options that have no real inventory | Every Vehicle Option gets the same two blunt, config-driven rules no
| tracking in v1 (see domain.md §2). Front Seat has no toggle it is | real inventory tracking in v1 (see domain.md §2): an on/off switch for
| always selectable, only capped per-booking by the max below. | 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_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), 'whole_vehicle_enabled' => env('BOOKING_WHOLE_VEHICLE_ENABLED', true),
'whole_vehicle_max_per_booking' => env('BOOKING_WHOLE_VEHICLE_MAX_PER_BOOKING', 4),
/*
|--------------------------------------------------------------------------
| 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),
// comma-separated list of admin emails to notify on booking events // comma-separated list of admin emails to notify on booking events
'admin_emails' => explode(',', env('BOOKING_ADMIN_EMAILS', 'admin@example.com')), 'admin_emails' => explode(',', env('BOOKING_ADMIN_EMAILS', 'admin@example.com')),
+4 -5
View File
@@ -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`. - 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. - 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. - 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). - 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):
- Back Seat and Whole Vehicle availability are controlled by **blunt config toggles**, not database rows: - `BOOKING_FRONT_SEAT_ENABLED` / `BOOKING_FRONT_SEAT_MAX_PER_BOOKING` (currently `1`)
- `BOOKING_BACK_SEAT_ENABLED` — whether Back Seat can be selected at all right now. - `BOOKING_BACK_SEAT_ENABLED` / `BOOKING_BACK_SEAT_MAX_PER_BOOKING` (currently `3`)
- `BOOKING_WHOLE_VEHICLE_ENABLED` — whether Whole Vehicle can be selected at all right now. - `BOOKING_WHOLE_VEHICLE_ENABLED` / `BOOKING_WHOLE_VEHICLE_MAX_PER_BOOKING` (currently `4`)
- `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. `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. - 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).