Compare commits

...

7 Commits

Author SHA1 Message Date
Nyan Lin Paing fa908cdcaf add notes/remark and refactor round-trip
PHP Tests / php-tests (push) Has been cancelled
2026-08-22 21:43:41 +07:00
Nyan Lin Paing 894352b43f fix ci/cd testing failure
PHP Tests / php-tests (push) Successful in 4m42s
2026-08-20 20:49:28 +07:00
Nyan Lin Paing 1aeb57f130 Update .env.example
PHP Tests / php-tests (push) Failing after 4m0s
2026-08-20 20:23:48 +07:00
Nyan Lin Paing 6be47aa35a Update tests.yml
PHP Tests / php-tests (push) Failing after 4m18s
2026-08-20 00:27:47 +07:00
Nyan Lin Paing 79f7f50706 Update tests.yml
PHP Tests / php-tests (push) Failing after 2m51s
2026-08-20 00:20:08 +07:00
Nyan Lin Paing 54b35ee087 Update tests.yml
PHP Tests / php-tests (push) Has been cancelled
2026-08-20 00:08:33 +07:00
Nyan Lin Paing dfffdd343b fix ci/cd test pipeline
PHP Tests / php-tests (push) Has been cancelled
2026-08-20 00:01:28 +07:00
51 changed files with 1716 additions and 196 deletions
+3 -3
View File
@@ -53,9 +53,9 @@ REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null REDIS_PASSWORD=null
REDIS_PORT=6379 REDIS_PORT=6379
BOOKING_BACK_SEAT_ENABLED= BOOKING_BACK_SEAT_ENABLED=true
BOOKING_WHOLE_VEHICLE_ENABLED= BOOKING_WHOLE_VEHICLE_ENABLED=true
BOOKING_FRONT_SEAT_MAX_PER_BOOKING= BOOKING_FRONT_SEAT_MAX_PER_BOOKING=1
KBZ_APP_ID= KBZ_APP_ID=
KBZ_MERCHANT_CODE= KBZ_MERCHANT_CODE=
+28 -7
View File
@@ -25,6 +25,18 @@ jobs:
--health-timeout 5s --health-timeout 5s
--health-retries 5 --health-retries 5
env:
DB_CONNECTION: pgsql
DB_HOST: postgres
DB_PORT: 5432
DB_DATABASE: testing
DB_USERNAME: root
DB_PASSWORD: ''
CACHE_STORE: array
CACHE_DRIVER: array
SESSION_DRIVER: array
QUEUE_CONNECTION: sync
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -56,12 +68,21 @@ jobs:
- name: Generate app key - name: Generate app key
run: php artisan key:generate run: php artisan key:generate
- name: Install postgresql-client
run: |
apt-get update
apt-get install -y postgresql-client
- name: Wait for Postgres
timeout-minutes: 1
run: |
until pg_isready -h postgres -p 5432 -U root; do
echo "Waiting for postgres..."
sleep 2
done
- name: Run migrations
run: php artisan migrate --force
- name: Run tests - name: Run tests
env:
DB_CONNECTION: pgsql
DB_HOST: 127.0.0.1
DB_PORT: 5432
DB_DATABASE: testing
DB_USERNAME: root
DB_PASSWORD: ''
run: php artisan test --compact run: php artisan test --compact
@@ -29,6 +29,8 @@ class BookingFactory extends Factory
'user_id' => null, 'user_id' => null,
'openid' => null, 'openid' => null,
'ev_route_id' => EvRoute::factory(), 'ev_route_id' => EvRoute::factory(),
'linked_booking_id' => null,
'is_return_leg' => false,
'departure_time_slot_id' => DepartureTimeSlot::factory(), 'departure_time_slot_id' => DepartureTimeSlot::factory(),
'travel_date' => now()->addDay()->toDateString(), 'travel_date' => now()->addDay()->toDateString(),
'passenger_name' => $this->faker->name(), 'passenger_name' => $this->faker->name(),
@@ -41,8 +43,6 @@ class BookingFactory extends Factory
'dropoff_lng' => null, 'dropoff_lng' => null,
'price' => $this->faker->randomFloat(2, 5000, 50000), 'price' => $this->faker->randomFloat(2, 5000, 50000),
'status' => BookingStatus::PendingPayment, 'status' => BookingStatus::PendingPayment,
'is_round_trip' => false,
'return_travel_date' => null,
'created_by_channel' => BookingChannel::MiniApp, 'created_by_channel' => BookingChannel::MiniApp,
'driver_name' => null, 'driver_name' => null,
'driver_phone' => null, 'driver_phone' => null,
@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 'notes' customer-supplied, submitted via the booking create API
* endpoint (StoreBookingRequest). 'remark' staff-only, set from the
* admin panel (SetRemarkTableAction); never exposed on the customer
* BookingResource. Both nullable, free text.
*/
public function up(): void
{
Schema::table('bookings', function (Blueprint $table) {
$table->text('notes')->nullable();
$table->text('remark')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('bookings', function (Blueprint $table) {
$table->dropColumn(['notes', 'remark']);
});
}
};
@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Round trip is redesigned as two linked one-way Booking rows (outbound
* + return) rather than a flag + a lone return date on a single row
* the return leg needs its own route/time-slot/price/driver-vehicle
* assignment, since it may run with a different vehicle than the
* outbound leg (domain.md §2b). `is_round_trip` becomes a computed
* accessor on the model (`linked_booking_id !== null`), so the column
* is dropped rather than kept redundant.
*/
public function up(): void
{
Schema::table('bookings', function (Blueprint $table) {
$table->dropColumn(['is_round_trip', 'return_travel_date']);
$table->foreignId('linked_booking_id')->nullable()->after('ev_route_id')
->constrained('bookings')->nullOnDelete();
$table->boolean('is_return_leg')->default(false)->after('linked_booking_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('bookings', function (Blueprint $table) {
$table->dropConstrainedForeignId('linked_booking_id');
$table->dropColumn('is_return_leg');
$table->boolean('is_round_trip')->default(false);
$table->date('return_travel_date')->nullable();
});
}
};
@@ -7,6 +7,7 @@ use Modules\Booking\Data\CreateBookingData;
use Modules\Booking\Data\VehicleSelectionData; use Modules\Booking\Data\VehicleSelectionData;
use Modules\Booking\Enums\BookingStatus; use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Events\BookingCreated; use Modules\Booking\Events\BookingCreated;
use Modules\Booking\Exceptions\InvalidReturnRouteException;
use Modules\Booking\Models\Booking; use Modules\Booking\Models\Booking;
use Modules\Booking\Services\BookingRefGenerator; use Modules\Booking\Services\BookingRefGenerator;
use Modules\Booking\Services\BookingService; use Modules\Booking\Services\BookingService;
@@ -26,12 +27,75 @@ class CreateBookingAction
{ {
$this->bookingService->validateSelections($data->selections); $this->bookingService->validateSelections($data->selections);
return DB::transaction(function () use ($data) { $isRoundTrip = $data->returnEvRouteId !== null;
$route = EvRoute::findOrFail($data->evRouteId);
if ($isRoundTrip) {
$this->bookingService->validateSelections($data->returnSelections);
}
return DB::transaction(function () use ($data, $isRoundTrip) {
$outboundRoute = EvRoute::findOrFail($data->evRouteId);
$outboundBooking = $this->createLeg(
data: $data,
route: $outboundRoute,
selections: $data->selections,
travelDate: $data->travelDate,
timeSlotId: $data->departureTimeSlotId,
isReturnLeg: false,
);
if (! $isRoundTrip) {
BookingCreated::dispatch($outboundBooking);
return $outboundBooking;
}
$returnRoute = EvRoute::findOrFail($data->returnEvRouteId);
if (! $returnRoute->isReverseOf($outboundRoute)) {
throw InvalidReturnRouteException::notReverseOfOutbound($returnRoute, $outboundRoute);
}
$returnBooking = $this->createLeg(
data: $data,
route: $returnRoute,
selections: $data->returnSelections,
travelDate: $data->returnTravelDate,
timeSlotId: $data->returnDepartureTimeSlotId,
isReturnLeg: true,
);
// Linked bidirectionally after both rows exist — a single
// `linked_booking_id` FK can't be set on either row at create
// time since the other side doesn't have an id yet.
$returnBooking->update(['linked_booking_id' => $outboundBooking->id]);
$outboundBooking->update(['linked_booking_id' => $returnBooking->id]);
// No registered listeners on BookingCreated today, so firing it
// twice per round-trip creation has no side effects — flagged
// here for whoever adds the first listener.
BookingCreated::dispatch($outboundBooking);
BookingCreated::dispatch($returnBooking);
return $outboundBooking->refresh();
});
}
/**
* @param list<VehicleSelectionData> $selections
*/
private function createLeg(
CreateBookingData $data,
EvRoute $route,
array $selections,
string $travelDate,
int $timeSlotId,
bool $isReturnLeg,
): Booking {
$lines = array_map( $lines = array_map(
fn (VehicleSelectionData $selection) => $this->priceSelection($route, $selection), fn (VehicleSelectionData $selection) => $this->priceSelection($route, $selection),
$data->selections, $selections,
); );
$totalPrice = array_reduce( $totalPrice = array_reduce(
@@ -44,11 +108,13 @@ class CreateBookingAction
'booking_ref' => $this->bookingRefGenerator->generate(), 'booking_ref' => $this->bookingRefGenerator->generate(),
'user_id' => $data->userId, 'user_id' => $data->userId,
'openid' => $data->openid, 'openid' => $data->openid,
'ev_route_id' => $data->evRouteId, 'ev_route_id' => $route->id,
'departure_time_slot_id' => $data->departureTimeSlotId, 'is_return_leg' => $isReturnLeg,
'travel_date' => $data->travelDate, 'departure_time_slot_id' => $timeSlotId,
'travel_date' => $travelDate,
'passenger_name' => $data->passengerName, 'passenger_name' => $data->passengerName,
'passenger_phone' => $data->passengerPhone, 'passenger_phone' => $data->passengerPhone,
'notes' => $data->notes,
'pickup_address' => $data->pickupAddress, 'pickup_address' => $data->pickupAddress,
'pickup_lat' => $data->pickupLat, 'pickup_lat' => $data->pickupLat,
'pickup_lng' => $data->pickupLng, 'pickup_lng' => $data->pickupLng,
@@ -57,17 +123,12 @@ class CreateBookingAction
'dropoff_lng' => $data->dropoffLng, 'dropoff_lng' => $data->dropoffLng,
'price' => $totalPrice, 'price' => $totalPrice,
'status' => BookingStatus::PendingPayment, 'status' => BookingStatus::PendingPayment,
'is_round_trip' => $data->isRoundTrip,
'return_travel_date' => $data->returnTravelDate,
'created_by_channel' => $data->createdByChannel, 'created_by_channel' => $data->createdByChannel,
]); ]);
$booking->vehicleOptions()->createMany($lines); $booking->vehicleOptions()->createMany($lines);
BookingCreated::dispatch($booking);
return $booking; return $booking;
});
} }
/** /**
@@ -0,0 +1,21 @@
<?php
namespace Modules\Booking\Actions;
use Modules\Booking\Models\Booking;
/**
* Staff-only internal note, set from the admin panel
* (SetRemarkTableAction). No status restriction staff can annotate a
* booking at any point in its lifecycle. Never exposed on the customer
* BookingResource.
*/
class SetRemarkAction
{
public function handle(Booking $booking, ?string $remark): Booking
{
$booking->update(['remark' => $remark]);
return $booking;
}
}
@@ -9,6 +9,9 @@ readonly class CreateBookingData
/** /**
* @param list<VehicleSelectionData> $selections One or more Vehicle Option * @param list<VehicleSelectionData> $selections One or more Vehicle Option
* selections (e.g. front_seat + back_seat) domain.md §2. * selections (e.g. front_seat + back_seat) domain.md §2.
* @param list<VehicleSelectionData>|null $returnSelections Same shape as $selections,
* priced independently against $returnEvRouteId. Presence of
* $returnEvRouteId is the round-trip signal (domain.md §2b).
*/ */
public function __construct( public function __construct(
public int $evRouteId, public int $evRouteId,
@@ -22,11 +25,14 @@ readonly class CreateBookingData
public BookingChannel $createdByChannel, public BookingChannel $createdByChannel,
public ?int $userId = null, public ?int $userId = null,
public ?string $openid = null, public ?string $openid = null,
public ?string $notes = null,
public ?float $pickupLat = null, public ?float $pickupLat = null,
public ?float $pickupLng = null, public ?float $pickupLng = null,
public ?float $dropoffLat = null, public ?float $dropoffLat = null,
public ?float $dropoffLng = null, public ?float $dropoffLng = null,
public bool $isRoundTrip = false, public ?int $returnEvRouteId = null,
public ?int $returnDepartureTimeSlotId = null,
public ?string $returnTravelDate = null, public ?string $returnTravelDate = null,
public ?array $returnSelections = null,
) {} ) {}
} }
@@ -0,0 +1,32 @@
<?php
namespace Modules\Booking\Exceptions;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\Routing\Models\EvRoute;
use RuntimeException;
class InvalidReturnRouteException extends RuntimeException
{
public static function notReverseOfOutbound(EvRoute $returnRoute, EvRoute $outboundRoute): self
{
return new self(
"Return route [{$returnRoute->id}] is not the reverse of outbound route [{$outboundRoute->id}] — ".
'from/to destinations must be swapped.'
);
}
/**
* A rejected return route is a client input problem, not a server
* error surface it as 422, matching InvalidVehicleSelectionException.
*/
public function render(Request $request): ?JsonResponse
{
if ($request->expectsJson()) {
return response()->json(['message' => $this->getMessage()], 422);
}
return null;
}
}
@@ -0,0 +1,40 @@
<?php
namespace Modules\Booking\Filament\Resources\Bookings\Actions;
use Filament\Actions\Action;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
use Filament\Support\Icons\Heroicon;
use Modules\Booking\Actions\SetRemarkAction;
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 SetRemarkTableAction
{
public static function make(): Action
{
return Action::make('setRemark')
->label('Remark')
->icon(Heroicon::OutlinedPencilSquare)
->color('gray')
->visible(fn (): bool => auth()->user()?->can('manage_bookings') ?? false)
->schema([
Textarea::make('remark')->maxLength(1000),
])
->fillForm(fn (Booking $record): array => [
'remark' => $record->remark,
])
->action(function (array $data, Booking $record, SetRemarkAction $setRemarkAction) {
$setRemarkAction->handle($record, $data['remark'] ?: null);
Notification::make()
->title('Remark saved')
->success()
->send();
});
}
}
@@ -5,6 +5,7 @@ namespace Modules\Booking\Filament\Resources\Bookings\Pages;
use Filament\Resources\Pages\ViewRecord; use Filament\Resources\Pages\ViewRecord;
use Modules\Booking\Filament\Resources\Bookings\Actions\AssignDriverTableAction; use Modules\Booking\Filament\Resources\Bookings\Actions\AssignDriverTableAction;
use Modules\Booking\Filament\Resources\Bookings\Actions\CancelBookingTableAction; use Modules\Booking\Filament\Resources\Bookings\Actions\CancelBookingTableAction;
use Modules\Booking\Filament\Resources\Bookings\Actions\SetRemarkTableAction;
use Modules\Booking\Filament\Resources\Bookings\BookingResource; use Modules\Booking\Filament\Resources\Bookings\BookingResource;
class ViewBooking extends ViewRecord class ViewBooking extends ViewRecord
@@ -15,6 +16,7 @@ class ViewBooking extends ViewRecord
{ {
return [ return [
AssignDriverTableAction::make(), AssignDriverTableAction::make(),
SetRemarkTableAction::make(),
CancelBookingTableAction::make(), CancelBookingTableAction::make(),
]; ];
} }
@@ -8,6 +8,7 @@ use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Modules\Booking\Enums\BookingStatus; use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Filament\Resources\Bookings\BookingResource;
use Modules\Payment\Enums\PaymentStatus; use Modules\Payment\Enums\PaymentStatus;
class BookingInfolist class BookingInfolist
@@ -32,7 +33,8 @@ class BookingInfolist
TextEntry::make('created_by_channel')->badge(), TextEntry::make('created_by_channel')->badge(),
TextEntry::make('created_at')->dateTime(), TextEntry::make('created_at')->dateTime(),
]), ]),
]), ])
->columnSpanFull(),
Section::make('Trip') Section::make('Trip')
->schema([ ->schema([
Grid::make(3) Grid::make(3)
@@ -43,10 +45,17 @@ class BookingInfolist
TextEntry::make('timeSlot.label')->label('Time Slot'), TextEntry::make('timeSlot.label')->label('Time Slot'),
TextEntry::make('travel_date')->date(), TextEntry::make('travel_date')->date(),
TextEntry::make('is_round_trip')->label('Round Trip')->badge(), TextEntry::make('is_round_trip')->label('Round Trip')->badge(),
TextEntry::make('return_travel_date')->date() TextEntry::make('is_return_leg')->label('Leg')->badge()
->formatStateUsing(fn (bool $state) => $state ? 'Return' : 'Outbound')
->visible(fn ($record) => $record->is_round_trip), ->visible(fn ($record) => $record->is_round_trip),
TextEntry::make('linkedBooking.booking_ref')->label('Linked Leg')
->visible(fn ($record) => $record->is_round_trip)
->url(fn ($record) => $record->linked_booking_id
? BookingResource::getUrl('view', ['record' => $record->linked_booking_id])
: null),
]), ]),
]), ])
->columnSpanFull(),
Section::make('Vehicle Options') Section::make('Vehicle Options')
->schema([ ->schema([
RepeatableEntry::make('vehicleOptions') RepeatableEntry::make('vehicleOptions')
@@ -61,15 +70,29 @@ class BookingInfolist
]), ]),
]), ]),
TextEntry::make('price')->label('Total Price')->numeric(2), TextEntry::make('price')->label('Total Price')->numeric(2),
]), ])
->columnSpanFull(),
Section::make('Passenger') Section::make('Passenger')
->schema([ ->schema([
Grid::make(2) Grid::make(2)
->schema([ ->schema([
TextEntry::make('passenger_name'), TextEntry::make('passenger_name'),
TextEntry::make('passenger_phone'), TextEntry::make('passenger_phone'),
TextEntry::make('notes')
->label('Customer Notes')
->placeholder('—')
->columnSpanFull(),
]), ]),
]), ])
->columnSpanFull(),
Section::make('Staff Remark')
->description('Internal only — never shown to the customer. Set via the Remark action.')
->schema([
TextEntry::make('remark')
->label('')
->placeholder('No remark yet.'),
])
->columnSpanFull(),
Section::make('Pickup & Dropoff') Section::make('Pickup & Dropoff')
->schema([ ->schema([
Grid::make(2) Grid::make(2)
@@ -81,7 +104,8 @@ class BookingInfolist
TextEntry::make('pickup_lng')->label('Pickup Lng')->placeholder('—'), TextEntry::make('pickup_lng')->label('Pickup Lng')->placeholder('—'),
TextEntry::make('dropoff_lng')->label('Dropoff Lng')->placeholder('—'), TextEntry::make('dropoff_lng')->label('Dropoff Lng')->placeholder('—'),
]), ]),
]), ])
->columnSpanFull(),
Section::make('Driver & Vehicle') Section::make('Driver & Vehicle')
->description('Filled in by staff once the booking is confirmed — see the Assign Driver action.') ->description('Filled in by staff once the booking is confirmed — see the Assign Driver action.')
->schema([ ->schema([
@@ -92,7 +116,8 @@ class BookingInfolist
TextEntry::make('car_plate_number')->label('Car Plate')->placeholder('Not yet assigned'), TextEntry::make('car_plate_number')->label('Car Plate')->placeholder('Not yet assigned'),
TextEntry::make('car_model')->label('Car Model')->placeholder('—'), TextEntry::make('car_model')->label('Car Model')->placeholder('—'),
]), ]),
]), ])
->columnSpanFull(),
// A booking can have more than one payment attempt if an // A booking can have more than one payment attempt if an
// earlier one failed and the customer retried (domain.md §1) // earlier one failed and the customer retried (domain.md §1)
// — full detail (gateway response, refunds) lives on the // — full detail (gateway response, refunds) lives on the
@@ -4,6 +4,8 @@ namespace Modules\Booking\Filament\Resources\Bookings\Tables;
use Filament\Actions\ViewAction; use Filament\Actions\ViewAction;
use Filament\Forms\Components\DatePicker; use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Toggle;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter; use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\SelectFilter;
@@ -15,6 +17,7 @@ use Modules\Booking\Filament\Resources\Bookings\Actions\AssignDriverTableAction;
use Modules\Booking\Filament\Resources\Bookings\Actions\CancelBookingTableAction; use Modules\Booking\Filament\Resources\Bookings\Actions\CancelBookingTableAction;
use Modules\Booking\Filament\Resources\Bookings\Actions\DeleteBookingTableAction; use Modules\Booking\Filament\Resources\Bookings\Actions\DeleteBookingTableAction;
use Modules\Booking\Filament\Resources\Bookings\Actions\RestoreBookingTableAction; use Modules\Booking\Filament\Resources\Bookings\Actions\RestoreBookingTableAction;
use Modules\Booking\Filament\Resources\Bookings\Actions\SetRemarkTableAction;
use Modules\Booking\Models\Booking; use Modules\Booking\Models\Booking;
use Modules\Catalog\Models\EvCompany; use Modules\Catalog\Models\EvCompany;
use Modules\Routing\Models\EvRoute; use Modules\Routing\Models\EvRoute;
@@ -54,6 +57,10 @@ class BookingsTable
->sortable(), ->sortable(),
TextColumn::make('timeSlot.label') TextColumn::make('timeSlot.label')
->label('Time Slot'), ->label('Time Slot'),
IconColumn::make('is_round_trip')
->label('Round Trip')
->boolean()
->toggleable(),
TextColumn::make('vehicleOptions') TextColumn::make('vehicleOptions')
->label('Vehicle Options') ->label('Vehicle Options')
->state(fn (Booking $record) => $record->vehicleOptions ->state(fn (Booking $record) => $record->vehicleOptions
@@ -79,6 +86,16 @@ class BookingsTable
->join(' • ') ?: null) ->join(' • ') ?: null)
->searchable(['driver_name', 'driver_phone', 'car_plate_number', 'car_model']) ->searchable(['driver_name', 'driver_phone', 'car_plate_number', 'car_model'])
->toggleable(), ->toggleable(),
TextColumn::make('notes')
->label('Customer Notes')
->placeholder('—')
->limit(50)
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('remark')
->label('Staff Remark')
->placeholder('—')
->limit(50)
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('created_at') TextColumn::make('created_at')
->dateTime() ->dateTime()
->sortable() ->sortable()
@@ -112,6 +129,15 @@ class BookingsTable
$data['value'] ?? null, $data['value'] ?? null,
fn (Builder $q, $companyId) => $q->whereHas('route', fn (Builder $rq) => $rq->where('ev_company_id', $companyId)), fn (Builder $q, $companyId) => $q->whereHas('route', fn (Builder $rq) => $rq->where('ev_company_id', $companyId)),
)), )),
// is_round_trip is a computed accessor (linked_booking_id
// !== null), not a DB column — TernaryFilter builds a raw
// where() on it, which breaks now that the column is gone.
Filter::make('is_round_trip')
->schema([Toggle::make('is_round_trip')])
->query(fn (Builder $query, array $data) => $query->when(
$data['is_round_trip'] ?? null,
fn (Builder $q) => $q->whereNotNull('linked_booking_id'),
)),
// Deleted bookings are soft-deleted, not hard-removed // Deleted bookings are soft-deleted, not hard-removed
// (domain.md; T7.x follow-up) — this is the only place they // (domain.md; T7.x follow-up) — this is the only place they
// become visible again, off by default. // become visible again, off by default.
@@ -120,6 +146,7 @@ class BookingsTable
->recordActions([ ->recordActions([
ViewAction::make(), ViewAction::make(),
AssignDriverTableAction::make(), AssignDriverTableAction::make(),
SetRemarkTableAction::make(),
CancelBookingTableAction::make(), CancelBookingTableAction::make(),
DeleteBookingTableAction::make(), DeleteBookingTableAction::make(),
RestoreBookingTableAction::make(), RestoreBookingTableAction::make(),
@@ -22,7 +22,11 @@ class BookingController extends Controller
/** /**
* @var list<string> * @var list<string>
*/ */
private const EAGER_LOADS = ['route', 'timeSlot', 'vehicleOptions']; private const EAGER_LOADS = [
'route', 'timeSlot', 'vehicleOptions',
'linkedBooking.route.company', 'linkedBooking.route.fromDestination', 'linkedBooking.route.toDestination',
'linkedBooking.timeSlot', 'linkedBooking.vehicleOptions',
];
public function __construct( public function __construct(
private CreateBookingAction $createBookingAction, private CreateBookingAction $createBookingAction,
@@ -83,6 +87,18 @@ class BookingController extends Controller
$validated['selections'], $validated['selections'],
); );
$isRoundTrip = $validated['is_round_trip'] ?? false;
$returnSelections = $isRoundTrip
? array_map(
fn (array $selection) => new VehicleSelectionData(
vehicleOption: VehicleOption::from($selection['vehicle_option']),
passengerCount: $selection['passenger_count'],
),
$validated['return_selections'],
)
: null;
// The agent's own auth path always wins over anything a header could // The agent's own auth path always wins over anything a header could
// claim; customer channels come from Device-Type, not a // claim; customer channels come from Device-Type, not a
// client-supplied body field (BookingChannel::fromDeviceTypeHeader // client-supplied body field (BookingChannel::fromDeviceTypeHeader
@@ -98,6 +114,7 @@ class BookingController extends Controller
selections: $selections, selections: $selections,
passengerName: $validated['passenger_name'], passengerName: $validated['passenger_name'],
passengerPhone: $validated['passenger_phone'], passengerPhone: $validated['passenger_phone'],
notes: $validated['notes'] ?? null,
pickupAddress: $validated['pickup_address'], pickupAddress: $validated['pickup_address'],
dropoffAddress: $validated['dropoff_address'], dropoffAddress: $validated['dropoff_address'],
createdByChannel: $channel, createdByChannel: $channel,
@@ -110,8 +127,10 @@ class BookingController extends Controller
pickupLng: $validated['pickup_lng'] ?? null, pickupLng: $validated['pickup_lng'] ?? null,
dropoffLat: $validated['dropoff_lat'] ?? null, dropoffLat: $validated['dropoff_lat'] ?? null,
dropoffLng: $validated['dropoff_lng'] ?? null, dropoffLng: $validated['dropoff_lng'] ?? null,
isRoundTrip: $validated['is_round_trip'] ?? false, returnEvRouteId: $isRoundTrip ? $validated['return_ev_route_id'] : null,
returnDepartureTimeSlotId: $isRoundTrip ? $validated['return_departure_time_slot_id'] : null,
returnTravelDate: $validated['return_travel_date'] ?? null, returnTravelDate: $validated['return_travel_date'] ?? null,
returnSelections: $returnSelections,
)); ));
return (new BookingResource($booking->load(self::EAGER_LOADS))) return (new BookingResource($booking->load(self::EAGER_LOADS)))
@@ -34,14 +34,24 @@ class StoreBookingRequest extends FormRequest
'selections.*.passenger_count' => ['required', 'integer', 'min:1'], 'selections.*.passenger_count' => ['required', 'integer', 'min:1'],
'passenger_name' => ['required', 'string', 'max:255'], 'passenger_name' => ['required', 'string', 'max:255'],
'passenger_phone' => ['required', 'string', 'max:50'], 'passenger_phone' => ['required', 'string', 'max:50'],
'notes' => ['nullable', 'string', 'max:1000'],
'pickup_address' => ['required', 'string', 'max:500'], 'pickup_address' => ['required', 'string', 'max:500'],
'pickup_lat' => ['nullable', 'numeric', 'between:-90,90'], 'pickup_lat' => ['nullable', 'numeric', 'between:-90,90'],
'pickup_lng' => ['nullable', 'numeric', 'between:-180,180'], 'pickup_lng' => ['nullable', 'numeric', 'between:-180,180'],
'dropoff_address' => ['required', 'string', 'max:500'], 'dropoff_address' => ['required', 'string', 'max:500'],
'dropoff_lat' => ['nullable', 'numeric', 'between:-90,90'], 'dropoff_lat' => ['nullable', 'numeric', 'between:-90,90'],
'dropoff_lng' => ['nullable', 'numeric', 'between:-180,180'], 'dropoff_lng' => ['nullable', 'numeric', 'between:-180,180'],
// Round trip = a second, independently-priced leg on its own
// route/time-slot/date — the return route must already exist as
// a catalog EvRoute and is validated server-side as the true
// reverse of ev_route_id (EvRoute::isReverseOf, domain.md §2b).
'is_round_trip' => ['sometimes', 'boolean'], 'is_round_trip' => ['sometimes', 'boolean'],
'return_travel_date' => ['nullable', 'date', 'required_if:is_round_trip,true'], 'return_ev_route_id' => ['required_if:is_round_trip,true', 'integer', 'exists:ev_routes,id'],
'return_departure_time_slot_id' => ['required_if:is_round_trip,true', 'integer', 'exists:departure_time_slots,id'],
'return_travel_date' => ['required_if:is_round_trip,true', 'date', 'after_or_equal:travel_date'],
'return_selections' => ['required_if:is_round_trip,true', 'array', 'min:1'],
'return_selections.*.vehicle_option' => ['required_if:is_round_trip,true', Rule::enum(VehicleOption::class)],
'return_selections.*.passenger_count' => ['required_if:is_round_trip,true', 'integer', 'min:1'],
]; ];
} }
} }
@@ -20,9 +20,10 @@ class BookingResource extends JsonResource
'status' => $this->status, 'status' => $this->status,
'travel_date' => $this->travel_date?->toDateString(), 'travel_date' => $this->travel_date?->toDateString(),
'is_round_trip' => $this->is_round_trip, 'is_round_trip' => $this->is_round_trip,
'return_travel_date' => $this->return_travel_date?->toDateString(), 'is_return_leg' => $this->is_return_leg,
'passenger_name' => $this->passenger_name, 'passenger_name' => $this->passenger_name,
'passenger_phone' => $this->passenger_phone, 'passenger_phone' => $this->passenger_phone,
'notes' => $this->notes,
'pickup_address' => $this->pickup_address, 'pickup_address' => $this->pickup_address,
'pickup_lat' => $this->pickup_lat, 'pickup_lat' => $this->pickup_lat,
'pickup_lng' => $this->pickup_lng, 'pickup_lng' => $this->pickup_lng,
@@ -53,6 +54,35 @@ class BookingResource extends JsonResource
'label' => $this->timeSlot->label, 'label' => $this->timeSlot->label,
'time' => $this->timeSlot->time?->format('H:i'), 'time' => $this->timeSlot->time?->format('H:i'),
]), ]),
// Hand-built, not a nested BookingResource — the linked leg's
// own linked_booking points right back here, so nesting the
// full resource would recurse forever (domain.md §2b).
'linked_booking' => $this->whenLoaded('linkedBooking', fn () => [
'id' => $this->linkedBooking->id,
'booking_ref' => $this->linkedBooking->booking_ref,
'status' => $this->linkedBooking->status,
'travel_date' => $this->linkedBooking->travel_date?->toDateString(),
'is_return_leg' => $this->linkedBooking->is_return_leg,
'route' => $this->linkedBooking->relationLoaded('route') ? [
'id' => $this->linkedBooking->route->id,
'ev_company_id' => $this->linkedBooking->route->ev_company_id,
'from_destination_id' => $this->linkedBooking->route->from_destination_id,
'to_destination_id' => $this->linkedBooking->route->to_destination_id,
] : null,
'time_slot' => $this->linkedBooking->relationLoaded('timeSlot') ? [
'id' => $this->linkedBooking->timeSlot->id,
'label' => $this->linkedBooking->timeSlot->label,
'time' => $this->linkedBooking->timeSlot->time?->format('H:i'),
] : null,
'vehicle_options' => $this->linkedBooking->relationLoaded('vehicleOptions')
? $this->linkedBooking->vehicleOptions->map(fn ($selection) => [
'vehicle_option' => $selection->vehicle_option,
'passenger_count' => $selection->passenger_count,
'unit_price' => $selection->unit_price,
'line_total' => $selection->line_total,
])
: null,
]),
'created_at' => $this->created_at, 'created_at' => $this->created_at,
]; ];
} }
+29 -4
View File
@@ -3,6 +3,7 @@
namespace Modules\Booking\Models; namespace Modules\Booking\Models;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -43,10 +44,14 @@ class Booking extends Model
'user_id', 'user_id',
'openid', 'openid',
'ev_route_id', 'ev_route_id',
'linked_booking_id',
'is_return_leg',
'departure_time_slot_id', 'departure_time_slot_id',
'travel_date', 'travel_date',
'passenger_name', 'passenger_name',
'passenger_phone', 'passenger_phone',
'notes',
'remark',
'pickup_address', 'pickup_address',
'pickup_lat', 'pickup_lat',
'pickup_lng', 'pickup_lng',
@@ -55,8 +60,6 @@ class Booking extends Model
'dropoff_lng', 'dropoff_lng',
'price', 'price',
'status', 'status',
'is_round_trip',
'return_travel_date',
'created_by_channel', 'created_by_channel',
'driver_name', 'driver_name',
'driver_phone', 'driver_phone',
@@ -77,8 +80,7 @@ class Booking extends Model
'dropoff_lng' => 'decimal:7', 'dropoff_lng' => 'decimal:7',
'price' => 'decimal:2', 'price' => 'decimal:2',
'status' => BookingStatus::class, 'status' => BookingStatus::class,
'is_round_trip' => 'boolean', 'is_return_leg' => 'boolean',
'return_travel_date' => 'date',
'created_by_channel' => BookingChannel::class, 'created_by_channel' => BookingChannel::class,
]; ];
} }
@@ -93,6 +95,16 @@ class Booking extends Model
return $this->belongsTo(EvRoute::class, 'ev_route_id'); return $this->belongsTo(EvRoute::class, 'ev_route_id');
} }
/**
* The other leg of a round trip (outbound <-> return), linked
* bidirectionally by CreateBookingAction. Null for a plain one-way
* booking see the `isRoundTrip()` accessor (domain.md §2b).
*/
public function linkedBooking(): BelongsTo
{
return $this->belongsTo(Booking::class, 'linked_booking_id');
}
public function timeSlot(): BelongsTo public function timeSlot(): BelongsTo
{ {
return $this->belongsTo(DepartureTimeSlot::class, 'departure_time_slot_id'); return $this->belongsTo(DepartureTimeSlot::class, 'departure_time_slot_id');
@@ -107,4 +119,17 @@ class Booking extends Model
{ {
return $this->hasMany(Payment::class); return $this->hasMany(Payment::class);
} }
/**
* True when this booking has a linked leg i.e. it's one half of a
* round trip. Computed, not stored: presence of `linked_booking_id` is
* the single source of truth, so it can't drift out of sync the way a
* separate flag column could (domain.md §2b).
*/
public function isRoundTrip(): Attribute
{
return Attribute::make(
get: fn (): bool => $this->linked_booking_id !== null,
);
}
} }
@@ -206,6 +206,33 @@ test('created_by_channel is taken from the Device-Type header', function (string
'kbz_miniapp' => ['kbz_miniapp', BookingChannel::MiniApp], 'kbz_miniapp' => ['kbz_miniapp', BookingChannel::MiniApp],
]); ]);
test('customer-supplied notes are stored and returned', function () {
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]);
$payload = bookingPayload($route, $timeSlot, [
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
]);
$payload['notes'] = 'Please call before arriving.';
$this->withHeader('Authorization', "Bearer {$this->token}")
->postJson('/api/v1/bookings', $payload)
->assertCreated()
->assertJsonPath('data.notes', 'Please call before arriving.');
expect(Booking::first()->notes)->toBe('Please call before arriving.');
});
test('notes is optional and defaults to null', function () {
[$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.notes', null);
});
test('a Device-Type header cannot spoof the agent or admin channel', function (string $deviceType) { test('a Device-Type header cannot spoof the agent or admin channel', function (string $deviceType) {
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]); [$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]);
@@ -221,3 +248,122 @@ test('a Device-Type header cannot spoof the agent or admin channel', function (s
'admin' => ['admin'], 'admin' => ['admin'],
'unrecognized value' => ['smart-fridge'], 'unrecognized value' => ['smart-fridge'],
]); ]);
/**
* Same company as $outbound, from/to swapped the true reverse route.
*
* @param array<int, array{0: VehicleOption, 1: string}> $pricedOptions
*/
function reverseRouteAndSlot(EvRoute $outbound, array $pricedOptions): array
{
$route = EvRoute::factory()->create([
'ev_company_id' => $outbound->ev_company_id,
'from_destination_id' => $outbound->to_destination_id,
'to_destination_id' => $outbound->from_destination_id,
'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];
}
test('round trip: creates two linked bookings, each priced against its own route', function () {
config(['booking.back_seat_enabled' => true]);
[$outboundRoute, $outboundSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '9000.00']]);
[$returnRoute, $returnSlot] = reverseRouteAndSlot($outboundRoute, [[VehicleOption::BackSeat, '11000.00']]);
$payload = bookingPayload($outboundRoute, $outboundSlot, [
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
]);
$payload['is_round_trip'] = true;
$payload['return_ev_route_id'] = $returnRoute->id;
$payload['return_departure_time_slot_id'] = $returnSlot->id;
$payload['return_travel_date'] = now()->addDays(3)->toDateString();
$payload['return_selections'] = [['vehicle_option' => 'back_seat', 'passenger_count' => 1]];
$this->withHeader('Authorization', "Bearer {$this->token}")
->postJson('/api/v1/bookings', $payload)
->assertCreated()
->assertJsonPath('data.is_round_trip', true)
->assertJsonPath('data.is_return_leg', false)
->assertJsonPath('data.price', '9000.00')
->assertJsonPath('data.linked_booking.is_return_leg', true)
->assertJsonPath('data.linked_booking.route.id', $returnRoute->id)
->assertJsonPath('data.linked_booking.vehicle_options.0.vehicle_option', 'back_seat')
->assertJsonPath('data.linked_booking.vehicle_options.0.unit_price', '11000.00');
expect(Booking::count())->toBe(2);
$return = Booking::where('is_return_leg', true)->firstOrFail();
expect($return->price)->toEqual('11000.00')
->and($return->ev_route_id)->toBe($returnRoute->id);
});
test('round trip: a return route that is not the reverse of the outbound route surfaces as 422', function () {
config(['booking.back_seat_enabled' => true]);
[$outboundRoute, $outboundSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '9000.00']]);
[$unrelatedRoute, $unrelatedSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '9000.00']]);
$payload = bookingPayload($outboundRoute, $outboundSlot, [
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
]);
$payload['is_round_trip'] = true;
$payload['return_ev_route_id'] = $unrelatedRoute->id;
$payload['return_departure_time_slot_id'] = $unrelatedSlot->id;
$payload['return_travel_date'] = now()->addDays(3)->toDateString();
$payload['return_selections'] = [['vehicle_option' => 'back_seat', 'passenger_count' => 1]];
$this->withHeader('Authorization', "Bearer {$this->token}")
->postJson('/api/v1/bookings', $payload)
->assertStatus(422);
expect(Booking::count())->toBe(0);
});
test('round trip: return fields are required when is_round_trip is true', function () {
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]);
$payload = bookingPayload($route, $timeSlot, [
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
]);
$payload['is_round_trip'] = true;
$this->withHeader('Authorization', "Bearer {$this->token}")
->postJson('/api/v1/bookings', $payload)
->assertStatus(422)
->assertJsonValidationErrors([
'return_ev_route_id', 'return_departure_time_slot_id', 'return_travel_date', 'return_selections',
]);
});
test('round trip: return_travel_date before travel_date is rejected', function () {
config(['booking.back_seat_enabled' => true]);
[$outboundRoute, $outboundSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '9000.00']]);
[$returnRoute, $returnSlot] = reverseRouteAndSlot($outboundRoute, [[VehicleOption::BackSeat, '9000.00']]);
$payload = bookingPayload($outboundRoute, $outboundSlot, [
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
]);
$payload['is_round_trip'] = true;
$payload['return_ev_route_id'] = $returnRoute->id;
$payload['return_departure_time_slot_id'] = $returnSlot->id;
$payload['return_travel_date'] = now()->toDateString(); // before travel_date (addDay())
$payload['return_selections'] = [['vehicle_option' => 'back_seat', 'passenger_count' => 1]];
$this->withHeader('Authorization', "Bearer {$this->token}")
->postJson('/api/v1/bookings', $payload)
->assertStatus(422)
->assertJsonValidationErrors(['return_travel_date']);
});
@@ -325,6 +325,45 @@ test('restoring a deleted booking brings it back', function () {
expect(Booking::find($booking->id)->trashed())->toBeFalse(); expect(Booking::find($booking->id)->trashed())->toBeFalse();
}); });
test('the remark action is visible for a user with manage_bookings', function () {
$booking = Booking::factory()->create();
Livewire::test(ListBookings::class)
->assertTableActionVisible('setRemark', $booking);
});
test('the remark action is hidden from a user without manage_bookings', function () {
$viewer = User::factory()->create()->givePermissionTo('view_bookings');
$this->actingAs($viewer);
$booking = Booking::factory()->create();
Livewire::test(ListBookings::class)
->assertTableActionHidden('setRemark', $booking);
});
test('calling the remark action sets the staff remark on a booking', function () {
$booking = Booking::factory()->create();
Livewire::test(ListBookings::class)
->callTableAction('setRemark', $booking, data: [
'remark' => 'Passenger requested a child seat.',
])
->assertNotified();
expect($booking->refresh()->remark)->toBe('Passenger requested a child seat.');
});
test('the remark form is pre-filled with the booking\'s existing remark', function () {
$booking = Booking::factory()->create(['remark' => 'Existing remark.']);
Livewire::test(ListBookings::class)
->mountTableAction('setRemark', $booking)
->assertTableActionDataSet([
'remark' => 'Existing remark.',
]);
});
test('the restore action is hidden from a user without manage_bookings', function () { test('the restore action is hidden from a user without manage_bookings', function () {
$stranger = User::factory()->create(); $stranger = User::factory()->create();
$booking = Booking::factory()->create(); $booking = Booking::factory()->create();
@@ -7,9 +7,11 @@ use Modules\Booking\Data\VehicleSelectionData;
use Modules\Booking\Enums\BookingChannel; use Modules\Booking\Enums\BookingChannel;
use Modules\Booking\Enums\BookingStatus; use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Events\BookingCreated; use Modules\Booking\Events\BookingCreated;
use Modules\Booking\Exceptions\InvalidReturnRouteException;
use Modules\Booking\Exceptions\InvalidVehicleSelectionException; use Modules\Booking\Exceptions\InvalidVehicleSelectionException;
use Modules\Booking\Models\Booking; use Modules\Booking\Models\Booking;
use Modules\Catalog\Models\DepartureTimeSlot; use Modules\Catalog\Models\DepartureTimeSlot;
use Modules\Routing\Exceptions\RoutePricingNotFoundException;
use Modules\Routing\Models\EvRoute; use Modules\Routing\Models\EvRoute;
use Modules\Routing\Models\RoutePricing; use Modules\Routing\Models\RoutePricing;
use Modules\Shared\Enums\VehicleOption; use Modules\Shared\Enums\VehicleOption;
@@ -33,7 +35,7 @@ function makeBookableRoute(array $pricedOptions): array
return [$route, $timeSlot]; return [$route, $timeSlot];
} }
function bookingData(EvRoute $route, DepartureTimeSlot $timeSlot, array $selections): CreateBookingData function bookingData(EvRoute $route, DepartureTimeSlot $timeSlot, array $selections, array $roundTrip = []): CreateBookingData
{ {
return new CreateBookingData( return new CreateBookingData(
evRouteId: $route->id, evRouteId: $route->id,
@@ -46,9 +48,38 @@ function bookingData(EvRoute $route, DepartureTimeSlot $timeSlot, array $selecti
dropoffAddress: '456 Dropoff Ave', dropoffAddress: '456 Dropoff Ave',
createdByChannel: BookingChannel::MiniApp, createdByChannel: BookingChannel::MiniApp,
openid: 'mini-app-openid-123', openid: 'mini-app-openid-123',
returnEvRouteId: $roundTrip['route']->id ?? null,
returnDepartureTimeSlotId: $roundTrip['timeSlot']->id ?? null,
returnTravelDate: $roundTrip['travelDate'] ?? (isset($roundTrip['route']) ? now()->addDays(3)->toDateString() : null),
returnSelections: $roundTrip['selections'] ?? null,
); );
} }
/**
* Same company as $outbound, from/to swapped the true reverse route.
*
* @param array<int, array{0: VehicleOption, 1: string}> $pricedOptions
*/
function makeReverseRoute(EvRoute $outbound, array $pricedOptions): array
{
$route = EvRoute::factory()->create([
'ev_company_id' => $outbound->ev_company_id,
'from_destination_id' => $outbound->to_destination_id,
'to_destination_id' => $outbound->from_destination_id,
]);
$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];
}
test('it persists a pending_payment booking with the price snapshotted from PricingService', function () { test('it persists a pending_payment booking with the price snapshotted from PricingService', function () {
config(['booking.back_seat_enabled' => true]); config(['booking.back_seat_enabled' => true]);
@@ -150,3 +181,144 @@ test('each booking created gets a unique, sequential booking_ref', function () {
expect($first->booking_ref)->toBe('EVB-AAAAA1') expect($first->booking_ref)->toBe('EVB-AAAAA1')
->and($second->booking_ref)->toBe('EVB-AAAAA2'); ->and($second->booking_ref)->toBe('EVB-AAAAA2');
}); });
test('a plain one-way booking has no linked leg', function () {
config(['booking.back_seat_enabled' => true]);
[$route, $timeSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]);
$booking = app(CreateBookingAction::class)->handle(
bookingData($route, $timeSlot, [new VehicleSelectionData(VehicleOption::BackSeat)])
);
expect($booking->linked_booking_id)->toBeNull()
->and($booking->is_round_trip)->toBeFalse()
->and($booking->is_return_leg)->toBeFalse()
->and(Booking::count())->toBe(1);
});
test('a round trip creates two bookings linked bidirectionally, each priced independently', function () {
config(['booking.back_seat_enabled' => true]);
[$outboundRoute, $outboundSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]);
[$returnRoute, $returnSlot] = makeReverseRoute($outboundRoute, [[VehicleOption::BackSeat, '11000.00']]);
$outbound = app(CreateBookingAction::class)->handle(bookingData(
$outboundRoute,
$outboundSlot,
[new VehicleSelectionData(VehicleOption::BackSeat)],
roundTrip: [
'route' => $returnRoute,
'timeSlot' => $returnSlot,
'selections' => [new VehicleSelectionData(VehicleOption::BackSeat)],
],
));
expect(Booking::count())->toBe(2)
->and($outbound->is_return_leg)->toBeFalse()
->and($outbound->is_round_trip)->toBeTrue()
->and($outbound->price)->toEqual('9000.00');
$return = $outbound->linkedBooking;
expect($return)->not->toBeNull()
->and($return->is_return_leg)->toBeTrue()
->and($return->is_round_trip)->toBeTrue()
->and($return->linked_booking_id)->toBe($outbound->id)
->and($return->ev_route_id)->toBe($returnRoute->id)
->and($return->departure_time_slot_id)->toBe($returnSlot->id)
->and($return->price)->toEqual('11000.00');
});
test('a round trip dispatches BookingCreated for both legs', function () {
Event::fake([BookingCreated::class]);
config(['booking.back_seat_enabled' => true]);
[$outboundRoute, $outboundSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]);
[$returnRoute, $returnSlot] = makeReverseRoute($outboundRoute, [[VehicleOption::BackSeat, '9000.00']]);
$outbound = app(CreateBookingAction::class)->handle(bookingData(
$outboundRoute,
$outboundSlot,
[new VehicleSelectionData(VehicleOption::BackSeat)],
roundTrip: [
'route' => $returnRoute,
'timeSlot' => $returnSlot,
'selections' => [new VehicleSelectionData(VehicleOption::BackSeat)],
],
));
Event::assertDispatched(BookingCreated::class, 2);
Event::assertDispatched(BookingCreated::class, fn (BookingCreated $event) => $event->booking->is($outbound));
Event::assertDispatched(BookingCreated::class, fn (BookingCreated $event) => $event->booking->is($outbound->linkedBooking));
});
test('it rejects a return route that is not the reverse of the outbound route', function () {
config(['booking.back_seat_enabled' => true]);
[$outboundRoute, $outboundSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]);
// Unrelated route — not from/to swapped.
[$unrelatedRoute, $unrelatedSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]);
expect(fn () => app(CreateBookingAction::class)->handle(bookingData(
$outboundRoute,
$outboundSlot,
[new VehicleSelectionData(VehicleOption::BackSeat)],
roundTrip: [
'route' => $unrelatedRoute,
'timeSlot' => $unrelatedSlot,
'selections' => [new VehicleSelectionData(VehicleOption::BackSeat)],
],
)))->toThrow(InvalidReturnRouteException::class);
// The whole transaction rolls back — no orphan outbound-only booking.
expect(Booking::count())->toBe(0);
});
test('return leg selections are validated independently of the outbound leg', function () {
config(['booking.back_seat_enabled' => true]);
[$outboundRoute, $outboundSlot] = makeBookableRoute([[VehicleOption::FrontSeat, '12000.00']]);
[$returnRoute, $returnSlot] = makeReverseRoute($outboundRoute, [[VehicleOption::FrontSeat, '12000.00']]);
expect(fn () => app(CreateBookingAction::class)->handle(bookingData(
$outboundRoute,
$outboundSlot,
[new VehicleSelectionData(VehicleOption::FrontSeat, 1)],
roundTrip: [
'route' => $returnRoute,
'timeSlot' => $returnSlot,
// Front seat max per booking is 1 — this should fail validation
// for the return leg even though the outbound leg is valid.
'selections' => [new VehicleSelectionData(VehicleOption::FrontSeat, 2)],
],
)))->toThrow(InvalidVehicleSelectionException::class);
expect(Booking::count())->toBe(0);
});
test('a failed return-leg price lookup rolls back the outbound leg too', function () {
config(['booking.back_seat_enabled' => true]);
[$outboundRoute, $outboundSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]);
// Return route exists (true reverse) but has no pricing rows at all.
$returnRoute = EvRoute::factory()->create([
'ev_company_id' => $outboundRoute->ev_company_id,
'from_destination_id' => $outboundRoute->to_destination_id,
'to_destination_id' => $outboundRoute->from_destination_id,
]);
$returnSlot = DepartureTimeSlot::factory()->create();
expect(fn () => app(CreateBookingAction::class)->handle(bookingData(
$outboundRoute,
$outboundSlot,
[new VehicleSelectionData(VehicleOption::BackSeat)],
roundTrip: [
'route' => $returnRoute,
'timeSlot' => $returnSlot,
'selections' => [new VehicleSelectionData(VehicleOption::BackSeat)],
],
)))->toThrow(RoutePricingNotFoundException::class);
expect(Booking::count())->toBe(0);
});
@@ -74,3 +74,25 @@ test('reassigning a different driver on a still-confirmed booking overwrites the
expect($booking->refresh()->driver_name)->toBe('Daw Hla') expect($booking->refresh()->driver_name)->toBe('Daw Hla')
->and($booking->car_plate_number)->toBe('YGN-5678'); ->and($booking->car_plate_number)->toBe('YGN-5678');
}); });
test('a round trip: assigning a driver to the outbound leg does not touch the linked return leg', function () {
$outbound = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
$return = Booking::factory()->create([
'status' => BookingStatus::Confirmed,
'is_return_leg' => true,
'linked_booking_id' => $outbound->id,
]);
$outbound->update(['linked_booking_id' => $return->id]);
(new AssignDriverAction)->handle($outbound, new AssignDriverData(
driverName: 'U Aung',
driverPhone: '+959111222333',
carPlateNumber: 'YGN-1234',
));
// Each leg has its own independent driver/vehicle slot — the return leg
// can get a completely different (or no-yet-assigned) vehicle, per the
// "next available vehicle" business rule (domain.md §2b).
expect($outbound->refresh()->driver_name)->toBe('U Aung')
->and($return->refresh()->driver_name)->toBeNull();
});
@@ -23,7 +23,9 @@ class EvCompanyFactory extends Factory
'slug' => fake()->unique()->slug(), 'slug' => fake()->unique()->slug(),
'description' => fake()->sentence(), 'description' => fake()->sentence(),
'mm_description' => null, 'mm_description' => null,
'contact' => fake()->phoneNumber(), // fake()->phoneNumber() occasionally emits formats (e.g. extensions like "x1234")
// that fail the form's ->tel() regex validation, making the test flaky.
'contact' => fake()->numerify('+959#########'),
'address' => fake()->address(), 'address' => fake()->address(),
'logo' => null, 'logo' => null,
'is_active' => true, 'is_active' => true,
@@ -6,6 +6,7 @@ use Modules\Booking\Models\Booking;
use Modules\Identity\Enums\TokenAbility; use Modules\Identity\Enums\TokenAbility;
use Modules\Payment\Enums\PaymentMethod; use Modules\Payment\Enums\PaymentMethod;
use Modules\Payment\Models\Payment; use Modules\Payment\Models\Payment;
use Modules\Routing\Models\EvRoute;
/** /**
* T6.4 full policy + agent-ability audit (domain.md §8). The FastAPI * T6.4 full policy + agent-ability audit (domain.md §8). The FastAPI
@@ -63,14 +64,30 @@ test('catalog writes have no customer-facing route at all', function () {
}); });
test('routing/pricing writes have no customer-facing route at all', function () { test('routing/pricing writes have no customer-facing route at all', function () {
// Only a read-only search endpoint exists for EvRoute — no create/update/
// delete route was ever registered, and the search endpoint itself
// never creates records regardless of payload (it's POST because
// round_trip returns two result sets, not because it writes anything).
// {route} only has a GET (show) handler registered, so PUT/DELETE hit
// that same URI pattern and are rejected as 405 (method not allowed).
$this->withHeader('Authorization', "Bearer {$this->agentToken}") $this->withHeader('Authorization', "Bearer {$this->agentToken}")
->postJson('/api/v1/routes', ['ev_company_id' => 1]) ->putJson('/api/v1/routes/1', ['ev_company_id' => 1])
->assertStatus(405); ->assertStatus(405);
$this->withHeader('Authorization', "Bearer {$this->agentToken}")
->deleteJson('/api/v1/routes/1')
->assertStatus(405);
$this->withHeader('Authorization', "Bearer {$this->agentToken}")
->postJson('/api/v1/routes/search', ['ev_company_id' => 1])
->assertSuccessful();
expect(EvRoute::count())->toBe(0);
}); });
test('the agent token can still read routes and create/read bookings', function () { test('the agent token can still read routes and create/read bookings', function () {
$this->withHeader('Authorization', "Bearer {$this->agentToken}") $this->withHeader('Authorization', "Bearer {$this->agentToken}")
->getJson('/api/v1/routes') ->postJson('/api/v1/routes/search')
->assertSuccessful(); ->assertSuccessful();
$booking = Booking::factory()->create(['user_id' => $this->agent->id]); $booking = Booking::factory()->create(['user_id' => $this->agent->id]);
@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* A round trip's Payment is combined on the primary (outbound) leg
* (domain.md §2b), so `refund->payment->booking` is no longer reliable
* for identifying which leg a refund actually cancels a refund
* against the return leg still hangs off the primary's Payment.
* `booking_id` records the actual leg RefundBookingAction was asked to
* refund, so MarkBookingRefunded flips the right booking to cancelled.
*/
public function up(): void
{
Schema::table('refunds', function (Blueprint $table) {
$table->foreignId('booking_id')->nullable()->after('payment_id')
->constrained('bookings')->nullOnDelete();
});
// Backfill existing rows from their Payment's booking — correct for
// every pre-existing refund, since round trip didn't exist yet.
DB::statement(
'update refunds set booking_id = payments.booking_id '.
'from payments where payments.id = refunds.payment_id'
);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('refunds', function (Blueprint $table) {
$table->dropConstrainedForeignId('booking_id');
});
}
};
@@ -43,6 +43,10 @@ class InitiatePaymentAction
throw PaymentInitiationNotAllowedException::notPendingPayment($booking); throw PaymentInitiationNotAllowedException::notPendingPayment($booking);
} }
if ($booking->is_return_leg) {
throw PaymentInitiationNotAllowedException::isReturnLeg($booking);
}
return DB::transaction(function () use ($booking, $method) { return DB::transaction(function () use ($booking, $method) {
$booking = Booking::whereKey($booking->id)->lockForUpdate()->first(); $booking = Booking::whereKey($booking->id)->lockForUpdate()->first();
@@ -62,11 +66,12 @@ class InitiatePaymentAction
} }
$merchantOrderId = $this->merchantOrderId($booking); $merchantOrderId = $this->merchantOrderId($booking);
$amount = $this->amount($booking);
$result = $this->paymentService->initiate(new PaymentRequestData( $result = $this->paymentService->initiate(new PaymentRequestData(
bookingId: $booking->id, bookingId: $booking->id,
merchantOrderId: $merchantOrderId, merchantOrderId: $merchantOrderId,
amount: (string) $booking->price, amount: $amount,
currency: self::CURRENCY, currency: self::CURRENCY,
method: $method, method: $method,
notifyUrl: $this->notifyUrl($booking, $method), notifyUrl: $this->notifyUrl($booking, $method),
@@ -76,7 +81,7 @@ class InitiatePaymentAction
'booking_id' => $booking->id, 'booking_id' => $booking->id,
'gateway' => $method, 'gateway' => $method,
'status' => $result->status, 'status' => $result->status,
'amount' => $booking->price, 'amount' => $amount,
'currency' => self::CURRENCY, 'currency' => self::CURRENCY,
'gateway_transaction_id' => $result->gatewayTransactionId ?? $merchantOrderId, 'gateway_transaction_id' => $result->gatewayTransactionId ?? $merchantOrderId,
'gateway_payload' => $result->gatewayPayload, 'gateway_payload' => $result->gatewayPayload,
@@ -85,6 +90,20 @@ class InitiatePaymentAction
}); });
} }
/**
* A round trip's payment is combined on the outbound leg covers both
* legs' price, since the return leg never gets its own Payment
* (domain.md §2b). A plain one-way booking just pays its own price.
*/
private function amount(Booking $booking): string
{
if ($booking->linked_booking_id === null) {
return (string) $booking->price;
}
return bcadd((string) $booking->price, (string) $booking->linkedBooking->price, 2);
}
/** /**
* A booking can have more than one payment attempt (retry after * A booking can have more than one payment attempt (retry after
* failure), so the merchant order id must be unique per attempt, not * failure), so the merchant order id must be unique per attempt, not
@@ -35,7 +35,14 @@ class RefundBookingAction
throw RefundNotAllowedException::notConfirmed($booking); throw RefundNotAllowedException::notConfirmed($booking);
} }
$payment = $booking->payments()->where('status', PaymentStatus::Completed->value)->latest()->first(); // Round trip: payment is combined on the outbound leg, so a return
// leg has no Payment of its own — refund against its linked leg's
// Payment instead (domain.md §2b). The Confirmed check above still
// applies to $booking itself, not the payment holder, so each leg
// remains independently cancellable/refundable.
$paymentBooking = $booking->is_return_leg ? ($booking->linkedBooking ?? $booking) : $booking;
$payment = $paymentBooking->payments()->where('status', PaymentStatus::Completed->value)->latest()->first();
if ($payment === null) { if ($payment === null) {
throw RefundNotAllowedException::noCompletedPayment($booking); throw RefundNotAllowedException::noCompletedPayment($booking);
@@ -45,9 +52,10 @@ class RefundBookingAction
$result = $this->paymentService->refund($payment->gateway, $payment->gateway_transaction_id, $amount, $reason); $result = $this->paymentService->refund($payment->gateway, $payment->gateway_transaction_id, $amount, $reason);
$refund = DB::transaction(function () use ($payment, $amount, $reason, $result, $requestedBy) { $refund = DB::transaction(function () use ($booking, $payment, $amount, $reason, $result, $requestedBy) {
$refund = Refund::create([ $refund = Refund::create([
'payment_id' => $payment->id, 'payment_id' => $payment->id,
'booking_id' => $booking->id,
'status' => $result->status, 'status' => $result->status,
'amount' => $amount, 'amount' => $amount,
'reason' => $reason, 'reason' => $reason,
@@ -16,6 +16,18 @@ class PaymentInitiationNotAllowedException extends RuntimeException
); );
} }
/**
* A round trip's payment is combined on the outbound leg the return
* leg is marked paid when the outbound leg's payment succeeds
* (MarkBookingPaid), never via its own Payment (domain.md §2b).
*/
public static function isReturnLeg(Booking $booking): self
{
return new self(
"Booking [{$booking->booking_ref}] is a round trip's return leg — initiate payment on its linked outbound booking instead."
);
}
public function render(Request $request): ?JsonResponse public function render(Request $request): ?JsonResponse
{ {
if ($request->expectsJson()) { if ($request->expectsJson()) {
@@ -22,7 +22,7 @@ class PaymentController extends Controller
$openid = $request->attributes->get('fastapi_openid'); $openid = $request->attributes->get('fastapi_openid');
if ($openid === null) { if ($openid === null) {
Gate::authorize('create', Booking::class); Gate::authorize('pay', $booking);
} }
$payment = $this->initiatePaymentAction->handle($booking); $payment = $this->initiatePaymentAction->handle($booking);
@@ -29,5 +29,14 @@ class MarkBookingPaid implements ShouldQueue
if ($booking->status === BookingStatus::PendingPayment) { if ($booking->status === BookingStatus::PendingPayment) {
$booking->update(['status' => BookingStatus::Confirmed]); $booking->update(['status' => BookingStatus::Confirmed]);
} }
// Round trip: payment is combined on the outbound leg, so its
// success also confirms the linked return leg — the return leg
// never gets its own Payment (domain.md §2b).
$linkedBooking = $booking->linkedBooking;
if ($linkedBooking !== null && $linkedBooking->status === BookingStatus::PendingPayment) {
$linkedBooking->update(['status' => BookingStatus::Confirmed]);
}
} }
} }
@@ -16,7 +16,11 @@ class MarkBookingRefunded implements ShouldQueue
{ {
public function handle(RefundProcessed $event): void public function handle(RefundProcessed $event): void
{ {
$booking = $event->refund->payment->booking; // The leg actually refunded — not payment->booking, since a round
// trip's return leg refunds against the primary leg's shared
// Payment (domain.md §2b). Falls back to payment->booking for
// pre-redesign rows where booking_id wasn't yet recorded.
$booking = $event->refund->booking ?? $event->refund->payment->booking;
// Booking uses SoftDeletes — normally unreachable here (a confirmed // Booking uses SoftDeletes — normally unreachable here (a confirmed
// booking is never deletable, BookingPolicy::delete), but this // booking is never deletable, BookingPolicy::delete), but this
+12
View File
@@ -6,6 +6,7 @@ use App\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Modules\Booking\Models\Booking;
use Modules\Payment\Database\Factories\RefundFactory; use Modules\Payment\Database\Factories\RefundFactory;
use Modules\Payment\Enums\RefundStatus; use Modules\Payment\Enums\RefundStatus;
use Spatie\Activitylog\Models\Concerns\LogsActivity; use Spatie\Activitylog\Models\Concerns\LogsActivity;
@@ -38,6 +39,7 @@ class Refund extends Model
*/ */
protected $fillable = [ protected $fillable = [
'payment_id', 'payment_id',
'booking_id',
'status', 'status',
'amount', 'amount',
'reason', 'reason',
@@ -67,6 +69,16 @@ class Refund extends Model
return $this->belongsTo(Payment::class); return $this->belongsTo(Payment::class);
} }
/**
* The leg actually being refunded/cancelled not necessarily
* payment->booking, since a round trip's return leg refunds against the
* primary leg's shared Payment (domain.md §2b).
*/
public function booking(): BelongsTo
{
return $this->belongsTo(Booking::class);
}
public function requestedBy(): BelongsTo public function requestedBy(): BelongsTo
{ {
return $this->belongsTo(User::class, 'requested_by'); return $this->belongsTo(User::class, 'requested_by');
@@ -191,3 +191,44 @@ test('404s for a booking that does not exist', function () {
->postJson('/api/v1/payments/EVB-DOES-NOT-EXIST/initiate') ->postJson('/api/v1/payments/EVB-DOES-NOT-EXIST/initiate')
->assertNotFound(); ->assertNotFound();
}); });
test('round trip: initiating payment on the primary leg charges the combined total of both legs', function () {
$outbound = Booking::factory()->create([
'user_id' => $this->owner->id,
'status' => BookingStatus::PendingPayment,
'price' => 9000,
]);
$return = Booking::factory()->create([
'status' => BookingStatus::PendingPayment,
'price' => 11000,
'is_return_leg' => true,
'linked_booking_id' => $outbound->id,
]);
$outbound->update(['linked_booking_id' => $return->id]);
$this->withHeader('Authorization', "Bearer {$this->token}")
->postJson("/api/v1/payments/{$outbound->booking_ref}/initiate")
->assertCreated();
$payment = Payment::where('booking_id', $outbound->id)->sole();
expect((float) $payment->amount)->toBe(20000.0)
->and(Payment::where('booking_id', $return->id)->count())->toBe(0);
});
test('round trip: initiating payment on the return leg directly surfaces as 422', function () {
$outbound = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::PendingPayment]);
$return = Booking::factory()->create([
'user_id' => $this->owner->id,
'status' => BookingStatus::PendingPayment,
'is_return_leg' => true,
'linked_booking_id' => $outbound->id,
]);
$outbound->update(['linked_booking_id' => $return->id]);
$this->withHeader('Authorization', "Bearer {$this->token}")
->postJson("/api/v1/payments/{$return->booking_ref}/initiate")
->assertStatus(422);
expect(Payment::where('booking_id', $return->id)->count())->toBe(0);
});
@@ -49,10 +49,10 @@ test('initiate returns a pending PaymentResultData on a successful precreate', f
Http::fake(['kbz.test/*' => Http::response(['Response' => ['result' => 'SUCCESS', 'prepay_id' => 'PREPAY123']])]); Http::fake(['kbz.test/*' => Http::response(['Response' => ['result' => 'SUCCESS', 'prepay_id' => 'PREPAY123']])]);
$result = (new KbzMiniAppGateway($config))->initiate($paymentRequest); $result = (new KbzMiniAppGateway($config))->initiate($paymentRequest);
logger()->info('Payment initiation result', ['result' => $result]);
expect($result->status)->toBe(PaymentStatus::Pending) expect($result->status)->toBe(PaymentStatus::Pending)
->and($result->gatewayTransactionId)->toBe('EVB-FIXTURE-001') ->and($result->gatewayTransactionId)->toBe('EVB-FIXTURE-001')
->and($result->gatewayPayload)->toBe(['result' => 'SUCCESS', 'prepay_id' => 'PREPAY123']); ->and($result->gatewayPayload['prepayId'])->toBe('PREPAY123');
}); });
test('initiate returns a failed PaymentResultData when KBZ rejects the request', function () use ($config, $paymentRequest) { test('initiate returns a failed PaymentResultData when KBZ rejects the request', function () use ($config, $paymentRequest) {
@@ -33,3 +33,20 @@ test('does not crash if the booking was soft-deleted before this queued listener
expect(fn () => (new MarkBookingPaid)->handle(new PaymentCompleted($payment->fresh()))) expect(fn () => (new MarkBookingPaid)->handle(new PaymentCompleted($payment->fresh())))
->not->toThrow(Throwable::class); ->not->toThrow(Throwable::class);
}); });
test('a round trip: paying the primary leg also confirms its linked return leg', function () {
$outbound = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
$return = Booking::factory()->create([
'status' => BookingStatus::PendingPayment,
'is_return_leg' => true,
'linked_booking_id' => $outbound->id,
]);
$outbound->update(['linked_booking_id' => $return->id]);
$payment = Payment::factory()->completed()->create(['booking_id' => $outbound->id]);
(new MarkBookingPaid)->handle(new PaymentCompleted($payment));
expect($outbound->refresh()->status)->toBe(BookingStatus::Confirmed)
->and($return->refresh()->status)->toBe(BookingStatus::Confirmed);
});
@@ -152,3 +152,57 @@ test('a failed gateway refund is persisted as failed, leaves the booking untouch
Event::assertNotDispatched(RefundProcessed::class); Event::assertNotDispatched(RefundProcessed::class);
}); });
/**
* Round trip: payment is combined on the outbound ("primary") leg the
* return leg has no Payment of its own (domain.md §2b).
*/
function confirmedRoundTripWithCombinedPayment(string $outboundPrice, string $returnPrice): array
{
$outbound = Booking::factory()->create(['status' => BookingStatus::Confirmed, 'price' => $outboundPrice]);
$return = Booking::factory()->create([
'status' => BookingStatus::Confirmed,
'price' => $returnPrice,
'is_return_leg' => true,
'linked_booking_id' => $outbound->id,
]);
$outbound->update(['linked_booking_id' => $return->id]);
$combined = bcadd($outboundPrice, $returnPrice, 2);
Payment::factory()->completed()->create([
'booking_id' => $outbound->id,
'gateway' => PaymentMethod::KbzMiniApp,
'amount' => $combined,
'gateway_transaction_id' => 'EVB-ROUNDTRIP-REFUND-1',
]);
return [$outbound->fresh(), $return->fresh()];
}
test('refunding a return leg draws a partial refund against the primary leg\'s combined payment', function () {
[$outbound, $return] = confirmedRoundTripWithCombinedPayment('9000.00', '11000.00');
$refund = app(RefundBookingAction::class)->handle($return, '11000', 'return leg cancelled');
expect($refund->status)->toBe(RefundStatus::Completed)
->and($refund->payment_id)->toBe($outbound->payments()->first()->id)
->and($return->refresh()->status)->toBe(BookingStatus::Cancelled)
->and($outbound->refresh()->status)->toBe(BookingStatus::Confirmed);
});
test('each leg of a round trip can be cancelled/refunded independently without exceeding the combined payment', function () {
[$outbound, $return] = confirmedRoundTripWithCombinedPayment('9000.00', '11000.00');
app(RefundBookingAction::class)->handle($return, '11000', 'return leg cancelled');
$second = app(RefundBookingAction::class)->handle($outbound, '9000', 'outbound leg cancelled too');
expect($second->status)->toBe(RefundStatus::Completed)
->and($outbound->refresh()->status)->toBe(BookingStatus::Cancelled)
->and($return->refresh()->status)->toBe(BookingStatus::Cancelled);
// Cumulative refunds (20000) exactly match the combined payment total —
// a third refund attempt on either leg must now fail.
expect(fn () => app(RefundBookingAction::class)->handle($outbound, '1', 'over the limit'))
->toThrow(RefundNotAllowedException::class);
});
@@ -23,7 +23,6 @@ class EvRouteFactory extends Factory
'ev_company_id' => EvCompany::factory(), 'ev_company_id' => EvCompany::factory(),
'from_destination_id' => Destination::factory(), 'from_destination_id' => Destination::factory(),
'to_destination_id' => Destination::factory(), 'to_destination_id' => Destination::factory(),
'is_round_trip' => false,
'is_active' => true, 'is_active' => true,
]; ];
} }
@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Staff-curated flag for surfacing a route as "popular" on the customer
* side filterable via the routes index endpoint's `popular` param.
*/
public function up(): void
{
Schema::table('ev_routes', function (Blueprint $table) {
$table->boolean('is_popular')->default(false)->after('is_active');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('ev_routes', function (Blueprint $table) {
$table->dropColumn('is_popular');
});
}
};
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Round trip is no longer a flag on the route a round-trip booking now
* explicitly supplies a `return_ev_route_id`, validated server-side as
* the true reverse of the outbound route (`EvRoute::isReverseOf`), so
* this flag has no remaining purpose (domain.md §2b).
*/
public function up(): void
{
Schema::table('ev_routes', function (Blueprint $table) {
$table->dropColumn('is_round_trip');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('ev_routes', function (Blueprint $table) {
$table->boolean('is_round_trip')->default(false);
});
}
};
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* "Popular routes" is being reworked from scratch to match the
* client's actual logic (details TBD) the blunt is_popular flag
* shipped in 2026_08_20_010000 didn't align with it, so it's removed
* rather than kept around unused.
*/
public function up(): void
{
Schema::table('ev_routes', function (Blueprint $table) {
$table->dropColumn('is_popular');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('ev_routes', function (Blueprint $table) {
$table->boolean('is_popular')->default(false);
});
}
};
@@ -4,7 +4,7 @@ use Illuminate\Support\Facades\Route;
use Modules\Routing\Http\Controllers\EvRouteController; use Modules\Routing\Http\Controllers\EvRouteController;
Route::prefix('api/v1')->middleware(['api', 'api.auth', 'throttle:api-read'])->group(function () { Route::prefix('api/v1')->middleware(['api', 'api.auth', 'throttle:api-read'])->group(function () {
Route::get('/routes', [EvRouteController::class, 'index'])->name('routing.routes.index'); Route::post('/routes/search', [EvRouteController::class, 'search'])->name('routing.routes.search');
Route::get('/routes/{route}', [EvRouteController::class, 'show'])->name('routing.routes.show'); Route::get('/routes/{route}', [EvRouteController::class, 'show'])->name('routing.routes.show');
Route::get('/routes/{route}/pricing', [EvRouteController::class, 'pricing'])->name('routing.routes.pricing'); Route::get('/routes/{route}/pricing', [EvRouteController::class, 'pricing'])->name('routing.routes.pricing');
Route::get('/routes/{route}/time-slots', [EvRouteController::class, 'timeSlots'])->name('routing.routes.time-slots'); Route::get('/routes/{route}/time-slots', [EvRouteController::class, 'timeSlots'])->name('routing.routes.time-slots');
@@ -6,6 +6,8 @@ use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle; use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Modules\Shared\Enums\VehicleOption; use Modules\Shared\Enums\VehicleOption;
@@ -15,6 +17,10 @@ class EvRouteForm
{ {
return $schema return $schema
->components([ ->components([
Section::make('Route')
->schema([
Grid::make(2)
->schema([
Select::make('ev_company_id') Select::make('ev_company_id')
->label('EV Company') ->label('EV Company')
->relationship('company', 'name') ->relationship('company', 'name')
@@ -43,16 +49,25 @@ class EvRouteForm
->multiple() ->multiple()
->searchable() ->searchable()
->preload(), ->preload(),
Toggle::make('is_round_trip') ]),
->required() ])
->default(false), ->columnSpanFull(),
Section::make('Options')
->schema([
Grid::make(2)
->schema([
Toggle::make('is_active') Toggle::make('is_active')
->required() ->required()
->default(false) ->default(false)
->helperText('Every non-blocked vehicle option must have a price above 0 before a route can be activated.'), ->helperText('Every non-blocked vehicle option must have a price above 0 before a route can be activated.'),
]),
])
->columnSpanFull(),
Section::make('Pricing')
->schema([
Repeater::make('pricing') Repeater::make('pricing')
->relationship() ->relationship()
->label('Pricing') ->hiddenLabel()
->schema([ ->schema([
Select::make('vehicle_option') Select::make('vehicle_option')
->options(array_combine( ->options(array_combine(
@@ -82,7 +97,8 @@ class EvRouteForm
) )
->addable(false) ->addable(false)
->deletable(false) ->deletable(false)
->reorderable(false) ->reorderable(false),
])
->columnSpanFull(), ->columnSpanFull(),
]); ]);
} }
@@ -5,8 +5,10 @@ namespace Modules\Routing\Filament\Resources\EvRoutes\Tables;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction; use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction; use Filament\Actions\EditAction;
use Filament\Support\Enums\Width;
use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter; use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Modules\Routing\Models\EvRoute; use Modules\Routing\Models\EvRoute;
@@ -40,8 +42,6 @@ class EvRoutesTable
.($pricing->is_blocked ? 'Blocked' : number_format($pricing->price, 0))) .($pricing->is_blocked ? 'Blocked' : number_format($pricing->price, 0)))
->all()) ->all())
->listWithLineBreaks(), ->listWithLineBreaks(),
IconColumn::make('is_round_trip')
->boolean(),
IconColumn::make('is_active') IconColumn::make('is_active')
->boolean(), ->boolean(),
TextColumn::make('created_at') TextColumn::make('created_at')
@@ -50,9 +50,25 @@ class EvRoutesTable
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
]) ])
->filters([ ->filters([
SelectFilter::make('ev_company_id')
->label('Company')
->relationship('company', 'name')
->searchable()
->preload(),
SelectFilter::make('from_destination_id')
->label('From')
->relationship('fromDestination', 'name')
->searchable()
->preload(),
SelectFilter::make('to_destination_id')
->label('To')
->relationship('toDestination', 'name')
->searchable()
->preload(),
TernaryFilter::make('is_active'), TernaryFilter::make('is_active'),
TernaryFilter::make('is_round_trip'),
]) ])
->filtersFormColumns(2)
->filtersFormWidth(Width::Large)
->recordActions([ ->recordActions([
EditAction::make(), EditAction::make(),
]) ])
@@ -2,10 +2,16 @@
namespace Modules\Routing\Http\Controllers; namespace Modules\Routing\Http\Controllers;
use Illuminate\Http\Request; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Routing\Controller; use Illuminate\Routing\Controller;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Modules\Catalog\Models\EvCompany;
use Modules\Routing\Http\Requests\SearchRoutesRequest;
use Modules\Routing\Http\Resources\EvRouteResource; use Modules\Routing\Http\Resources\EvRouteResource;
use Modules\Routing\Http\Resources\RoutePricingResource; use Modules\Routing\Http\Resources\RoutePricingResource;
use Modules\Routing\Http\Resources\RouteTimeSlotResource; use Modules\Routing\Http\Resources\RouteTimeSlotResource;
@@ -22,26 +28,111 @@ class EvRouteController extends Controller
private const CACHE_TTL_MINUTES = 5; private const CACHE_TTL_MINUTES = 5;
public function index(Request $request): AnonymousResourceCollection /**
* POST, not GET: round_trip=true returns two independent result sets
* (routes + return_routes) in one response, which doesn't fit a plain
* GET-with-query-params search shape as cleanly (domain.md §2b).
*/
public function search(SearchRoutesRequest $request): JsonResponse
{ {
$filters = $request->only(['company', 'from', 'to', 'date']); $filters = $request->only(['company', 'from', 'to', 'date', 'time_slot']);
$page = $request->integer('page', 1); $isRoundTrip = $request->boolean('round_trip');
$routes = Cache::tags(self::CACHE_TAG)->remember( // Separate page params: routes and return_routes almost always have
'routes:index:'.md5(json_encode($filters + ['page' => $page])), // different totals, so paging one must never slice the other at the
// same offset (e.g. return_routes with only 3 rows would come back
// empty on page=2 while routes still has real data there).
$routes = $this->searchRoutes($filters, fromKey: 'from', toKey: 'to', pageName: 'page', page: $request->integer('page', 1));
$returnRoutes = $isRoundTrip
? $this->searchRoutes($filters, fromKey: 'to', toKey: 'from', pageName: 'return_page', page: $request->integer('return_page', 1))
: new LengthAwarePaginator([], 0, 15);
return response()->json([
'routes' => EvRouteResource::collection($routes)->response()->getData(true),
'return_routes' => EvRouteResource::collection($returnRoutes)->response()->getData(true),
// The company/time_slot options actually available for this
// from->to pair — computed from from/to alone, ignoring any
// company/time_slot already applied, so the client can offer
// switching between them rather than guessing a static list.
'filters' => $this->filterOptions($filters['from'] ?? null, $filters['to'] ?? null),
'return_filters' => $isRoundTrip
? $this->filterOptions($filters['to'] ?? null, $filters['from'] ?? null)
: ['companies' => [], 'time_slots' => []],
]);
}
/**
* @return array{companies: array<int, array<string, mixed>>, time_slots: array<int, array<string, mixed>>}
*/
private function filterOptions(mixed $from, mixed $to): array
{
if (blank($from) || blank($to)) {
return ['companies' => [], 'time_slots' => []];
}
$cacheKey = "routes:filter-options:{$from}:{$to}";
return Cache::tags(self::CACHE_TAG)->remember(
$cacheKey,
now()->addMinutes(self::CACHE_TTL_MINUTES),
function () use ($from, $to) {
$routes = EvRoute::query()
->where('is_active', true)
->where('from_destination_id', $from)
->where('to_destination_id', $to)
->with(['company', 'timeSlots' => fn (BelongsToMany $query) => $query->wherePivot('is_active', true)])
->get();
$companies = $routes->pluck('company')->filter()->unique('id')->sortBy('name')->values();
$timeSlots = $routes->flatMap(fn (EvRoute $route) => $route->timeSlots)->unique('id')->sortBy('time')->values();
return [
// Facet purposes only — not the full EvCompanyResource
// (no slug/description/contact/logo needed just to
// populate a filter option).
'companies' => $companies->map(fn (EvCompany $company) => [
'id' => $company->id,
'name' => $company->name,
'mm_name' => $company->mm_name,
])->all(),
'time_slots' => $timeSlots->map(fn ($slot) => [
'id' => $slot->id,
'label' => $slot->label,
'time' => $slot->time?->format('H:i'),
])->all(),
];
},
);
}
/**
* @param array<string, mixed> $filters Keyed by 'from'/'to' regardless of
* $fromKey/$toKey swapped for the return leg of a round trip.
*/
private function searchRoutes(array $filters, string $fromKey, string $toKey, string $pageName, int $page): LengthAwarePaginator
{
$cacheKey = 'routes:search:'.md5(json_encode($filters + ['fromKey' => $fromKey, 'page' => $page]));
return Cache::tags(self::CACHE_TAG)->remember(
$cacheKey,
now()->addMinutes(self::CACHE_TTL_MINUTES), now()->addMinutes(self::CACHE_TTL_MINUTES),
fn () => EvRoute::query() fn () => EvRoute::query()
->where('is_active', true) ->where('is_active', true)
->when($request->filled('company'), fn ($query) => $query->where('ev_company_id', $request->integer('company'))) ->when(filled($filters['company'] ?? null), fn (Builder $query) => $query->where('ev_company_id', $filters['company']))
->when($request->filled('from'), fn ($query) => $query->where('from_destination_id', $request->integer('from'))) ->when(filled($filters[$fromKey] ?? null), fn (Builder $query) => $query->where('from_destination_id', $filters[$fromKey]))
->when($request->filled('to'), fn ($query) => $query->where('to_destination_id', $request->integer('to'))) ->when(filled($filters[$toKey] ?? null), fn (Builder $query) => $query->where('to_destination_id', $filters[$toKey]))
->when(filled($filters['time_slot'] ?? null), fn (Builder $query) => $query->whereHas(
'timeSlots',
fn (Builder $timeSlotQuery) => $timeSlotQuery
->where('departure_time_slots.time', Carbon::createFromFormat('H:i', $filters['time_slot'])->format('H:i:s'))
->where('ev_route_time_slots.is_active', true),
))
// `date` is accepted for forward-compatibility with future per-date capacity // `date` is accepted for forward-compatibility with future per-date capacity
// checks (domain.md §7), but v1 has no route-level calendar to filter against. // checks (domain.md §7), but v1 has no route-level calendar to filter against.
->with(self::EAGER_LOADS) ->with(self::EAGER_LOADS)
->paginate(), ->paginate(perPage: 15, pageName: $pageName, page: $page),
); );
return EvRouteResource::collection($routes);
} }
public function show(EvRoute $route): EvRouteResource public function show(EvRoute $route): EvRouteResource
@@ -0,0 +1,41 @@
<?php
namespace Modules\Routing\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* Shape validation only for the routes search endpoint. round_trip=true
* requires both from and to "return" only means something for a specific
* origin/destination pair, not an unfiltered route list.
*/
class SearchRoutesRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, array<int, mixed>>
*/
public function rules(): array
{
return [
'company' => ['nullable', 'integer', 'exists:ev_companies,id'],
'from' => ['nullable', 'integer', 'exists:destinations,id', 'required_if:round_trip,true'],
'to' => ['nullable', 'integer', 'exists:destinations,id', 'different:from', 'required_if:round_trip,true'],
'date' => ['nullable', 'date'],
// The catalog's shared time value (e.g. "06:00"), not a
// DepartureTimeSlot id — matches how customers think about
// departure times (domain.md §1).
'time_slot' => ['nullable', 'date_format:H:i'],
'round_trip' => ['sometimes', 'boolean'],
// Independent page cursors — routes and return_routes almost
// always have different totals, so they can't share one `page`
// without one side silently paginating the other's offset.
'page' => ['nullable', 'integer', 'min:1'],
'return_page' => ['nullable', 'integer', 'min:1'],
];
}
}
@@ -18,7 +18,6 @@ class EvRouteResource extends JsonResource
{ {
return [ return [
'id' => $this->id, 'id' => $this->id,
'is_round_trip' => $this->is_round_trip,
'is_active' => $this->is_active, 'is_active' => $this->is_active,
'company' => new EvCompanyResource($this->whenLoaded('company')), 'company' => new EvCompanyResource($this->whenLoaded('company')),
'from_destination' => new DestinationResource($this->whenLoaded('fromDestination')), 'from_destination' => new DestinationResource($this->whenLoaded('fromDestination')),
+12 -2
View File
@@ -42,7 +42,6 @@ class EvRoute extends Model
'ev_company_id', 'ev_company_id',
'from_destination_id', 'from_destination_id',
'to_destination_id', 'to_destination_id',
'is_round_trip',
'is_active', 'is_active',
]; ];
@@ -52,7 +51,6 @@ class EvRoute extends Model
protected function casts(): array protected function casts(): array
{ {
return [ return [
'is_round_trip' => 'boolean',
'is_active' => 'boolean', 'is_active' => 'boolean',
]; ];
} }
@@ -99,4 +97,16 @@ class EvRoute extends Model
get: fn (): string => $this->fromDestination->name.' → '.$this->toDestination->name, get: fn (): string => $this->fromDestination->name.' → '.$this->toDestination->name,
); );
} }
/**
* True when this route is the exact reverse direction of $other (from
* and to swapped) used to validate a booking's `return_ev_route_id`
* is genuinely the return leg of its outbound route, not an unrelated
* pair (domain.md §2b).
*/
public function isReverseOf(EvRoute $other): bool
{
return $this->from_destination_id === $other->to_destination_id
&& $this->to_destination_id === $other->from_destination_id;
}
} }
@@ -41,6 +41,39 @@ test('can list ev routes', function () {
->assertCanSeeTableRecords($routes); ->assertCanSeeTableRecords($routes);
}); });
test('can filter ev routes by company, from, and to', function () {
$companyA = EvCompany::factory()->create();
$companyB = EvCompany::factory()->create();
$yangon = Destination::factory()->create();
$mandalay = Destination::factory()->create();
$bagan = Destination::factory()->create();
$matching = EvRoute::factory()->create([
'ev_company_id' => $companyA->id,
'from_destination_id' => $yangon->id,
'to_destination_id' => $mandalay->id,
]);
$wrongCompany = EvRoute::factory()->create([
'ev_company_id' => $companyB->id,
'from_destination_id' => $yangon->id,
'to_destination_id' => $mandalay->id,
]);
$wrongDestination = EvRoute::factory()->create([
'ev_company_id' => $companyA->id,
'from_destination_id' => $yangon->id,
'to_destination_id' => $bagan->id,
]);
Livewire::test(ListEvRoutes::class)
->filterTable('ev_company_id', $companyA->id)
->filterTable('from_destination_id', $yangon->id)
->filterTable('to_destination_id', $mandalay->id)
->assertCanSeeTableRecords([$matching])
->assertCanNotSeeTableRecords([$wrongCompany, $wrongDestination]);
});
test('list shows each vehicle option price stacked, and blocked options instead of a price', function () { test('list shows each vehicle option price stacked, and blocked options instead of a price', function () {
$route = EvRoute::factory()->create(); $route = EvRoute::factory()->create();
@@ -63,7 +96,6 @@ test('creating a route also creates all three vehicle option pricing rows, defau
'ev_company_id' => $company->id, 'ev_company_id' => $company->id,
'from_destination_id' => $from->id, 'from_destination_id' => $from->id,
'to_destination_id' => $to->id, 'to_destination_id' => $to->id,
'is_round_trip' => false,
'is_active' => false, 'is_active' => false,
'pricing' => pricingPayload(), 'pricing' => pricingPayload(),
]) ])
@@ -25,14 +25,26 @@ test('an ev route belongs to a company and two destinations', function () {
->and($route->toDestination->is($to))->toBeTrue(); ->and($route->toDestination->is($to))->toBeTrue();
}); });
test('is_round_trip and is_active cast to boolean', function () { test('is_active casts to boolean', function () {
$route = EvRoute::factory()->create([ $route = EvRoute::factory()->create([
'is_round_trip' => 1,
'is_active' => 0, 'is_active' => 0,
]); ]);
expect($route->is_round_trip)->toBeTrue() expect($route->is_active)->toBeFalse();
->and($route->is_active)->toBeFalse(); });
test('isReverseOf detects a route with from/to swapped', function () {
$a = Destination::factory()->create();
$b = Destination::factory()->create();
$outbound = EvRoute::factory()->create(['from_destination_id' => $a->id, 'to_destination_id' => $b->id]);
$return = EvRoute::factory()->create(['from_destination_id' => $b->id, 'to_destination_id' => $a->id]);
$unrelated = EvRoute::factory()->create();
expect($return->isReverseOf($outbound))->toBeTrue()
->and($outbound->isReverseOf($return))->toBeTrue()
->and($unrelated->isReverseOf($outbound))->toBeFalse()
->and($outbound->isReverseOf($outbound))->toBeFalse();
}); });
test('a route can be attached to time slots via the pivot, carrying its own is_active flag', function () { test('a route can be attached to time slots via the pivot, carrying its own is_active flag', function () {
@@ -41,42 +41,42 @@ test('saving an ev route invalidates the routes cache tag', function () {
$route = EvRoute::factory()->create(['is_active' => true]); $route = EvRoute::factory()->create(['is_active' => true]);
$this->withHeader('Authorization', "Bearer {$this->token}") $this->withHeader('Authorization', "Bearer {$this->token}")
->getJson('/api/v1/routes') ->postJson('/api/v1/routes/search')
->assertJsonCount(1, 'data'); ->assertJsonCount(1, 'routes.data');
// Bypass Eloquent so the change wouldn't be visible without invalidation. // Bypass Eloquent so the change wouldn't be visible without invalidation.
EvRoute::query()->where('id', $route->id)->update(['is_active' => false]); EvRoute::query()->where('id', $route->id)->update(['is_active' => false]);
$this->withHeader('Authorization', "Bearer {$this->token}") $this->withHeader('Authorization', "Bearer {$this->token}")
->getJson('/api/v1/routes') ->postJson('/api/v1/routes/search')
->assertJsonCount(1, 'data'); ->assertJsonCount(1, 'routes.data');
$route->refresh()->save(); $route->refresh()->save();
$this->withHeader('Authorization', "Bearer {$this->token}") $this->withHeader('Authorization', "Bearer {$this->token}")
->getJson('/api/v1/routes') ->postJson('/api/v1/routes/search')
->assertJsonCount(0, 'data'); ->assertJsonCount(0, 'routes.data');
}); });
test('deleting an ev route invalidates the routes cache tag', function () { test('deleting an ev route invalidates the routes cache tag', function () {
$route = EvRoute::factory()->create(['is_active' => true]); $route = EvRoute::factory()->create(['is_active' => true]);
$this->withHeader('Authorization', "Bearer {$this->token}") $this->withHeader('Authorization', "Bearer {$this->token}")
->getJson('/api/v1/routes') ->postJson('/api/v1/routes/search')
->assertJsonCount(1, 'data'); ->assertJsonCount(1, 'routes.data');
$route->delete(); $route->delete();
$this->withHeader('Authorization', "Bearer {$this->token}") $this->withHeader('Authorization', "Bearer {$this->token}")
->getJson('/api/v1/routes') ->postJson('/api/v1/routes/search')
->assertJsonCount(0, 'data'); ->assertJsonCount(0, 'routes.data');
}); });
test('flushing the routes cache tag does not affect other cached data', function () { test('flushing the routes cache tag does not affect other cached data', function () {
Cache::put('unrelated-key', 'still here', now()->addMinutes(5)); Cache::put('unrelated-key', 'still here', now()->addMinutes(5));
$route = EvRoute::factory()->create(['is_active' => true]); $route = EvRoute::factory()->create(['is_active' => true]);
$route->update(['is_round_trip' => true]); $route->update(['is_active' => false]);
expect(Cache::get('unrelated-key'))->toBe('still here'); expect(Cache::get('unrelated-key'))->toBe('still here');
}); });
@@ -12,7 +12,7 @@ beforeEach(function () {
$this->token = User::factory()->create()->createToken('test-token')->plainTextToken; $this->token = User::factory()->create()->createToken('test-token')->plainTextToken;
}); });
test('lists active routes with nested company, destinations, time slots and pricing', function () { test('searches active routes with nested company, destinations, time slots and pricing', function () {
$route = EvRoute::factory()->create(['is_active' => true]); $route = EvRoute::factory()->create(['is_active' => true]);
EvRoute::factory()->create(['is_active' => false]); EvRoute::factory()->create(['is_active' => false]);
@@ -26,17 +26,18 @@ test('lists active routes with nested company, destinations, time slots and pric
]); ]);
$this->withHeader('Authorization', "Bearer {$this->token}") $this->withHeader('Authorization', "Bearer {$this->token}")
->getJson('/api/v1/routes') ->postJson('/api/v1/routes/search')
->assertSuccessful() ->assertSuccessful()
->assertJsonCount(1, 'data') ->assertJsonCount(1, 'routes.data')
->assertJsonPath('data.0.id', $route->id) ->assertJsonPath('routes.data.0.id', $route->id)
->assertJsonPath('data.0.company.id', $route->ev_company_id) ->assertJsonPath('routes.data.0.company.id', $route->ev_company_id)
->assertJsonPath('data.0.from_destination.id', $route->from_destination_id) ->assertJsonPath('routes.data.0.from_destination.id', $route->from_destination_id)
->assertJsonPath('data.0.to_destination.id', $route->to_destination_id) ->assertJsonPath('routes.data.0.to_destination.id', $route->to_destination_id)
->assertJsonPath('data.0.time_slots.0.id', $slot->id) ->assertJsonPath('routes.data.0.time_slots.0.id', $slot->id)
->assertJsonPath('data.0.time_slots.0.is_active', true) ->assertJsonPath('routes.data.0.time_slots.0.is_active', true)
->assertJsonPath('data.0.pricing.0.vehicle_option', 'front_seat') ->assertJsonPath('routes.data.0.pricing.0.vehicle_option', 'front_seat')
->assertJsonPath('data.0.pricing.0.price', '12000.00'); ->assertJsonPath('routes.data.0.pricing.0.price', '12000.00')
->assertJsonCount(0, 'return_routes.data');
}); });
test('filters routes by company, from, and to', function () { test('filters routes by company, from, and to', function () {
@@ -68,24 +69,211 @@ test('filters routes by company, from, and to', function () {
]); ]);
$this->withHeader('Authorization', "Bearer {$this->token}") $this->withHeader('Authorization', "Bearer {$this->token}")
->getJson('/api/v1/routes?'.http_build_query([ ->postJson('/api/v1/routes/search', [
'company' => $companyA->id, 'company' => $companyA->id,
'from' => $yangon->id, 'from' => $yangon->id,
'to' => $mandalay->id, 'to' => $mandalay->id,
])) ])
->assertSuccessful() ->assertSuccessful()
->assertJsonCount(1, 'data') ->assertJsonCount(1, 'routes.data')
->assertJsonPath('data.0.id', $matching->id); ->assertJsonPath('routes.data.0.id', $matching->id);
});
test('filters routes by time_slot', function () {
$morning = DepartureTimeSlot::factory()->create(['time' => '06:00']);
$evening = DepartureTimeSlot::factory()->create(['time' => '18:00']);
$morningRoute = EvRoute::factory()->create(['is_active' => true]);
$morningRoute->timeSlots()->attach($morning->id, ['is_active' => true]);
$eveningRoute = EvRoute::factory()->create(['is_active' => true]);
$eveningRoute->timeSlots()->attach($evening->id, ['is_active' => true]);
// Attached but inactive on this route — must not match.
$inactivePivotRoute = EvRoute::factory()->create(['is_active' => true]);
$inactivePivotRoute->timeSlots()->attach($morning->id, ['is_active' => false]);
$this->withHeader('Authorization', "Bearer {$this->token}")
->postJson('/api/v1/routes/search', ['time_slot' => '06:00'])
->assertSuccessful()
->assertJsonCount(1, 'routes.data')
->assertJsonPath('routes.data.0.id', $morningRoute->id);
});
test('round trip: returns both routes and return_routes, swapped from/to', function () {
$company = EvCompany::factory()->create();
$yangon = Destination::factory()->create();
$mandalay = Destination::factory()->create();
$outbound = EvRoute::factory()->create([
'ev_company_id' => $company->id,
'from_destination_id' => $yangon->id,
'to_destination_id' => $mandalay->id,
'is_active' => true,
]);
$return = EvRoute::factory()->create([
'ev_company_id' => $company->id,
'from_destination_id' => $mandalay->id,
'to_destination_id' => $yangon->id,
'is_active' => true,
]);
$this->withHeader('Authorization', "Bearer {$this->token}")
->postJson('/api/v1/routes/search', [
'round_trip' => true,
'from' => $yangon->id,
'to' => $mandalay->id,
])
->assertSuccessful()
->assertJsonCount(1, 'routes.data')
->assertJsonPath('routes.data.0.id', $outbound->id)
->assertJsonCount(1, 'return_routes.data')
->assertJsonPath('return_routes.data.0.id', $return->id);
});
test('response includes the distinct companies and time_slots actually available for the from-to pair', function () {
$yangon = Destination::factory()->create();
$mandalay = Destination::factory()->create();
$companyA = EvCompany::factory()->create(['name' => 'Alpha EV']);
$companyB = EvCompany::factory()->create(['name' => 'Beta EV']);
$morning = DepartureTimeSlot::factory()->create(['time' => '06:00']);
$evening = DepartureTimeSlot::factory()->create(['time' => '18:00']);
$inactiveSlot = DepartureTimeSlot::factory()->create(['time' => '12:00']);
$routeA = EvRoute::factory()->create([
'ev_company_id' => $companyA->id,
'from_destination_id' => $yangon->id,
'to_destination_id' => $mandalay->id,
'is_active' => true,
]);
$routeA->timeSlots()->attach([$morning->id => ['is_active' => true], $inactiveSlot->id => ['is_active' => false]]);
$routeB = EvRoute::factory()->create([
'ev_company_id' => $companyB->id,
'from_destination_id' => $yangon->id,
'to_destination_id' => $mandalay->id,
'is_active' => true,
]);
$routeB->timeSlots()->attach($evening->id, ['is_active' => true]);
// Unrelated pair — must not leak into the facets.
$bagan = Destination::factory()->create();
$unrelated = EvRoute::factory()->create([
'from_destination_id' => $yangon->id,
'to_destination_id' => $bagan->id,
'is_active' => true,
]);
$unrelated->timeSlots()->attach(DepartureTimeSlot::factory()->create(['time' => '09:00'])->id, ['is_active' => true]);
// Applying a company filter narrows `routes.data` but must not narrow
// the facets themselves — facets always reflect the full from-to pair.
$response = $this->withHeader('Authorization', "Bearer {$this->token}")
->postJson('/api/v1/routes/search', [
'from' => $yangon->id,
'to' => $mandalay->id,
'company' => $companyA->id,
])
->assertSuccessful()
->assertJsonCount(1, 'routes.data')
->json();
expect(collect($response['filters']['companies'])->pluck('id')->sort()->values()->all())
->toBe([$companyA->id, $companyB->id]);
// Facet shape is trimmed to id/name/mm_name — not the full company resource.
expect(array_keys($response['filters']['companies'][0]))->toBe(['id', 'name', 'mm_name']);
expect(collect($response['filters']['time_slots'])->pluck('time')->all())
->toBe(['06:00', '18:00']); // sorted by time, inactive pivot and unrelated pair excluded
});
test('filter options are empty when from/to are not both given', function () {
$this->withHeader('Authorization', "Bearer {$this->token}")
->postJson('/api/v1/routes/search')
->assertSuccessful()
->assertJson(['filters' => ['companies' => [], 'time_slots' => []]]);
});
test('round trip: return_filters reflect the swapped to-from pair', function () {
$yangon = Destination::factory()->create();
$mandalay = Destination::factory()->create();
$returnCompany = EvCompany::factory()->create();
EvRoute::factory()->create([
'from_destination_id' => $yangon->id,
'to_destination_id' => $mandalay->id,
'is_active' => true,
]);
EvRoute::factory()->create([
'ev_company_id' => $returnCompany->id,
'from_destination_id' => $mandalay->id,
'to_destination_id' => $yangon->id,
'is_active' => true,
]);
$response = $this->withHeader('Authorization', "Bearer {$this->token}")
->postJson('/api/v1/routes/search', [
'round_trip' => true,
'from' => $yangon->id,
'to' => $mandalay->id,
])
->assertSuccessful()
->json();
expect(collect($response['return_filters']['companies'])->pluck('id')->all())
->toBe([$returnCompany->id]);
});
test('round trip: routes and return_routes paginate independently via page and return_page', function () {
$company = EvCompany::factory()->create();
$yangon = Destination::factory()->create();
$mandalay = Destination::factory()->create();
// 20 outbound routes (2 pages of 15), only 3 return routes (1 page).
EvRoute::factory()->count(20)->create([
'ev_company_id' => $company->id,
'from_destination_id' => $yangon->id,
'to_destination_id' => $mandalay->id,
'is_active' => true,
]);
EvRoute::factory()->count(3)->create([
'ev_company_id' => $company->id,
'from_destination_id' => $mandalay->id,
'to_destination_id' => $yangon->id,
'is_active' => true,
]);
// page=2 must give the 2nd page of routes (5 remaining), while
// return_routes — with no return_page given — must still return its own
// full page 1 (all 3), not an empty slice at offset 2.
$this->withHeader('Authorization', "Bearer {$this->token}")
->postJson('/api/v1/routes/search', [
'round_trip' => true,
'from' => $yangon->id,
'to' => $mandalay->id,
'page' => 2,
])
->assertSuccessful()
->assertJsonCount(5, 'routes.data')
->assertJsonPath('routes.meta.current_page', 2)
->assertJsonCount(3, 'return_routes.data')
->assertJsonPath('return_routes.meta.current_page', 1);
});
test('round_trip without from and to is rejected', function () {
$this->withHeader('Authorization', "Bearer {$this->token}")
->postJson('/api/v1/routes/search', ['round_trip' => true])
->assertStatus(422)
->assertJsonValidationErrors(['from', 'to']);
}); });
test('paginates routes', function () { test('paginates routes', function () {
EvRoute::factory()->count(20)->create(['is_active' => true]); EvRoute::factory()->count(20)->create(['is_active' => true]);
$this->withHeader('Authorization', "Bearer {$this->token}") $this->withHeader('Authorization', "Bearer {$this->token}")
->getJson('/api/v1/routes') ->postJson('/api/v1/routes/search')
->assertSuccessful() ->assertSuccessful()
->assertJsonCount(15, 'data') ->assertJsonCount(15, 'routes.data')
->assertJsonPath('meta.total', 20); ->assertJsonPath('routes.meta.total', 20);
}); });
test('shows a single active route', function () { test('shows a single active route', function () {
@@ -148,7 +336,7 @@ test('lists a route\'s time slots with the pivot active flag', function () {
test('routes endpoints reject unauthenticated requests', function () { test('routes endpoints reject unauthenticated requests', function () {
$route = EvRoute::factory()->create(['is_active' => true]); $route = EvRoute::factory()->create(['is_active' => true]);
$this->getJson('/api/v1/routes')->assertUnauthorized(); $this->postJson('/api/v1/routes/search')->assertUnauthorized();
$this->getJson("/api/v1/routes/{$route->id}")->assertUnauthorized(); $this->getJson("/api/v1/routes/{$route->id}")->assertUnauthorized();
$this->getJson("/api/v1/routes/{$route->id}/pricing")->assertUnauthorized(); $this->getJson("/api/v1/routes/{$route->id}/pricing")->assertUnauthorized();
$this->getJson("/api/v1/routes/{$route->id}/time-slots")->assertUnauthorized(); $this->getJson("/api/v1/routes/{$route->id}/time-slots")->assertUnauthorized();
+20 -2
View File
@@ -11,10 +11,10 @@ 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. |
| **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 + round-trip flag + one or more Time Slots + pricing per Vehicle Option. | | **EV Route** | Company + From Destination + To Destination + one or more Time Slots + pricing per Vehicle Option. One row is one direction only — round trip is not a flag on the route, see §2b. |
| **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. |
| **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. | | **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. | | **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. A round trip is **two** linked Bookings (outbound + return), not one — see §2b. |
| **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). |
@@ -53,6 +53,21 @@ The real-world business model is **door-to-door**: the EV drives to wherever the
--- ---
## 2b. Round Trips
Client-confirmed business rule: a round trip's return leg is driven by **whichever vehicle/driver is next available**, never guaranteed to be the same car that did the outbound leg. This is why round trip is **not** a flag on a single `EvRoute`/`Booking` row — it's modeled as **two independent, linked one-way `Booking` rows** (outbound + return), each with its own route, time slot, price, status, and driver/vehicle assignment slot (a single `Booking` only has one set of `driver_name`/`car_plate_number`/etc. columns, which can't represent two different vehicles).
- **Linking**: `bookings.linked_booking_id` — a nullable, self-referencing FK, set bidirectionally once both legs exist. `bookings.is_return_leg` distinguishes which half is which. `Booking::isRoundTrip` is a computed accessor (`linked_booking_id !== null`), not a stored column.
- **Return route**: the client explicitly supplies `return_ev_route_id` (mirroring `ev_route_id` for the outbound leg) — it must already exist as a real catalog `EvRoute` (admin-created, e.g. B→A). The server validates it's genuinely the reverse of the outbound route (`EvRoute::isReverseOf` — from/to swapped), rejecting with 422 otherwise. There is no auto-derivation of a reverse route, since multiple companies could plausibly run the same pair.
- `EvRoute.is_round_trip` was removed — it was never load-bearing, and with the return route now explicit + validated it has no remaining purpose.
- **Discovering the return route**: `POST /api/v1/routes/search` (not GET — see below) accepts `round_trip=true` alongside `from`/`to` (both required when round trip) and returns **two** result sets in one response: `routes` (from→to) and `return_routes` (to→from, swapped), each a normal paginated collection with its own nested `data`/`links`/`meta` — not a single shared pagination block, since the two sides almost always have different totals. Paging them is likewise independent: `page` pages `routes`, `return_page` pages `return_routes`, each defaulting to 1 and generating links under its own param name. This is how a client finds the `return_ev_route_id` to submit with the booking. It's POST rather than GET because the response shape genuinely branches (two independent collections) rather than being a single filtered list — a query-string GET stays a better fit for the plain `show`/`pricing`/`time-slots` single-route endpoints, which are unchanged. The search endpoint also accepts `time_slot` (a catalog time value like `"06:00"`, not a `DepartureTimeSlot` id) to filter to routes offering that departure time, applied identically to both `routes` and `return_routes`.
- **Filter facets**: the response also carries `filters` (and `return_filters` when round trip) — the distinct companies and active time slots actually available for that specific from→to pair, computed independently of any `company`/`time_slot` already applied (so narrowing by one doesn't collapse the options shown for the other). Empty when `from`/`to` aren't both given. Company facet entries are trimmed to `id`/`name`/`mm_name` — not the full company resource (no slug/description/contact/logo needed just to populate a filter dropdown).
- **"Popular routes"** (`EvRoute.is_popular`) was removed (2026-08-22) — the blunt boolean flag didn't match the client's actual popularity logic. Revisit once that logic is specified; don't re-add a plain boolean without it.
- **Cancellation/refund**: each leg cancels and refunds **independently** — cancelling the return leg does not touch the outbound leg and vice versa.
- **Payment**: **combined** on the outbound ("primary") leg — one `Payment` row covers both legs' total (`InitiatePaymentAction` sums `outbound.price + return.price`). The return leg is marked `confirmed` when the primary's payment succeeds (`MarkBookingPaid` confirms both). Since the return leg has no `Payment` of its own, `RefundBookingAction` resolves the payment-holder via `linkedBooking` when refunding a return leg — partial refunds (already supported, §6) keep the running total correctly bounded to the combined `Payment.amount` regardless of which leg is cancelled first.
---
## 3. Pricing ## 3. Pricing
- `RoutePricing` holds one price per (Route, Vehicle Option) pair. - `RoutePricing` holds one price per (Route, Vehicle Option) pair.
@@ -90,6 +105,7 @@ Once a Booking is `confirmed` (paid), dispatch assigns who's actually doing the
- 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`. - 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. - 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. - **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.
- Round trip needed no schema change here: since a round trip is two independent `Booking` rows (§2b), each leg already has its own independent set of these columns — the outbound and return leg can be assigned different drivers/vehicles with zero extra modeling.
--- ---
@@ -114,6 +130,8 @@ The existing KBZ Mini App payment code at `/home/marcspecta/company_projects/bnf
**Webhook idempotency**: KBZ may retry the notify webhook. The handler must check the Payment's current status before transitioning it — never assume a webhook call is the first/only delivery. **Webhook idempotency**: KBZ may retry the notify webhook. The handler must check the Payment's current status before transitioning it — never assume a webhook call is the first/only delivery.
**Round trips**: payment is combined on the outbound leg (§2b) — `MarkBookingPaid` confirms both the primary booking and its linked return leg when payment succeeds, and `RefundBookingAction` resolves the payment-holder via `linkedBooking` when refunding a return leg (it has no `Payment` of its own).
--- ---
## 7. Deferred / Future (do not build yet) ## 7. Deferred / Future (do not build yet)