Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f0f20659d | |||
| 98dacef556 | |||
| da6d51b7b2 | |||
| 0e55e36cea | |||
| da9cd9bbe0 | |||
| 41c9454334 | |||
| fa908cdcaf | |||
| 894352b43f | |||
| 1aeb57f130 | |||
| 6be47aa35a | |||
| 79f7f50706 | |||
| 54b35ee087 | |||
| dfffdd343b |
+10
-3
@@ -53,9 +53,16 @@ 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
|
||||||
|
|
||||||
|
BOOKING_ADMIN_EMAILS="example@gmail.com"
|
||||||
|
|
||||||
|
SMS_ENABLED=false
|
||||||
|
SMS_SERVER=
|
||||||
|
SMS_TOKEN=
|
||||||
|
SMS_SENDER=
|
||||||
|
|
||||||
KBZ_APP_ID=
|
KBZ_APP_ID=
|
||||||
KBZ_MERCHANT_CODE=
|
KBZ_MERCHANT_CODE=
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+32
@@ -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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
+40
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -4,6 +4,7 @@ namespace Modules\Booking\Actions;
|
|||||||
|
|
||||||
use Modules\Booking\Data\AssignDriverData;
|
use Modules\Booking\Data\AssignDriverData;
|
||||||
use Modules\Booking\Enums\BookingStatus;
|
use Modules\Booking\Enums\BookingStatus;
|
||||||
|
use Modules\Booking\Events\DriverAssigned;
|
||||||
use Modules\Booking\Exceptions\DriverAssignmentNotAllowedException;
|
use Modules\Booking\Exceptions\DriverAssignmentNotAllowedException;
|
||||||
use Modules\Booking\Models\Booking;
|
use Modules\Booking\Models\Booking;
|
||||||
|
|
||||||
@@ -21,6 +22,12 @@ class AssignDriverAction
|
|||||||
throw DriverAssignmentNotAllowedException::notConfirmed($booking);
|
throw DriverAssignmentNotAllowedException::notConfirmed($booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($booking->travel_date->lt(today())) {
|
||||||
|
throw DriverAssignmentNotAllowedException::travelDateInPast($booking);
|
||||||
|
}
|
||||||
|
|
||||||
|
$isFirstAssignment = $booking->driver_name === null;
|
||||||
|
|
||||||
$booking->update([
|
$booking->update([
|
||||||
'driver_name' => $data->driverName,
|
'driver_name' => $data->driverName,
|
||||||
'driver_phone' => $data->driverPhone,
|
'driver_phone' => $data->driverPhone,
|
||||||
@@ -28,6 +35,13 @@ class AssignDriverAction
|
|||||||
'car_model' => $data->carModel,
|
'car_model' => $data->carModel,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Guards against a double-submit of the same form resulting in two
|
||||||
|
// identical SMS notifications to the passenger — a genuine
|
||||||
|
// reassignment always changes at least one of these columns.
|
||||||
|
if ($booking->wasChanged(['driver_name', 'driver_phone', 'car_plate_number', 'car_model'])) {
|
||||||
|
DriverAssigned::dispatch($booking, $isFirstAssignment);
|
||||||
|
}
|
||||||
|
|
||||||
return $booking;
|
return $booking;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,50 +27,110 @@ 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);
|
|
||||||
|
|
||||||
$lines = array_map(
|
if ($isRoundTrip) {
|
||||||
fn (VehicleSelectionData $selection) => $this->priceSelection($route, $selection),
|
$this->bookingService->validateSelections($data->returnSelections);
|
||||||
$data->selections,
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
);
|
);
|
||||||
|
|
||||||
$totalPrice = array_reduce(
|
if (! $isRoundTrip) {
|
||||||
$lines,
|
BookingCreated::dispatch($outboundBooking);
|
||||||
fn (string $carry, array $line) => bcadd($carry, $line['line_total'], 2),
|
|
||||||
'0.00',
|
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,
|
||||||
);
|
);
|
||||||
|
|
||||||
$booking = Booking::create([
|
// Linked bidirectionally after both rows exist — a single
|
||||||
'booking_ref' => $this->bookingRefGenerator->generate(),
|
// `linked_booking_id` FK can't be set on either row at create
|
||||||
'user_id' => $data->userId,
|
// time since the other side doesn't have an id yet.
|
||||||
'openid' => $data->openid,
|
$returnBooking->update(['linked_booking_id' => $outboundBooking->id]);
|
||||||
'ev_route_id' => $data->evRouteId,
|
$outboundBooking->update(['linked_booking_id' => $returnBooking->id]);
|
||||||
'departure_time_slot_id' => $data->departureTimeSlotId,
|
|
||||||
'travel_date' => $data->travelDate,
|
|
||||||
'passenger_name' => $data->passengerName,
|
|
||||||
'passenger_phone' => $data->passengerPhone,
|
|
||||||
'pickup_address' => $data->pickupAddress,
|
|
||||||
'pickup_lat' => $data->pickupLat,
|
|
||||||
'pickup_lng' => $data->pickupLng,
|
|
||||||
'dropoff_address' => $data->dropoffAddress,
|
|
||||||
'dropoff_lat' => $data->dropoffLat,
|
|
||||||
'dropoff_lng' => $data->dropoffLng,
|
|
||||||
'price' => $totalPrice,
|
|
||||||
'status' => BookingStatus::PendingPayment,
|
|
||||||
'is_round_trip' => $data->isRoundTrip,
|
|
||||||
'return_travel_date' => $data->returnTravelDate,
|
|
||||||
'created_by_channel' => $data->createdByChannel,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$booking->vehicleOptions()->createMany($lines);
|
// 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);
|
||||||
|
|
||||||
BookingCreated::dispatch($booking);
|
return $outboundBooking->refresh();
|
||||||
|
|
||||||
return $booking;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<VehicleSelectionData> $selections
|
||||||
|
*/
|
||||||
|
private function createLeg(
|
||||||
|
CreateBookingData $data,
|
||||||
|
EvRoute $route,
|
||||||
|
array $selections,
|
||||||
|
string $travelDate,
|
||||||
|
int $timeSlotId,
|
||||||
|
bool $isReturnLeg,
|
||||||
|
): Booking {
|
||||||
|
$lines = array_map(
|
||||||
|
fn (VehicleSelectionData $selection) => $this->priceSelection($route, $selection),
|
||||||
|
$selections,
|
||||||
|
);
|
||||||
|
|
||||||
|
$totalPrice = array_reduce(
|
||||||
|
$lines,
|
||||||
|
fn (string $carry, array $line) => bcadd($carry, $line['line_total'], 2),
|
||||||
|
'0.00',
|
||||||
|
);
|
||||||
|
|
||||||
|
$booking = Booking::create([
|
||||||
|
'booking_ref' => $this->bookingRefGenerator->generate(),
|
||||||
|
'user_id' => $data->userId,
|
||||||
|
'openid' => $data->openid,
|
||||||
|
'ev_route_id' => $route->id,
|
||||||
|
'is_return_leg' => $isReturnLeg,
|
||||||
|
'departure_time_slot_id' => $timeSlotId,
|
||||||
|
'travel_date' => $travelDate,
|
||||||
|
'passenger_name' => $data->passengerName,
|
||||||
|
'passenger_phone' => $data->passengerPhone,
|
||||||
|
'notes' => $data->notes,
|
||||||
|
'pickup_address' => $data->pickupAddress,
|
||||||
|
'pickup_lat' => $data->pickupLat,
|
||||||
|
'pickup_lng' => $data->pickupLng,
|
||||||
|
'dropoff_address' => $data->dropoffAddress,
|
||||||
|
'dropoff_lat' => $data->dropoffLat,
|
||||||
|
'dropoff_lng' => $data->dropoffLng,
|
||||||
|
'price' => $totalPrice,
|
||||||
|
'status' => BookingStatus::PendingPayment,
|
||||||
|
'created_by_channel' => $data->createdByChannel,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$booking->vehicleOptions()->createMany($lines);
|
||||||
|
|
||||||
|
return $booking;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{vehicle_option: VehicleOption, passenger_count: int, unit_price: string, line_total: string}
|
* @return array{vehicle_option: VehicleOption, passenger_count: int, unit_price: string, line_total: string}
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Booking\Events;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Modules\Booking\Models\Booking;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fired whenever AssignDriverAction sets or updates a booking's
|
||||||
|
* driver/vehicle details — covers both the first assignment and any later
|
||||||
|
* reassignment, since both go through the same action. $isFirstAssignment
|
||||||
|
* lets listeners (e.g. the SMS notification) word the message differently
|
||||||
|
* for "driver assigned" vs "driver info updated".
|
||||||
|
*/
|
||||||
|
class DriverAssigned
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
|
||||||
|
public function __construct(public Booking $booking, public bool $isFirstAssignment) {}
|
||||||
|
}
|
||||||
@@ -16,6 +16,13 @@ class DriverAssignmentNotAllowedException extends RuntimeException
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function travelDateInPast(Booking $booking): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
"Booking [{$booking->booking_ref}] cannot have a driver assigned because its travel date [{$booking->travel_date->toDateString()}] is in the past."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function render(Request $request): ?JsonResponse
|
public function render(Request $request): ?JsonResponse
|
||||||
{
|
{
|
||||||
if ($request->expectsJson()) {
|
if ($request->expectsJson()) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -25,6 +25,7 @@ class AssignDriverTableAction
|
|||||||
->icon(Heroicon::OutlinedTruck)
|
->icon(Heroicon::OutlinedTruck)
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->visible(fn (Booking $record): bool => $record->status === BookingStatus::Confirmed
|
->visible(fn (Booking $record): bool => $record->status === BookingStatus::Confirmed
|
||||||
|
&& $record->travel_date->gte(today())
|
||||||
&& (auth()->user()?->can('manage_bookings') ?? false))
|
&& (auth()->user()?->can('manage_bookings') ?? false))
|
||||||
->schema([
|
->schema([
|
||||||
TextInput::make('driver_name')->required(),
|
TextInput::make('driver_name')->required(),
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use Modules\Booking\Enums\BookingChannel;
|
|||||||
use Modules\Booking\Http\Requests\StoreBookingRequest;
|
use Modules\Booking\Http\Requests\StoreBookingRequest;
|
||||||
use Modules\Booking\Http\Resources\BookingResource;
|
use Modules\Booking\Http\Resources\BookingResource;
|
||||||
use Modules\Booking\Models\Booking;
|
use Modules\Booking\Models\Booking;
|
||||||
|
use Modules\Payment\Enums\PaymentStatus;
|
||||||
use Modules\Shared\Enums\VehicleOption;
|
use Modules\Shared\Enums\VehicleOption;
|
||||||
|
|
||||||
class BookingController extends Controller
|
class BookingController extends Controller
|
||||||
@@ -22,7 +23,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,
|
||||||
@@ -46,6 +51,17 @@ class BookingController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$bookings = $query
|
$bookings = $query
|
||||||
|
// Only bookings that actually have a completed payment — a
|
||||||
|
// pending_payment booking never had money move, so it's noise
|
||||||
|
// in a booking list, not a real reservation to show.
|
||||||
|
->whereHas('payments', fn ($paymentQuery) => $paymentQuery->where('status', PaymentStatus::Completed))
|
||||||
|
// A round trip is two Booking rows (outbound + return leg,
|
||||||
|
// linked via linked_booking_id — domain.md §2b), but it should
|
||||||
|
// still surface once here, not as two separate list entries.
|
||||||
|
// The outbound row's `linked_booking` already carries the
|
||||||
|
// return leg's full detail (including vehicle_options).
|
||||||
|
->where('is_return_leg', false)
|
||||||
|
->when($request->filled('booking_ref'), fn ($q) => $q->where('booking_ref', 'ilike', '%'.$request->string('booking_ref').'%'))
|
||||||
->with(self::EAGER_LOADS)
|
->with(self::EAGER_LOADS)
|
||||||
->latest()
|
->latest()
|
||||||
->paginate();
|
->paginate();
|
||||||
@@ -83,6 +99,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 +126,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 +139,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,
|
||||||
@@ -30,6 +31,15 @@ class BookingResource extends JsonResource
|
|||||||
'dropoff_lat' => $this->dropoff_lat,
|
'dropoff_lat' => $this->dropoff_lat,
|
||||||
'dropoff_lng' => $this->dropoff_lng,
|
'dropoff_lng' => $this->dropoff_lng,
|
||||||
'price' => $this->price,
|
'price' => $this->price,
|
||||||
|
// This leg's own price, same value CancelBookingAction/
|
||||||
|
// RefundBookingAction use for this specific leg. total_price is
|
||||||
|
// the round-trip total (this leg + linked leg) — computed here,
|
||||||
|
// not left to the client to sum, since it must always match what
|
||||||
|
// InitiatePaymentAction actually charges (bcadd, same as there).
|
||||||
|
// Equal to `price` for a plain one-way booking.
|
||||||
|
'total_price' => $this->relationLoaded('linkedBooking') && $this->linkedBooking !== null
|
||||||
|
? bcadd((string) $this->price, (string) $this->linkedBooking->price, 2)
|
||||||
|
: $this->price,
|
||||||
'created_by_channel' => $this->created_by_channel,
|
'created_by_channel' => $this->created_by_channel,
|
||||||
// Only ever populated once status is confirmed — see AssignDriverAction.
|
// Only ever populated once status is confirmed — see AssignDriverAction.
|
||||||
'driver_name' => $this->driver_name,
|
'driver_name' => $this->driver_name,
|
||||||
@@ -53,6 +63,44 @@ 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,
|
||||||
|
// Each leg gets its own independent driver/vehicle
|
||||||
|
// assignment — the return leg is never guaranteed the same
|
||||||
|
// car as the outbound leg (domain.md §2b). Only ever
|
||||||
|
// populated once that leg's own status is confirmed — see
|
||||||
|
// AssignDriverAction.
|
||||||
|
'driver_name' => $this->linkedBooking->driver_name,
|
||||||
|
'driver_phone' => $this->linkedBooking->driver_phone,
|
||||||
|
'car_plate_number' => $this->linkedBooking->car_plate_number,
|
||||||
|
'car_model' => $this->linkedBooking->car_model,
|
||||||
|
]),
|
||||||
'created_at' => $this->created_at,
|
'created_at' => $this->created_at,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Booking\Listeners;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Modules\Booking\Events\DriverAssigned;
|
||||||
|
use Modules\Shared\Sms\SmsService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifies the passenger of their driver/car details whenever a driver is
|
||||||
|
* assigned or reassigned (domain.md — driver/vehicle assignment). Queued
|
||||||
|
* since it's an outbound HTTP call to the SMS gateway.
|
||||||
|
*/
|
||||||
|
class SendDriverAssignedSms implements ShouldQueue
|
||||||
|
{
|
||||||
|
public function __construct(private readonly SmsService $smsService) {}
|
||||||
|
|
||||||
|
public function handle(DriverAssigned $event): void
|
||||||
|
{
|
||||||
|
$booking = $event->booking;
|
||||||
|
|
||||||
|
$this->smsService->send($booking->passenger_phone, $this->message($event));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function message(DriverAssigned $event): string
|
||||||
|
{
|
||||||
|
$booking = $event->booking;
|
||||||
|
|
||||||
|
$vehicle = trim($booking->car_model !== null
|
||||||
|
? "{$booking->car_plate_number} ({$booking->car_model})"
|
||||||
|
: $booking->car_plate_number);
|
||||||
|
$route = $booking->route->fromDestination->name.' - '.$booking->route->toDestination->name;
|
||||||
|
$mmRoute = $booking->route->fromDestination->mm_name.' - '.$booking->route->toDestination->mm_name;
|
||||||
|
|
||||||
|
$appName = 'BNF Express - '.config('app.name');
|
||||||
|
$supportPhone = config('app.support_phone');
|
||||||
|
$supportEmail = config('app.support_email');
|
||||||
|
$contact = "Help: {$supportPhone} / {$supportEmail}\nအကူအညီလိုအပ်ပါက ဆက်သွယ်ရန်: {$supportPhone} / {$supportEmail}";
|
||||||
|
|
||||||
|
if ($event->isFirstAssignment) {
|
||||||
|
$en = "Your driver has been assigned for booking {$booking->booking_ref} ({$route}). Driver: {$booking->driver_name}, {$booking->driver_phone}. Vehicle: {$vehicle}.";
|
||||||
|
$mm = "ဘွတ်ကင် {$booking->booking_ref} ({$mmRoute}) အတွက် ယာဉ်မောင်း သတ်မှတ်ပြီးပါပြီ။ ယာဉ်မောင်း - {$booking->driver_name}, {$booking->driver_phone}။ ယာဉ် - {$vehicle}။";
|
||||||
|
} else {
|
||||||
|
$en = "Driver info updated for booking {$booking->booking_ref} ({$route}). Driver: {$booking->driver_name}, {$booking->driver_phone}. Vehicle: {$vehicle}.";
|
||||||
|
$mm = "ဘွတ်ကင် {$booking->booking_ref} ({$mmRoute}) ၏ ယာဉ်မောင်းအချက်အလက်ကို ပြင်ဆင်ထားပါသည်။ ယာဉ်မောင်း - {$booking->driver_name}, {$booking->driver_phone}။ ယာဉ် - {$vehicle}။";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "{$appName}\n{$en}\n{$mm}\n{$contact}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
namespace Modules\Booking\Providers;
|
namespace Modules\Booking\Providers;
|
||||||
|
|
||||||
use Illuminate\Contracts\Auth\Access\Gate;
|
use Illuminate\Contracts\Auth\Access\Gate;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Modules\Booking\Events\DriverAssigned;
|
||||||
|
use Modules\Booking\Listeners\SendDriverAssignedSms;
|
||||||
use Modules\Booking\Policies\BookingPolicy;
|
use Modules\Booking\Policies\BookingPolicy;
|
||||||
|
|
||||||
class BookingServiceProvider extends ServiceProvider
|
class BookingServiceProvider extends ServiceProvider
|
||||||
@@ -13,5 +16,7 @@ class BookingServiceProvider extends ServiceProvider
|
|||||||
public function boot(Gate $gate): void
|
public function boot(Gate $gate): void
|
||||||
{
|
{
|
||||||
$gate->policy('Modules\Booking\Models\Booking', BookingPolicy::class);
|
$gate->policy('Modules\Booking\Models\Booking', BookingPolicy::class);
|
||||||
|
|
||||||
|
// Event::listen(DriverAssigned::class, SendDriverAssignedSms::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use Modules\Booking\Enums\BookingStatus;
|
||||||
use Modules\Booking\Models\Booking;
|
use Modules\Booking\Models\Booking;
|
||||||
|
use Modules\Payment\Enums\PaymentMethod;
|
||||||
|
use Modules\Payment\Models\Payment;
|
||||||
use Spatie\Permission\Models\Permission;
|
use Spatie\Permission\Models\Permission;
|
||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
@@ -11,10 +14,27 @@ beforeEach(function () {
|
|||||||
$this->token = $this->owner->createToken('test-token')->plainTextToken;
|
$this->token = $this->owner->createToken('test-token')->plainTextToken;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Index only ever shows bookings with a completed payment — give the
|
||||||
|
* booking a completed Payment row so it's not silently excluded.
|
||||||
|
*/
|
||||||
|
function paidBooking(array $attributes = []): Booking
|
||||||
|
{
|
||||||
|
$booking = Booking::factory()->create($attributes);
|
||||||
|
|
||||||
|
Payment::factory()->completed()->create([
|
||||||
|
'booking_id' => $booking->id,
|
||||||
|
'gateway' => PaymentMethod::KbzMiniApp,
|
||||||
|
'amount' => $booking->price,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $booking;
|
||||||
|
}
|
||||||
|
|
||||||
test('index lists only the authenticated user\'s own bookings, latest first', function () {
|
test('index lists only the authenticated user\'s own bookings, latest first', function () {
|
||||||
$mine = Booking::factory()->create(['user_id' => $this->owner->id, 'created_at' => now()->subMinute()]);
|
$mine = paidBooking(['user_id' => $this->owner->id, 'created_at' => now()->subMinute()]);
|
||||||
$mineNewer = Booking::factory()->create(['user_id' => $this->owner->id]);
|
$mineNewer = paidBooking(['user_id' => $this->owner->id]);
|
||||||
Booking::factory()->create(['user_id' => User::factory()->create()->id]);
|
paidBooking(['user_id' => User::factory()->create()->id]);
|
||||||
|
|
||||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||||
->getJson('/api/v1/bookings')
|
->getJson('/api/v1/bookings')
|
||||||
@@ -24,6 +44,115 @@ test('index lists only the authenticated user\'s own bookings, latest first', fu
|
|||||||
->assertJsonPath('data.1.id', $mine->id);
|
->assertJsonPath('data.1.id', $mine->id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('index excludes bookings with no completed payment', function () {
|
||||||
|
// pending_payment, never paid.
|
||||||
|
Booking::factory()->create(['user_id' => $this->owner->id]);
|
||||||
|
|
||||||
|
// Has a payment attempt, but it failed — still not "complete".
|
||||||
|
$failedPayment = Booking::factory()->create(['user_id' => $this->owner->id]);
|
||||||
|
Payment::factory()->failed()->create(['booking_id' => $failedPayment->id, 'gateway' => PaymentMethod::KbzMiniApp]);
|
||||||
|
|
||||||
|
$paid = paidBooking(['user_id' => $this->owner->id]);
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||||
|
->getJson('/api/v1/bookings')
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertJsonCount(1, 'data')
|
||||||
|
->assertJsonPath('data.0.id', $paid->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('index surfaces a round trip once, not as two separate rows, with a combined total_price', function () {
|
||||||
|
$outbound = paidBooking(['user_id' => $this->owner->id, 'price' => '9000.00']);
|
||||||
|
$return = Booking::factory()->create([
|
||||||
|
'user_id' => $this->owner->id,
|
||||||
|
'price' => '11000.00',
|
||||||
|
'is_return_leg' => true,
|
||||||
|
'linked_booking_id' => $outbound->id,
|
||||||
|
]);
|
||||||
|
$outbound->update(['linked_booking_id' => $return->id]);
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||||
|
->getJson('/api/v1/bookings')
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertJsonCount(1, 'data')
|
||||||
|
->assertJsonPath('data.0.id', $outbound->id)
|
||||||
|
->assertJsonPath('data.0.price', '9000.00')
|
||||||
|
->assertJsonPath('data.0.total_price', '20000.00')
|
||||||
|
->assertJsonPath('data.0.linked_booking.id', $return->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('linked_booking carries the return leg\'s own driver/vehicle assignment, independent of the outbound leg\'s', function () {
|
||||||
|
$outbound = paidBooking([
|
||||||
|
'user_id' => $this->owner->id,
|
||||||
|
'status' => BookingStatus::Confirmed,
|
||||||
|
'driver_name' => 'U Aung',
|
||||||
|
'driver_phone' => '+959111222333',
|
||||||
|
'car_plate_number' => 'YGN-1234',
|
||||||
|
'car_model' => 'Tesla Model Y',
|
||||||
|
]);
|
||||||
|
$return = Booking::factory()->create([
|
||||||
|
'user_id' => $this->owner->id,
|
||||||
|
'is_return_leg' => true,
|
||||||
|
'linked_booking_id' => $outbound->id,
|
||||||
|
'status' => BookingStatus::Confirmed,
|
||||||
|
'driver_name' => 'Daw Hla',
|
||||||
|
'driver_phone' => '+959444555666',
|
||||||
|
'car_plate_number' => 'MDY-5678',
|
||||||
|
'car_model' => null,
|
||||||
|
]);
|
||||||
|
$outbound->update(['linked_booking_id' => $return->id]);
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||||
|
->getJson("/api/v1/bookings/{$outbound->booking_ref}")
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertJsonPath('data.driver_name', 'U Aung')
|
||||||
|
->assertJsonPath('data.car_plate_number', 'YGN-1234')
|
||||||
|
->assertJsonPath('data.linked_booking.driver_name', 'Daw Hla')
|
||||||
|
->assertJsonPath('data.linked_booking.driver_phone', '+959444555666')
|
||||||
|
->assertJsonPath('data.linked_booking.car_plate_number', 'MDY-5678')
|
||||||
|
->assertJsonPath('data.linked_booking.car_model', null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('total_price equals price for a plain one-way booking, on both index and show', function () {
|
||||||
|
$booking = paidBooking(['user_id' => $this->owner->id, 'price' => '15000.00']);
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||||
|
->getJson('/api/v1/bookings')
|
||||||
|
->assertJsonPath('data.0.total_price', '15000.00');
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||||
|
->getJson("/api/v1/bookings/{$booking->booking_ref}")
|
||||||
|
->assertJsonPath('data.total_price', '15000.00');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('show returns the combined total_price for a round trip', function () {
|
||||||
|
$outbound = Booking::factory()->create(['user_id' => $this->owner->id, 'price' => '9000.00']);
|
||||||
|
$return = Booking::factory()->create([
|
||||||
|
'user_id' => $this->owner->id,
|
||||||
|
'price' => '11000.00',
|
||||||
|
'is_return_leg' => true,
|
||||||
|
'linked_booking_id' => $outbound->id,
|
||||||
|
]);
|
||||||
|
$outbound->update(['linked_booking_id' => $return->id]);
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||||
|
->getJson("/api/v1/bookings/{$outbound->booking_ref}")
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertJsonPath('data.price', '9000.00')
|
||||||
|
->assertJsonPath('data.total_price', '20000.00');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('index filters by booking_ref, partial and case-insensitive', function () {
|
||||||
|
$match = paidBooking(['user_id' => $this->owner->id, 'booking_ref' => 'EVB-FINDME1']);
|
||||||
|
paidBooking(['user_id' => $this->owner->id, 'booking_ref' => 'EVB-OTHER01']);
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||||
|
->getJson('/api/v1/bookings?booking_ref=findme')
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertJsonCount(1, 'data')
|
||||||
|
->assertJsonPath('data.0.id', $match->id);
|
||||||
|
});
|
||||||
|
|
||||||
test('index rejects unauthenticated requests', function () {
|
test('index rejects unauthenticated requests', function () {
|
||||||
$this->getJson('/api/v1/bookings')->assertUnauthorized();
|
$this->getJson('/api/v1/bookings')->assertUnauthorized();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -149,6 +149,15 @@ test('the assign driver action is visible for a confirmed booking and hidden oth
|
|||||||
->assertTableActionHidden('assignDriver', $pending);
|
->assertTableActionHidden('assignDriver', $pending);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the assign driver action is hidden once the travel date has passed', function () {
|
||||||
|
$past = Booking::factory()->create(['status' => BookingStatus::Confirmed, 'travel_date' => today()->subDay()]);
|
||||||
|
$today = Booking::factory()->create(['status' => BookingStatus::Confirmed, 'travel_date' => today()]);
|
||||||
|
|
||||||
|
Livewire::test(ListBookings::class)
|
||||||
|
->assertTableActionHidden('assignDriver', $past)
|
||||||
|
->assertTableActionVisible('assignDriver', $today);
|
||||||
|
});
|
||||||
|
|
||||||
test('the assign driver action is hidden from a user without manage_bookings', function () {
|
test('the assign driver action is hidden from a user without manage_bookings', function () {
|
||||||
$viewer = User::factory()->create()->givePermissionTo('view_bookings');
|
$viewer = User::factory()->create()->givePermissionTo('view_bookings');
|
||||||
$this->actingAs($viewer);
|
$this->actingAs($viewer);
|
||||||
@@ -325,6 +334,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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ use Firebase\JWT\JWT;
|
|||||||
use Modules\Booking\Enums\BookingChannel;
|
use Modules\Booking\Enums\BookingChannel;
|
||||||
use Modules\Booking\Models\Booking;
|
use Modules\Booking\Models\Booking;
|
||||||
use Modules\Catalog\Models\DepartureTimeSlot;
|
use Modules\Catalog\Models\DepartureTimeSlot;
|
||||||
|
use Modules\Payment\Enums\PaymentMethod;
|
||||||
|
use Modules\Payment\Models\Payment;
|
||||||
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;
|
||||||
@@ -59,6 +61,7 @@ test('a FastAPI JWT booking is stored against the verified openid, ignoring a sp
|
|||||||
|
|
||||||
test('a FastAPI JWT can list and show only its own openid\'s bookings', function () {
|
test('a FastAPI JWT can list and show only its own openid\'s bookings', function () {
|
||||||
$mine = Booking::factory()->create(['openid' => 'agent-openid-mine']);
|
$mine = Booking::factory()->create(['openid' => 'agent-openid-mine']);
|
||||||
|
Payment::factory()->completed()->create(['booking_id' => $mine->id, 'gateway' => PaymentMethod::KbzMiniApp]);
|
||||||
Booking::factory()->create(['openid' => 'agent-openid-someone-else']);
|
Booking::factory()->create(['openid' => 'agent-openid-someone-else']);
|
||||||
|
|
||||||
$token = fastApiAgentToken('agent-openid-mine');
|
$token = fastApiAgentToken('agent-openid-mine');
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Modules\Booking\Enums\BookingStatus;
|
||||||
|
use Modules\Booking\Events\DriverAssigned;
|
||||||
|
use Modules\Booking\Listeners\SendDriverAssignedSms;
|
||||||
|
use Modules\Booking\Models\Booking;
|
||||||
|
use Modules\Catalog\Models\Destination;
|
||||||
|
use Modules\Routing\Models\EvRoute;
|
||||||
|
use Modules\Shared\Sms\SmsService;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
config([
|
||||||
|
'app.name' => 'FamousLY4 EV',
|
||||||
|
'app.support_phone' => '+959123456789',
|
||||||
|
'app.support_email' => 'support@famousLY4.test',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a first driver assignment texts the passenger with an "assigned" message including the route', function () {
|
||||||
|
$booking = Booking::factory()->create([
|
||||||
|
'status' => BookingStatus::Confirmed,
|
||||||
|
'passenger_phone' => '+959999888777',
|
||||||
|
'driver_name' => 'U Aung',
|
||||||
|
'driver_phone' => '+959111222333',
|
||||||
|
'car_plate_number' => 'YGN-1234',
|
||||||
|
'car_model' => 'Tesla Model Y',
|
||||||
|
'ev_route_id' => EvRoute::factory()->create([
|
||||||
|
'from_destination_id' => Destination::factory()->create(['name' => 'Yangon'])->id,
|
||||||
|
'to_destination_id' => Destination::factory()->create(['name' => 'Mandalay'])->id,
|
||||||
|
])->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$sms = Mockery::mock(SmsService::class);
|
||||||
|
$sms->shouldReceive('send')
|
||||||
|
->once()
|
||||||
|
->with('+959999888777', Mockery::on(fn (string $message) => str_contains($message, 'assigned')
|
||||||
|
&& str_contains($message, 'U Aung')
|
||||||
|
&& str_contains($message, 'YGN-1234')
|
||||||
|
&& str_contains($message, 'Yangon - Mandalay')
|
||||||
|
&& str_contains($message, config('app.name'))
|
||||||
|
&& str_contains($message, config('app.support_phone'))
|
||||||
|
&& str_contains($message, config('app.support_email'))
|
||||||
|
&& str_contains($message, 'ယာဉ်မောင်း')
|
||||||
|
&& str_contains($message, 'အကူအညီလိုအပ်ပါက ဆက်သွယ်ရန်')));
|
||||||
|
|
||||||
|
(new SendDriverAssignedSms($sms))->handle(new DriverAssigned($booking, isFirstAssignment: true));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a driver reassignment texts the passenger with an "updated" message including the route', function () {
|
||||||
|
$booking = Booking::factory()->create([
|
||||||
|
'status' => BookingStatus::Confirmed,
|
||||||
|
'passenger_phone' => '+959999888777',
|
||||||
|
'driver_name' => 'Daw Hla',
|
||||||
|
'driver_phone' => '+959444555666',
|
||||||
|
'car_plate_number' => 'YGN-5678',
|
||||||
|
'ev_route_id' => EvRoute::factory()->create([
|
||||||
|
'from_destination_id' => Destination::factory()->create(['name' => 'Yangon'])->id,
|
||||||
|
'to_destination_id' => Destination::factory()->create(['name' => 'Mandalay'])->id,
|
||||||
|
])->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$sms = Mockery::mock(SmsService::class);
|
||||||
|
$sms->shouldReceive('send')
|
||||||
|
->once()
|
||||||
|
->with('+959999888777', Mockery::on(fn (string $message) => str_contains($message, 'updated')
|
||||||
|
&& str_contains($message, 'Daw Hla')
|
||||||
|
&& str_contains($message, 'Yangon - Mandalay')
|
||||||
|
&& str_contains($message, config('app.name'))
|
||||||
|
&& str_contains($message, config('app.support_phone'))
|
||||||
|
&& str_contains($message, config('app.support_email'))
|
||||||
|
&& str_contains($message, 'ယာဉ်မောင်း')
|
||||||
|
&& str_contains($message, 'အကူအညီလိုအပ်ပါက ဆက်သွယ်ရန်')));
|
||||||
|
|
||||||
|
(new SendDriverAssignedSms($sms))->handle(new DriverAssigned($booking, isFirstAssignment: false));
|
||||||
|
});
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
use Modules\Booking\Actions\AssignDriverAction;
|
use Modules\Booking\Actions\AssignDriverAction;
|
||||||
use Modules\Booking\Data\AssignDriverData;
|
use Modules\Booking\Data\AssignDriverData;
|
||||||
use Modules\Booking\Enums\BookingStatus;
|
use Modules\Booking\Enums\BookingStatus;
|
||||||
|
use Modules\Booking\Events\DriverAssigned;
|
||||||
use Modules\Booking\Exceptions\DriverAssignmentNotAllowedException;
|
use Modules\Booking\Exceptions\DriverAssignmentNotAllowedException;
|
||||||
use Modules\Booking\Models\Booking;
|
use Modules\Booking\Models\Booking;
|
||||||
|
|
||||||
@@ -23,6 +25,57 @@ test('it assigns driver and car details to a confirmed booking', function () {
|
|||||||
->and($booking->refresh()->driver_name)->toBe('U Aung');
|
->and($booking->refresh()->driver_name)->toBe('U Aung');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('it dispatches DriverAssigned with isFirstAssignment true for a booking with no prior driver', function () {
|
||||||
|
Event::fake([DriverAssigned::class]);
|
||||||
|
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||||
|
|
||||||
|
(new AssignDriverAction)->handle($booking, new AssignDriverData(
|
||||||
|
driverName: 'U Aung',
|
||||||
|
driverPhone: '+959111222333',
|
||||||
|
carPlateNumber: 'YGN-1234',
|
||||||
|
));
|
||||||
|
|
||||||
|
Event::assertDispatched(DriverAssigned::class, fn (DriverAssigned $event) => $event->booking->is($booking) && $event->isFirstAssignment === true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it dispatches DriverAssigned with isFirstAssignment false when reassigning', function () {
|
||||||
|
Event::fake([DriverAssigned::class]);
|
||||||
|
$booking = Booking::factory()->create([
|
||||||
|
'status' => BookingStatus::Confirmed,
|
||||||
|
'driver_name' => 'U Aung',
|
||||||
|
'driver_phone' => '+959111222333',
|
||||||
|
'car_plate_number' => 'YGN-1234',
|
||||||
|
]);
|
||||||
|
|
||||||
|
(new AssignDriverAction)->handle($booking, new AssignDriverData(
|
||||||
|
driverName: 'Daw Hla',
|
||||||
|
driverPhone: '+959444555666',
|
||||||
|
carPlateNumber: 'YGN-5678',
|
||||||
|
));
|
||||||
|
|
||||||
|
Event::assertDispatched(DriverAssigned::class, fn (DriverAssigned $event) => $event->isFirstAssignment === false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it does not dispatch DriverAssigned again when resubmitted with identical driver/car details', function () {
|
||||||
|
Event::fake([DriverAssigned::class]);
|
||||||
|
$booking = Booking::factory()->create([
|
||||||
|
'status' => BookingStatus::Confirmed,
|
||||||
|
'driver_name' => 'U Aung',
|
||||||
|
'driver_phone' => '+959111222333',
|
||||||
|
'car_plate_number' => 'YGN-1234',
|
||||||
|
'car_model' => 'Tesla Model Y',
|
||||||
|
]);
|
||||||
|
|
||||||
|
(new AssignDriverAction)->handle($booking, new AssignDriverData(
|
||||||
|
driverName: 'U Aung',
|
||||||
|
driverPhone: '+959111222333',
|
||||||
|
carPlateNumber: 'YGN-1234',
|
||||||
|
carModel: 'Tesla Model Y',
|
||||||
|
));
|
||||||
|
|
||||||
|
Event::assertNotDispatched(DriverAssigned::class);
|
||||||
|
});
|
||||||
|
|
||||||
test('car_model is optional', function () {
|
test('car_model is optional', function () {
|
||||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||||
|
|
||||||
@@ -47,6 +100,36 @@ test('it guards against assigning a driver to a pending_payment booking', functi
|
|||||||
expect($booking->refresh()->driver_name)->toBeNull();
|
expect($booking->refresh()->driver_name)->toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('it guards against assigning a driver when the travel date has already passed', function () {
|
||||||
|
$booking = Booking::factory()->create([
|
||||||
|
'status' => BookingStatus::Confirmed,
|
||||||
|
'travel_date' => today()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(fn () => (new AssignDriverAction)->handle($booking, new AssignDriverData(
|
||||||
|
driverName: 'U Aung',
|
||||||
|
driverPhone: '+959111222333',
|
||||||
|
carPlateNumber: 'YGN-1234',
|
||||||
|
)))->toThrow(DriverAssignmentNotAllowedException::class);
|
||||||
|
|
||||||
|
expect($booking->refresh()->driver_name)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it allows assigning a driver when the travel date is today', function () {
|
||||||
|
$booking = Booking::factory()->create([
|
||||||
|
'status' => BookingStatus::Confirmed,
|
||||||
|
'travel_date' => today(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$updated = (new AssignDriverAction)->handle($booking, new AssignDriverData(
|
||||||
|
driverName: 'U Aung',
|
||||||
|
driverPhone: '+959111222333',
|
||||||
|
carPlateNumber: 'YGN-1234',
|
||||||
|
));
|
||||||
|
|
||||||
|
expect($updated->driver_name)->toBe('U Aung');
|
||||||
|
});
|
||||||
|
|
||||||
test('it guards against assigning a driver to a cancelled booking', function () {
|
test('it guards against assigning a driver to a cancelled booking', function () {
|
||||||
$booking = Booking::factory()->create(['status' => BookingStatus::Cancelled]);
|
$booking = Booking::factory()->create(['status' => BookingStatus::Cancelled]);
|
||||||
|
|
||||||
@@ -74,3 +157,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,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class EvCompanyResource extends JsonResource
|
|||||||
'mm_description' => $this->mm_description,
|
'mm_description' => $this->mm_description,
|
||||||
'contact' => $this->contact,
|
'contact' => $this->contact,
|
||||||
'address' => $this->address,
|
'address' => $this->address,
|
||||||
'logo' => $this->logo,
|
'logo' => $this->logo_url,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
namespace Modules\Catalog\Models;
|
namespace Modules\Catalog\Models;
|
||||||
|
|
||||||
|
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\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Modules\Catalog\Database\Factories\EvCompanyFactory;
|
use Modules\Catalog\Database\Factories\EvCompanyFactory;
|
||||||
use Spatie\Activitylog\Models\Concerns\LogsActivity;
|
use Spatie\Activitylog\Models\Concerns\LogsActivity;
|
||||||
@@ -72,4 +74,26 @@ class EvCompany extends Model
|
|||||||
'is_active' => 'boolean',
|
'is_active' => 'boolean',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `logo` is stored as the disk-relative path Filament's FileUpload
|
||||||
|
* writes (e.g. "logos/xxx.png"), not a URL — API consumers need a full
|
||||||
|
* absolute URL to render it directly. Guards against the disk itself
|
||||||
|
* already returning an absolute URL (e.g. an s3 disk), so this stays
|
||||||
|
* correct if the storage disk ever changes from local.
|
||||||
|
*/
|
||||||
|
public function logoUrl(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::make(
|
||||||
|
get: function (): ?string {
|
||||||
|
if (blank($this->logo)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = Storage::disk(config('filesystems.default'))->url($this->logo);
|
||||||
|
|
||||||
|
return str($url)->startsWith(['http://', 'https://']) ? $url : url($url);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Modules\Catalog\Models\Destination;
|
use Modules\Catalog\Models\Destination;
|
||||||
use Modules\Catalog\Models\EvCompany;
|
use Modules\Catalog\Models\EvCompany;
|
||||||
|
|
||||||
@@ -19,6 +20,24 @@ test('lists active ev companies', function () {
|
|||||||
->assertJsonFragment(['id' => $active->id]);
|
->assertJsonFragment(['id' => $active->id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('returns the company logo as a full absolute url', function () {
|
||||||
|
$company = EvCompany::factory()->create(['is_active' => true, 'logo' => 'logos/example.png']);
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||||
|
->getJson('/api/v1/companies')
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertJsonFragment(['logo' => url(Storage::disk(config('filesystems.default'))->url($company->logo))]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns a null logo when the company has none', function () {
|
||||||
|
EvCompany::factory()->create(['is_active' => true, 'logo' => null]);
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||||
|
->getJson('/api/v1/companies')
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertJsonFragment(['logo' => null]);
|
||||||
|
});
|
||||||
|
|
||||||
test('lists active destinations', function () {
|
test('lists active destinations', function () {
|
||||||
$active = Destination::factory()->create(['is_active' => true]);
|
$active = Destination::factory()->create(['is_active' => true]);
|
||||||
Destination::factory()->create(['is_active' => false]);
|
Destination::factory()->create(['is_active' => false]);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class RolePermissionSeeder extends Seeder
|
|||||||
'manage_roles',
|
'manage_roles',
|
||||||
'view_customers',
|
'view_customers',
|
||||||
'manage_settings',
|
'manage_settings',
|
||||||
|
'view_reports',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,6 +45,7 @@ class RolePermissionSeeder extends Seeder
|
|||||||
'manage_roles',
|
'manage_roles',
|
||||||
'view_customers',
|
'view_customers',
|
||||||
'manage_settings',
|
'manage_settings',
|
||||||
|
'view_reports',
|
||||||
],
|
],
|
||||||
'admin' => [
|
'admin' => [
|
||||||
'manage_catalog',
|
'manage_catalog',
|
||||||
@@ -56,6 +58,7 @@ class RolePermissionSeeder extends Seeder
|
|||||||
'view_audit_log',
|
'view_audit_log',
|
||||||
'view_customers',
|
'view_customers',
|
||||||
'manage_settings',
|
'manage_settings',
|
||||||
|
'view_reports',
|
||||||
],
|
],
|
||||||
'support' => [
|
'support' => [
|
||||||
'view_bookings',
|
'view_bookings',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace Modules\Identity\Filament\Pages;
|
|||||||
|
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Forms\Components\TagsInput;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
@@ -12,6 +13,7 @@ use Filament\Schemas\Components\Actions;
|
|||||||
use Filament\Schemas\Components\Form;
|
use Filament\Schemas\Components\Form;
|
||||||
use Filament\Schemas\Components\Tabs;
|
use Filament\Schemas\Components\Tabs;
|
||||||
use Filament\Schemas\Components\Tabs\Tab;
|
use Filament\Schemas\Components\Tabs\Tab;
|
||||||
|
use Filament\Schemas\Components\Utilities\Get;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Support\Icons\Heroicon;
|
use Filament\Support\Icons\Heroicon;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
@@ -61,6 +63,11 @@ class ManageAppSettings extends Page
|
|||||||
'back_seat_enabled' => (bool) config('booking.back_seat_enabled'),
|
'back_seat_enabled' => (bool) config('booking.back_seat_enabled'),
|
||||||
'whole_vehicle_enabled' => (bool) config('booking.whole_vehicle_enabled'),
|
'whole_vehicle_enabled' => (bool) config('booking.whole_vehicle_enabled'),
|
||||||
'front_seat_max_per_booking' => config('booking.front_seat_max_per_booking'),
|
'front_seat_max_per_booking' => config('booking.front_seat_max_per_booking'),
|
||||||
|
'booking_admin_emails' => config('booking.admin_emails'),
|
||||||
|
'sms_enabled' => (bool) config('services.sms.enabled'),
|
||||||
|
'sms_server' => config('services.sms.sms_poh.server'),
|
||||||
|
'sms_token' => config('services.sms.sms_poh.token'),
|
||||||
|
'sms_sender' => config('services.sms.sms_poh.sender'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +118,34 @@ class ManageAppSettings extends Page
|
|||||||
->minValue(1)
|
->minValue(1)
|
||||||
->required()
|
->required()
|
||||||
->helperText('Max Front Seats a single booking may request.'),
|
->helperText('Max Front Seats a single booking may request.'),
|
||||||
|
TagsInput::make('booking_admin_emails')
|
||||||
|
->label('Admin Emails')
|
||||||
|
->required()
|
||||||
|
->helperText('Notified on booking events. Press enter after each address.'),
|
||||||
]),
|
]),
|
||||||
|
Tab::make('SMS')
|
||||||
|
->schema([
|
||||||
|
Toggle::make('sms_enabled')
|
||||||
|
->label('SMS Enabled')
|
||||||
|
->live()
|
||||||
|
->helperText('Whether driver/car SMS notifications are sent at all.'),
|
||||||
|
TextInput::make('sms_server')
|
||||||
|
->label('SMS Server URL')
|
||||||
|
->url()
|
||||||
|
->maxLength(255)
|
||||||
|
->required(fn (Get $get): bool => (bool) $get('sms_enabled')),
|
||||||
|
TextInput::make('sms_token')
|
||||||
|
->label('SMS Token')
|
||||||
|
->password()
|
||||||
|
->revealable()
|
||||||
|
->maxLength(255)
|
||||||
|
->required(fn (Get $get): bool => (bool) $get('sms_enabled')),
|
||||||
|
TextInput::make('sms_sender')
|
||||||
|
->label('SMS Sender')
|
||||||
|
->maxLength(255)
|
||||||
|
->helperText('Default sender name/number for outgoing SMS.'),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
]),
|
]),
|
||||||
])
|
])
|
||||||
->livewireSubmitHandler('save')
|
->livewireSubmitHandler('save')
|
||||||
@@ -139,6 +173,11 @@ class ManageAppSettings extends Page
|
|||||||
'BOOKING_BACK_SEAT_ENABLED' => (bool) $state['back_seat_enabled'],
|
'BOOKING_BACK_SEAT_ENABLED' => (bool) $state['back_seat_enabled'],
|
||||||
'BOOKING_WHOLE_VEHICLE_ENABLED' => (bool) $state['whole_vehicle_enabled'],
|
'BOOKING_WHOLE_VEHICLE_ENABLED' => (bool) $state['whole_vehicle_enabled'],
|
||||||
'BOOKING_FRONT_SEAT_MAX_PER_BOOKING' => (int) $state['front_seat_max_per_booking'],
|
'BOOKING_FRONT_SEAT_MAX_PER_BOOKING' => (int) $state['front_seat_max_per_booking'],
|
||||||
|
'BOOKING_ADMIN_EMAILS' => implode(',', $state['booking_admin_emails'] ?? []),
|
||||||
|
'SMS_ENABLED' => (bool) $state['sms_enabled'],
|
||||||
|
'SMS_SERVER' => $state['sms_server'],
|
||||||
|
'SMS_TOKEN' => $state['sms_token'],
|
||||||
|
'SMS_SENDER' => $state['sms_sender'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Artisan::call('config:clear');
|
Artisan::call('config:clear');
|
||||||
|
|||||||
@@ -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,17 +64,34 @@ 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]);
|
||||||
|
Payment::factory()->completed()->create(['booking_id' => $booking->id, 'gateway' => PaymentMethod::KbzMiniApp]);
|
||||||
|
|
||||||
$this->withHeader('Authorization', "Bearer {$this->agentToken}")
|
$this->withHeader('Authorization', "Bearer {$this->agentToken}")
|
||||||
->getJson('/api/v1/bookings')
|
->getJson('/api/v1/bookings')
|
||||||
|
|||||||
@@ -28,6 +28,37 @@ test('a booking status transition is recorded in the audit log', function () {
|
|||||||
expect($activity->attribute_changes->get('attributes'))->toMatchArray(['status' => BookingStatus::Confirmed->value]);
|
expect($activity->attribute_changes->get('attributes'))->toMatchArray(['status' => BookingStatus::Confirmed->value]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('assigning a driver is recorded in the audit log with who and when', function () {
|
||||||
|
$dispatcher = User::factory()->create();
|
||||||
|
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||||
|
|
||||||
|
$this->actingAs($dispatcher);
|
||||||
|
|
||||||
|
$booking->update([
|
||||||
|
'driver_name' => 'U Aung',
|
||||||
|
'driver_phone' => '+959111222333',
|
||||||
|
'car_plate_number' => 'YGN-1234',
|
||||||
|
'car_model' => 'Tesla Model Y',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$activity = Activity::where('subject_type', Booking::class)
|
||||||
|
->where('subject_id', $booking->id)
|
||||||
|
->where('log_name', 'booking')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($activity)->not->toBeNull();
|
||||||
|
expect($activity->attribute_changes->get('attributes'))->toMatchArray([
|
||||||
|
'driver_name' => 'U Aung',
|
||||||
|
'driver_phone' => '+959111222333',
|
||||||
|
'car_plate_number' => 'YGN-1234',
|
||||||
|
'car_model' => 'Tesla Model Y',
|
||||||
|
]);
|
||||||
|
expect($activity->causer_type)->toBe(User::class);
|
||||||
|
expect($activity->causer_id)->toBe($dispatcher->id);
|
||||||
|
expect($activity->created_at)->not->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
test('a catalog CRUD write is recorded in the audit log', function () {
|
test('a catalog CRUD write is recorded in the audit log', function () {
|
||||||
$company = EvCompany::factory()->create(['name' => 'Original Name']);
|
$company = EvCompany::factory()->create(['name' => 'Original Name']);
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ test('a super_admin can view and save app settings, writing them to .env', funct
|
|||||||
'back_seat_enabled' => false,
|
'back_seat_enabled' => false,
|
||||||
'whole_vehicle_enabled' => true,
|
'whole_vehicle_enabled' => true,
|
||||||
'front_seat_max_per_booking' => 2,
|
'front_seat_max_per_booking' => 2,
|
||||||
|
'booking_admin_emails' => ['ops@evbooking.test', 'dispatch@evbooking.test'],
|
||||||
|
'sms_enabled' => true,
|
||||||
|
'sms_server' => 'https://sms.example.test/send',
|
||||||
|
'sms_token' => 'secret-token',
|
||||||
|
'sms_sender' => 'EVBooking',
|
||||||
])
|
])
|
||||||
->call('save')
|
->call('save')
|
||||||
->assertHasNoFormErrors();
|
->assertHasNoFormErrors();
|
||||||
@@ -56,7 +61,23 @@ test('a super_admin can view and save app settings, writing them to .env', funct
|
|||||||
->toContain('APP_CURRENCY=MMK')
|
->toContain('APP_CURRENCY=MMK')
|
||||||
->toContain('BOOKING_BACK_SEAT_ENABLED=false')
|
->toContain('BOOKING_BACK_SEAT_ENABLED=false')
|
||||||
->toContain('BOOKING_WHOLE_VEHICLE_ENABLED=true')
|
->toContain('BOOKING_WHOLE_VEHICLE_ENABLED=true')
|
||||||
->toContain('BOOKING_FRONT_SEAT_MAX_PER_BOOKING=2');
|
->toContain('BOOKING_FRONT_SEAT_MAX_PER_BOOKING=2')
|
||||||
|
->toContain('BOOKING_ADMIN_EMAILS=ops@evbooking.test,dispatch@evbooking.test')
|
||||||
|
->toContain('SMS_ENABLED=true')
|
||||||
|
->toContain('SMS_SERVER=https://sms.example.test/send')
|
||||||
|
->toContain('SMS_TOKEN=secret-token')
|
||||||
|
->toContain('SMS_SENDER=EVBooking');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sms server and token are required once sms is enabled', function () {
|
||||||
|
$superAdmin = User::factory()->create();
|
||||||
|
$superAdmin->assignRole('super_admin');
|
||||||
|
$this->actingAs($superAdmin);
|
||||||
|
|
||||||
|
Livewire::test(ManageAppSettings::class)
|
||||||
|
->fillForm(['sms_enabled' => true, 'sms_server' => '', 'sms_token' => ''])
|
||||||
|
->call('save')
|
||||||
|
->assertHasFormErrors(['sms_server', 'sms_token']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('front seat max per booking must be at least 1', function () {
|
test('front seat max per booking must be at least 1', function () {
|
||||||
|
|||||||
+42
@@ -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
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -2,15 +2,10 @@
|
|||||||
|
|
||||||
namespace Modules\Payment\Providers;
|
namespace Modules\Payment\Providers;
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Event;
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Modules\Payment\Enums\PaymentMethod;
|
use Modules\Payment\Enums\PaymentMethod;
|
||||||
use Modules\Payment\Events\PaymentCompleted;
|
|
||||||
use Modules\Payment\Events\RefundProcessed;
|
|
||||||
use Modules\Payment\Factories\PaymentGatewayFactory;
|
use Modules\Payment\Factories\PaymentGatewayFactory;
|
||||||
use Modules\Payment\Gateways\KbzMiniAppGateway;
|
use Modules\Payment\Gateways\KbzMiniAppGateway;
|
||||||
use Modules\Payment\Listeners\MarkBookingPaid;
|
|
||||||
use Modules\Payment\Listeners\MarkBookingRefunded;
|
|
||||||
use Modules\Payment\Models\Payment;
|
use Modules\Payment\Models\Payment;
|
||||||
use Modules\Payment\Observers\PaymentObserver;
|
use Modules\Payment\Observers\PaymentObserver;
|
||||||
|
|
||||||
@@ -28,9 +23,11 @@ class PaymentServiceProvider extends ServiceProvider
|
|||||||
|
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
Event::listen(PaymentCompleted::class, MarkBookingPaid::class);
|
// MarkBookingPaid/MarkBookingRefunded are auto-discovered by
|
||||||
Event::listen(RefundProcessed::class, MarkBookingRefunded::class);
|
// internachi/modular's EventsPlugin (any Listeners/*.php with a
|
||||||
|
// handle(SomeEvent $event) signature) — registering them here too
|
||||||
|
// used to double-dispatch both listeners (see DriverAssigned's
|
||||||
|
// BookingServiceProvider for the same fix).
|
||||||
Payment::observe(PaymentObserver::class);
|
Payment::observe(PaymentObserver::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "modules/reporting",
|
||||||
|
"description": "",
|
||||||
|
"type": "library",
|
||||||
|
"version": "1.0",
|
||||||
|
"license": "proprietary",
|
||||||
|
"require": {
|
||||||
|
"maatwebsite/excel": "^4.0"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Modules\\Reporting\\": "src/",
|
||||||
|
"Modules\\Reporting\\Tests\\": "tests/",
|
||||||
|
"Modules\\Reporting\\Database\\Factories\\": "database/factories/",
|
||||||
|
"Modules\\Reporting\\Database\\Seeders\\": "database/seeders/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"minimum-stability": "stable",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"providers": [
|
||||||
|
"Modules\\Reporting\\Providers\\ReportingServiceProvider"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
@@ -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
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Supports the Bookings & Revenue report's filters — travel_date/status/
|
||||||
|
* created_by_channel on bookings and completed_at on payments had no
|
||||||
|
* standalone index before this (only openid and the composite
|
||||||
|
* [ev_route_id, travel_date, departure_time_slot_id] existed).
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('bookings', function (Blueprint $table) {
|
||||||
|
$table->index('travel_date');
|
||||||
|
$table->index('status');
|
||||||
|
$table->index('created_by_channel');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('payments', function (Blueprint $table) {
|
||||||
|
$table->index('completed_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('bookings', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['travel_date']);
|
||||||
|
$table->dropIndex(['status']);
|
||||||
|
$table->dropIndex(['created_by_channel']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('payments', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['completed_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<x-filament-panels::page>
|
||||||
|
{{ $this->filtersForm }}
|
||||||
|
|
||||||
|
{{ $this->table }}
|
||||||
|
</x-filament-panels::page>
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Reporting\Exports;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Maatwebsite\Excel\Concerns\FromQuery;
|
||||||
|
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithCustomCsvSettings;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithEvents;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithMapping;
|
||||||
|
use Maatwebsite\Excel\Events\AfterSheet;
|
||||||
|
use Modules\Booking\Models\Booking;
|
||||||
|
use Modules\Payment\Enums\PaymentStatus;
|
||||||
|
use Modules\Payment\Models\Payment;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row per Booking, with its "best" Payment (completed, else most recent)
|
||||||
|
* joined on, plus a bold TOTAL row summing passenger count, price, and
|
||||||
|
* payment amount. Backs both the CSV and Excel exports of the Bookings &
|
||||||
|
* Revenue report — Excel::download() picks the writer, this class supplies
|
||||||
|
* the columns once for both formats.
|
||||||
|
*/
|
||||||
|
class BookingsRevenueExport implements FromQuery, ShouldAutoSize, WithCustomCsvSettings, WithEvents, WithHeadings, WithMapping
|
||||||
|
{
|
||||||
|
public function __construct(private readonly Builder $query) {}
|
||||||
|
|
||||||
|
public function query(): Builder
|
||||||
|
{
|
||||||
|
return $this->query;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function headings(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'Booking Ref', 'Travel Date', 'Route', 'Channel', 'Status',
|
||||||
|
'Passenger Name', 'Passenger Count', 'Price', 'Payment Status',
|
||||||
|
'Payment Amount', 'Driver Name', 'Driver Phone', 'Car Plate',
|
||||||
|
'Car Model', 'Vehicle Options',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, mixed>
|
||||||
|
*/
|
||||||
|
public function map($booking): array
|
||||||
|
{
|
||||||
|
/** @var Booking $booking */
|
||||||
|
$payment = $this->bestPayment($booking);
|
||||||
|
|
||||||
|
return [
|
||||||
|
$booking->booking_ref,
|
||||||
|
$booking->travel_date?->toDateString(),
|
||||||
|
$booking->route ? $booking->route->name : '',
|
||||||
|
$booking->created_by_channel?->value,
|
||||||
|
$booking->status->value,
|
||||||
|
$booking->passenger_name,
|
||||||
|
$booking->vehicleOptions->sum('passenger_count'),
|
||||||
|
(float) $booking->price,
|
||||||
|
$payment?->status?->value ?? '',
|
||||||
|
$payment ? (float) $payment->amount : null,
|
||||||
|
$booking->driver_name,
|
||||||
|
$booking->driver_phone,
|
||||||
|
$booking->car_plate_number,
|
||||||
|
$booking->car_model,
|
||||||
|
$booking->vehicleOptions
|
||||||
|
->map(fn ($v) => str($v->vehicle_option->value)->headline().' x'.$v->passenger_count)
|
||||||
|
->implode('; '),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends a bold TOTAL row (passenger count, price, payment amount)
|
||||||
|
* below the last data row. Re-fetches the already-filtered query rather
|
||||||
|
* than accumulating during map() — FromQuery streams rows in chunks, so
|
||||||
|
* there's no single point with the full result set to total as it's
|
||||||
|
* written; report-sized result sets make a second fetch cheap enough to
|
||||||
|
* trade for keeping the chunked write untouched.
|
||||||
|
*
|
||||||
|
* @return array<string, callable>
|
||||||
|
*/
|
||||||
|
public function registerEvents(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
AfterSheet::class => function (AfterSheet $event): void {
|
||||||
|
$bookings = (clone $this->query)->get();
|
||||||
|
|
||||||
|
$totalPassengers = $bookings->sum(fn (Booking $b) => $b->vehicleOptions->sum('passenger_count'));
|
||||||
|
$totalPrice = $bookings->sum('price');
|
||||||
|
$totalPaid = $bookings->sum(fn (Booking $b) => $this->bestPayment($b)?->amount ?? 0);
|
||||||
|
|
||||||
|
$worksheet = $event->getDelegate();
|
||||||
|
$row = $worksheet->getHighestRow() + 1;
|
||||||
|
$lastColumn = Coordinate::stringFromColumnIndex(count($this->headings()));
|
||||||
|
|
||||||
|
$worksheet->setCellValue("A{$row}", 'TOTAL');
|
||||||
|
$worksheet->setCellValue($this->columnFor('Passenger Count').$row, $totalPassengers);
|
||||||
|
$worksheet->setCellValue($this->columnFor('Price').$row, $totalPrice);
|
||||||
|
$worksheet->setCellValue($this->columnFor('Payment Amount').$row, $totalPaid);
|
||||||
|
$worksheet->getStyle("A{$row}:{$lastColumn}{$row}")->getFont()->setBold(true);
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function columnFor(string $heading): string
|
||||||
|
{
|
||||||
|
return Coordinate::stringFromColumnIndex(array_search($heading, $this->headings(), true) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function bestPayment(Booking $booking): ?Payment
|
||||||
|
{
|
||||||
|
return $booking->payments->sortByDesc(
|
||||||
|
fn (Payment $p) => $p->status === PaymentStatus::Completed ? 1 : 0
|
||||||
|
)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Excel's CSV import guesses encoding from the system locale unless a
|
||||||
|
* UTF-8 BOM is present, so passenger names/routes containing Burmese
|
||||||
|
* (or other non-Latin) text open correctly instead of as mojibake.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function getCsvSettings(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'use_bom' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Reporting\Filament\Pages;
|
||||||
|
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Forms\Components\DatePicker;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Filament\Tables\Columns\Summarizers\Sum;
|
||||||
|
use Filament\Tables\Columns\Summarizers\Summarizer;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
|
use Filament\Tables\Contracts\HasTable;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Query\Builder as QueryBuilder;
|
||||||
|
use Maatwebsite\Excel\Excel as ExcelFormat;
|
||||||
|
use Maatwebsite\Excel\Facades\Excel;
|
||||||
|
use Modules\Booking\Enums\BookingChannel;
|
||||||
|
use Modules\Booking\Enums\BookingStatus;
|
||||||
|
use Modules\Booking\Models\Booking;
|
||||||
|
use Modules\Payment\Enums\PaymentStatus;
|
||||||
|
use Modules\Payment\Models\Payment;
|
||||||
|
use Modules\Reporting\Exports\BookingsRevenueExport;
|
||||||
|
use Modules\Routing\Models\EvRoute;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class BookingsRevenueReport extends Page implements HasTable
|
||||||
|
{
|
||||||
|
use InteractsWithTable;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedDocumentChartBar;
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Reports';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Bookings & Revenue';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Bookings & Revenue Report';
|
||||||
|
|
||||||
|
protected string $view = 'reporting::filament.pages.bookings-revenue-report';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public ?array $filters = [];
|
||||||
|
|
||||||
|
public static function canAccess(): bool
|
||||||
|
{
|
||||||
|
return auth()->user()?->can('view_reports') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->filtersForm->fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function filtersForm(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->components([
|
||||||
|
DatePicker::make('travel_date_from')
|
||||||
|
->label('Travel date from')
|
||||||
|
->live(),
|
||||||
|
DatePicker::make('travel_date_to')
|
||||||
|
->label('Travel date to')
|
||||||
|
->afterOrEqual('travel_date_from')
|
||||||
|
->live(),
|
||||||
|
Select::make('status')
|
||||||
|
->label('Status')
|
||||||
|
->options(BookingStatus::class)
|
||||||
|
->native(false)
|
||||||
|
->placeholder('All statuses')
|
||||||
|
->live(),
|
||||||
|
Select::make('ev_route_id')
|
||||||
|
->label('Route')
|
||||||
|
->options(fn () => EvRoute::with(['fromDestination', 'toDestination'])->get()
|
||||||
|
->mapWithKeys(fn (EvRoute $route) => [$route->id => $route->name]))
|
||||||
|
->searchable()
|
||||||
|
->placeholder('All routes')
|
||||||
|
->live(),
|
||||||
|
Select::make('created_by_channel')
|
||||||
|
->label('Channel')
|
||||||
|
->options(BookingChannel::class)
|
||||||
|
->native(false)
|
||||||
|
->placeholder('All channels')
|
||||||
|
->live(),
|
||||||
|
])
|
||||||
|
->columns(3)
|
||||||
|
->statePath('filters');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->query(fn (): Builder => $this->reportQuery())
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('booking_ref')
|
||||||
|
->label('Ref')
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('travel_date')
|
||||||
|
->date()
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('route.name')
|
||||||
|
->label('Route'),
|
||||||
|
TextColumn::make('created_by_channel')
|
||||||
|
->badge(),
|
||||||
|
TextColumn::make('status')
|
||||||
|
->badge(),
|
||||||
|
TextColumn::make('passenger_name')
|
||||||
|
->label('Passenger'),
|
||||||
|
TextColumn::make('passenger_count')
|
||||||
|
->label('Pax')
|
||||||
|
->state(fn (Booking $record) => $record->vehicleOptions->sum('passenger_count'))
|
||||||
|
->summarize(Summarizer::make()
|
||||||
|
->label('Total')
|
||||||
|
->using(fn (QueryBuilder $query) => Booking::query()
|
||||||
|
->with('vehicleOptions')
|
||||||
|
->whereIn('id', (clone $query)->pluck('id'))
|
||||||
|
->get()
|
||||||
|
->sum(fn (Booking $b) => $b->vehicleOptions->sum('passenger_count')))),
|
||||||
|
TextColumn::make('price')
|
||||||
|
->numeric(2)
|
||||||
|
->sortable()
|
||||||
|
->summarize(Sum::make()->label('Total')),
|
||||||
|
TextColumn::make('payment_status')
|
||||||
|
->label('Payment')
|
||||||
|
->state(fn (Booking $record) => $this->bestPayment($record)?->status?->value ?? '—'),
|
||||||
|
TextColumn::make('payment_amount')
|
||||||
|
->label('Paid')
|
||||||
|
->state(fn (Booking $record) => $this->bestPayment($record)?->amount)
|
||||||
|
->summarize(Summarizer::make()
|
||||||
|
->label('Total')
|
||||||
|
->using(fn (QueryBuilder $query) => Booking::query()
|
||||||
|
->with('payments')
|
||||||
|
->whereIn('id', (clone $query)->pluck('id'))
|
||||||
|
->get()
|
||||||
|
->sum(fn (Booking $b) => $this->bestPayment($b)?->amount ?? 0))),
|
||||||
|
TextColumn::make('driver_name')
|
||||||
|
->label('Driver')
|
||||||
|
->placeholder('—'),
|
||||||
|
])
|
||||||
|
->defaultSort('travel_date', 'desc')
|
||||||
|
->paginated([25, 50, 100]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reportQuery(): Builder
|
||||||
|
{
|
||||||
|
$data = $this->filters ?? [];
|
||||||
|
|
||||||
|
return Booking::query()
|
||||||
|
->with(['route.fromDestination', 'route.toDestination', 'payments', 'vehicleOptions'])
|
||||||
|
->when($data['travel_date_from'] ?? null, fn (Builder $q, $d) => $q->whereDate('travel_date', '>=', $d))
|
||||||
|
->when($data['travel_date_to'] ?? null, fn (Builder $q, $d) => $q->whereDate('travel_date', '<=', $d))
|
||||||
|
->when($data['status'] ?? null, fn (Builder $q, $s) => $q->where('status', $s))
|
||||||
|
->when($data['ev_route_id'] ?? null, fn (Builder $q, $id) => $q->where('ev_route_id', $id))
|
||||||
|
->when($data['created_by_channel'] ?? null, fn (Builder $q, $c) => $q->where('created_by_channel', $c))
|
||||||
|
// A unique tie-breaker after travel_date — required for FromQuery's
|
||||||
|
// chunked export to paginate deterministically (see its docblock);
|
||||||
|
// the table's own defaultSort() applies on top of this for display.
|
||||||
|
->orderBy('travel_date', 'desc')
|
||||||
|
->orderBy('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function bestPayment(Booking $record): ?Payment
|
||||||
|
{
|
||||||
|
return $record->payments->sortByDesc(
|
||||||
|
fn (Payment $p) => $p->status === PaymentStatus::Completed ? 1 : 0
|
||||||
|
)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Action::make('exportCsv')
|
||||||
|
->label('Export CSV')
|
||||||
|
->icon(Heroicon::OutlinedArrowDownTray)
|
||||||
|
->action(fn () => Excel::download(
|
||||||
|
new BookingsRevenueExport($this->reportQuery()),
|
||||||
|
'bookings-revenue-'.now()->format('Y-m-d').'.csv',
|
||||||
|
ExcelFormat::CSV,
|
||||||
|
)),
|
||||||
|
Action::make('exportXlsx')
|
||||||
|
->label('Export Excel')
|
||||||
|
->icon(Heroicon::OutlinedArrowDownTray)
|
||||||
|
->action(fn () => Excel::download(
|
||||||
|
new BookingsRevenueExport($this->reportQuery()),
|
||||||
|
'bookings-revenue-'.now()->format('Y-m-d').'.xlsx',
|
||||||
|
ExcelFormat::XLSX,
|
||||||
|
)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Reporting\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
|
class ReportingServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
public function register(): void {}
|
||||||
|
|
||||||
|
public function boot(): void {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Reporting;
|
||||||
|
|
||||||
|
use Filament\Contracts\Plugin;
|
||||||
|
use Filament\Panel;
|
||||||
|
|
||||||
|
class ReportingPlugin implements Plugin
|
||||||
|
{
|
||||||
|
public function getId(): string
|
||||||
|
{
|
||||||
|
return 'reporting';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register(Panel $panel): void
|
||||||
|
{
|
||||||
|
$panel->discoverPages(
|
||||||
|
in: __DIR__.'/Filament/Pages',
|
||||||
|
for: 'Modules\Reporting\Filament\Pages',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function boot(Panel $panel): void {}
|
||||||
|
|
||||||
|
public static function make(): static
|
||||||
|
{
|
||||||
|
return app(static::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Maatwebsite\Excel\Excel as ExcelFormat;
|
||||||
|
use Maatwebsite\Excel\Facades\Excel;
|
||||||
|
use Modules\Booking\Enums\BookingStatus;
|
||||||
|
use Modules\Booking\Models\Booking;
|
||||||
|
use Modules\Reporting\Exports\BookingsRevenueExport;
|
||||||
|
use Modules\Reporting\Filament\Pages\BookingsRevenueReport;
|
||||||
|
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||||
|
use Spatie\Permission\Models\Permission;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
Permission::findOrCreate('view_reports', 'web');
|
||||||
|
|
||||||
|
$this->admin = User::factory()->create()->givePermissionTo(['view_reports']);
|
||||||
|
$this->actingAs($this->admin);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it renders for a user with view_reports', function () {
|
||||||
|
Livewire::test(BookingsRevenueReport::class)->assertOk();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a user without view_reports cannot access it', function () {
|
||||||
|
$this->actingAs(User::factory()->create());
|
||||||
|
|
||||||
|
expect(BookingsRevenueReport::canAccess())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it narrows results by travel date range and status', function () {
|
||||||
|
$inRange = Booking::factory()->create(['travel_date' => today(), 'status' => BookingStatus::Confirmed]);
|
||||||
|
$outOfRange = Booking::factory()->create(['travel_date' => today()->addMonths(2), 'status' => BookingStatus::Confirmed]);
|
||||||
|
$wrongStatus = Booking::factory()->create(['travel_date' => today(), 'status' => BookingStatus::Cancelled]);
|
||||||
|
|
||||||
|
Livewire::test(BookingsRevenueReport::class)
|
||||||
|
->fillForm([
|
||||||
|
'travel_date_from' => today()->toDateString(),
|
||||||
|
'travel_date_to' => today()->toDateString(),
|
||||||
|
'status' => BookingStatus::Confirmed->value,
|
||||||
|
], 'filtersForm')
|
||||||
|
->assertCanSeeTableRecords([$inRange])
|
||||||
|
->assertCanNotSeeTableRecords([$outOfRange, $wrongStatus]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exporting csv triggers a download', function () {
|
||||||
|
Excel::fake();
|
||||||
|
|
||||||
|
Booking::factory()->create();
|
||||||
|
|
||||||
|
Livewire::test(BookingsRevenueReport::class)->callAction('exportCsv');
|
||||||
|
|
||||||
|
Excel::assertDownloaded('bookings-revenue-'.now()->format('Y-m-d').'.csv');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exporting excel triggers a download', function () {
|
||||||
|
Excel::fake();
|
||||||
|
|
||||||
|
Booking::factory()->create();
|
||||||
|
|
||||||
|
Livewire::test(BookingsRevenueReport::class)->callAction('exportXlsx');
|
||||||
|
|
||||||
|
Excel::assertDownloaded('bookings-revenue-'.now()->format('Y-m-d').'.xlsx');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the export includes passenger name/count columns and a total row', function () {
|
||||||
|
$a = Booking::factory()->create(['passenger_name' => 'Jane Doe', 'price' => 10000]);
|
||||||
|
$a->vehicleOptions()->create(['vehicle_option' => 'back_seat', 'passenger_count' => 2, 'unit_price' => 5000, 'line_total' => 10000]);
|
||||||
|
|
||||||
|
$b = Booking::factory()->create(['passenger_name' => 'John Roe', 'price' => 15000]);
|
||||||
|
$b->vehicleOptions()->create(['vehicle_option' => 'back_seat', 'passenger_count' => 3, 'unit_price' => 5000, 'line_total' => 15000]);
|
||||||
|
|
||||||
|
$export = new BookingsRevenueExport(Booking::query()->with(['route.fromDestination', 'route.toDestination', 'payments', 'vehicleOptions']));
|
||||||
|
|
||||||
|
$path = storage_path('app/test-bookings-revenue.xlsx');
|
||||||
|
file_put_contents($path, Excel::raw($export, ExcelFormat::XLSX));
|
||||||
|
|
||||||
|
$sheet = IOFactory::load($path)->getActiveSheet();
|
||||||
|
unlink($path);
|
||||||
|
|
||||||
|
expect($sheet->getCell('F1')->getValue())->toBe('Passenger Name')
|
||||||
|
->and($sheet->getCell('G1')->getValue())->toBe('Passenger Count')
|
||||||
|
->and([$sheet->getCell('F2')->getValue(), $sheet->getCell('F3')->getValue()])->toContain('Jane Doe', 'John Roe');
|
||||||
|
|
||||||
|
$totalRow = $sheet->getHighestRow();
|
||||||
|
expect($sheet->getCell("A{$totalRow}")->getValue())->toBe('TOTAL')
|
||||||
|
->and((int) $sheet->getCell("G{$totalRow}")->getValue())->toBe(5) // 2 + 3 passengers
|
||||||
|
->and((float) $sheet->getCell("H{$totalRow}")->getValue())->toBe(25000.0); // 10000 + 15000 price
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+29
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
+31
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
+31
@@ -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,74 +17,88 @@ class EvRouteForm
|
|||||||
{
|
{
|
||||||
return $schema
|
return $schema
|
||||||
->components([
|
->components([
|
||||||
Select::make('ev_company_id')
|
Section::make('Route')
|
||||||
->label('EV Company')
|
|
||||||
->relationship('company', 'name')
|
|
||||||
->required()
|
|
||||||
->searchable()
|
|
||||||
->preload(),
|
|
||||||
Select::make('from_destination_id')
|
|
||||||
->label('From')
|
|
||||||
->relationship('fromDestination', 'name')
|
|
||||||
->required()
|
|
||||||
->searchable()
|
|
||||||
->preload(),
|
|
||||||
Select::make('to_destination_id')
|
|
||||||
->label('To')
|
|
||||||
->relationship('toDestination', 'name')
|
|
||||||
->required()
|
|
||||||
->searchable()
|
|
||||||
->preload()
|
|
||||||
->different('from_destination_id')
|
|
||||||
->validationMessages([
|
|
||||||
'different' => 'The destination must be different from the origin.',
|
|
||||||
]),
|
|
||||||
Select::make('timeSlots')
|
|
||||||
->label('Departure Time Slots')
|
|
||||||
->relationship('timeSlots', 'label')
|
|
||||||
->multiple()
|
|
||||||
->searchable()
|
|
||||||
->preload(),
|
|
||||||
Toggle::make('is_round_trip')
|
|
||||||
->required()
|
|
||||||
->default(false),
|
|
||||||
Toggle::make('is_active')
|
|
||||||
->required()
|
|
||||||
->default(false)
|
|
||||||
->helperText('Every non-blocked vehicle option must have a price above 0 before a route can be activated.'),
|
|
||||||
Repeater::make('pricing')
|
|
||||||
->relationship()
|
|
||||||
->label('Pricing')
|
|
||||||
->schema([
|
->schema([
|
||||||
Select::make('vehicle_option')
|
Grid::make(2)
|
||||||
->options(array_combine(
|
->schema([
|
||||||
array_map(fn (VehicleOption $option) => $option->value, VehicleOption::cases()),
|
Select::make('ev_company_id')
|
||||||
array_map(fn (VehicleOption $option) => str($option->value)->headline()->toString(), VehicleOption::cases()),
|
->label('EV Company')
|
||||||
))
|
->relationship('company', 'name')
|
||||||
->disabled()
|
->required()
|
||||||
->dehydrated()
|
->searchable()
|
||||||
->required(),
|
->preload(),
|
||||||
TextInput::make('price')
|
Select::make('from_destination_id')
|
||||||
->numeric()
|
->label('From')
|
||||||
->minValue(0)
|
->relationship('fromDestination', 'name')
|
||||||
->required(),
|
->required()
|
||||||
Toggle::make('is_blocked')
|
->searchable()
|
||||||
->label('Blocked')
|
->preload(),
|
||||||
->helperText('Hidden from booking regardless of price.'),
|
Select::make('to_destination_id')
|
||||||
|
->label('To')
|
||||||
|
->relationship('toDestination', 'name')
|
||||||
|
->required()
|
||||||
|
->searchable()
|
||||||
|
->preload()
|
||||||
|
->different('from_destination_id')
|
||||||
|
->validationMessages([
|
||||||
|
'different' => 'The destination must be different from the origin.',
|
||||||
|
]),
|
||||||
|
Select::make('timeSlots')
|
||||||
|
->label('Departure Time Slots')
|
||||||
|
->relationship('timeSlots', 'label')
|
||||||
|
->multiple()
|
||||||
|
->searchable()
|
||||||
|
->preload(),
|
||||||
|
]),
|
||||||
])
|
])
|
||||||
->columns(3)
|
->columnSpanFull(),
|
||||||
->default(
|
Section::make('Options')
|
||||||
collect(VehicleOption::cases())
|
->schema([
|
||||||
->map(fn (VehicleOption $option) => [
|
Grid::make(2)
|
||||||
'vehicle_option' => $option->value,
|
->schema([
|
||||||
'price' => 0,
|
Toggle::make('is_active')
|
||||||
'is_blocked' => false,
|
->required()
|
||||||
|
->default(false)
|
||||||
|
->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')
|
||||||
|
->relationship()
|
||||||
|
->hiddenLabel()
|
||||||
|
->schema([
|
||||||
|
Select::make('vehicle_option')
|
||||||
|
->options(array_combine(
|
||||||
|
array_map(fn (VehicleOption $option) => $option->value, VehicleOption::cases()),
|
||||||
|
array_map(fn (VehicleOption $option) => str($option->value)->headline()->toString(), VehicleOption::cases()),
|
||||||
|
))
|
||||||
|
->disabled()
|
||||||
|
->dehydrated()
|
||||||
|
->required(),
|
||||||
|
TextInput::make('price')
|
||||||
|
->numeric()
|
||||||
|
->minValue(0)
|
||||||
|
->required(),
|
||||||
|
Toggle::make('is_blocked')
|
||||||
|
->label('Blocked')
|
||||||
|
->helperText('Hidden from booking regardless of price.'),
|
||||||
])
|
])
|
||||||
->all()
|
->columns(3)
|
||||||
)
|
->default(
|
||||||
->addable(false)
|
collect(VehicleOption::cases())
|
||||||
->deletable(false)
|
->map(fn (VehicleOption $option) => [
|
||||||
->reorderable(false)
|
'vehicle_option' => $option->value,
|
||||||
|
'price' => 0,
|
||||||
|
'is_blocked' => false,
|
||||||
|
])
|
||||||
|
->all()
|
||||||
|
)
|
||||||
|
->addable(false)
|
||||||
|
->deletable(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')),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Shared\Sms;
|
||||||
|
|
||||||
|
use Illuminate\Http\Client\ConnectionException;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin wrapper around the sms_poh gateway (the only provider configured
|
||||||
|
* today, config('services.sms')). No-ops when SMS is disabled so callers
|
||||||
|
* (queued listeners) can call send() unconditionally in every environment.
|
||||||
|
*/
|
||||||
|
class SmsService
|
||||||
|
{
|
||||||
|
private readonly bool $enabled;
|
||||||
|
|
||||||
|
private readonly ?string $server;
|
||||||
|
|
||||||
|
private readonly ?string $token;
|
||||||
|
|
||||||
|
private readonly ?string $sender;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|null $config
|
||||||
|
*/
|
||||||
|
public function __construct(?array $config = null)
|
||||||
|
{
|
||||||
|
$config ??= (array) config('services.sms');
|
||||||
|
$providerConfig = (array) ($config['sms_poh'] ?? []);
|
||||||
|
|
||||||
|
$this->enabled = (bool) ($config['enabled'] ?? false);
|
||||||
|
$this->server = $providerConfig['server'] ?? null;
|
||||||
|
$this->token = $providerConfig['token'] ?? null;
|
||||||
|
$this->sender = $providerConfig['sender'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function send(string $to, string $message, ?string $from = null): bool
|
||||||
|
{
|
||||||
|
if (! $this->enabled || $this->server === null || $this->token === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = Http::withToken($this->token)
|
||||||
|
->post($this->server, [
|
||||||
|
'to' => $to,
|
||||||
|
'message' => $message,
|
||||||
|
'from' => $from ?? $this->sender,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Log::notice('Send SMS Response : '.$to.' '.$response->body());
|
||||||
|
|
||||||
|
return $response->successful();
|
||||||
|
} catch (ConnectionException $exception) {
|
||||||
|
Log::error('Send SMS Error : '.$to.' '.$exception->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Modules\Shared\Sms\SmsService;
|
||||||
|
|
||||||
|
$config = [
|
||||||
|
'enabled' => true,
|
||||||
|
'sms_poh' => [
|
||||||
|
'server' => 'https://sms.test/send',
|
||||||
|
'token' => 'test-token',
|
||||||
|
'sender' => 'FamousLY4',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
test('send posts to the configured server with a bearer token and returns true on success', function () use ($config) {
|
||||||
|
Http::fake(['sms.test/*' => Http::response(['status' => 'ok'])]);
|
||||||
|
|
||||||
|
$result = (new SmsService($config))->send('+959111222333', 'Your driver is here.');
|
||||||
|
|
||||||
|
expect($result)->toBeTrue();
|
||||||
|
Http::assertSent(function ($request) {
|
||||||
|
return $request->url() === 'https://sms.test/send'
|
||||||
|
&& $request->hasHeader('Authorization', 'Bearer test-token')
|
||||||
|
&& $request['to'] === '+959111222333'
|
||||||
|
&& $request['message'] === 'Your driver is here.'
|
||||||
|
&& $request['from'] === 'FamousLY4';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('send returns false and does not call the gateway when disabled', function () use ($config) {
|
||||||
|
Http::fake();
|
||||||
|
$config['enabled'] = false;
|
||||||
|
|
||||||
|
$result = (new SmsService($config))->send('+959111222333', 'Your driver is here.');
|
||||||
|
|
||||||
|
expect($result)->toBeFalse();
|
||||||
|
Http::assertNothingSent();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('send returns false on a non-successful gateway response', function () use ($config) {
|
||||||
|
Http::fake(['sms.test/*' => Http::response(['error' => 'invalid'], 422)]);
|
||||||
|
|
||||||
|
$result = (new SmsService($config))->send('+959111222333', 'Your driver is here.');
|
||||||
|
|
||||||
|
expect($result)->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('send uses an explicit from over the configured sender', function () use ($config) {
|
||||||
|
Http::fake(['sms.test/*' => Http::response(['status' => 'ok'])]);
|
||||||
|
|
||||||
|
(new SmsService($config))->send('+959111222333', 'Hello', 'OtherSender');
|
||||||
|
|
||||||
|
Http::assertSent(fn ($request) => $request['from'] === 'OtherSender');
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ namespace App\Providers;
|
|||||||
use Illuminate\Cache\RateLimiting\Limit;
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
@@ -23,6 +24,16 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
$this->configureRateLimiting();
|
$this->configureRateLimiting();
|
||||||
|
|
||||||
|
// Belt-and-suspenders alongside bootstrap/app.php's trustProxies():
|
||||||
|
// that already makes url()/asset() respect the proxy's
|
||||||
|
// X-Forwarded-Proto, but if that header is ever missing or a proxy
|
||||||
|
// is misconfigured, this still forces https:// asset/route URLs on
|
||||||
|
// any environment whose APP_URL is itself https — so a plain-http
|
||||||
|
// request never causes a mixed-content-blocked asset again.
|
||||||
|
if (str(config('app.url'))->startsWith('https://')) {
|
||||||
|
URL::forceScheme('https');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ use Modules\Booking\BookingPlugin;
|
|||||||
use Modules\Catalog\CatalogPlugin;
|
use Modules\Catalog\CatalogPlugin;
|
||||||
use Modules\Identity\IdentityPlugin;
|
use Modules\Identity\IdentityPlugin;
|
||||||
use Modules\Payment\PaymentPlugin;
|
use Modules\Payment\PaymentPlugin;
|
||||||
|
use Modules\Reporting\ReportingPlugin;
|
||||||
use Modules\Routing\RoutingPlugin;
|
use Modules\Routing\RoutingPlugin;
|
||||||
|
|
||||||
class AdminPanelProvider extends PanelProvider
|
class AdminPanelProvider extends PanelProvider
|
||||||
@@ -47,6 +48,7 @@ class AdminPanelProvider extends PanelProvider
|
|||||||
NavigationGroup::make()->label('Catalog'),
|
NavigationGroup::make()->label('Catalog'),
|
||||||
NavigationGroup::make()->label('Routing'),
|
NavigationGroup::make()->label('Routing'),
|
||||||
NavigationGroup::make()->label('Operations'),
|
NavigationGroup::make()->label('Operations'),
|
||||||
|
NavigationGroup::make()->label('Reports'),
|
||||||
])
|
])
|
||||||
->plugins([
|
->plugins([
|
||||||
CatalogPlugin::make(),
|
CatalogPlugin::make(),
|
||||||
@@ -54,6 +56,7 @@ class AdminPanelProvider extends PanelProvider
|
|||||||
BookingPlugin::make(),
|
BookingPlugin::make(),
|
||||||
PaymentPlugin::make(),
|
PaymentPlugin::make(),
|
||||||
IdentityPlugin::make(),
|
IdentityPlugin::make(),
|
||||||
|
ReportingPlugin::make(),
|
||||||
// T6.5 — ops convenience for browsing storage/logs/*.log
|
// T6.5 — ops convenience for browsing storage/logs/*.log
|
||||||
// in-browser; distinct from the structured, per-model audit
|
// in-browser; distinct from the structured, per-model audit
|
||||||
// trail (AuditLogResource, T6.2). No extra permission gate:
|
// trail (AuditLogResource, T6.2). No extra permission gate:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use Illuminate\Http\Request;
|
|||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Modules\Identity\Http\Middleware\AuthenticateSanctumOrFastApiJwt;
|
use Modules\Identity\Http\Middleware\AuthenticateSanctumOrFastApiJwt;
|
||||||
use Modules\Identity\Http\Middleware\EnsureFastApiAgent;
|
use Modules\Identity\Http\Middleware\EnsureFastApiAgent;
|
||||||
|
use Symfony\Component\HttpFoundation\Request as SymfonyRequest;
|
||||||
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
||||||
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
|
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
@@ -24,6 +25,22 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
|
// Staging/production sit behind a reverse proxy/load balancer that
|
||||||
|
// terminates SSL — without this, Laravel never sees the original
|
||||||
|
// request as HTTPS, so it generates http:// asset URLs, which
|
||||||
|
// browsers then block as mixed content on the https:// page (e.g.
|
||||||
|
// Filament's file-upload.js failing to load, breaking that field's
|
||||||
|
// JS-enhanced dropzone). Trusting '*' is the standard Laravel
|
||||||
|
// pattern when the proxy's IP isn't fixed/known in advance.
|
||||||
|
$middleware->trustProxies(
|
||||||
|
at: '*',
|
||||||
|
headers: SymfonyRequest::HEADER_X_FORWARDED_FOR
|
||||||
|
| SymfonyRequest::HEADER_X_FORWARDED_HOST
|
||||||
|
| SymfonyRequest::HEADER_X_FORWARDED_PORT
|
||||||
|
| SymfonyRequest::HEADER_X_FORWARDED_PROTO
|
||||||
|
| SymfonyRequest::HEADER_X_FORWARDED_AWS_ELB,
|
||||||
|
);
|
||||||
|
|
||||||
$middleware->alias([
|
$middleware->alias([
|
||||||
'fastapi.agent' => EnsureFastApiAgent::class,
|
'fastapi.agent' => EnsureFastApiAgent::class,
|
||||||
'api.auth' => AuthenticateSanctumOrFastApiJwt::class,
|
'api.auth' => AuthenticateSanctumOrFastApiJwt::class,
|
||||||
|
|||||||
Regular → Executable
@@ -18,6 +18,7 @@
|
|||||||
"modules/catalog": "*",
|
"modules/catalog": "*",
|
||||||
"modules/identity": "*",
|
"modules/identity": "*",
|
||||||
"modules/payment": "*",
|
"modules/payment": "*",
|
||||||
|
"modules/reporting": "*",
|
||||||
"modules/routing": "*",
|
"modules/routing": "*",
|
||||||
"modules/shared": "*",
|
"modules/shared": "*",
|
||||||
"spatie/laravel-activitylog": "^5.0",
|
"spatie/laravel-activitylog": "^5.0",
|
||||||
|
|||||||
Generated
+419
-1
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "323e86b8a07e30ef4c9ed6f5a2f01b8f",
|
"content-hash": "b3018ca42fa6d16d0da6113b3aa6b1a8",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "anourvalar/eloquent-serialize",
|
"name": "anourvalar/eloquent-serialize",
|
||||||
@@ -4316,6 +4316,173 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-08-10T15:24:05+00:00"
|
"time": "2026-08-10T15:24:05+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "maatwebsite/excel",
|
||||||
|
"version": "4.0.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/SpartnerNL/Laravel-Excel.git",
|
||||||
|
"reference": "5d1c617c9fea810d0c547d69d4dfddd3f0a9fea8"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/SpartnerNL/Laravel-Excel/zipball/5d1c617c9fea810d0c547d69d4dfddd3f0a9fea8",
|
||||||
|
"reference": "5d1c617c9fea810d0c547d69d4dfddd3f0a9fea8",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"composer/semver": "^3.4",
|
||||||
|
"illuminate/support": "^12.0 || ^13.0",
|
||||||
|
"php": "^8.3",
|
||||||
|
"phpoffice/phpspreadsheet": "^5.8",
|
||||||
|
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"brianium/paratest": "^7.20",
|
||||||
|
"driftingly/rector-laravel": "^2.5",
|
||||||
|
"ext-sqlite3": "*",
|
||||||
|
"larastan/larastan": "^3.10",
|
||||||
|
"laravel/pint": "^1.29",
|
||||||
|
"laravel/scout": "^10.25 || ^11.2",
|
||||||
|
"orchestra/testbench": "^10.11 || ^11.1",
|
||||||
|
"phpstan/extension-installer": "^1.4",
|
||||||
|
"phpstan/phpstan-mockery": "^2.0",
|
||||||
|
"phpunit/phpunit": "^12.5 || ~13.1.14",
|
||||||
|
"predis/predis": "^2.3 || ^3.0",
|
||||||
|
"rector/rector": "^2.4.2"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"aliases": {
|
||||||
|
"Excel": "Maatwebsite\\Excel\\Facades\\Excel"
|
||||||
|
},
|
||||||
|
"providers": [
|
||||||
|
"Maatwebsite\\Excel\\ExcelServiceProvider"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Maatwebsite\\Excel\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Patrick Brouwers",
|
||||||
|
"email": "patrick@spartner.nl"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Supercharged Excel exports and imports in Laravel",
|
||||||
|
"keywords": [
|
||||||
|
"PHPExcel",
|
||||||
|
"batch",
|
||||||
|
"csv",
|
||||||
|
"excel",
|
||||||
|
"export",
|
||||||
|
"import",
|
||||||
|
"laravel",
|
||||||
|
"php",
|
||||||
|
"phpspreadsheet"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/SpartnerNL/Laravel-Excel/issues",
|
||||||
|
"source": "https://github.com/SpartnerNL/Laravel-Excel/tree/4.0.1"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://laravel-excel.com/commercial-support",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/patrickbrouwers",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-08-18T12:32:09+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "maennchen/zipstream-php",
|
||||||
|
"version": "3.2.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/maennchen/ZipStream-PHP.git",
|
||||||
|
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
|
||||||
|
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-mbstring": "*",
|
||||||
|
"ext-zlib": "*",
|
||||||
|
"php-64bit": "^8.3"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"brianium/paratest": "^7.7",
|
||||||
|
"ext-zip": "*",
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.86",
|
||||||
|
"guzzlehttp/guzzle": "^7.5",
|
||||||
|
"mikey179/vfsstream": "^1.6",
|
||||||
|
"php-coveralls/php-coveralls": "^2.5",
|
||||||
|
"phpunit/phpunit": "^12.0",
|
||||||
|
"vimeo/psalm": "^6.0"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"guzzlehttp/psr7": "^2.4",
|
||||||
|
"psr/http-message": "^2.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"ZipStream\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Paul Duncan",
|
||||||
|
"email": "pabs@pablotron.org"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jonatan Männchen",
|
||||||
|
"email": "jonatan@maennchen.ch"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jesse Donat",
|
||||||
|
"email": "donatj@gmail.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "András Kolesár",
|
||||||
|
"email": "kolesar@kolesar.hu"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
|
||||||
|
"keywords": [
|
||||||
|
"stream",
|
||||||
|
"zip"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
|
||||||
|
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.2"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/maennchen",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-04-11T18:38:28+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "marc-mabe/php-enum",
|
"name": "marc-mabe/php-enum",
|
||||||
"version": "v4.7.2",
|
"version": "v4.7.2",
|
||||||
@@ -4389,6 +4556,113 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-09-14T11:18:39+00:00"
|
"time": "2025-09-14T11:18:39+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "markbaker/complex",
|
||||||
|
"version": "3.0.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/MarkBaker/PHPComplex.git",
|
||||||
|
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
|
||||||
|
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.2 || ^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
|
||||||
|
"phpcompatibility/php-compatibility": "^9.3",
|
||||||
|
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
|
||||||
|
"squizlabs/php_codesniffer": "^3.7"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Complex\\": "classes/src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Mark Baker",
|
||||||
|
"email": "mark@lange.demon.co.uk"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PHP Class for working with complex numbers",
|
||||||
|
"homepage": "https://github.com/MarkBaker/PHPComplex",
|
||||||
|
"keywords": [
|
||||||
|
"complex",
|
||||||
|
"mathematics"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/MarkBaker/PHPComplex/issues",
|
||||||
|
"source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2"
|
||||||
|
},
|
||||||
|
"time": "2022-12-06T16:21:08+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "markbaker/matrix",
|
||||||
|
"version": "3.0.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/MarkBaker/PHPMatrix.git",
|
||||||
|
"reference": "728434227fe21be27ff6d86621a1b13107a2562c"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c",
|
||||||
|
"reference": "728434227fe21be27ff6d86621a1b13107a2562c",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.1 || ^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
|
||||||
|
"phpcompatibility/php-compatibility": "^9.3",
|
||||||
|
"phpdocumentor/phpdocumentor": "2.*",
|
||||||
|
"phploc/phploc": "^4.0",
|
||||||
|
"phpmd/phpmd": "2.*",
|
||||||
|
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
|
||||||
|
"sebastian/phpcpd": "^4.0",
|
||||||
|
"squizlabs/php_codesniffer": "^3.7"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Matrix\\": "classes/src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Mark Baker",
|
||||||
|
"email": "mark@demon-angel.eu"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PHP Class for working with matrices",
|
||||||
|
"homepage": "https://github.com/MarkBaker/PHPMatrix",
|
||||||
|
"keywords": [
|
||||||
|
"mathematics",
|
||||||
|
"matrix",
|
||||||
|
"vector"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/MarkBaker/PHPMatrix/issues",
|
||||||
|
"source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1"
|
||||||
|
},
|
||||||
|
"time": "2022-12-02T22:17:43+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "modules/booking",
|
"name": "modules/booking",
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
@@ -4517,6 +4791,41 @@
|
|||||||
"relative": true
|
"relative": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "modules/reporting",
|
||||||
|
"version": "1.0",
|
||||||
|
"dist": {
|
||||||
|
"type": "path",
|
||||||
|
"url": "app-modules/reporting",
|
||||||
|
"reference": "39c235e3c324b47b1c890e9aedd07044b670aad8"
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"maatwebsite/excel": "^4.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"providers": [
|
||||||
|
"Modules\\Reporting\\Providers\\ReportingServiceProvider"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Modules\\Reporting\\": "src/",
|
||||||
|
"Modules\\Reporting\\Tests\\": "tests/",
|
||||||
|
"Modules\\Reporting\\Database\\Factories\\": "database/factories/",
|
||||||
|
"Modules\\Reporting\\Database\\Seeders\\": "database/seeders/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"license": [
|
||||||
|
"proprietary"
|
||||||
|
],
|
||||||
|
"transport-options": {
|
||||||
|
"symlink": true,
|
||||||
|
"relative": true
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "modules/routing",
|
"name": "modules/routing",
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
@@ -5327,6 +5636,115 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-09-24T15:06:41+00:00"
|
"time": "2025-09-24T15:06:41+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "phpoffice/phpspreadsheet",
|
||||||
|
"version": "5.9.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
|
||||||
|
"reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/05e99ebf61238a70227b4d9cc02d0030d34f6339",
|
||||||
|
"reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"composer/pcre": "^1||^2||^3",
|
||||||
|
"ext-ctype": "*",
|
||||||
|
"ext-dom": "*",
|
||||||
|
"ext-fileinfo": "*",
|
||||||
|
"ext-filter": "*",
|
||||||
|
"ext-gd": "*",
|
||||||
|
"ext-iconv": "*",
|
||||||
|
"ext-libxml": "*",
|
||||||
|
"ext-mbstring": "*",
|
||||||
|
"ext-simplexml": "*",
|
||||||
|
"ext-xml": "*",
|
||||||
|
"ext-xmlreader": "*",
|
||||||
|
"ext-xmlwriter": "*",
|
||||||
|
"ext-zip": "*",
|
||||||
|
"ext-zlib": "*",
|
||||||
|
"maennchen/zipstream-php": "^2.1 || ^3.0",
|
||||||
|
"markbaker/complex": "^3.0",
|
||||||
|
"markbaker/matrix": "^3.0",
|
||||||
|
"php": "^8.2",
|
||||||
|
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
|
||||||
|
"dompdf/dompdf": "^2.0 || ^3.0",
|
||||||
|
"ext-intl": "*",
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.2",
|
||||||
|
"mitoteam/jpgraph": "^10.5",
|
||||||
|
"mpdf/mpdf": "^8.1.1",
|
||||||
|
"phpcompatibility/php-compatibility": "^9.3",
|
||||||
|
"phpstan/phpstan": "^1.1 || ^2.0",
|
||||||
|
"phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0",
|
||||||
|
"phpstan/phpstan-phpunit": "^1.0 || ^2.0",
|
||||||
|
"phpunit/phpunit": "^10.5 || ^11.0",
|
||||||
|
"squizlabs/php_codesniffer": "^3.7",
|
||||||
|
"tecnickcom/tcpdf": "^6.5"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
|
||||||
|
"ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()",
|
||||||
|
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
|
||||||
|
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
|
||||||
|
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Maarten Balliauw",
|
||||||
|
"homepage": "https://blog.maartenballiauw.be"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mark Baker",
|
||||||
|
"homepage": "https://markbakeruk.net"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Franck Lefevre",
|
||||||
|
"homepage": "https://rootslabs.net"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Erik Tilt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Adrien Crivelli"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Owen Leibman"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
|
||||||
|
"homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
|
||||||
|
"keywords": [
|
||||||
|
"OpenXML",
|
||||||
|
"excel",
|
||||||
|
"gnumeric",
|
||||||
|
"ods",
|
||||||
|
"php",
|
||||||
|
"spreadsheet",
|
||||||
|
"xls",
|
||||||
|
"xlsx"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
|
||||||
|
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.9.0"
|
||||||
|
},
|
||||||
|
"time": "2026-07-12T19:17:39+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "phpoption/phpoption",
|
"name": "phpoption/phpoption",
|
||||||
"version": "1.9.5",
|
"version": "1.9.5",
|
||||||
|
|||||||
@@ -40,6 +40,15 @@ return [
|
|||||||
'jwt_algorithm' => env('FASTAPI_AGENT_JWT_ALGORITHM', 'HS256'),
|
'jwt_algorithm' => env('FASTAPI_AGENT_JWT_ALGORITHM', 'HS256'),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'sms' => [
|
||||||
|
'enabled' => env('SMS_ENABLED', false),
|
||||||
|
'sms_poh' => [
|
||||||
|
'server' => env('SMS_SERVER'),
|
||||||
|
'token' => env('SMS_TOKEN'),
|
||||||
|
'sender' => env('SMS_SENDER'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
'kbz' => [
|
'kbz' => [
|
||||||
'app_id' => env('KBZ_APP_ID'),
|
'app_id' => env('KBZ_APP_ID'),
|
||||||
'merchant_code' => env('KBZ_MERCHANT_CODE'),
|
'merchant_code' => env('KBZ_MERCHANT_CODE'),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Reference in New Issue
Block a user