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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
@@ -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"),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
Reference in New Issue
Block a user