Compare commits
10 Commits
bf5d2c676a
...
d528cf16ec
| Author | SHA1 | Date | |
|---|---|---|---|
| d528cf16ec | |||
| fd3a195453 | |||
| 46f9b8d5a3 | |||
| e2e7902307 | |||
| d19a14a45e | |||
| 4737838021 | |||
| e0bcc5f81a | |||
| 5b68f4fa38 | |||
| 4da9ecfe7d | |||
| 7872105f2f |
+8
-1
@@ -8,6 +8,11 @@ APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
APP_TIMEZONE=Asia/Yangon
|
||||
APP_CURRENCY=MMK
|
||||
SUPPORT_EMAIL=
|
||||
SUPPORT_PHONE=
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
# APP_MAINTENANCE_STORE=database
|
||||
|
||||
@@ -15,7 +20,8 @@ APP_MAINTENANCE_DRIVER=file
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_CHANNEL=daily
|
||||
FILAMENT_LOG_VIEWER_DRIVER=daily
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
@@ -51,6 +57,7 @@ BOOKING_BACK_SEAT_ENABLED=
|
||||
BOOKING_WHOLE_VEHICLE_ENABLED=
|
||||
BOOKING_FRONT_SEAT_MAX_PER_BOOKING=
|
||||
|
||||
KBZ_APP_ID=
|
||||
KBZ_MERCHANT_CODE=
|
||||
KBZ_MERCHANT_KEY=
|
||||
KBZ_BASE_URL=
|
||||
|
||||
@@ -110,6 +110,13 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
|
||||
|
||||
- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications.
|
||||
|
||||
=== tests rules ===
|
||||
|
||||
# Test Enforcement
|
||||
|
||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
# Do Things the Laravel Way
|
||||
|
||||
@@ -110,6 +110,13 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
|
||||
|
||||
- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications.
|
||||
|
||||
=== tests rules ===
|
||||
|
||||
# Test Enforcement
|
||||
|
||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
# Do Things the Laravel Way
|
||||
@@ -163,3 +170,13 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
|
||||
- This application runs inside Docker via Laravel Sail. Use `./vendor/bin/sail artisan ...` instead of `php artisan ...`, and `./vendor/bin/sail composer ...` instead of `composer ...`.
|
||||
- For binaries not wrapped by Sail's own commands (e.g. Pint), run them inside the container: `./vendor/bin/sail exec laravel.test vendor/bin/pint --dirty --format agent`.
|
||||
- Check containers are up first with `./vendor/bin/sail ps` before running commands; start them with `./vendor/bin/sail up -d` if they aren't.
|
||||
|
||||
## Database / Migrations
|
||||
|
||||
- **Never run `php artisan migrate:fresh`, `migrate:refresh`, `migrate:reset`, or `db:wipe` against the dev database unless the user explicitly asks for it in that turn.** These drop/recreate all tables and destroy dev data. Use `php artisan migrate` (apply pending) and `php artisan migrate:rollback` (undo the last batch) instead for normal migration work.
|
||||
- Dev data loss happened once (2026-08-08 ~22:05 local) from exactly this kind of command — do not repeat it.
|
||||
|
||||
## Architecture / ERD Diagram
|
||||
|
||||
- The canonical tldraw board for this project's architecture and ERD lives at `/home/marcspecta/Documents/EV Booking System Architecture.tldraw` (outside the repo — not committed). Use this path when opening/updating the board with the tldraw-offline skill/agent.
|
||||
- Do not copy or save this file into the project directory; a stray copy there was previously deleted.
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Database\Factories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\Booking\Enums\BookingChannel;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Catalog\Models\DepartureTimeSlot;
|
||||
use Modules\Routing\Models\EvRoute;
|
||||
|
||||
/**
|
||||
* @extends Factory<Booking>
|
||||
*/
|
||||
class BookingFactory extends Factory
|
||||
{
|
||||
protected $model = Booking::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'booking_ref' => 'EVB-'.strtoupper(Str::random(6)),
|
||||
'user_id' => null,
|
||||
'openid' => null,
|
||||
'ev_route_id' => EvRoute::factory(),
|
||||
'departure_time_slot_id' => DepartureTimeSlot::factory(),
|
||||
'travel_date' => now()->addDay()->toDateString(),
|
||||
'passenger_name' => $this->faker->name(),
|
||||
'passenger_phone' => $this->faker->phoneNumber(),
|
||||
'pickup_address' => $this->faker->address(),
|
||||
'pickup_lat' => null,
|
||||
'pickup_lng' => null,
|
||||
'dropoff_address' => $this->faker->address(),
|
||||
'dropoff_lat' => null,
|
||||
'dropoff_lng' => null,
|
||||
'price' => $this->faker->randomFloat(2, 5000, 50000),
|
||||
'status' => BookingStatus::PendingPayment,
|
||||
'is_round_trip' => false,
|
||||
'return_travel_date' => null,
|
||||
'created_by_channel' => BookingChannel::MiniApp,
|
||||
'driver_name' => null,
|
||||
'driver_phone' => null,
|
||||
'car_plate_number' => null,
|
||||
'car_model' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Database\Factories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Booking\Models\BookingVehicleOption;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
|
||||
/**
|
||||
* @extends Factory<BookingVehicleOption>
|
||||
*/
|
||||
class BookingVehicleOptionFactory extends Factory
|
||||
{
|
||||
protected $model = BookingVehicleOption::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'booking_id' => Booking::factory(),
|
||||
'vehicle_option' => VehicleOption::BackSeat,
|
||||
'passenger_count' => 1,
|
||||
'unit_price' => 9000.00,
|
||||
'line_total' => 9000.00,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('bookings', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('booking_ref')->unique();
|
||||
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->string('openid')->nullable()->index();
|
||||
$table->foreignId('ev_route_id')->constrained('ev_routes')->cascadeOnDelete();
|
||||
$table->foreignId('departure_time_slot_id')->constrained('departure_time_slots')->cascadeOnDelete();
|
||||
$table->date('travel_date');
|
||||
$table->string('passenger_name');
|
||||
$table->string('passenger_phone');
|
||||
$table->string('pickup_address');
|
||||
$table->decimal('pickup_lat', 10, 7)->nullable();
|
||||
$table->decimal('pickup_lng', 10, 7)->nullable();
|
||||
$table->string('dropoff_address');
|
||||
$table->decimal('dropoff_lat', 10, 7)->nullable();
|
||||
$table->decimal('dropoff_lng', 10, 7)->nullable();
|
||||
// Total across all booking_vehicle_options lines — see that table for the
|
||||
// per-vehicle-option breakdown (a booking can mix e.g. front_seat + back_seat).
|
||||
$table->decimal('price', 10, 2);
|
||||
$table->string('status')->default('pending_payment');
|
||||
$table->boolean('is_round_trip')->default(false);
|
||||
$table->date('return_travel_date')->nullable();
|
||||
$table->string('created_by_channel');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['ev_route_id', 'travel_date', 'departure_time_slot_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('bookings');
|
||||
}
|
||||
};
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* One row per Vehicle Option selected on a booking — a booking can mix
|
||||
* e.g. front_seat + back_seat in one go (domain.md §2). The unique
|
||||
* constraint keeps each option to a single line per booking (no
|
||||
* duplicate front_seat rows); BookingService still enforces the
|
||||
* business rules (front-seat max, disabled options, whole-vehicle
|
||||
* exclusivity) on top of this shape.
|
||||
*
|
||||
* Also the natural source table for the deferred real-capacity-check
|
||||
* phase (domain.md §7): summing passenger_count per vehicle_option for
|
||||
* a route/date/time-slot is exactly what that future check needs.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('booking_vehicle_options', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('booking_id')->constrained('bookings')->cascadeOnDelete();
|
||||
$table->string('vehicle_option');
|
||||
$table->unsignedInteger('passenger_count')->default(1);
|
||||
// Snapshotted at booking time, same as bookings.price — never re-read
|
||||
// from route_pricing later (domain.md §3).
|
||||
$table->decimal('unit_price', 10, 2);
|
||||
$table->decimal('line_total', 10, 2);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['booking_id', 'vehicle_option']);
|
||||
$table->index('vehicle_option');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('booking_vehicle_options');
|
||||
}
|
||||
};
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Driver/vehicle details, filled in by admin staff once a booking is
|
||||
* confirmed (paid) and dispatch assigns who's actually doing the trip.
|
||||
* Nullable — unknown until assignment happens, and never required for
|
||||
* pending_payment/cancelled/expired bookings.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('bookings', function (Blueprint $table) {
|
||||
$table->string('driver_name')->nullable();
|
||||
$table->string('driver_phone')->nullable();
|
||||
$table->string('car_plate_number')->nullable();
|
||||
$table->string('car_model')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('bookings', function (Blueprint $table) {
|
||||
$table->dropColumn(['driver_name', 'driver_phone', 'car_plate_number', 'car_model']);
|
||||
});
|
||||
}
|
||||
};
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* Soft deletes only — a booking is never hard-removed. Admin staff may
|
||||
* delete a cancelled/expired booking (BookingResource, gated by
|
||||
* manage_bookings + BookingPolicy::delete), but the row stays
|
||||
* recoverable and its Payment/Refund history stays intact.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('bookings', function (Blueprint $table) {
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('bookings', function (Blueprint $table) {
|
||||
$table->dropSoftDeletes();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1 +1,11 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Booking\Http\Controllers\BookingController;
|
||||
|
||||
Route::prefix('api/v1')->middleware(['api', 'auth:sanctum', 'throttle:api-write'])->group(function () {
|
||||
Route::get('/bookings', [BookingController::class, 'index'])->name('booking.bookings.index');
|
||||
Route::get('/bookings/{booking:booking_ref}', [BookingController::class, 'show'])->name('booking.bookings.show');
|
||||
Route::post('/bookings', [BookingController::class, 'store'])->name('booking.bookings.store');
|
||||
Route::post('/bookings/{booking:booking_ref}/cancel', [BookingController::class, 'cancel'])->name('booking.bookings.cancel');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Actions;
|
||||
|
||||
use Modules\Booking\Data\AssignDriverData;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Exceptions\DriverAssignmentNotAllowedException;
|
||||
use Modules\Booking\Models\Booking;
|
||||
|
||||
/**
|
||||
* Driver/vehicle details only make sense once a booking is confirmed
|
||||
* (paid) — dispatch assigns who's actually doing the trip at that point,
|
||||
* not before. Re-running this (e.g. reassigning a different driver) is
|
||||
* allowed as long as the booking is still confirmed.
|
||||
*/
|
||||
class AssignDriverAction
|
||||
{
|
||||
public function handle(Booking $booking, AssignDriverData $data): Booking
|
||||
{
|
||||
if ($booking->status !== BookingStatus::Confirmed) {
|
||||
throw DriverAssignmentNotAllowedException::notConfirmed($booking);
|
||||
}
|
||||
|
||||
$booking->update([
|
||||
'driver_name' => $data->driverName,
|
||||
'driver_phone' => $data->driverPhone,
|
||||
'car_plate_number' => $data->carPlateNumber,
|
||||
'car_model' => $data->carModel,
|
||||
]);
|
||||
|
||||
return $booking;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Actions;
|
||||
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Exceptions\BookingCannotBeCancelledException;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Payment\Actions\RefundBookingAction;
|
||||
|
||||
/**
|
||||
* A pending_payment booking has no money moved yet, so it cancels directly.
|
||||
* A confirmed (paid) booking is cancelled by refunding it in full first —
|
||||
* delegates to RefundBookingAction (Payment module); the booking only
|
||||
* actually flips to cancelled once that refund succeeds, via
|
||||
* RefundProcessed/MarkBookingRefunded, not here (domain.md §5). Any other
|
||||
* status (already cancelled/expired) is rejected outright.
|
||||
*/
|
||||
class CancelBookingAction
|
||||
{
|
||||
private const CANCELLATION_REFUND_REASON = 'Booking cancellation';
|
||||
|
||||
public function __construct(
|
||||
private RefundBookingAction $refundBookingAction,
|
||||
) {}
|
||||
|
||||
public function handle(Booking $booking, ?int $requestedBy = null): Booking
|
||||
{
|
||||
if ($booking->status === BookingStatus::Confirmed) {
|
||||
$this->refundBookingAction->handle(
|
||||
$booking,
|
||||
(string) $booking->price,
|
||||
self::CANCELLATION_REFUND_REASON,
|
||||
$requestedBy,
|
||||
);
|
||||
|
||||
return $booking->refresh();
|
||||
}
|
||||
|
||||
if ($booking->status !== BookingStatus::PendingPayment) {
|
||||
throw BookingCannotBeCancelledException::notPendingPayment($booking);
|
||||
}
|
||||
|
||||
$booking->update(['status' => BookingStatus::Cancelled]);
|
||||
|
||||
return $booking;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Actions;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\Booking\Data\CreateBookingData;
|
||||
use Modules\Booking\Data\VehicleSelectionData;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Events\BookingCreated;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Booking\Services\BookingRefGenerator;
|
||||
use Modules\Booking\Services\BookingService;
|
||||
use Modules\Routing\Models\EvRoute;
|
||||
use Modules\Routing\Services\PricingService;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
|
||||
class CreateBookingAction
|
||||
{
|
||||
public function __construct(
|
||||
private BookingService $bookingService,
|
||||
private PricingService $pricingService,
|
||||
private BookingRefGenerator $bookingRefGenerator,
|
||||
) {}
|
||||
|
||||
public function handle(CreateBookingData $data): Booking
|
||||
{
|
||||
$this->bookingService->validateSelections($data->selections);
|
||||
|
||||
return DB::transaction(function () use ($data) {
|
||||
$route = EvRoute::findOrFail($data->evRouteId);
|
||||
|
||||
$lines = array_map(
|
||||
fn (VehicleSelectionData $selection) => $this->priceSelection($route, $selection),
|
||||
$data->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' => $data->evRouteId,
|
||||
'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);
|
||||
|
||||
BookingCreated::dispatch($booking);
|
||||
|
||||
return $booking;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{vehicle_option: VehicleOption, passenger_count: int, unit_price: string, line_total: string}
|
||||
*/
|
||||
private function priceSelection(EvRoute $route, VehicleSelectionData $selection): array
|
||||
{
|
||||
$quote = $this->pricingService->quote($route, $selection->vehicleOption);
|
||||
|
||||
return [
|
||||
'vehicle_option' => $selection->vehicleOption,
|
||||
'passenger_count' => $selection->passengerCount,
|
||||
'unit_price' => $quote->price,
|
||||
'line_total' => bcmul($quote->price, (string) $selection->passengerCount, 2),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking;
|
||||
|
||||
use Filament\Contracts\Plugin;
|
||||
use Filament\Panel;
|
||||
|
||||
class BookingPlugin implements Plugin
|
||||
{
|
||||
public function getId(): string
|
||||
{
|
||||
return 'booking';
|
||||
}
|
||||
|
||||
public function register(Panel $panel): void
|
||||
{
|
||||
$panel
|
||||
->discoverResources(
|
||||
in: __DIR__.'/Filament/Resources',
|
||||
for: 'Modules\Booking\Filament\Resources',
|
||||
)
|
||||
->discoverPages(
|
||||
in: __DIR__.'/Filament/Pages',
|
||||
for: 'Modules\Booking\Filament\Pages',
|
||||
)
|
||||
->discoverWidgets(
|
||||
in: __DIR__.'/Filament/Widgets',
|
||||
for: 'Modules\Booking\Filament\Widgets',
|
||||
);
|
||||
}
|
||||
|
||||
public function boot(Panel $panel): void {}
|
||||
|
||||
public static function make(): static
|
||||
{
|
||||
return app(static::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Data;
|
||||
|
||||
readonly class AssignDriverData
|
||||
{
|
||||
public function __construct(
|
||||
public string $driverName,
|
||||
public string $driverPhone,
|
||||
public string $carPlateNumber,
|
||||
public ?string $carModel = null,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Data;
|
||||
|
||||
use Modules\Booking\Enums\BookingChannel;
|
||||
|
||||
readonly class CreateBookingData
|
||||
{
|
||||
/**
|
||||
* @param list<VehicleSelectionData> $selections One or more Vehicle Option
|
||||
* selections (e.g. front_seat + back_seat) — domain.md §2.
|
||||
*/
|
||||
public function __construct(
|
||||
public int $evRouteId,
|
||||
public int $departureTimeSlotId,
|
||||
public string $travelDate,
|
||||
public array $selections,
|
||||
public string $passengerName,
|
||||
public string $passengerPhone,
|
||||
public string $pickupAddress,
|
||||
public string $dropoffAddress,
|
||||
public BookingChannel $createdByChannel,
|
||||
public ?int $userId = null,
|
||||
public ?string $openid = null,
|
||||
public ?float $pickupLat = null,
|
||||
public ?float $pickupLng = null,
|
||||
public ?float $dropoffLat = null,
|
||||
public ?float $dropoffLng = null,
|
||||
public bool $isRoundTrip = false,
|
||||
public ?string $returnTravelDate = null,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Data;
|
||||
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
|
||||
readonly class VehicleSelectionData
|
||||
{
|
||||
public function __construct(
|
||||
public VehicleOption $vehicleOption,
|
||||
public int $passengerCount = 1,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Enums;
|
||||
|
||||
/**
|
||||
* Which external actor created the booking (domain.md §8).
|
||||
*/
|
||||
enum BookingChannel: string
|
||||
{
|
||||
case MiniApp = 'mini_app';
|
||||
case Android = 'android';
|
||||
case Ios = 'ios';
|
||||
case Web = 'web';
|
||||
case Agent = 'agent';
|
||||
case Admin = 'admin';
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Enums;
|
||||
|
||||
enum BookingStatus: string
|
||||
{
|
||||
case PendingPayment = 'pending_payment';
|
||||
case Confirmed = 'confirmed';
|
||||
case Cancelled = 'cancelled';
|
||||
case Expired = 'expired';
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Modules\Booking\Models\Booking;
|
||||
|
||||
class BookingCreated
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct(public Booking $booking) {}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Exceptions;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use RuntimeException;
|
||||
|
||||
class BookingCannotBeCancelledException extends RuntimeException
|
||||
{
|
||||
/**
|
||||
* Confirmed bookings no longer reach this — CancelBookingAction (T5.12)
|
||||
* refunds them instead. This is only for statuses that can't be
|
||||
* cancelled at all (already cancelled/expired).
|
||||
*/
|
||||
public static function notPendingPayment(Booking $booking): self
|
||||
{
|
||||
return new self(
|
||||
"Booking [{$booking->booking_ref}] cannot be cancelled because its status is [{$booking->status->value}]."
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A rejected cancel attempt is a client input problem, not a server
|
||||
* error — surface it as 422 rather than the default 500.
|
||||
*/
|
||||
public function render(Request $request): ?JsonResponse
|
||||
{
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['message' => $this->getMessage()], 422);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Exceptions;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use RuntimeException;
|
||||
|
||||
class DriverAssignmentNotAllowedException extends RuntimeException
|
||||
{
|
||||
public static function notConfirmed(Booking $booking): self
|
||||
{
|
||||
return new self(
|
||||
"Booking [{$booking->booking_ref}] cannot have a driver assigned because its status is [{$booking->status->value}], not confirmed."
|
||||
);
|
||||
}
|
||||
|
||||
public function render(Request $request): ?JsonResponse
|
||||
{
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['message' => $this->getMessage()], 422);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Exceptions;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
use RuntimeException;
|
||||
|
||||
class InvalidVehicleSelectionException extends RuntimeException
|
||||
{
|
||||
public static function frontSeatLimitExceeded(int $requested, int $max): self
|
||||
{
|
||||
return new self("Front seat request [{$requested}] exceeds the max of [{$max}] per booking.");
|
||||
}
|
||||
|
||||
public static function optionDisabled(VehicleOption $vehicleOption): self
|
||||
{
|
||||
return new self("Vehicle option [{$vehicleOption->value}] is not currently available for booking.");
|
||||
}
|
||||
|
||||
public static function duplicateOption(VehicleOption $vehicleOption): self
|
||||
{
|
||||
return new self("Vehicle option [{$vehicleOption->value}] was selected more than once — combine it into a single selection.");
|
||||
}
|
||||
|
||||
public static function wholeVehicleCannotBeCombined(): self
|
||||
{
|
||||
return new self('Whole Vehicle cannot be combined with other vehicle options in the same booking.');
|
||||
}
|
||||
|
||||
/**
|
||||
* A rejected vehicle selection is a client input problem, not a server
|
||||
* error — surface it as 422 rather than the default 500. A broader JSON
|
||||
* error envelope for all of api/* is Phase 6 (T6.3); this keeps the
|
||||
* mapping local until that lands.
|
||||
*/
|
||||
public function render(Request $request): ?JsonResponse
|
||||
{
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['message' => $this->getMessage()], 422);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Filament\Resources\Bookings\Actions;
|
||||
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Modules\Booking\Actions\AssignDriverAction;
|
||||
use Modules\Booking\Data\AssignDriverData;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Exceptions\DriverAssignmentNotAllowedException;
|
||||
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 AssignDriverTableAction
|
||||
{
|
||||
public static function make(): Action
|
||||
{
|
||||
return Action::make('assignDriver')
|
||||
->label('Assign Driver')
|
||||
->icon(Heroicon::OutlinedTruck)
|
||||
->color('primary')
|
||||
->visible(fn (Booking $record): bool => $record->status === BookingStatus::Confirmed
|
||||
&& (auth()->user()?->can('manage_bookings') ?? false))
|
||||
->schema([
|
||||
TextInput::make('driver_name')->required(),
|
||||
TextInput::make('driver_phone')->required(),
|
||||
TextInput::make('car_plate_number')->required(),
|
||||
TextInput::make('car_model'),
|
||||
])
|
||||
->fillForm(fn (Booking $record): array => [
|
||||
'driver_name' => $record->driver_name,
|
||||
'driver_phone' => $record->driver_phone,
|
||||
'car_plate_number' => $record->car_plate_number,
|
||||
'car_model' => $record->car_model,
|
||||
])
|
||||
->action(function (array $data, Booking $record, AssignDriverAction $assignDriverAction) {
|
||||
try {
|
||||
$assignDriverAction->handle($record, new AssignDriverData(
|
||||
driverName: $data['driver_name'],
|
||||
driverPhone: $data['driver_phone'],
|
||||
carPlateNumber: $data['car_plate_number'],
|
||||
carModel: $data['car_model'] ?: null,
|
||||
));
|
||||
|
||||
Notification::make()
|
||||
->title('Driver assigned')
|
||||
->success()
|
||||
->send();
|
||||
} catch (DriverAssignmentNotAllowedException $exception) {
|
||||
Notification::make()
|
||||
->title('Cannot assign driver')
|
||||
->body($exception->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Filament\Resources\Bookings\Actions;
|
||||
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Modules\Booking\Actions\CancelBookingAction;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Exceptions\BookingCannotBeCancelledException;
|
||||
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 CancelBookingTableAction
|
||||
{
|
||||
public static function make(): Action
|
||||
{
|
||||
return Action::make('cancel')
|
||||
->label('Cancel')
|
||||
->icon(Heroicon::OutlinedXCircle)
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Booking $record): bool => Gate::allows('cancel', $record))
|
||||
->disabled(fn (Booking $record): bool => $record->status !== BookingStatus::PendingPayment)
|
||||
->action(function (Booking $record, CancelBookingAction $cancelBookingAction) {
|
||||
try {
|
||||
$cancelBookingAction->handle($record);
|
||||
|
||||
Notification::make()
|
||||
->title('Booking cancelled')
|
||||
->success()
|
||||
->send();
|
||||
} catch (BookingCannotBeCancelledException $exception) {
|
||||
Notification::make()
|
||||
->title('Cannot cancel booking')
|
||||
->body($exception->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Filament\Resources\Bookings\Actions;
|
||||
|
||||
use Filament\Actions\DeleteAction;
|
||||
|
||||
/**
|
||||
* Soft-delete only (Booking uses SoftDeletes). `authorize('delete')` ties
|
||||
* both the visible/hidden state AND the actual delete call itself to
|
||||
* BookingPolicy::delete (manage_bookings + terminal status) — unlike
|
||||
* visible()/disabled(), which are UI-only, authorize() is enforced when the
|
||||
* action runs (Filament\Actions\Concerns\CanBeAuthorized). A booking that
|
||||
* isn't cancelled/expired never shows this button at all, rather than a
|
||||
* dead disabled one.
|
||||
*/
|
||||
class DeleteBookingTableAction
|
||||
{
|
||||
public static function make(): DeleteAction
|
||||
{
|
||||
return DeleteAction::make()
|
||||
->authorize('delete');
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Filament\Resources\Bookings\Actions;
|
||||
|
||||
use Filament\Actions\RestoreAction;
|
||||
|
||||
/**
|
||||
* Pairs with DeleteBookingTableAction — RestoreAction is already visible
|
||||
* only for trashed records out of the box; authorize('restore') layers
|
||||
* BookingPolicy::restore (manage_bookings) on top, enforced at call time
|
||||
* as well as driving visibility (Filament\Actions\Concerns\CanBeAuthorized).
|
||||
*/
|
||||
class RestoreBookingTableAction
|
||||
{
|
||||
public static function make(): RestoreAction
|
||||
{
|
||||
return RestoreAction::make()
|
||||
->authorize('restore');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Filament\Resources\Bookings;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Pages\ListBookings;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Pages\ViewBooking;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Schemas\BookingInfolist;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Tables\BookingsTable;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use UnitEnum;
|
||||
|
||||
/**
|
||||
* Read-mostly by design: bookings are created through the API (T4.4), not
|
||||
* hand-entered in the admin — so this resource has no create/edit form, just
|
||||
* a list with filters and a status-gated Cancel action (T4.6).
|
||||
*/
|
||||
class BookingResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Booking::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedTicket;
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Operations';
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return BookingsTable::configure($table);
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return BookingInfolist::configure($schema);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListBookings::route('/'),
|
||||
'view' => ViewBooking::route('/{record}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Filament\Resources\Bookings\Pages;
|
||||
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Modules\Booking\Filament\Resources\Bookings\BookingResource;
|
||||
|
||||
class ListBookings extends ListRecords
|
||||
{
|
||||
protected static string $resource = BookingResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
// No CreateAction — bookings are created through the API (T4.4), not
|
||||
// hand-entered here.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Filament\Resources\Bookings\Pages;
|
||||
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Actions\AssignDriverTableAction;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Actions\CancelBookingTableAction;
|
||||
use Modules\Booking\Filament\Resources\Bookings\BookingResource;
|
||||
|
||||
class ViewBooking extends ViewRecord
|
||||
{
|
||||
protected static string $resource = BookingResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
AssignDriverTableAction::make(),
|
||||
CancelBookingTableAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Filament\Resources\Bookings\Schemas;
|
||||
|
||||
use Filament\Infolists\Components\RepeatableEntry;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
|
||||
class BookingInfolist
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make('Booking')
|
||||
->schema([
|
||||
Grid::make(4)
|
||||
->schema([
|
||||
TextEntry::make('booking_ref')->label('Ref'),
|
||||
TextEntry::make('status')
|
||||
->badge()
|
||||
->color(fn (BookingStatus $state) => match ($state) {
|
||||
BookingStatus::PendingPayment => 'warning',
|
||||
BookingStatus::Confirmed => 'success',
|
||||
BookingStatus::Cancelled => 'gray',
|
||||
BookingStatus::Expired => 'danger',
|
||||
}),
|
||||
TextEntry::make('created_by_channel')->badge(),
|
||||
TextEntry::make('created_at')->dateTime(),
|
||||
]),
|
||||
]),
|
||||
Section::make('Trip')
|
||||
->schema([
|
||||
Grid::make(3)
|
||||
->schema([
|
||||
TextEntry::make('route.company.name')->label('Company'),
|
||||
TextEntry::make('route.fromDestination.name')->label('From'),
|
||||
TextEntry::make('route.toDestination.name')->label('To'),
|
||||
TextEntry::make('timeSlot.label')->label('Time Slot'),
|
||||
TextEntry::make('travel_date')->date(),
|
||||
TextEntry::make('is_round_trip')->label('Round Trip')->badge(),
|
||||
TextEntry::make('return_travel_date')->date()
|
||||
->visible(fn ($record) => $record->is_round_trip),
|
||||
]),
|
||||
]),
|
||||
Section::make('Vehicle Options')
|
||||
->schema([
|
||||
RepeatableEntry::make('vehicleOptions')
|
||||
->label('')
|
||||
->schema([
|
||||
Grid::make(4)
|
||||
->schema([
|
||||
TextEntry::make('vehicle_option')->badge(),
|
||||
TextEntry::make('passenger_count'),
|
||||
TextEntry::make('unit_price')->numeric(2),
|
||||
TextEntry::make('line_total')->numeric(2),
|
||||
]),
|
||||
]),
|
||||
TextEntry::make('price')->label('Total Price')->numeric(2),
|
||||
]),
|
||||
Section::make('Passenger')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
TextEntry::make('passenger_name'),
|
||||
TextEntry::make('passenger_phone'),
|
||||
]),
|
||||
]),
|
||||
Section::make('Pickup & Dropoff')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
TextEntry::make('pickup_address'),
|
||||
TextEntry::make('dropoff_address'),
|
||||
TextEntry::make('pickup_lat')->label('Pickup Lat')->placeholder('—'),
|
||||
TextEntry::make('dropoff_lat')->label('Dropoff Lat')->placeholder('—'),
|
||||
TextEntry::make('pickup_lng')->label('Pickup Lng')->placeholder('—'),
|
||||
TextEntry::make('dropoff_lng')->label('Dropoff Lng')->placeholder('—'),
|
||||
]),
|
||||
]),
|
||||
Section::make('Driver & Vehicle')
|
||||
->description('Filled in by staff once the booking is confirmed — see the Assign Driver action.')
|
||||
->schema([
|
||||
Grid::make(4)
|
||||
->schema([
|
||||
TextEntry::make('driver_name')->label('Driver')->placeholder('Not yet assigned'),
|
||||
TextEntry::make('driver_phone')->label('Driver Phone')->placeholder('Not yet assigned'),
|
||||
TextEntry::make('car_plate_number')->label('Car Plate')->placeholder('Not yet assigned'),
|
||||
TextEntry::make('car_model')->label('Car Model')->placeholder('—'),
|
||||
]),
|
||||
]),
|
||||
// A booking can have more than one payment attempt if an
|
||||
// earlier one failed and the customer retried (domain.md §1)
|
||||
// — full detail (gateway response, refunds) lives on the
|
||||
// Payment/Refund Filament resources (T5.13), this is just a
|
||||
// quick-glance summary from the booking side.
|
||||
Section::make('Payments')
|
||||
->schema([
|
||||
RepeatableEntry::make('payments')
|
||||
->label('')
|
||||
->schema([
|
||||
Grid::make(6)
|
||||
->schema([
|
||||
TextEntry::make('gateway')->badge(),
|
||||
TextEntry::make('status')
|
||||
->badge()
|
||||
->color(fn (PaymentStatus $state) => match ($state) {
|
||||
PaymentStatus::Pending => 'warning',
|
||||
PaymentStatus::Completed => 'success',
|
||||
PaymentStatus::Failed => 'danger',
|
||||
}),
|
||||
TextEntry::make('amount')->numeric(2),
|
||||
TextEntry::make('currency'),
|
||||
TextEntry::make('gateway_transaction_id')->label('Gateway Txn ID')->placeholder('—'),
|
||||
TextEntry::make('completed_at')->dateTime()->placeholder('—'),
|
||||
]),
|
||||
])
|
||||
->placeholder('No payment attempts yet.'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Filament\Resources\Bookings\Tables;
|
||||
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\Filter;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Filters\TrashedFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Actions\AssignDriverTableAction;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Actions\CancelBookingTableAction;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Actions\DeleteBookingTableAction;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Actions\RestoreBookingTableAction;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Catalog\Models\EvCompany;
|
||||
use Modules\Routing\Models\EvRoute;
|
||||
|
||||
class BookingsTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->with([
|
||||
'route.company', 'route.fromDestination', 'route.toDestination', 'timeSlot', 'vehicleOptions',
|
||||
]))
|
||||
->defaultSort('created_at', 'desc')
|
||||
->columns([
|
||||
TextColumn::make('booking_ref')
|
||||
->label('Ref')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->color(fn (BookingStatus $state) => match ($state) {
|
||||
BookingStatus::PendingPayment => 'warning',
|
||||
BookingStatus::Confirmed => 'success',
|
||||
BookingStatus::Cancelled => 'gray',
|
||||
BookingStatus::Expired => 'danger',
|
||||
}),
|
||||
TextColumn::make('route.company.name')
|
||||
->label('Company')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('route.fromDestination.name')
|
||||
->label('From'),
|
||||
TextColumn::make('route.toDestination.name')
|
||||
->label('To'),
|
||||
TextColumn::make('travel_date')
|
||||
->date()
|
||||
->sortable(),
|
||||
TextColumn::make('timeSlot.label')
|
||||
->label('Time Slot'),
|
||||
TextColumn::make('vehicleOptions')
|
||||
->label('Vehicle Options')
|
||||
->state(fn (Booking $record) => $record->vehicleOptions
|
||||
->map(fn ($line) => str($line->vehicle_option->value)->headline().' x'.$line->passenger_count)
|
||||
->all())
|
||||
->listWithLineBreaks(),
|
||||
TextColumn::make('price')
|
||||
->numeric(2)
|
||||
->sortable(),
|
||||
TextColumn::make('passenger_name')
|
||||
->label('Passenger')
|
||||
->description(fn (Booking $record) => $record->passenger_phone)
|
||||
->searchable(['passenger_name', 'passenger_phone'])
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('created_by_channel')
|
||||
->badge()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('driver_name')
|
||||
->label('Driver')
|
||||
->placeholder('Not yet assigned')
|
||||
->description(fn (Booking $record) => collect([$record->driver_phone, $record->car_plate_number, $record->car_model])
|
||||
->filter()
|
||||
->join(' • ') ?: null)
|
||||
->searchable(['driver_name', 'driver_phone', 'car_plate_number', 'car_model'])
|
||||
->toggleable(),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('status')
|
||||
->options(array_combine(
|
||||
array_map(fn (BookingStatus $status) => $status->value, BookingStatus::cases()),
|
||||
array_map(fn (BookingStatus $status) => str($status->value)->headline()->toString(), BookingStatus::cases()),
|
||||
)),
|
||||
Filter::make('travel_date')
|
||||
->schema([
|
||||
DatePicker::make('travel_date'),
|
||||
])
|
||||
->query(fn (Builder $query, array $data) => $query->when(
|
||||
$data['travel_date'] ?? null,
|
||||
fn (Builder $q, $date) => $q->whereDate('travel_date', $date),
|
||||
)),
|
||||
SelectFilter::make('ev_route_id')
|
||||
->label('Route')
|
||||
->options(fn () => EvRoute::with(['fromDestination', 'toDestination'])->get()
|
||||
->mapWithKeys(fn (EvRoute $route) => [
|
||||
$route->id => "{$route->fromDestination?->name} → {$route->toDestination?->name}",
|
||||
]))
|
||||
->searchable(),
|
||||
SelectFilter::make('company')
|
||||
->options(fn () => EvCompany::pluck('name', 'id'))
|
||||
->searchable()
|
||||
->query(fn (Builder $query, array $data) => $query->when(
|
||||
$data['value'] ?? null,
|
||||
fn (Builder $q, $companyId) => $q->whereHas('route', fn (Builder $rq) => $rq->where('ev_company_id', $companyId)),
|
||||
)),
|
||||
// Deleted bookings are soft-deleted, not hard-removed
|
||||
// (domain.md; T7.x follow-up) — this is the only place they
|
||||
// become visible again, off by default.
|
||||
TrashedFilter::make(),
|
||||
])
|
||||
->recordActions([
|
||||
ViewAction::make(),
|
||||
AssignDriverTableAction::make(),
|
||||
CancelBookingTableAction::make(),
|
||||
DeleteBookingTableAction::make(),
|
||||
RestoreBookingTableAction::make(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Modules\Booking\Actions\CancelBookingAction;
|
||||
use Modules\Booking\Actions\CreateBookingAction;
|
||||
use Modules\Booking\Data\CreateBookingData;
|
||||
use Modules\Booking\Data\VehicleSelectionData;
|
||||
use Modules\Booking\Enums\BookingChannel;
|
||||
use Modules\Booking\Http\Requests\StoreBookingRequest;
|
||||
use Modules\Booking\Http\Resources\BookingResource;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
|
||||
class BookingController extends Controller
|
||||
{
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
private const EAGER_LOADS = ['route', 'timeSlot', 'vehicleOptions'];
|
||||
|
||||
public function __construct(
|
||||
private CreateBookingAction $createBookingAction,
|
||||
private CancelBookingAction $cancelBookingAction,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
Gate::authorize('viewAny', Booking::class);
|
||||
|
||||
$bookings = Booking::query()
|
||||
->where('user_id', $request->user()->id)
|
||||
->with(self::EAGER_LOADS)
|
||||
->latest()
|
||||
->paginate();
|
||||
|
||||
return BookingResource::collection($bookings);
|
||||
}
|
||||
|
||||
public function show(Booking $booking): BookingResource
|
||||
{
|
||||
Gate::authorize('view', $booking);
|
||||
|
||||
return new BookingResource($booking->load(self::EAGER_LOADS));
|
||||
}
|
||||
|
||||
public function store(StoreBookingRequest $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
$selections = array_map(
|
||||
fn (array $selection) => new VehicleSelectionData(
|
||||
vehicleOption: VehicleOption::from($selection['vehicle_option']),
|
||||
passengerCount: $selection['passenger_count'],
|
||||
),
|
||||
$validated['selections'],
|
||||
);
|
||||
|
||||
$booking = $this->createBookingAction->handle(new CreateBookingData(
|
||||
evRouteId: $validated['ev_route_id'],
|
||||
departureTimeSlotId: $validated['departure_time_slot_id'],
|
||||
travelDate: $validated['travel_date'],
|
||||
selections: $selections,
|
||||
passengerName: $validated['passenger_name'],
|
||||
passengerPhone: $validated['passenger_phone'],
|
||||
pickupAddress: $validated['pickup_address'],
|
||||
dropoffAddress: $validated['dropoff_address'],
|
||||
createdByChannel: isset($validated['created_by_channel'])
|
||||
? BookingChannel::from($validated['created_by_channel'])
|
||||
: BookingChannel::MiniApp,
|
||||
userId: $request->user()?->id,
|
||||
openid: $validated['openid'] ?? null,
|
||||
pickupLat: $validated['pickup_lat'] ?? null,
|
||||
pickupLng: $validated['pickup_lng'] ?? null,
|
||||
dropoffLat: $validated['dropoff_lat'] ?? null,
|
||||
dropoffLng: $validated['dropoff_lng'] ?? null,
|
||||
isRoundTrip: $validated['is_round_trip'] ?? false,
|
||||
returnTravelDate: $validated['return_travel_date'] ?? null,
|
||||
));
|
||||
|
||||
return (new BookingResource($booking->load(self::EAGER_LOADS)))
|
||||
->response()
|
||||
->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function cancel(Request $request, Booking $booking): BookingResource
|
||||
{
|
||||
Gate::authorize('cancel', $booking);
|
||||
|
||||
$this->cancelBookingAction->handle($booking, $request->user()?->id);
|
||||
|
||||
return new BookingResource($booking->load(self::EAGER_LOADS));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Modules\Booking\Enums\BookingChannel;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
|
||||
/**
|
||||
* Shape validation only — business rules (front-seat limit, disabled vehicle
|
||||
* options, pricing) stay in BookingService/PricingService, not here.
|
||||
*/
|
||||
class StoreBookingRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, mixed>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'ev_route_id' => ['required', 'integer', 'exists:ev_routes,id'],
|
||||
'departure_time_slot_id' => ['required', 'integer', 'exists:departure_time_slots,id'],
|
||||
'travel_date' => ['required', 'date'],
|
||||
// One or more Vehicle Option lines — e.g. front_seat + back_seat together
|
||||
// (domain.md §2). Duplicate-option/whole-vehicle-exclusivity rules stay in
|
||||
// BookingService, not here.
|
||||
'selections' => ['required', 'array', 'min:1'],
|
||||
'selections.*.vehicle_option' => ['required', Rule::enum(VehicleOption::class)],
|
||||
'selections.*.passenger_count' => ['required', 'integer', 'min:1'],
|
||||
'passenger_name' => ['required', 'string', 'max:255'],
|
||||
'passenger_phone' => ['required', 'string', 'max:50'],
|
||||
'pickup_address' => ['required', 'string', 'max:500'],
|
||||
'pickup_lat' => ['nullable', 'numeric', 'between:-90,90'],
|
||||
'pickup_lng' => ['nullable', 'numeric', 'between:-180,180'],
|
||||
'dropoff_address' => ['required', 'string', 'max:500'],
|
||||
'dropoff_lat' => ['nullable', 'numeric', 'between:-90,90'],
|
||||
'dropoff_lng' => ['nullable', 'numeric', 'between:-180,180'],
|
||||
'openid' => ['nullable', 'string', 'max:255'],
|
||||
'is_round_trip' => ['sometimes', 'boolean'],
|
||||
'return_travel_date' => ['nullable', 'date', 'required_if:is_round_trip,true'],
|
||||
// Admin-created bookings go through the Filament resource (T4.7), not this API.
|
||||
'created_by_channel' => ['sometimes', Rule::enum(BookingChannel::class)->except(BookingChannel::Admin)],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class BookingResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'booking_ref' => $this->booking_ref,
|
||||
'status' => $this->status,
|
||||
'travel_date' => $this->travel_date?->toDateString(),
|
||||
'is_round_trip' => $this->is_round_trip,
|
||||
'return_travel_date' => $this->return_travel_date?->toDateString(),
|
||||
'passenger_name' => $this->passenger_name,
|
||||
'passenger_phone' => $this->passenger_phone,
|
||||
'pickup_address' => $this->pickup_address,
|
||||
'pickup_lat' => $this->pickup_lat,
|
||||
'pickup_lng' => $this->pickup_lng,
|
||||
'dropoff_address' => $this->dropoff_address,
|
||||
'dropoff_lat' => $this->dropoff_lat,
|
||||
'dropoff_lng' => $this->dropoff_lng,
|
||||
'price' => $this->price,
|
||||
'created_by_channel' => $this->created_by_channel,
|
||||
// Only ever populated once status is confirmed — see AssignDriverAction.
|
||||
'driver_name' => $this->driver_name,
|
||||
'driver_phone' => $this->driver_phone,
|
||||
'car_plate_number' => $this->car_plate_number,
|
||||
'car_model' => $this->car_model,
|
||||
'vehicle_options' => $this->whenLoaded('vehicleOptions', fn () => $this->vehicleOptions->map(fn ($selection) => [
|
||||
'vehicle_option' => $selection->vehicle_option,
|
||||
'passenger_count' => $selection->passenger_count,
|
||||
'unit_price' => $selection->unit_price,
|
||||
'line_total' => $selection->line_total,
|
||||
])),
|
||||
'route' => $this->whenLoaded('route', fn () => [
|
||||
'id' => $this->route->id,
|
||||
'ev_company_id' => $this->route->ev_company_id,
|
||||
'from_destination_id' => $this->route->from_destination_id,
|
||||
'to_destination_id' => $this->route->to_destination_id,
|
||||
]),
|
||||
'time_slot' => $this->whenLoaded('timeSlot', fn () => [
|
||||
'id' => $this->timeSlot->id,
|
||||
'label' => $this->timeSlot->label,
|
||||
'time' => $this->timeSlot->time?->format('H:i'),
|
||||
]),
|
||||
'created_at' => $this->created_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Models;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Modules\Booking\Database\Factories\BookingFactory;
|
||||
use Modules\Booking\Enums\BookingChannel;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Catalog\Models\DepartureTimeSlot;
|
||||
use Modules\Payment\Models\Payment;
|
||||
use Modules\Routing\Models\EvRoute;
|
||||
use Spatie\Activitylog\Models\Concerns\LogsActivity;
|
||||
use Spatie\Activitylog\Support\LogOptions;
|
||||
|
||||
class Booking extends Model
|
||||
{
|
||||
/** @use HasFactory<BookingFactory> */
|
||||
use HasFactory, LogsActivity, SoftDeletes;
|
||||
|
||||
/**
|
||||
* Audit trail on status transitions and driver/vehicle assignment only —
|
||||
* not every column (domain.md §6, §5a; T6.2).
|
||||
*/
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logOnly(['status', 'driver_name', 'driver_phone', 'car_plate_number', 'car_model'])
|
||||
->logOnlyDirty()
|
||||
->dontLogEmptyChanges()
|
||||
->useLogName('booking');
|
||||
}
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'booking_ref',
|
||||
'user_id',
|
||||
'openid',
|
||||
'ev_route_id',
|
||||
'departure_time_slot_id',
|
||||
'travel_date',
|
||||
'passenger_name',
|
||||
'passenger_phone',
|
||||
'pickup_address',
|
||||
'pickup_lat',
|
||||
'pickup_lng',
|
||||
'dropoff_address',
|
||||
'dropoff_lat',
|
||||
'dropoff_lng',
|
||||
'price',
|
||||
'status',
|
||||
'is_round_trip',
|
||||
'return_travel_date',
|
||||
'created_by_channel',
|
||||
'driver_name',
|
||||
'driver_phone',
|
||||
'car_plate_number',
|
||||
'car_model',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'travel_date' => 'date',
|
||||
'pickup_lat' => 'decimal:7',
|
||||
'pickup_lng' => 'decimal:7',
|
||||
'dropoff_lat' => 'decimal:7',
|
||||
'dropoff_lng' => 'decimal:7',
|
||||
'price' => 'decimal:2',
|
||||
'status' => BookingStatus::class,
|
||||
'is_round_trip' => 'boolean',
|
||||
'return_travel_date' => 'date',
|
||||
'created_by_channel' => BookingChannel::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function route(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EvRoute::class, 'ev_route_id');
|
||||
}
|
||||
|
||||
public function timeSlot(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DepartureTimeSlot::class, 'departure_time_slot_id');
|
||||
}
|
||||
|
||||
public function vehicleOptions(): HasMany
|
||||
{
|
||||
return $this->hasMany(BookingVehicleOption::class);
|
||||
}
|
||||
|
||||
public function payments(): HasMany
|
||||
{
|
||||
return $this->hasMany(Payment::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Modules\Booking\Database\Factories\BookingVehicleOptionFactory;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
|
||||
/**
|
||||
* One Vehicle Option line on a Booking (e.g. "back_seat x2"). A booking can
|
||||
* have more than one of these — see domain.md §2.
|
||||
*/
|
||||
class BookingVehicleOption extends Model
|
||||
{
|
||||
/** @use HasFactory<BookingVehicleOptionFactory> */
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'booking_id',
|
||||
'vehicle_option',
|
||||
'passenger_count',
|
||||
'unit_price',
|
||||
'line_total',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'vehicle_option' => VehicleOption::class,
|
||||
'passenger_count' => 'integer',
|
||||
'unit_price' => 'decimal:2',
|
||||
'line_total' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
|
||||
public function booking(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Booking::class);
|
||||
}
|
||||
}
|
||||
@@ -3,22 +3,29 @@
|
||||
namespace Modules\Booking\Policies;
|
||||
|
||||
use App\Models\User;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Models\Booking;
|
||||
|
||||
/**
|
||||
* Skeleton only — role/permission gates for now. Per-booking ownership
|
||||
* checks (e.g. a customer may only view/cancel their own booking) are
|
||||
* filled in against the real Booking model once it exists (Phase 4).
|
||||
*/
|
||||
class BookingPolicy
|
||||
{
|
||||
/**
|
||||
* Listing is always scoped to the caller's own bookings at the query
|
||||
* level (BookingController::index) — any authenticated user may look at
|
||||
* their own list. Staff get the full, unscoped list via the Filament
|
||||
* BookingResource (T4.7), not this gate.
|
||||
*/
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->can('view_bookings');
|
||||
return true;
|
||||
}
|
||||
|
||||
public function view(User $user, mixed $booking): bool
|
||||
/**
|
||||
* A booking's owner may always view it; anyone else needs the
|
||||
* view_bookings permission (admin/support roles).
|
||||
*/
|
||||
public function view(User $user, Booking $booking): bool
|
||||
{
|
||||
return $user->can('view_bookings');
|
||||
return $user->id === $booking->user_id || $user->can('view_bookings');
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
@@ -26,13 +33,56 @@ class BookingPolicy
|
||||
return true;
|
||||
}
|
||||
|
||||
public function cancel(User $user, mixed $booking): bool
|
||||
/**
|
||||
* A booking's owner may cancel their own pending_payment booking; staff
|
||||
* can cancel any pending_payment booking via manage_bookings. Cancelling
|
||||
* a confirmed (paid) booking refunds it (CancelBookingAction, T5.12) —
|
||||
* that's the same authorization boundary as refund(), staff only
|
||||
* (domain.md §8: refund initiation is a staff-only operation).
|
||||
*/
|
||||
public function cancel(User $user, Booking $booking): bool
|
||||
{
|
||||
return $user->can('manage_bookings');
|
||||
if ($booking->status === BookingStatus::Confirmed) {
|
||||
return $user->can('process_refunds');
|
||||
}
|
||||
|
||||
return $user->id === $booking->user_id || $user->can('manage_bookings');
|
||||
}
|
||||
|
||||
public function refund(User $user, mixed $booking): bool
|
||||
{
|
||||
return $user->can('process_refunds');
|
||||
}
|
||||
|
||||
/**
|
||||
* A booking's owner may pay for their own (still pending_payment only —
|
||||
* enforced by InitiatePaymentAction, not here); staff can initiate on
|
||||
* behalf of a customer via manage_bookings.
|
||||
*/
|
||||
public function pay(User $user, Booking $booking): bool
|
||||
{
|
||||
return $user->id === $booking->user_id || $user->can('manage_bookings');
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff-only, and only once a booking is terminal (cancelled/expired) —
|
||||
* a pending_payment or confirmed (paid) booking must never be deleted
|
||||
* out from under an in-flight payment/refund flow. Soft delete only
|
||||
* (Booking uses SoftDeletes); Payment/Refund history stays intact.
|
||||
*/
|
||||
public function delete(User $user, Booking $booking): bool
|
||||
{
|
||||
return in_array($booking->status, [BookingStatus::Cancelled, BookingStatus::Expired], true)
|
||||
&& $user->can('manage_bookings');
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff-only. No status restriction beyond RestoreAction's own built-in
|
||||
* "only if trashed" visibility — a booking's status doesn't change on
|
||||
* delete, so whatever made it deletable still holds once restored.
|
||||
*/
|
||||
public function restore(User $user, Booking $booking): bool
|
||||
{
|
||||
return $user->can('manage_bookings');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Services;
|
||||
|
||||
use Modules\Booking\Models\Booking;
|
||||
|
||||
class BookingRefGenerator
|
||||
{
|
||||
private const PREFIX = 'EVB';
|
||||
|
||||
private const CHARS = '123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
|
||||
/**
|
||||
* Must be called inside the same DB::transaction() as the booking insert
|
||||
* — the row lock on the latest booking is what keeps concurrent callers
|
||||
* from generating the same ref, and it only holds for the transaction's
|
||||
* lifetime.
|
||||
*/
|
||||
public function generate(): string
|
||||
{
|
||||
// Lock the latest row so concurrent transactions can't read the same ref.
|
||||
$latest = Booking::lockForUpdate()->orderByDesc('id')->value('booking_ref');
|
||||
|
||||
// If the latest ref doesn't match the expected format, start the sequence fresh.
|
||||
if ($latest && preg_match('/^[A-Z]+-[A-Z0-9]+$/', $latest)) {
|
||||
return $this->incrementRef($latest);
|
||||
}
|
||||
|
||||
return self::PREFIX.'-AAAAA1';
|
||||
}
|
||||
|
||||
private function incrementRef(string $ref): string
|
||||
{
|
||||
preg_match('/^(.*)-([A-Z0-9]+)$/', $ref, $matches);
|
||||
|
||||
$prefix = $matches[1];
|
||||
$suffix = str_split($matches[2]);
|
||||
$base = strlen(self::CHARS);
|
||||
$i = count($suffix) - 1;
|
||||
$carry = true;
|
||||
|
||||
while ($i >= 0 && $carry) {
|
||||
$idx = strpos(self::CHARS, $suffix[$i]);
|
||||
|
||||
if ($idx + 1 < $base) {
|
||||
$suffix[$i] = self::CHARS[$idx + 1];
|
||||
$carry = false;
|
||||
} else {
|
||||
$suffix[$i] = self::CHARS[0];
|
||||
}
|
||||
$i--;
|
||||
}
|
||||
|
||||
if ($carry) {
|
||||
array_unshift($suffix, self::CHARS[0]);
|
||||
}
|
||||
|
||||
return $prefix.'-'.implode('', $suffix);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Services;
|
||||
|
||||
use Modules\Booking\Data\VehicleSelectionData;
|
||||
use Modules\Booking\Exceptions\InvalidVehicleSelectionException;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
|
||||
class BookingService
|
||||
{
|
||||
/**
|
||||
* Enforces the only v1 inventory rule (max Front Seats per booking), the
|
||||
* blunt config toggles for Back Seat / Whole Vehicle availability, and
|
||||
* shape rules around combining options in one booking (no duplicate
|
||||
* option lines, Whole Vehicle can't be mixed with anything else since it
|
||||
* already covers the whole car).
|
||||
*
|
||||
* Deliberately does not check real capacity/availability — that's an
|
||||
* explicitly deferred future phase (domain.md §2, §7).
|
||||
*
|
||||
* @param list<VehicleSelectionData> $selections
|
||||
*
|
||||
* @throws InvalidVehicleSelectionException
|
||||
*/
|
||||
public function validateSelections(array $selections): void
|
||||
{
|
||||
$seen = [];
|
||||
|
||||
foreach ($selections as $selection) {
|
||||
if (isset($seen[$selection->vehicleOption->value])) {
|
||||
throw InvalidVehicleSelectionException::duplicateOption($selection->vehicleOption);
|
||||
}
|
||||
|
||||
$seen[$selection->vehicleOption->value] = true;
|
||||
|
||||
$this->validateOption($selection->vehicleOption, $selection->passengerCount);
|
||||
}
|
||||
|
||||
if (isset($seen[VehicleOption::WholeVehicle->value]) && count($seen) > 1) {
|
||||
throw InvalidVehicleSelectionException::wholeVehicleCannotBeCombined();
|
||||
}
|
||||
}
|
||||
|
||||
private function validateOption(VehicleOption $vehicleOption, int $passengerCount): void
|
||||
{
|
||||
match ($vehicleOption) {
|
||||
VehicleOption::FrontSeat => $this->validateFrontSeat($passengerCount),
|
||||
VehicleOption::BackSeat => $this->validateEnabled($vehicleOption, 'booking.back_seat_enabled'),
|
||||
VehicleOption::WholeVehicle => $this->validateEnabled($vehicleOption, 'booking.whole_vehicle_enabled'),
|
||||
};
|
||||
}
|
||||
|
||||
private function validateFrontSeat(int $passengerCount): void
|
||||
{
|
||||
$max = config('booking.front_seat_max_per_booking');
|
||||
|
||||
if ($passengerCount > $max) {
|
||||
throw InvalidVehicleSelectionException::frontSeatLimitExceeded($passengerCount, $max);
|
||||
}
|
||||
}
|
||||
|
||||
private function validateEnabled(VehicleOption $vehicleOption, string $configKey): void
|
||||
{
|
||||
if (! config($configKey)) {
|
||||
throw InvalidVehicleSelectionException::optionDisabled($vehicleOption);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Payment\Contracts\PaymentGatewayInterface;
|
||||
use Modules\Payment\Data\PaymentRequestData;
|
||||
use Modules\Payment\Data\PaymentResultData;
|
||||
use Modules\Payment\Data\RefundResultData;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use Modules\Payment\Enums\RefundStatus;
|
||||
use Modules\Payment\Factories\PaymentGatewayFactory;
|
||||
use Modules\Payment\Models\Payment;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
|
||||
/**
|
||||
* Never calls the real KBZ refund API in tests.
|
||||
*/
|
||||
class FakeCancelApiRefundGateway implements PaymentGatewayInterface
|
||||
{
|
||||
public function initiate(PaymentRequestData $data): PaymentResultData
|
||||
{
|
||||
throw new RuntimeException('not needed for this test');
|
||||
}
|
||||
|
||||
public function verify(string $gatewayTransactionId): PaymentResultData
|
||||
{
|
||||
throw new RuntimeException('not needed for this test');
|
||||
}
|
||||
|
||||
public function refund(string $gatewayTransactionId, string $amount, string $reason): RefundResultData
|
||||
{
|
||||
return new RefundResultData(status: RefundStatus::Completed, gatewayRefundId: 'REFUND123', gatewayPayload: []);
|
||||
}
|
||||
|
||||
public function handleWebhook(array $payload): PaymentResultData
|
||||
{
|
||||
throw new RuntimeException('not needed for this test');
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
foreach (['manage_bookings', 'process_refunds'] as $permission) {
|
||||
Permission::findOrCreate($permission, 'web');
|
||||
}
|
||||
|
||||
app(PaymentGatewayFactory::class)->register(PaymentMethod::KbzMiniApp, FakeCancelApiRefundGateway::class);
|
||||
|
||||
$this->owner = User::factory()->create();
|
||||
$this->token = $this->owner->createToken('test-token')->plainTextToken;
|
||||
});
|
||||
|
||||
test('the owner can cancel their own pending_payment booking', function () {
|
||||
$booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::PendingPayment]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson("/api/v1/bookings/{$booking->booking_ref}/cancel")
|
||||
->assertSuccessful()
|
||||
->assertJsonPath('data.status', BookingStatus::Cancelled->value);
|
||||
|
||||
expect($booking->refresh()->status)->toBe(BookingStatus::Cancelled);
|
||||
});
|
||||
|
||||
test('the owner cannot cancel their own confirmed booking without process_refunds', function () {
|
||||
$booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::Confirmed]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson("/api/v1/bookings/{$booking->booking_ref}/cancel")
|
||||
->assertForbidden();
|
||||
|
||||
expect($booking->refresh()->status)->toBe(BookingStatus::Confirmed);
|
||||
});
|
||||
|
||||
test('staff with process_refunds can cancel a confirmed booking, which refunds it in full', function () {
|
||||
$staff = User::factory()->create()->givePermissionTo('process_refunds');
|
||||
$staffToken = $staff->createToken('staff-token')->plainTextToken;
|
||||
|
||||
$booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::Confirmed, 'price' => 15000]);
|
||||
Payment::factory()->completed()->create([
|
||||
'booking_id' => $booking->id,
|
||||
'gateway' => PaymentMethod::KbzMiniApp,
|
||||
'amount' => 15000,
|
||||
'gateway_transaction_id' => 'EVB-CANCEL-API-1',
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$staffToken}")
|
||||
->postJson("/api/v1/bookings/{$booking->booking_ref}/cancel")
|
||||
->assertSuccessful()
|
||||
->assertJsonPath('data.status', BookingStatus::Cancelled->value);
|
||||
|
||||
expect($booking->refresh()->status)->toBe(BookingStatus::Cancelled);
|
||||
});
|
||||
|
||||
test('cancelling a confirmed booking with no completed payment surfaces as 422 and leaves it untouched', function () {
|
||||
$staff = User::factory()->create()->givePermissionTo('process_refunds');
|
||||
$staffToken = $staff->createToken('staff-token')->plainTextToken;
|
||||
|
||||
$booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::Confirmed]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$staffToken}")
|
||||
->postJson("/api/v1/bookings/{$booking->booking_ref}/cancel")
|
||||
->assertStatus(422);
|
||||
|
||||
expect($booking->refresh()->status)->toBe(BookingStatus::Confirmed);
|
||||
});
|
||||
|
||||
test('a non-owner without manage_bookings cannot cancel someone else\'s booking', function () {
|
||||
$booking = Booking::factory()->create([
|
||||
'user_id' => User::factory()->create()->id,
|
||||
'status' => BookingStatus::PendingPayment,
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson("/api/v1/bookings/{$booking->booking_ref}/cancel")
|
||||
->assertForbidden();
|
||||
|
||||
expect($booking->refresh()->status)->toBe(BookingStatus::PendingPayment);
|
||||
});
|
||||
|
||||
test('staff with manage_bookings can cancel someone else\'s pending_payment booking', function () {
|
||||
$staff = User::factory()->create()->givePermissionTo('manage_bookings');
|
||||
$staffToken = $staff->createToken('staff-token')->plainTextToken;
|
||||
|
||||
$booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::PendingPayment]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$staffToken}")
|
||||
->postJson("/api/v1/bookings/{$booking->booking_ref}/cancel")
|
||||
->assertSuccessful();
|
||||
|
||||
expect($booking->refresh()->status)->toBe(BookingStatus::Cancelled);
|
||||
});
|
||||
|
||||
test('unauthenticated requests are rejected', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
|
||||
|
||||
$this->postJson("/api/v1/bookings/{$booking->booking_ref}/cancel")->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('404s for a booking that does not exist', function () {
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/bookings/EVB-DOES-NOT-EXIST/cancel')
|
||||
->assertNotFound();
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Catalog\Models\DepartureTimeSlot;
|
||||
use Modules\Routing\Models\EvRoute;
|
||||
use Modules\Routing\Models\RoutePricing;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->token = User::factory()->create()->createToken('test-token')->plainTextToken;
|
||||
});
|
||||
|
||||
/**
|
||||
* @param array<int, array{0: VehicleOption, 1: string}> $pricedOptions
|
||||
*/
|
||||
function bookableRouteAndSlot(array $pricedOptions): array
|
||||
{
|
||||
$route = EvRoute::factory()->create(['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];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{vehicle_option: string, passenger_count: int}> $selections
|
||||
*/
|
||||
function bookingPayload(EvRoute $route, DepartureTimeSlot $timeSlot, array $selections): array
|
||||
{
|
||||
return [
|
||||
'ev_route_id' => $route->id,
|
||||
'departure_time_slot_id' => $timeSlot->id,
|
||||
'travel_date' => now()->addDay()->toDateString(),
|
||||
'selections' => $selections,
|
||||
'passenger_name' => 'Jane Doe',
|
||||
'passenger_phone' => '+959123456789',
|
||||
'pickup_address' => '123 Pickup St',
|
||||
'dropoff_address' => '456 Dropoff Ave',
|
||||
];
|
||||
}
|
||||
|
||||
test('happy path: it creates a pending_payment booking with a snapshotted price', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
[$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.status', BookingStatus::PendingPayment->value)
|
||||
->assertJsonPath('data.price', '15000.00')
|
||||
->assertJsonPath('data.vehicle_options.0.vehicle_option', VehicleOption::BackSeat->value)
|
||||
->assertJsonPath('data.route.id', $route->id)
|
||||
->assertJsonPath('data.time_slot.id', $timeSlot->id);
|
||||
|
||||
expect(Booking::count())->toBe(1);
|
||||
});
|
||||
|
||||
test('happy path: front seat and back seat can be booked together', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
[$route, $timeSlot] = bookableRouteAndSlot([
|
||||
[VehicleOption::FrontSeat, '12000.00'],
|
||||
[VehicleOption::BackSeat, '9000.00'],
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [
|
||||
['vehicle_option' => 'front_seat', 'passenger_count' => 1],
|
||||
['vehicle_option' => 'back_seat', 'passenger_count' => 2],
|
||||
]))
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.price', '30000.00')
|
||||
->assertJsonCount(2, 'data.vehicle_options');
|
||||
});
|
||||
|
||||
test('front-seat-limit rejection surfaces as 422', function () {
|
||||
config(['booking.front_seat_max_per_booking' => 1]);
|
||||
|
||||
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::FrontSeat, '12000.00']]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [
|
||||
['vehicle_option' => 'front_seat', 'passenger_count' => 2],
|
||||
]))
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('message', 'Front seat request [2] exceeds the max of [1] per booking.');
|
||||
|
||||
expect(Booking::count())->toBe(0);
|
||||
});
|
||||
|
||||
test('disabled-vehicle-option rejection surfaces as 422', function () {
|
||||
config(['booking.whole_vehicle_enabled' => false]);
|
||||
|
||||
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::WholeVehicle, '30000.00']]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [
|
||||
['vehicle_option' => 'whole_vehicle', 'passenger_count' => 1],
|
||||
]))
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('message', 'Vehicle option [whole_vehicle] is not currently available for booking.');
|
||||
|
||||
expect(Booking::count())->toBe(0);
|
||||
});
|
||||
|
||||
test('mixing whole vehicle with another option surfaces as 422', function () {
|
||||
config([
|
||||
'booking.back_seat_enabled' => true,
|
||||
'booking.whole_vehicle_enabled' => true,
|
||||
]);
|
||||
|
||||
[$route, $timeSlot] = bookableRouteAndSlot([
|
||||
[VehicleOption::WholeVehicle, '30000.00'],
|
||||
[VehicleOption::BackSeat, '9000.00'],
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [
|
||||
['vehicle_option' => 'whole_vehicle', 'passenger_count' => 1],
|
||||
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
|
||||
]))
|
||||
->assertStatus(422);
|
||||
|
||||
expect(Booking::count())->toBe(0);
|
||||
});
|
||||
|
||||
test('unauthenticated requests are rejected', function () {
|
||||
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]);
|
||||
|
||||
$this->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [
|
||||
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
|
||||
]))->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('shape validation rejects a missing required field', function () {
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/bookings', [])
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors([
|
||||
'ev_route_id', 'departure_time_slot_id', 'travel_date', 'selections',
|
||||
'passenger_name', 'passenger_phone', 'pickup_address', 'dropoff_address',
|
||||
]);
|
||||
});
|
||||
|
||||
test('shape validation rejects an invalid vehicle_option value', function () {
|
||||
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]);
|
||||
|
||||
$payload = bookingPayload($route, $timeSlot, [
|
||||
['vehicle_option' => 'business_class', 'passenger_count' => 1],
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/bookings', $payload)
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors(['selections.0.vehicle_option']);
|
||||
});
|
||||
|
||||
test('shape validation rejects an empty selections array', function () {
|
||||
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, []))
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors(['selections']);
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Booking\Policies\BookingPolicy;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
|
||||
@@ -10,16 +12,37 @@ beforeEach(function () {
|
||||
}
|
||||
});
|
||||
|
||||
test('viewAny and view require the view_bookings permission', function () {
|
||||
test('viewAny is open to any authenticated user — listing is scoped to their own bookings at the query level', function () {
|
||||
$policy = new BookingPolicy;
|
||||
|
||||
$withPermission = User::factory()->create()->givePermissionTo('view_bookings');
|
||||
$withoutPermission = User::factory()->create();
|
||||
expect($policy->viewAny(User::factory()->create()))->toBeTrue();
|
||||
});
|
||||
|
||||
expect($policy->viewAny($withPermission))->toBeTrue()
|
||||
->and($policy->view($withPermission, null))->toBeTrue()
|
||||
->and($policy->viewAny($withoutPermission))->toBeFalse()
|
||||
->and($policy->view($withoutPermission, null))->toBeFalse();
|
||||
test('view allows the booking\'s owner', function () {
|
||||
$policy = new BookingPolicy;
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$booking = Booking::factory()->create(['user_id' => $owner->id]);
|
||||
|
||||
expect($policy->view($owner, $booking))->toBeTrue();
|
||||
});
|
||||
|
||||
test('view rejects a non-owner without the view_bookings permission', function () {
|
||||
$policy = new BookingPolicy;
|
||||
|
||||
$stranger = User::factory()->create();
|
||||
$booking = Booking::factory()->create(['user_id' => User::factory()->create()->id]);
|
||||
|
||||
expect($policy->view($stranger, $booking))->toBeFalse();
|
||||
});
|
||||
|
||||
test('view allows a non-owner with the view_bookings permission (admin/support)', function () {
|
||||
$policy = new BookingPolicy;
|
||||
|
||||
$admin = User::factory()->create()->givePermissionTo('view_bookings');
|
||||
$booking = Booking::factory()->create(['user_id' => User::factory()->create()->id]);
|
||||
|
||||
expect($policy->view($admin, $booking))->toBeTrue();
|
||||
});
|
||||
|
||||
test('create is open to any authenticated user', function () {
|
||||
@@ -28,14 +51,31 @@ test('create is open to any authenticated user', function () {
|
||||
expect($policy->create(User::factory()->create()))->toBeTrue();
|
||||
});
|
||||
|
||||
test('cancel requires the manage_bookings permission', function () {
|
||||
test('cancel allows the booking\'s owner', function () {
|
||||
$policy = new BookingPolicy;
|
||||
|
||||
$withPermission = User::factory()->create()->givePermissionTo('manage_bookings');
|
||||
$withoutPermission = User::factory()->create();
|
||||
$owner = User::factory()->create();
|
||||
$booking = Booking::factory()->create(['user_id' => $owner->id]);
|
||||
|
||||
expect($policy->cancel($withPermission, null))->toBeTrue()
|
||||
->and($policy->cancel($withoutPermission, null))->toBeFalse();
|
||||
expect($policy->cancel($owner, $booking))->toBeTrue();
|
||||
});
|
||||
|
||||
test('cancel allows staff with the manage_bookings permission on someone else\'s booking', function () {
|
||||
$policy = new BookingPolicy;
|
||||
|
||||
$staff = User::factory()->create()->givePermissionTo('manage_bookings');
|
||||
$booking = Booking::factory()->create(['user_id' => User::factory()->create()->id]);
|
||||
|
||||
expect($policy->cancel($staff, $booking))->toBeTrue();
|
||||
});
|
||||
|
||||
test('cancel rejects a non-owner without the manage_bookings permission', function () {
|
||||
$policy = new BookingPolicy;
|
||||
|
||||
$stranger = User::factory()->create();
|
||||
$booking = Booking::factory()->create(['user_id' => User::factory()->create()->id]);
|
||||
|
||||
expect($policy->cancel($stranger, $booking))->toBeFalse();
|
||||
});
|
||||
|
||||
test('refund requires the process_refunds permission', function () {
|
||||
@@ -47,3 +87,53 @@ test('refund requires the process_refunds permission', function () {
|
||||
expect($policy->refund($withPermission, null))->toBeTrue()
|
||||
->and($policy->refund($withoutPermission, null))->toBeFalse();
|
||||
});
|
||||
|
||||
test('delete allows staff with manage_bookings on a cancelled booking', function () {
|
||||
$policy = new BookingPolicy;
|
||||
|
||||
$staff = User::factory()->create()->givePermissionTo('manage_bookings');
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Cancelled]);
|
||||
|
||||
expect($policy->delete($staff, $booking))->toBeTrue();
|
||||
});
|
||||
|
||||
test('delete allows staff with manage_bookings on an expired booking', function () {
|
||||
$policy = new BookingPolicy;
|
||||
|
||||
$staff = User::factory()->create()->givePermissionTo('manage_bookings');
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Expired]);
|
||||
|
||||
expect($policy->delete($staff, $booking))->toBeTrue();
|
||||
});
|
||||
|
||||
test('delete rejects a pending_payment or confirmed booking even with manage_bookings', function () {
|
||||
$policy = new BookingPolicy;
|
||||
|
||||
$staff = User::factory()->create()->givePermissionTo('manage_bookings');
|
||||
|
||||
$pending = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
|
||||
$confirmed = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
expect($policy->delete($staff, $pending))->toBeFalse()
|
||||
->and($policy->delete($staff, $confirmed))->toBeFalse();
|
||||
});
|
||||
|
||||
test('delete rejects a cancelled booking without manage_bookings, even for the owner', function () {
|
||||
$policy = new BookingPolicy;
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$booking = Booking::factory()->create(['user_id' => $owner->id, 'status' => BookingStatus::Cancelled]);
|
||||
|
||||
expect($policy->delete($owner, $booking))->toBeFalse();
|
||||
});
|
||||
|
||||
test('restore requires the manage_bookings permission', function () {
|
||||
$policy = new BookingPolicy;
|
||||
|
||||
$staff = User::factory()->create()->givePermissionTo('manage_bookings');
|
||||
$stranger = User::factory()->create();
|
||||
$booking = Booking::factory()->create();
|
||||
|
||||
expect($policy->restore($staff, $booking))->toBeTrue()
|
||||
->and($policy->restore($stranger, $booking))->toBeFalse();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
|
||||
beforeEach(function () {
|
||||
Permission::findOrCreate('view_bookings', 'web');
|
||||
|
||||
$this->owner = User::factory()->create();
|
||||
$this->token = $this->owner->createToken('test-token')->plainTextToken;
|
||||
});
|
||||
|
||||
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()]);
|
||||
$mineNewer = Booking::factory()->create(['user_id' => $this->owner->id]);
|
||||
Booking::factory()->create(['user_id' => User::factory()->create()->id]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson('/api/v1/bookings')
|
||||
->assertSuccessful()
|
||||
->assertJsonCount(2, 'data')
|
||||
->assertJsonPath('data.0.id', $mineNewer->id)
|
||||
->assertJsonPath('data.1.id', $mine->id);
|
||||
});
|
||||
|
||||
test('index rejects unauthenticated requests', function () {
|
||||
$this->getJson('/api/v1/bookings')->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('show allows the owner to view their own booking', function () {
|
||||
$booking = Booking::factory()->create(['user_id' => $this->owner->id]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson("/api/v1/bookings/{$booking->booking_ref}")
|
||||
->assertSuccessful()
|
||||
->assertJsonPath('data.id', $booking->id);
|
||||
});
|
||||
|
||||
test('show rejects a non-owner without the view_bookings permission', function () {
|
||||
$booking = Booking::factory()->create(['user_id' => User::factory()->create()->id]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson("/api/v1/bookings/{$booking->booking_ref}")
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('show allows an admin/support user (view_bookings permission) to view someone else\'s booking', function () {
|
||||
$admin = User::factory()->create();
|
||||
$admin->givePermissionTo('view_bookings');
|
||||
$adminToken = $admin->createToken('admin-token')->plainTextToken;
|
||||
|
||||
$booking = Booking::factory()->create(['user_id' => $this->owner->id]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$adminToken}")
|
||||
->getJson("/api/v1/bookings/{$booking->booking_ref}")
|
||||
->assertSuccessful()
|
||||
->assertJsonPath('data.id', $booking->id);
|
||||
});
|
||||
|
||||
test('show rejects unauthenticated requests', function () {
|
||||
$booking = Booking::factory()->create();
|
||||
|
||||
$this->getJson("/api/v1/bookings/{$booking->id}")->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('show 404s for a booking that does not exist', function () {
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson('/api/v1/bookings/EVB-DOES-NOT-EXIST')
|
||||
->assertNotFound();
|
||||
});
|
||||
@@ -0,0 +1,338 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Pages\ListBookings;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Pages\ViewBooking;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Booking\Models\BookingVehicleOption;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
use Modules\Payment\Models\Payment;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
|
||||
beforeEach(function () {
|
||||
foreach (['view_bookings', 'manage_bookings', 'process_refunds'] as $permission) {
|
||||
Permission::findOrCreate($permission, 'web');
|
||||
}
|
||||
|
||||
$this->admin = User::factory()->create()->givePermissionTo(['view_bookings', 'manage_bookings', 'process_refunds']);
|
||||
$this->actingAs($this->admin);
|
||||
});
|
||||
|
||||
test('can list bookings', function () {
|
||||
$bookings = Booking::factory()->count(3)->create();
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertOk()
|
||||
->assertCanSeeTableRecords($bookings);
|
||||
});
|
||||
|
||||
test('can filter bookings by status', function () {
|
||||
$pending = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
|
||||
$confirmed = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->filterTable('status', BookingStatus::PendingPayment->value)
|
||||
->assertCanSeeTableRecords([$pending])
|
||||
->assertCanNotSeeTableRecords([$confirmed]);
|
||||
});
|
||||
|
||||
test('the cancel action is visible and enabled for a pending_payment booking', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertTableActionVisible('cancel', $booking)
|
||||
->assertTableActionEnabled('cancel', $booking);
|
||||
});
|
||||
|
||||
test('the cancel action is visible but disabled for a confirmed booking', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertTableActionVisible('cancel', $booking)
|
||||
->assertTableActionDisabled('cancel', $booking);
|
||||
});
|
||||
|
||||
test('the cancel action is hidden from a user without manage_bookings and not the owner', function () {
|
||||
$stranger = User::factory()->create();
|
||||
$this->actingAs($stranger);
|
||||
|
||||
$booking = Booking::factory()->create(['user_id' => User::factory()->create()->id, 'status' => BookingStatus::PendingPayment]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertTableActionHidden('cancel', $booking);
|
||||
});
|
||||
|
||||
test('calling the cancel action cancels a pending_payment booking', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->callTableAction('cancel', $booking)
|
||||
->assertNotified();
|
||||
|
||||
expect($booking->refresh()->status)->toBe(BookingStatus::Cancelled);
|
||||
});
|
||||
|
||||
test('the view action is visible for a user with view_bookings', function () {
|
||||
$booking = Booking::factory()->create();
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertTableActionVisible('view', $booking);
|
||||
});
|
||||
|
||||
test('the view action is hidden from a non-owner without view_bookings', function () {
|
||||
$stranger = User::factory()->create();
|
||||
$this->actingAs($stranger);
|
||||
|
||||
$booking = Booking::factory()->create(['user_id' => User::factory()->create()->id]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertTableActionHidden('view', $booking);
|
||||
});
|
||||
|
||||
test('can view a booking\'s detail page', function () {
|
||||
$booking = Booking::factory()->create([
|
||||
'passenger_name' => 'Jane Doe',
|
||||
'passenger_phone' => '+959123456789',
|
||||
]);
|
||||
|
||||
BookingVehicleOption::factory()->create([
|
||||
'booking_id' => $booking->id,
|
||||
'vehicle_option' => VehicleOption::BackSeat,
|
||||
'passenger_count' => 2,
|
||||
'unit_price' => 9000,
|
||||
'line_total' => 18000,
|
||||
]);
|
||||
|
||||
Livewire::test(ViewBooking::class, ['record' => $booking->getRouteKey()])
|
||||
->assertOk()
|
||||
->assertSee($booking->booking_ref)
|
||||
->assertSee('Jane Doe')
|
||||
->assertSee('+959123456789')
|
||||
->assertSee($booking->route->company->name)
|
||||
->assertSee($booking->pickup_address)
|
||||
->assertSee($booking->dropoff_address);
|
||||
});
|
||||
|
||||
test('the booking detail page shows its related payments', function () {
|
||||
$booking = Booking::factory()->create();
|
||||
|
||||
Payment::factory()->completed()->create([
|
||||
'booking_id' => $booking->id,
|
||||
'gateway' => PaymentMethod::KbzMiniApp,
|
||||
'gateway_transaction_id' => 'EVB-INFOLIST-TEST-1',
|
||||
]);
|
||||
|
||||
Livewire::test(ViewBooking::class, ['record' => $booking->getRouteKey()])
|
||||
->assertOk()
|
||||
->assertSee('EVB-INFOLIST-TEST-1')
|
||||
->assertSee(PaymentStatus::Completed->value);
|
||||
});
|
||||
|
||||
test('the booking detail page shows a placeholder when there are no payments yet', function () {
|
||||
$booking = Booking::factory()->create();
|
||||
|
||||
Livewire::test(ViewBooking::class, ['record' => $booking->getRouteKey()])
|
||||
->assertOk()
|
||||
->assertSee('No payment attempts yet.');
|
||||
});
|
||||
|
||||
test('the assign driver action is visible for a confirmed booking and hidden otherwise', function () {
|
||||
$confirmed = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
$pending = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertTableActionVisible('assignDriver', $confirmed)
|
||||
->assertTableActionHidden('assignDriver', $pending);
|
||||
});
|
||||
|
||||
test('the assign driver action is hidden from a user without manage_bookings', function () {
|
||||
$viewer = User::factory()->create()->givePermissionTo('view_bookings');
|
||||
$this->actingAs($viewer);
|
||||
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertTableActionHidden('assignDriver', $booking);
|
||||
});
|
||||
|
||||
test('calling the assign driver action sets driver and car details on a confirmed booking', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->callTableAction('assignDriver', $booking, data: [
|
||||
'driver_name' => 'U Aung',
|
||||
'driver_phone' => '+959111222333',
|
||||
'car_plate_number' => 'YGN-1234',
|
||||
'car_model' => 'Tesla Model Y',
|
||||
])
|
||||
->assertNotified();
|
||||
|
||||
$booking->refresh();
|
||||
|
||||
expect($booking->driver_name)->toBe('U Aung')
|
||||
->and($booking->driver_phone)->toBe('+959111222333')
|
||||
->and($booking->car_plate_number)->toBe('YGN-1234')
|
||||
->and($booking->car_model)->toBe('Tesla Model Y');
|
||||
});
|
||||
|
||||
test('the assign driver form requires driver_name, driver_phone, and car_plate_number', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->callTableAction('assignDriver', $booking, data: [
|
||||
'driver_name' => '',
|
||||
'driver_phone' => '',
|
||||
'car_plate_number' => '',
|
||||
])
|
||||
->assertHasTableActionErrors(['driver_name' => 'required', 'driver_phone' => 'required', 'car_plate_number' => 'required']);
|
||||
|
||||
expect($booking->refresh()->driver_name)->toBeNull();
|
||||
});
|
||||
|
||||
test('the assign driver form is pre-filled with the booking\'s existing driver/car details', function () {
|
||||
$booking = Booking::factory()->create([
|
||||
'status' => BookingStatus::Confirmed,
|
||||
'driver_name' => 'U Aung',
|
||||
'driver_phone' => '+959111222333',
|
||||
'car_plate_number' => 'YGN-1234',
|
||||
'car_model' => 'Tesla Model Y',
|
||||
]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->mountTableAction('assignDriver', $booking)
|
||||
->assertTableActionDataSet([
|
||||
'driver_name' => 'U Aung',
|
||||
'driver_phone' => '+959111222333',
|
||||
'car_plate_number' => 'YGN-1234',
|
||||
'car_model' => 'Tesla Model Y',
|
||||
]);
|
||||
});
|
||||
|
||||
test('the detail page also has assign driver and cancel actions, shared with the table', function () {
|
||||
$confirmed = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
Livewire::test(ViewBooking::class, ['record' => $confirmed->getRouteKey()])
|
||||
->assertActionVisible('assignDriver')
|
||||
->assertActionVisible('cancel')
|
||||
->assertActionDisabled('cancel');
|
||||
});
|
||||
|
||||
test('calling assign driver from the detail page sets driver and car details', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
Livewire::test(ViewBooking::class, ['record' => $booking->getRouteKey()])
|
||||
->callAction('assignDriver', data: [
|
||||
'driver_name' => 'U Aung',
|
||||
'driver_phone' => '+959111222333',
|
||||
'car_plate_number' => 'YGN-1234',
|
||||
'car_model' => 'Tesla Model Y',
|
||||
])
|
||||
->assertNotified();
|
||||
|
||||
expect($booking->refresh()->driver_name)->toBe('U Aung');
|
||||
});
|
||||
|
||||
test('the detail page\'s assign driver action is hidden for a pending_payment booking', function () {
|
||||
$pending = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
|
||||
|
||||
Livewire::test(ViewBooking::class, ['record' => $pending->getRouteKey()])
|
||||
->assertActionHidden('assignDriver')
|
||||
->assertActionEnabled('cancel');
|
||||
});
|
||||
|
||||
test('the delete action is hidden for a pending_payment or confirmed booking, even with manage_bookings', function () {
|
||||
$pending = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
|
||||
$confirmed = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
// authorize('delete') ties visibility straight to BookingPolicy::delete
|
||||
// (status + permission combined) — a non-terminal booking never shows
|
||||
// this button at all, rather than a dead disabled one.
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertTableActionHidden('delete', $pending)
|
||||
->assertTableActionHidden('delete', $confirmed);
|
||||
});
|
||||
|
||||
test('the delete action is visible and enabled for a cancelled or expired booking', function () {
|
||||
$cancelled = Booking::factory()->create(['status' => BookingStatus::Cancelled]);
|
||||
$expired = Booking::factory()->create(['status' => BookingStatus::Expired]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertTableActionVisible('delete', $cancelled)
|
||||
->assertTableActionEnabled('delete', $cancelled)
|
||||
->assertTableActionVisible('delete', $expired)
|
||||
->assertTableActionEnabled('delete', $expired);
|
||||
});
|
||||
|
||||
test('the delete action is hidden from a user without manage_bookings', function () {
|
||||
$stranger = User::factory()->create();
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Cancelled]);
|
||||
|
||||
$this->actingAs($stranger);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertTableActionHidden('delete', $booking);
|
||||
});
|
||||
|
||||
test('deleting a cancelled booking soft-deletes it', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Cancelled]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->callTableAction('delete', $booking)
|
||||
->assertSuccessful();
|
||||
|
||||
expect(Booking::find($booking->id))->toBeNull();
|
||||
expect(Booking::withTrashed()->find($booking->id))->not->toBeNull();
|
||||
expect(Booking::withTrashed()->find($booking->id)->trashed())->toBeTrue();
|
||||
});
|
||||
|
||||
test('a soft-deleted booking is hidden from the default list but visible via the trashed filter', function () {
|
||||
$active = Booking::factory()->create();
|
||||
$deleted = Booking::factory()->create();
|
||||
$deleted->delete();
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertCanSeeTableRecords([$active])
|
||||
->assertCanNotSeeTableRecords([$deleted])
|
||||
->filterTable('trashed', true)
|
||||
->assertCanSeeTableRecords([$active, $deleted]);
|
||||
});
|
||||
|
||||
test('the restore action is only visible for a trashed booking', function () {
|
||||
$active = Booking::factory()->create();
|
||||
$deleted = Booking::factory()->create();
|
||||
$deleted->delete();
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->filterTable('trashed', true)
|
||||
->assertTableActionHidden('restore', $active)
|
||||
->assertTableActionVisible('restore', $deleted);
|
||||
});
|
||||
|
||||
test('restoring a deleted booking brings it back', function () {
|
||||
$booking = Booking::factory()->create();
|
||||
$booking->delete();
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->filterTable('trashed', true)
|
||||
->callTableAction('restore', $booking)
|
||||
->assertSuccessful();
|
||||
|
||||
expect(Booking::find($booking->id))->not->toBeNull();
|
||||
expect(Booking::find($booking->id)->trashed())->toBeFalse();
|
||||
});
|
||||
|
||||
test('the restore action is hidden from a user without manage_bookings', function () {
|
||||
$stranger = User::factory()->create();
|
||||
$booking = Booking::factory()->create();
|
||||
$booking->delete();
|
||||
|
||||
$this->actingAs($stranger);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->filterTable('trashed', true)
|
||||
->assertTableActionHidden('restore', $booking);
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\QueryException;
|
||||
use Modules\Booking\Enums\BookingChannel;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Booking\Models\BookingVehicleOption;
|
||||
use Modules\Catalog\Models\DepartureTimeSlot;
|
||||
use Modules\Routing\Models\EvRoute;
|
||||
use Modules\Routing\Models\RoutePricing;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
|
||||
test('a booking belongs to a route and a time slot', function () {
|
||||
$route = EvRoute::factory()->create();
|
||||
$timeSlot = DepartureTimeSlot::factory()->create();
|
||||
|
||||
$booking = Booking::factory()->create([
|
||||
'ev_route_id' => $route->id,
|
||||
'departure_time_slot_id' => $timeSlot->id,
|
||||
]);
|
||||
|
||||
expect($booking->route)->toBeInstanceOf(EvRoute::class)
|
||||
->and($booking->route->is($route))->toBeTrue()
|
||||
->and($booking->timeSlot)->toBeInstanceOf(DepartureTimeSlot::class)
|
||||
->and($booking->timeSlot->is($timeSlot))->toBeTrue();
|
||||
});
|
||||
|
||||
test('booking_ref is unique', function () {
|
||||
Booking::factory()->create(['booking_ref' => 'EVB-DUPLICATE']);
|
||||
|
||||
expect(fn () => Booking::factory()->create(['booking_ref' => 'EVB-DUPLICATE']))
|
||||
->toThrow(QueryException::class);
|
||||
});
|
||||
|
||||
test('status and created_by_channel cast to their enums', function () {
|
||||
$booking = Booking::factory()->create([
|
||||
'status' => BookingStatus::Confirmed,
|
||||
'created_by_channel' => BookingChannel::Android,
|
||||
]);
|
||||
|
||||
expect($booking->status)->toBe(BookingStatus::Confirmed)
|
||||
->and($booking->created_by_channel)->toBe(BookingChannel::Android);
|
||||
});
|
||||
|
||||
test('a booking defaults to pending_payment', function () {
|
||||
$booking = Booking::factory()->create();
|
||||
|
||||
expect($booking->status)->toBe(BookingStatus::PendingPayment);
|
||||
});
|
||||
|
||||
test('a booking can have multiple vehicle option lines, e.g. front seat and back seat together', function () {
|
||||
$booking = Booking::factory()->create();
|
||||
|
||||
BookingVehicleOption::factory()->create([
|
||||
'booking_id' => $booking->id,
|
||||
'vehicle_option' => VehicleOption::FrontSeat,
|
||||
'passenger_count' => 1,
|
||||
'unit_price' => 12000,
|
||||
'line_total' => 12000,
|
||||
]);
|
||||
BookingVehicleOption::factory()->create([
|
||||
'booking_id' => $booking->id,
|
||||
'vehicle_option' => VehicleOption::BackSeat,
|
||||
'passenger_count' => 2,
|
||||
'unit_price' => 9000,
|
||||
'line_total' => 18000,
|
||||
]);
|
||||
|
||||
expect($booking->vehicleOptions)->toHaveCount(2)
|
||||
->and($booking->vehicleOptions->pluck('vehicle_option')->map(fn ($option) => $option->value)->sort()->values()->all())
|
||||
->toEqual(['back_seat', 'front_seat']);
|
||||
});
|
||||
|
||||
test('a vehicle option line cannot be duplicated on the same booking', function () {
|
||||
$booking = Booking::factory()->create();
|
||||
|
||||
BookingVehicleOption::factory()->create([
|
||||
'booking_id' => $booking->id,
|
||||
'vehicle_option' => VehicleOption::BackSeat,
|
||||
]);
|
||||
|
||||
expect(fn () => BookingVehicleOption::factory()->create([
|
||||
'booking_id' => $booking->id,
|
||||
'vehicle_option' => VehicleOption::BackSeat,
|
||||
]))->toThrow(QueryException::class);
|
||||
});
|
||||
|
||||
test('price is snapshotted onto the booking and does not change when RoutePricing is edited later', function () {
|
||||
$route = EvRoute::factory()->create();
|
||||
|
||||
$pricing = RoutePricing::factory()->create([
|
||||
'ev_route_id' => $route->id,
|
||||
'vehicle_option' => VehicleOption::BackSeat,
|
||||
'price' => 15000,
|
||||
]);
|
||||
|
||||
$booking = Booking::factory()->create([
|
||||
'ev_route_id' => $route->id,
|
||||
'price' => $pricing->price,
|
||||
]);
|
||||
|
||||
BookingVehicleOption::factory()->create([
|
||||
'booking_id' => $booking->id,
|
||||
'vehicle_option' => VehicleOption::BackSeat,
|
||||
'unit_price' => $pricing->price,
|
||||
'line_total' => $pricing->price,
|
||||
]);
|
||||
|
||||
$pricing->update(['price' => 25000]);
|
||||
|
||||
expect($booking->refresh()->price)->toEqual('15000.00')
|
||||
->and($booking->vehicleOptions()->first()->unit_price)->toEqual('15000.00')
|
||||
->and($pricing->refresh()->price)->toEqual('25000.00');
|
||||
});
|
||||
|
||||
test('a booking can have a user or be guest-checked-out via mini app openid', function () {
|
||||
$guestBooking = Booking::factory()->create([
|
||||
'user_id' => null,
|
||||
'openid' => 'mini-app-openid-123',
|
||||
]);
|
||||
|
||||
expect($guestBooking->user_id)->toBeNull()
|
||||
->and($guestBooking->openid)->toBe('mini-app-openid-123');
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Modules\Booking\Actions\CreateBookingAction;
|
||||
use Modules\Booking\Data\CreateBookingData;
|
||||
use Modules\Booking\Data\VehicleSelectionData;
|
||||
use Modules\Booking\Enums\BookingChannel;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Events\BookingCreated;
|
||||
use Modules\Booking\Exceptions\InvalidVehicleSelectionException;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Catalog\Models\DepartureTimeSlot;
|
||||
use Modules\Routing\Models\EvRoute;
|
||||
use Modules\Routing\Models\RoutePricing;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
|
||||
/**
|
||||
* @param array<int, array{0: VehicleOption, 1: string}> $pricedOptions
|
||||
*/
|
||||
function makeBookableRoute(array $pricedOptions): array
|
||||
{
|
||||
$route = EvRoute::factory()->create();
|
||||
$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];
|
||||
}
|
||||
|
||||
function bookingData(EvRoute $route, DepartureTimeSlot $timeSlot, array $selections): CreateBookingData
|
||||
{
|
||||
return new CreateBookingData(
|
||||
evRouteId: $route->id,
|
||||
departureTimeSlotId: $timeSlot->id,
|
||||
travelDate: now()->addDay()->toDateString(),
|
||||
selections: $selections,
|
||||
passengerName: 'Jane Doe',
|
||||
passengerPhone: '+959123456789',
|
||||
pickupAddress: '123 Pickup St',
|
||||
dropoffAddress: '456 Dropoff Ave',
|
||||
createdByChannel: BookingChannel::MiniApp,
|
||||
openid: 'mini-app-openid-123',
|
||||
);
|
||||
}
|
||||
|
||||
test('it persists a pending_payment booking with the price snapshotted from PricingService', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
[$route, $timeSlot] = makeBookableRoute([[VehicleOption::BackSeat, '15000.00']]);
|
||||
|
||||
$booking = app(CreateBookingAction::class)->handle(
|
||||
bookingData($route, $timeSlot, [new VehicleSelectionData(VehicleOption::BackSeat)])
|
||||
);
|
||||
|
||||
expect($booking->exists)->toBeTrue()
|
||||
->and($booking->booking_ref)->toBe('EVB-AAAAA1')
|
||||
->and($booking->status)->toBe(BookingStatus::PendingPayment)
|
||||
->and($booking->price)->toEqual('15000.00')
|
||||
->and($booking->ev_route_id)->toBe($route->id)
|
||||
->and($booking->departure_time_slot_id)->toBe($timeSlot->id)
|
||||
->and($booking->openid)->toBe('mini-app-openid-123')
|
||||
->and($booking->vehicleOptions)->toHaveCount(1)
|
||||
->and($booking->vehicleOptions->first()->vehicle_option)->toBe(VehicleOption::BackSeat)
|
||||
->and($booking->vehicleOptions->first()->unit_price)->toEqual('15000.00');
|
||||
});
|
||||
|
||||
test('it books front seat and back seat together and sums the price across both lines', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
[$route, $timeSlot] = makeBookableRoute([
|
||||
[VehicleOption::FrontSeat, '12000.00'],
|
||||
[VehicleOption::BackSeat, '9000.00'],
|
||||
]);
|
||||
|
||||
$booking = app(CreateBookingAction::class)->handle(bookingData($route, $timeSlot, [
|
||||
new VehicleSelectionData(VehicleOption::FrontSeat, 1),
|
||||
new VehicleSelectionData(VehicleOption::BackSeat, 2),
|
||||
]));
|
||||
|
||||
expect($booking->price)->toEqual('30000.00') // 12000 + (9000 * 2)
|
||||
->and($booking->vehicleOptions)->toHaveCount(2);
|
||||
|
||||
$frontSeatLine = $booking->vehicleOptions->firstWhere('vehicle_option', VehicleOption::FrontSeat);
|
||||
$backSeatLine = $booking->vehicleOptions->firstWhere('vehicle_option', VehicleOption::BackSeat);
|
||||
|
||||
expect($frontSeatLine->passenger_count)->toBe(1)
|
||||
->and($frontSeatLine->line_total)->toEqual('12000.00')
|
||||
->and($backSeatLine->passenger_count)->toBe(2)
|
||||
->and($backSeatLine->line_total)->toEqual('18000.00');
|
||||
});
|
||||
|
||||
test('it dispatches BookingCreated', function () {
|
||||
Event::fake([BookingCreated::class]);
|
||||
config(['booking.whole_vehicle_enabled' => true]);
|
||||
|
||||
[$route, $timeSlot] = makeBookableRoute([[VehicleOption::WholeVehicle, '30000.00']]);
|
||||
|
||||
$booking = app(CreateBookingAction::class)->handle(
|
||||
bookingData($route, $timeSlot, [new VehicleSelectionData(VehicleOption::WholeVehicle)])
|
||||
);
|
||||
|
||||
Event::assertDispatched(BookingCreated::class, fn (BookingCreated $event) => $event->booking->is($booking));
|
||||
});
|
||||
|
||||
test('it rejects a disabled vehicle option before touching the database', function () {
|
||||
config(['booking.whole_vehicle_enabled' => false]);
|
||||
|
||||
[$route, $timeSlot] = makeBookableRoute([[VehicleOption::WholeVehicle, '30000.00']]);
|
||||
|
||||
expect(fn () => app(CreateBookingAction::class)->handle(
|
||||
bookingData($route, $timeSlot, [new VehicleSelectionData(VehicleOption::WholeVehicle)])
|
||||
))->toThrow(InvalidVehicleSelectionException::class);
|
||||
|
||||
expect(Booking::count())->toBe(0);
|
||||
});
|
||||
|
||||
test('it rejects mixing whole vehicle with another option before touching the database', function () {
|
||||
config([
|
||||
'booking.back_seat_enabled' => true,
|
||||
'booking.whole_vehicle_enabled' => true,
|
||||
]);
|
||||
|
||||
[$route, $timeSlot] = makeBookableRoute([
|
||||
[VehicleOption::WholeVehicle, '30000.00'],
|
||||
[VehicleOption::BackSeat, '9000.00'],
|
||||
]);
|
||||
|
||||
expect(fn () => app(CreateBookingAction::class)->handle(bookingData($route, $timeSlot, [
|
||||
new VehicleSelectionData(VehicleOption::WholeVehicle),
|
||||
new VehicleSelectionData(VehicleOption::BackSeat),
|
||||
])))->toThrow(InvalidVehicleSelectionException::class);
|
||||
|
||||
expect(Booking::count())->toBe(0);
|
||||
});
|
||||
|
||||
test('each booking created gets a unique, sequential booking_ref', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
[$route, $timeSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]);
|
||||
|
||||
$first = app(CreateBookingAction::class)->handle(bookingData($route, $timeSlot, [new VehicleSelectionData(VehicleOption::BackSeat)]));
|
||||
$second = app(CreateBookingAction::class)->handle(bookingData($route, $timeSlot, [new VehicleSelectionData(VehicleOption::BackSeat)]));
|
||||
|
||||
expect($first->booking_ref)->toBe('EVB-AAAAA1')
|
||||
->and($second->booking_ref)->toBe('EVB-AAAAA2');
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
use Modules\Booking\Actions\AssignDriverAction;
|
||||
use Modules\Booking\Data\AssignDriverData;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Exceptions\DriverAssignmentNotAllowedException;
|
||||
use Modules\Booking\Models\Booking;
|
||||
|
||||
test('it assigns driver and car details to a confirmed booking', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
$updated = (new AssignDriverAction)->handle($booking, new AssignDriverData(
|
||||
driverName: 'U Aung',
|
||||
driverPhone: '+959111222333',
|
||||
carPlateNumber: 'YGN-1234',
|
||||
carModel: 'Tesla Model Y',
|
||||
));
|
||||
|
||||
expect($updated->driver_name)->toBe('U Aung')
|
||||
->and($updated->driver_phone)->toBe('+959111222333')
|
||||
->and($updated->car_plate_number)->toBe('YGN-1234')
|
||||
->and($updated->car_model)->toBe('Tesla Model Y')
|
||||
->and($booking->refresh()->driver_name)->toBe('U Aung');
|
||||
});
|
||||
|
||||
test('car_model is optional', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
$updated = (new AssignDriverAction)->handle($booking, new AssignDriverData(
|
||||
driverName: 'U Aung',
|
||||
driverPhone: '+959111222333',
|
||||
carPlateNumber: 'YGN-1234',
|
||||
));
|
||||
|
||||
expect($updated->car_model)->toBeNull();
|
||||
});
|
||||
|
||||
test('it guards against assigning a driver to a pending_payment booking', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
|
||||
|
||||
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 guards against assigning a driver to a cancelled booking', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Cancelled]);
|
||||
|
||||
expect(fn () => (new AssignDriverAction)->handle($booking, new AssignDriverData(
|
||||
driverName: 'U Aung',
|
||||
driverPhone: '+959111222333',
|
||||
carPlateNumber: 'YGN-1234',
|
||||
)))->toThrow(DriverAssignmentNotAllowedException::class);
|
||||
});
|
||||
|
||||
test('reassigning a different driver on a still-confirmed booking overwrites the previous values', function () {
|
||||
$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',
|
||||
));
|
||||
|
||||
expect($booking->refresh()->driver_name)->toBe('Daw Hla')
|
||||
->and($booking->car_plate_number)->toBe('YGN-5678');
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Booking\Services\BookingRefGenerator;
|
||||
|
||||
test('the first booking ref starts the sequence at AAAAA1', function () {
|
||||
$ref = (new BookingRefGenerator)->generate();
|
||||
|
||||
expect($ref)->toBe('EVB-AAAAA1');
|
||||
});
|
||||
|
||||
test('the ref increments digit by digit through the alphabet', function () {
|
||||
Booking::factory()->create(['booking_ref' => 'EVB-AAAAA9']);
|
||||
|
||||
expect((new BookingRefGenerator)->generate())->toBe('EVB-AAAAAA');
|
||||
});
|
||||
|
||||
test('the ref carries over into the next position once the alphabet is exhausted', function () {
|
||||
Booking::factory()->create(['booking_ref' => 'EVB-AAAAZZ']);
|
||||
|
||||
expect((new BookingRefGenerator)->generate())->toBe('EVB-AAAB11');
|
||||
});
|
||||
|
||||
test('generated refs are unique across repeated calls', function () {
|
||||
$refs = [];
|
||||
|
||||
for ($i = 0; $i < 20; $i++) {
|
||||
$ref = (new BookingRefGenerator)->generate();
|
||||
Booking::factory()->create(['booking_ref' => $ref]);
|
||||
$refs[] = $ref;
|
||||
}
|
||||
|
||||
expect($refs)->toEqual(array_unique($refs));
|
||||
});
|
||||
|
||||
test('an unrecognised existing ref format resets the sequence rather than throwing', function () {
|
||||
Booking::factory()->create(['booking_ref' => 'LEGACY-2024-0001']);
|
||||
|
||||
expect((new BookingRefGenerator)->generate())->toBe('EVB-AAAAA1');
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
use Modules\Booking\Data\VehicleSelectionData;
|
||||
use Modules\Booking\Exceptions\InvalidVehicleSelectionException;
|
||||
use Modules\Booking\Services\BookingService;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
|
||||
test('a normal single-option selection of each vehicle option passes', function () {
|
||||
config([
|
||||
'booking.back_seat_enabled' => true,
|
||||
'booking.whole_vehicle_enabled' => true,
|
||||
]);
|
||||
|
||||
$service = new BookingService;
|
||||
|
||||
foreach (VehicleOption::cases() as $option) {
|
||||
expect(fn () => $service->validateSelections([new VehicleSelectionData($option)]))
|
||||
->not->toThrow(InvalidVehicleSelectionException::class);
|
||||
}
|
||||
});
|
||||
|
||||
test('front seat and back seat can be selected together in one booking', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
$service = new BookingService;
|
||||
|
||||
expect(fn () => $service->validateSelections([
|
||||
new VehicleSelectionData(VehicleOption::FrontSeat, 1),
|
||||
new VehicleSelectionData(VehicleOption::BackSeat, 2),
|
||||
]))->not->toThrow(InvalidVehicleSelectionException::class);
|
||||
});
|
||||
|
||||
test('requesting more front seats than the configured max is rejected', function () {
|
||||
config(['booking.front_seat_max_per_booking' => 1]);
|
||||
|
||||
$service = new BookingService;
|
||||
|
||||
expect(fn () => $service->validateSelections([new VehicleSelectionData(VehicleOption::FrontSeat, 2)]))
|
||||
->toThrow(InvalidVehicleSelectionException::class);
|
||||
});
|
||||
|
||||
test('requesting front seats up to the configured max passes', function () {
|
||||
config(['booking.front_seat_max_per_booking' => 2]);
|
||||
|
||||
$service = new BookingService;
|
||||
|
||||
expect(fn () => $service->validateSelections([new VehicleSelectionData(VehicleOption::FrontSeat, 2)]))
|
||||
->not->toThrow(InvalidVehicleSelectionException::class);
|
||||
});
|
||||
|
||||
test('back seat is rejected when disabled via config', function () {
|
||||
config(['booking.back_seat_enabled' => false]);
|
||||
|
||||
$service = new BookingService;
|
||||
|
||||
expect(fn () => $service->validateSelections([new VehicleSelectionData(VehicleOption::BackSeat)]))
|
||||
->toThrow(InvalidVehicleSelectionException::class);
|
||||
});
|
||||
|
||||
test('whole vehicle is rejected when disabled via config', function () {
|
||||
config(['booking.whole_vehicle_enabled' => false]);
|
||||
|
||||
$service = new BookingService;
|
||||
|
||||
expect(fn () => $service->validateSelections([new VehicleSelectionData(VehicleOption::WholeVehicle)]))
|
||||
->toThrow(InvalidVehicleSelectionException::class);
|
||||
});
|
||||
|
||||
test('the same vehicle option cannot be selected twice in one booking', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
$service = new BookingService;
|
||||
|
||||
expect(fn () => $service->validateSelections([
|
||||
new VehicleSelectionData(VehicleOption::BackSeat, 1),
|
||||
new VehicleSelectionData(VehicleOption::BackSeat, 1),
|
||||
]))->toThrow(InvalidVehicleSelectionException::class);
|
||||
});
|
||||
|
||||
test('whole vehicle cannot be combined with another vehicle option', function () {
|
||||
config([
|
||||
'booking.back_seat_enabled' => true,
|
||||
'booking.whole_vehicle_enabled' => true,
|
||||
]);
|
||||
|
||||
$service = new BookingService;
|
||||
|
||||
expect(fn () => $service->validateSelections([
|
||||
new VehicleSelectionData(VehicleOption::WholeVehicle),
|
||||
new VehicleSelectionData(VehicleOption::BackSeat),
|
||||
]))->toThrow(InvalidVehicleSelectionException::class);
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
use Modules\Booking\Actions\CancelBookingAction;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Exceptions\BookingCannotBeCancelledException;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Payment\Contracts\PaymentGatewayInterface;
|
||||
use Modules\Payment\Data\PaymentRequestData;
|
||||
use Modules\Payment\Data\PaymentResultData;
|
||||
use Modules\Payment\Data\RefundResultData;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use Modules\Payment\Enums\RefundStatus;
|
||||
use Modules\Payment\Factories\PaymentGatewayFactory;
|
||||
use Modules\Payment\Models\Payment;
|
||||
|
||||
/**
|
||||
* Never calls the real KBZ refund API in tests.
|
||||
*/
|
||||
class FakeCancelRefundGateway implements PaymentGatewayInterface
|
||||
{
|
||||
public static ?string $lastAmount = null;
|
||||
|
||||
public function initiate(PaymentRequestData $data): PaymentResultData
|
||||
{
|
||||
throw new RuntimeException('not needed for this test');
|
||||
}
|
||||
|
||||
public function verify(string $gatewayTransactionId): PaymentResultData
|
||||
{
|
||||
throw new RuntimeException('not needed for this test');
|
||||
}
|
||||
|
||||
public function refund(string $gatewayTransactionId, string $amount, string $reason): RefundResultData
|
||||
{
|
||||
self::$lastAmount = $amount;
|
||||
|
||||
return new RefundResultData(status: RefundStatus::Completed, gatewayRefundId: 'REFUND123', gatewayPayload: []);
|
||||
}
|
||||
|
||||
public function handleWebhook(array $payload): PaymentResultData
|
||||
{
|
||||
throw new RuntimeException('not needed for this test');
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
FakeCancelRefundGateway::$lastAmount = null;
|
||||
app(PaymentGatewayFactory::class)->register(PaymentMethod::KbzMiniApp, FakeCancelRefundGateway::class);
|
||||
});
|
||||
|
||||
test('it cancels a pending_payment booking directly', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
|
||||
|
||||
$cancelled = app(CancelBookingAction::class)->handle($booking);
|
||||
|
||||
expect($cancelled->status)->toBe(BookingStatus::Cancelled)
|
||||
->and($booking->refresh()->status)->toBe(BookingStatus::Cancelled);
|
||||
});
|
||||
|
||||
test('it cancels a confirmed booking by refunding it in full', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed, 'price' => 15000]);
|
||||
Payment::factory()->completed()->create([
|
||||
'booking_id' => $booking->id,
|
||||
'gateway' => PaymentMethod::KbzMiniApp,
|
||||
'amount' => 15000,
|
||||
'gateway_transaction_id' => 'EVB-CANCEL-TEST-1',
|
||||
]);
|
||||
|
||||
$cancelled = app(CancelBookingAction::class)->handle($booking);
|
||||
|
||||
expect($cancelled->status)->toBe(BookingStatus::Cancelled)
|
||||
->and(FakeCancelRefundGateway::$lastAmount)->toBe('15000.00');
|
||||
});
|
||||
|
||||
test('it guards against cancelling an already cancelled booking', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Cancelled]);
|
||||
|
||||
expect(fn () => app(CancelBookingAction::class)->handle($booking))
|
||||
->toThrow(BookingCannotBeCancelledException::class);
|
||||
});
|
||||
|
||||
test('it guards against cancelling an expired booking', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Expired]);
|
||||
|
||||
expect(fn () => app(CancelBookingAction::class)->handle($booking))
|
||||
->toThrow(BookingCannotBeCancelledException::class);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Catalog\Database\Factories;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Modules\Catalog\Models\DepartureTimeSlot;
|
||||
|
||||
/**
|
||||
* @extends Factory<DepartureTimeSlot>
|
||||
*/
|
||||
class DepartureTimeSlotFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$time = Carbon::createFromTime(fake()->numberBetween(0, 23), fake()->randomElement([0, 15, 30, 45]));
|
||||
|
||||
return [
|
||||
'label' => $time->format('h:i A'),
|
||||
'time' => $time->format('H:i'),
|
||||
'is_active' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('departure_time_slots', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('label');
|
||||
$table->time('time');
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('departure_time_slots');
|
||||
}
|
||||
};
|
||||
@@ -1 +1,10 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Catalog\Http\Controllers\DestinationController;
|
||||
use Modules\Catalog\Http\Controllers\EvCompanyController;
|
||||
|
||||
Route::prefix('api/v1')->middleware(['api', 'auth:sanctum', 'throttle:api-read'])->group(function () {
|
||||
Route::get('/companies', [EvCompanyController::class, 'index'])->name('catalog.companies.index');
|
||||
Route::get('/destinations', [DestinationController::class, 'index'])->name('catalog.destinations.index');
|
||||
});
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Catalog\Filament\Resources\DepartureTimeSlots;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
use Modules\Catalog\Filament\Resources\DepartureTimeSlots\Pages\CreateDepartureTimeSlot;
|
||||
use Modules\Catalog\Filament\Resources\DepartureTimeSlots\Pages\EditDepartureTimeSlot;
|
||||
use Modules\Catalog\Filament\Resources\DepartureTimeSlots\Pages\ListDepartureTimeSlots;
|
||||
use Modules\Catalog\Filament\Resources\DepartureTimeSlots\Schemas\DepartureTimeSlotForm;
|
||||
use Modules\Catalog\Filament\Resources\DepartureTimeSlots\Tables\DepartureTimeSlotsTable;
|
||||
use Modules\Catalog\Models\DepartureTimeSlot;
|
||||
use UnitEnum;
|
||||
|
||||
class DepartureTimeSlotResource extends Resource
|
||||
{
|
||||
protected static ?string $model = DepartureTimeSlot::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedClock;
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Catalog';
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return DepartureTimeSlotForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return DepartureTimeSlotsTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListDepartureTimeSlots::route('/'),
|
||||
'create' => CreateDepartureTimeSlot::route('/create'),
|
||||
'edit' => EditDepartureTimeSlot::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Catalog\Filament\Resources\DepartureTimeSlots\Pages;
|
||||
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Modules\Catalog\Filament\Resources\DepartureTimeSlots\DepartureTimeSlotResource;
|
||||
|
||||
class CreateDepartureTimeSlot extends CreateRecord
|
||||
{
|
||||
protected static string $resource = DepartureTimeSlotResource::class;
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Catalog\Filament\Resources\DepartureTimeSlots\Pages;
|
||||
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Modules\Catalog\Filament\Resources\DepartureTimeSlots\DepartureTimeSlotResource;
|
||||
|
||||
class EditDepartureTimeSlot extends EditRecord
|
||||
{
|
||||
protected static string $resource = DepartureTimeSlotResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Catalog\Filament\Resources\DepartureTimeSlots\Pages;
|
||||
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Modules\Catalog\Filament\Resources\DepartureTimeSlots\DepartureTimeSlotResource;
|
||||
|
||||
class ListDepartureTimeSlots extends ListRecords
|
||||
{
|
||||
protected static string $resource = DepartureTimeSlotResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Catalog\Filament\Resources\DepartureTimeSlots\Schemas;
|
||||
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\TimePicker;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class DepartureTimeSlotForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
TextInput::make('label')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TimePicker::make('time')
|
||||
->required(),
|
||||
Toggle::make('is_active')
|
||||
->required()
|
||||
->default(true),
|
||||
]);
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Catalog\Filament\Resources\DepartureTimeSlots\Tables;
|
||||
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\TernaryFilter;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class DepartureTimeSlotsTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('label')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('time')
|
||||
->time('H:i')
|
||||
->sortable(),
|
||||
IconColumn::make('is_active')
|
||||
->boolean(),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->defaultSort('time')
|
||||
->filters([
|
||||
TernaryFilter::make('is_active'),
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Catalog\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Modules\Catalog\Http\Resources\DestinationResource;
|
||||
use Modules\Catalog\Models\Destination;
|
||||
|
||||
class DestinationController extends Controller
|
||||
{
|
||||
public function index(): AnonymousResourceCollection
|
||||
{
|
||||
return DestinationResource::collection(
|
||||
Destination::query()->where('is_active', true)->get()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Catalog\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Modules\Catalog\Http\Resources\EvCompanyResource;
|
||||
use Modules\Catalog\Models\EvCompany;
|
||||
|
||||
class EvCompanyController extends Controller
|
||||
{
|
||||
public function index(): AnonymousResourceCollection
|
||||
{
|
||||
return EvCompanyResource::collection(
|
||||
EvCompany::query()->where('is_active', true)->get()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Catalog\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class DestinationResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'mm_name' => $this->mm_name,
|
||||
'region' => $this->region,
|
||||
'description' => $this->description,
|
||||
'mm_description' => $this->mm_description,
|
||||
'latitude' => $this->latitude,
|
||||
'longitude' => $this->longitude,
|
||||
'popular' => $this->popular,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Catalog\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class EvCompanyResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'mm_name' => $this->mm_name,
|
||||
'slug' => $this->slug,
|
||||
'description' => $this->description,
|
||||
'mm_description' => $this->mm_description,
|
||||
'contact' => $this->contact,
|
||||
'address' => $this->address,
|
||||
'logo' => $this->logo,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Catalog\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Modules\Catalog\Database\Factories\DepartureTimeSlotFactory;
|
||||
use Modules\Routing\Models\EvRoute;
|
||||
use Spatie\Activitylog\Models\Concerns\LogsActivity;
|
||||
use Spatie\Activitylog\Support\LogOptions;
|
||||
|
||||
/**
|
||||
* A shared catalog of departure times, attached to routes via a pivot in the
|
||||
* Routing module — not owned by any single route.
|
||||
*/
|
||||
class DepartureTimeSlot extends Model
|
||||
{
|
||||
/** @use HasFactory<DepartureTimeSlotFactory> */
|
||||
use HasFactory, LogsActivity;
|
||||
|
||||
/**
|
||||
* Full CRUD audit trail — catalog admin writes are staff-only and
|
||||
* infrequent, so logging every attribute change is affordable
|
||||
* (domain.md §6; T6.2).
|
||||
*/
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logFillable()
|
||||
->logOnlyDirty()
|
||||
->dontLogEmptyChanges()
|
||||
->useLogName('catalog');
|
||||
}
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'label',
|
||||
'time',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'time' => 'datetime:H:i',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function routes(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(EvRoute::class, 'ev_route_time_slots')
|
||||
->withPivot('is_active')
|
||||
->withTimestamps();
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,27 @@ namespace Modules\Catalog\Models;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Modules\Catalog\Database\Factories\DestinationFactory;
|
||||
use Spatie\Activitylog\Models\Concerns\LogsActivity;
|
||||
use Spatie\Activitylog\Support\LogOptions;
|
||||
|
||||
class Destination extends Model
|
||||
{
|
||||
/** @use HasFactory<DestinationFactory> */
|
||||
use HasFactory;
|
||||
use HasFactory, LogsActivity;
|
||||
|
||||
/**
|
||||
* Full CRUD audit trail — catalog admin writes are staff-only and
|
||||
* infrequent, so logging every attribute change is affordable
|
||||
* (domain.md §6; T6.2).
|
||||
*/
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logFillable()
|
||||
->logOnlyDirty()
|
||||
->dontLogEmptyChanges()
|
||||
->useLogName('catalog');
|
||||
}
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
|
||||
@@ -6,11 +6,27 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\Catalog\Database\Factories\EvCompanyFactory;
|
||||
use Spatie\Activitylog\Models\Concerns\LogsActivity;
|
||||
use Spatie\Activitylog\Support\LogOptions;
|
||||
|
||||
class EvCompany extends Model
|
||||
{
|
||||
/** @use HasFactory<EvCompanyFactory> */
|
||||
use HasFactory;
|
||||
use HasFactory, LogsActivity;
|
||||
|
||||
/**
|
||||
* Full CRUD audit trail — catalog admin writes are staff-only and
|
||||
* infrequent, so logging every attribute change is affordable
|
||||
* (domain.md §6; T6.2).
|
||||
*/
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logFillable()
|
||||
->logOnlyDirty()
|
||||
->dontLogEmptyChanges()
|
||||
->useLogName('catalog');
|
||||
}
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Modules\Catalog\Models\Destination;
|
||||
use Modules\Catalog\Models\EvCompany;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->token = User::factory()->create()->createToken('test-token')->plainTextToken;
|
||||
});
|
||||
|
||||
test('lists active ev companies', function () {
|
||||
$active = EvCompany::factory()->create(['is_active' => true]);
|
||||
EvCompany::factory()->create(['is_active' => false]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson('/api/v1/companies')
|
||||
->assertSuccessful()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonFragment(['id' => $active->id]);
|
||||
});
|
||||
|
||||
test('lists active destinations', function () {
|
||||
$active = Destination::factory()->create(['is_active' => true]);
|
||||
Destination::factory()->create(['is_active' => false]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson('/api/v1/destinations')
|
||||
->assertSuccessful()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonFragment(['id' => $active->id]);
|
||||
});
|
||||
|
||||
test('companies endpoint rejects unauthenticated requests', function () {
|
||||
$this->getJson('/api/v1/companies')->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('destinations endpoint rejects unauthenticated requests', function () {
|
||||
$this->getJson('/api/v1/destinations')->assertUnauthorized();
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
use Modules\Catalog\Filament\Resources\DepartureTimeSlots\Pages\CreateDepartureTimeSlot;
|
||||
use Modules\Catalog\Filament\Resources\DepartureTimeSlots\Pages\EditDepartureTimeSlot;
|
||||
use Modules\Catalog\Filament\Resources\DepartureTimeSlots\Pages\ListDepartureTimeSlots;
|
||||
use Modules\Catalog\Models\DepartureTimeSlot;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
use function Pest\Laravel\assertDatabaseHas;
|
||||
|
||||
beforeEach(function () {
|
||||
Role::findOrCreate('admin', 'web');
|
||||
|
||||
$this->admin = User::factory()->create()->assignRole('admin');
|
||||
$this->actingAs($this->admin);
|
||||
});
|
||||
|
||||
test('can list departure time slots', function () {
|
||||
$timeSlots = DepartureTimeSlot::factory()->count(3)->create();
|
||||
|
||||
Livewire::test(ListDepartureTimeSlots::class)
|
||||
->assertOk()
|
||||
->assertCanSeeTableRecords($timeSlots);
|
||||
});
|
||||
|
||||
test('can create a departure time slot', function () {
|
||||
$timeSlot = DepartureTimeSlot::factory()->make();
|
||||
|
||||
Livewire::test(CreateDepartureTimeSlot::class)
|
||||
->fillForm([
|
||||
'label' => $timeSlot->label,
|
||||
'time' => $timeSlot->time,
|
||||
'is_active' => true,
|
||||
])
|
||||
->call('create')
|
||||
->assertNotified()
|
||||
->assertRedirect();
|
||||
|
||||
assertDatabaseHas(DepartureTimeSlot::class, [
|
||||
'label' => $timeSlot->label,
|
||||
]);
|
||||
});
|
||||
|
||||
test('can edit a departure time slot', function () {
|
||||
$timeSlot = DepartureTimeSlot::factory()->create();
|
||||
|
||||
Livewire::test(EditDepartureTimeSlot::class, ['record' => $timeSlot->getRouteKey()])
|
||||
->assertOk()
|
||||
->fillForm(['label' => 'Updated Label'])
|
||||
->call('save')
|
||||
->assertNotified();
|
||||
|
||||
assertDatabaseHas(DepartureTimeSlot::class, [
|
||||
'id' => $timeSlot->id,
|
||||
'label' => 'Updated Label',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a departure time slot is a shared catalog entity not owned by a single route', function () {
|
||||
$timeSlot = DepartureTimeSlot::factory()->create();
|
||||
|
||||
expect($timeSlot->getFillable())->not->toContain('ev_route_id');
|
||||
});
|
||||
@@ -21,6 +21,10 @@ class RolePermissionSeeder extends Seeder
|
||||
'view_payments',
|
||||
'process_refunds',
|
||||
'view_audit_log',
|
||||
'manage_staff',
|
||||
'manage_roles',
|
||||
'view_customers',
|
||||
'manage_settings',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -36,6 +40,10 @@ class RolePermissionSeeder extends Seeder
|
||||
'view_payments',
|
||||
'process_refunds',
|
||||
'view_audit_log',
|
||||
'manage_staff',
|
||||
'manage_roles',
|
||||
'view_customers',
|
||||
'manage_settings',
|
||||
],
|
||||
'admin' => [
|
||||
'manage_catalog',
|
||||
@@ -46,11 +54,14 @@ class RolePermissionSeeder extends Seeder
|
||||
'view_payments',
|
||||
'process_refunds',
|
||||
'view_audit_log',
|
||||
'view_customers',
|
||||
'manage_settings',
|
||||
],
|
||||
'support' => [
|
||||
'view_bookings',
|
||||
'view_payments',
|
||||
'view_audit_log',
|
||||
'view_customers',
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<x-filament-panels::page>
|
||||
{{ $this->form }}
|
||||
</x-filament-panels::page>
|
||||
@@ -3,6 +3,6 @@
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Identity\Http\Controllers\TokenController;
|
||||
|
||||
Route::prefix('api/v1')->middleware('api')->group(function () {
|
||||
Route::prefix('api/v1')->middleware(['api', 'throttle:api-auth'])->group(function () {
|
||||
Route::post('/auth/token', [TokenController::class, 'store'])->name('identity.auth.token');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Filament\Pages;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Actions;
|
||||
use Filament\Schemas\Components\Form;
|
||||
use Filament\Schemas\Components\Tabs;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Modules\Shared\Support\EnvFileWriter;
|
||||
use UnitEnum;
|
||||
|
||||
/**
|
||||
* Edits real env-backed config values (config('app.*'), config('booking.*'))
|
||||
* in place via EnvFileWriter, rather than introducing a parallel DB-backed
|
||||
* settings table — so BookingService and everything else that already reads
|
||||
* config('booking.*') keeps working unchanged (domain.md §2).
|
||||
*
|
||||
* Requires the .env file to be writable by the app process; if it isn't
|
||||
* (e.g. some production containers ship a read-only filesystem), saving
|
||||
* will throw and the admin needs to edit .env directly on that host instead.
|
||||
*/
|
||||
class ManageAppSettings extends Page
|
||||
{
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedCog6Tooth;
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Access';
|
||||
|
||||
protected static ?string $navigationLabel = 'App Settings';
|
||||
|
||||
protected static ?string $title = 'App Settings';
|
||||
|
||||
protected string $view = 'identity::filament.pages.manage-app-settings';
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
public ?array $data = [];
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
return auth()->user()?->can('manage_settings') ?? false;
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->form->fill([
|
||||
'site_name' => config('app.name'),
|
||||
'support_email' => config('app.support_email'),
|
||||
'support_phone' => config('app.support_phone'),
|
||||
'timezone' => config('app.timezone'),
|
||||
'currency' => config('app.currency'),
|
||||
'back_seat_enabled' => (bool) config('booking.back_seat_enabled'),
|
||||
'whole_vehicle_enabled' => (bool) config('booking.whole_vehicle_enabled'),
|
||||
'front_seat_max_per_booking' => config('booking.front_seat_max_per_booking'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Form::make([
|
||||
Tabs::make('Settings')
|
||||
->tabs([
|
||||
Tab::make('General')
|
||||
->schema([
|
||||
TextInput::make('site_name')
|
||||
->label('Site Name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('support_email')
|
||||
->label('Support Email')
|
||||
->email()
|
||||
->maxLength(255),
|
||||
TextInput::make('support_phone')
|
||||
->label('Support Phone')
|
||||
->tel()
|
||||
->maxLength(255),
|
||||
TextInput::make('timezone')
|
||||
->label('Timezone')
|
||||
->required()
|
||||
->maxLength(64)
|
||||
->helperText('A valid PHP timezone identifier, e.g. Asia/Yangon.'),
|
||||
TextInput::make('currency')
|
||||
->label('Currency Code')
|
||||
->required()
|
||||
->maxLength(3)
|
||||
->helperText('ISO 4217 currency code, e.g. MMK.'),
|
||||
])
|
||||
->columns(2),
|
||||
Tab::make('Booking')
|
||||
->schema([
|
||||
Toggle::make('back_seat_enabled')
|
||||
->label('Back Seat Enabled')
|
||||
->helperText('Whether customers can select Back Seat at all right now.'),
|
||||
Toggle::make('whole_vehicle_enabled')
|
||||
->label('Whole Vehicle Enabled')
|
||||
->helperText('Whether customers can select Whole Vehicle at all right now.'),
|
||||
TextInput::make('front_seat_max_per_booking')
|
||||
->label('Front Seat Max Per Booking')
|
||||
->numeric()
|
||||
->minValue(1)
|
||||
->required()
|
||||
->helperText('Max Front Seats a single booking may request.'),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
->livewireSubmitHandler('save')
|
||||
->footer([
|
||||
Actions::make([
|
||||
Action::make('save')
|
||||
->submit('save')
|
||||
->keyBindings(['mod+s']),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
->statePath('data');
|
||||
}
|
||||
|
||||
public function save(EnvFileWriter $writer): void
|
||||
{
|
||||
$state = $this->form->getState();
|
||||
|
||||
$writer->write([
|
||||
'APP_NAME' => $state['site_name'],
|
||||
'SUPPORT_EMAIL' => $state['support_email'],
|
||||
'SUPPORT_PHONE' => $state['support_phone'],
|
||||
'APP_TIMEZONE' => $state['timezone'],
|
||||
'APP_CURRENCY' => $state['currency'],
|
||||
'BOOKING_BACK_SEAT_ENABLED' => (bool) $state['back_seat_enabled'],
|
||||
'BOOKING_WHOLE_VEHICLE_ENABLED' => (bool) $state['whole_vehicle_enabled'],
|
||||
'BOOKING_FRONT_SEAT_MAX_PER_BOOKING' => (int) $state['front_seat_max_per_booking'],
|
||||
]);
|
||||
|
||||
Artisan::call('config:clear');
|
||||
|
||||
Notification::make()
|
||||
->title('Settings saved')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Filament\Resources\AuditLogs;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
use Modules\Identity\Filament\Resources\AuditLogs\Pages\ListAuditLogs;
|
||||
use Modules\Identity\Filament\Resources\AuditLogs\Pages\ViewAuditLog;
|
||||
use Modules\Identity\Filament\Resources\AuditLogs\Schemas\AuditLogInfolist;
|
||||
use Modules\Identity\Filament\Resources\AuditLogs\Tables\AuditLogsTable;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
use UnitEnum;
|
||||
|
||||
/**
|
||||
* Read-only by design (T6.2, gated by view_audit_log — AuditLogPolicy):
|
||||
* every row here comes from the LogsActivity trait on Booking/Payment/
|
||||
* Refund/catalog/pricing models, never hand-entered.
|
||||
*/
|
||||
class AuditLogResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Activity::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedClipboardDocumentList;
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Access';
|
||||
|
||||
protected static ?string $navigationLabel = 'Audit Log';
|
||||
|
||||
protected static ?string $modelLabel = 'Audit Log Entry';
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return AuditLogsTable::configure($table);
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return AuditLogInfolist::configure($schema);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListAuditLogs::route('/'),
|
||||
'view' => ViewAuditLog::route('/{record}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Filament\Resources\AuditLogs\Pages;
|
||||
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Modules\Identity\Filament\Resources\AuditLogs\AuditLogResource;
|
||||
|
||||
class ListAuditLogs extends ListRecords
|
||||
{
|
||||
protected static string $resource = AuditLogResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
// No CreateAction — audit log rows are only ever written by the
|
||||
// LogsActivity trait, never hand-entered here.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Filament\Resources\AuditLogs\Pages;
|
||||
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Modules\Identity\Filament\Resources\AuditLogs\AuditLogResource;
|
||||
|
||||
class ViewAuditLog extends ViewRecord
|
||||
{
|
||||
protected static string $resource = AuditLogResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
// Read-only — no EditAction/DeleteAction.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Filament\Resources\AuditLogs\Schemas;
|
||||
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class AuditLogInfolist
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make('Event')
|
||||
->schema([
|
||||
Grid::make(3)
|
||||
->schema([
|
||||
TextEntry::make('log_name')->label('Module')->badge(),
|
||||
TextEntry::make('event')->badge()->placeholder('—'),
|
||||
TextEntry::make('created_at')->dateTime(),
|
||||
TextEntry::make('subject_type')->label('Subject Type')->placeholder('—'),
|
||||
TextEntry::make('subject_id')->label('Subject ID')->placeholder('—'),
|
||||
TextEntry::make('causer.name')->label('Caused By')->placeholder('System'),
|
||||
]),
|
||||
TextEntry::make('description')->columnSpanFull(),
|
||||
]),
|
||||
Section::make('Changes')
|
||||
->schema([
|
||||
TextEntry::make('attribute_changes')
|
||||
->label('')
|
||||
->formatStateUsing(fn (mixed $state) => $state
|
||||
? json_encode($state, JSON_PRETTY_PRINT)
|
||||
: null)
|
||||
->placeholder('—')
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Filament\Resources\AuditLogs\Tables;
|
||||
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Str;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
class AuditLogsTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->with(['causer', 'subject']))
|
||||
->defaultSort('created_at', 'desc')
|
||||
->columns([
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
TextColumn::make('log_name')
|
||||
->label('Module')
|
||||
->badge(),
|
||||
TextColumn::make('event')
|
||||
->badge()
|
||||
->color(fn (?string $state) => match ($state) {
|
||||
'created' => 'success',
|
||||
'updated' => 'warning',
|
||||
'deleted' => 'danger',
|
||||
default => 'gray',
|
||||
})
|
||||
->placeholder('—'),
|
||||
TextColumn::make('subject_type')
|
||||
->label('Subject')
|
||||
->formatStateUsing(fn (?string $state) => $state ? Str::afterLast($state, '\\') : '—')
|
||||
->description(fn (Activity $record) => $record->subject_id ? "#{$record->subject_id}" : null),
|
||||
TextColumn::make('description')
|
||||
->wrap(),
|
||||
TextColumn::make('causer.name')
|
||||
->label('Caused By')
|
||||
->placeholder('System')
|
||||
->searchable(),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('log_name')
|
||||
->label('Module')
|
||||
->options(fn () => Activity::query()->distinct()->pluck('log_name', 'log_name')->filter()->all()),
|
||||
SelectFilter::make('event')
|
||||
->options([
|
||||
'created' => 'Created',
|
||||
'updated' => 'Updated',
|
||||
'deleted' => 'Deleted',
|
||||
]),
|
||||
])
|
||||
->recordActions([
|
||||
ViewAction::make(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Filament\Resources\Customers;
|
||||
|
||||
use App\Models\User;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Modules\Identity\Filament\Resources\Customers\Pages\ListCustomers;
|
||||
use Modules\Identity\Filament\Resources\Customers\Pages\ViewCustomer;
|
||||
use Modules\Identity\Filament\Resources\Customers\Schemas\CustomerInfolist;
|
||||
use Modules\Identity\Filament\Resources\Customers\Tables\CustomersTable;
|
||||
use UnitEnum;
|
||||
|
||||
/**
|
||||
* Customers are `users` rows carrying no role at all — the inverse scope of
|
||||
* StaffResource (domain.md §4, single `users` table). Read-only by design:
|
||||
* customer accounts are created via the mini app/mobile token flow (T1.2),
|
||||
* never hand-entered by staff.
|
||||
*/
|
||||
class CustomerResource extends Resource
|
||||
{
|
||||
protected static ?string $model = User::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedUsers;
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Access';
|
||||
|
||||
protected static ?string $navigationLabel = 'Customers';
|
||||
|
||||
protected static ?string $modelLabel = 'Customer';
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getEloquentQuery()->doesntHave('roles');
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return CustomersTable::configure($table);
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return CustomerInfolist::configure($schema);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListCustomers::route('/'),
|
||||
'view' => ViewCustomer::route('/{record}'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
return auth()->user()?->can('view_customers') ?? false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Filament\Resources\Customers\Pages;
|
||||
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Modules\Identity\Filament\Resources\Customers\CustomerResource;
|
||||
|
||||
class ListCustomers extends ListRecords
|
||||
{
|
||||
protected static string $resource = CustomerResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
// No CreateAction — customer accounts are created via the mini
|
||||
// app/mobile token flow (T1.2), never hand-entered here.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Filament\Resources\Customers\Pages;
|
||||
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Modules\Identity\Filament\Resources\Customers\CustomerResource;
|
||||
|
||||
class ViewCustomer extends ViewRecord
|
||||
{
|
||||
protected static string $resource = CustomerResource::class;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Filament\Resources\Customers\Schemas;
|
||||
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class CustomerInfolist
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make('Customer')
|
||||
->schema([
|
||||
Grid::make(3)
|
||||
->schema([
|
||||
TextEntry::make('name'),
|
||||
TextEntry::make('email'),
|
||||
TextEntry::make('created_at')->label('Joined')->dateTime(),
|
||||
TextEntry::make('bookings_count')->label('Total Bookings')->state(
|
||||
fn ($record) => $record->bookings()->count(),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Filament\Resources\Customers\Tables;
|
||||
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class CustomersTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->withCount('bookings'))
|
||||
->defaultSort('created_at', 'desc')
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('email')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('bookings_count')
|
||||
->label('Bookings'),
|
||||
TextColumn::make('created_at')
|
||||
->label('Joined')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
])
|
||||
->recordActions([
|
||||
ViewAction::make(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Filament\Resources\Roles\Pages;
|
||||
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Modules\Identity\Filament\Resources\Roles\RoleResource;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
|
||||
class EditRole extends EditRecord
|
||||
{
|
||||
protected static string $resource = RoleResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
// No DeleteAction — the role set is fixed (see RoleResource docblock).
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Spatie caches resolved permissions per-request/process — without
|
||||
* this, a permission just toggled here wouldn't take effect until the
|
||||
* cache naturally expires (RolePermissionSeeder does the same flush).
|
||||
*/
|
||||
protected function afterSave(): void
|
||||
{
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Filament\Resources\Roles\Pages;
|
||||
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Modules\Identity\Filament\Resources\Roles\RoleResource;
|
||||
|
||||
class ListRoles extends ListRecords
|
||||
{
|
||||
protected static string $resource = RoleResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
// No CreateAction — the role set is fixed (see RoleResource docblock).
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Filament\Resources\Roles;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Modules\Identity\Filament\Resources\Roles\Pages\EditRole;
|
||||
use Modules\Identity\Filament\Resources\Roles\Pages\ListRoles;
|
||||
use Modules\Identity\Filament\Resources\Roles\Schemas\RoleForm;
|
||||
use Modules\Identity\Filament\Resources\Roles\Tables\RolesTable;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use UnitEnum;
|
||||
|
||||
/**
|
||||
* Deliberately no create/delete — role names (super_admin/admin/support)
|
||||
* are hardcoded across policies, User::ADMIN_TIER_ROLES, and the panel
|
||||
* login gate (domain.md §4, §8), so the role set itself must stay fixed.
|
||||
* Only what each role can do (its permissions) is editable here.
|
||||
*/
|
||||
class RoleResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Role::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedShieldCheck;
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Access';
|
||||
|
||||
protected static ?string $navigationLabel = 'Roles';
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return RoleForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return RolesTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListRoles::route('/'),
|
||||
'edit' => EditRole::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
return auth()->user()?->can('manage_roles') ?? false;
|
||||
}
|
||||
|
||||
public static function canEdit(Model $record): bool
|
||||
{
|
||||
return auth()->user()?->can('manage_roles') ?? false;
|
||||
}
|
||||
|
||||
public static function canCreate(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function canDelete(Model $record): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Filament\Resources\Roles\Schemas;
|
||||
|
||||
use Filament\Forms\Components\CheckboxList;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class RoleForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
TextInput::make('name')
|
||||
->disabled()
|
||||
->dehydrated(false),
|
||||
CheckboxList::make('permissions')
|
||||
->relationship(name: 'permissions', titleAttribute: 'name')
|
||||
->columns(2)
|
||||
->bulkToggleable()
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Filament\Resources\Roles\Tables;
|
||||
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class RolesTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->withCount(['permissions', 'users']))
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->badge()
|
||||
->sortable(),
|
||||
TextColumn::make('permissions_count')
|
||||
->label('Permissions'),
|
||||
TextColumn::make('users_count')
|
||||
->label('Staff'),
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Filament\Resources\Staff\Pages;
|
||||
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Modules\Identity\Filament\Resources\Staff\StaffResource;
|
||||
|
||||
class CreateStaff extends CreateRecord
|
||||
{
|
||||
protected static string $resource = StaffResource::class;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Filament\Resources\Staff\Pages;
|
||||
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Modules\Identity\Filament\Resources\Staff\StaffResource;
|
||||
|
||||
class EditStaff extends EditRecord
|
||||
{
|
||||
protected static string $resource = StaffResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Filament\Resources\Staff\Pages;
|
||||
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Modules\Identity\Filament\Resources\Staff\StaffResource;
|
||||
|
||||
class ListStaff extends ListRecords
|
||||
{
|
||||
protected static string $resource = StaffResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Filament\Resources\Staff\Schemas;
|
||||
|
||||
use App\Models\User;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class StaffForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
TextInput::make('name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('email')
|
||||
->required()
|
||||
->email()
|
||||
->unique(ignoreRecord: true)
|
||||
->maxLength(255),
|
||||
TextInput::make('password')
|
||||
->password()
|
||||
->revealable()
|
||||
->required(fn (string $operation) => $operation === 'create')
|
||||
->minLength(8)
|
||||
->dehydrateStateUsing(fn (?string $state) => filled($state) ? Hash::make($state) : null)
|
||||
->dehydrated(fn (?string $state) => filled($state))
|
||||
->helperText('Leave blank to keep the current password.'),
|
||||
Select::make('roles')
|
||||
->relationship(
|
||||
name: 'roles',
|
||||
titleAttribute: 'name',
|
||||
modifyQueryUsing: fn ($query) => $query->whereIn('name', User::ADMIN_TIER_ROLES),
|
||||
)
|
||||
->multiple()
|
||||
->preload()
|
||||
->required()
|
||||
->helperText('Determines whether this staff member can sign in here at all, and what they can do.'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Filament\Resources\Staff;
|
||||
|
||||
use App\Models\User;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Modules\Identity\Filament\Resources\Staff\Pages\CreateStaff;
|
||||
use Modules\Identity\Filament\Resources\Staff\Pages\EditStaff;
|
||||
use Modules\Identity\Filament\Resources\Staff\Pages\ListStaff;
|
||||
use Modules\Identity\Filament\Resources\Staff\Schemas\StaffForm;
|
||||
use Modules\Identity\Filament\Resources\Staff\Tables\StaffTable;
|
||||
use UnitEnum;
|
||||
|
||||
/**
|
||||
* Staff and Customer both read from the single `users` table (domain.md §4
|
||||
* — no separate tables, no auth-guard split); this resource scopes to users
|
||||
* carrying an admin-tier role, the same set that can sign in to this panel
|
||||
* at all (User::ADMIN_TIER_ROLES). Gated by manage_staff — deliberately
|
||||
* separate from manage_roles (T7.x follow-up decision): granting someone
|
||||
* access to the panel is a different, more sensitive action than editing
|
||||
* what a role can do.
|
||||
*/
|
||||
class StaffResource extends Resource
|
||||
{
|
||||
protected static ?string $model = User::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedUserGroup;
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Access';
|
||||
|
||||
protected static ?string $navigationLabel = 'Staff';
|
||||
|
||||
protected static ?string $modelLabel = 'Staff Member';
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getEloquentQuery()->role(User::ADMIN_TIER_ROLES);
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return StaffForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return StaffTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListStaff::route('/'),
|
||||
'create' => CreateStaff::route('/create'),
|
||||
'edit' => EditStaff::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
return auth()->user()?->can('manage_staff') ?? false;
|
||||
}
|
||||
|
||||
public static function canCreate(): bool
|
||||
{
|
||||
return auth()->user()?->can('manage_staff') ?? false;
|
||||
}
|
||||
|
||||
public static function canEdit(Model $record): bool
|
||||
{
|
||||
return auth()->user()?->can('manage_staff') ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocks the one obviously destructive foot-gun (a staff member
|
||||
* deleting their own account and locking themselves out) on top of the
|
||||
* manage_staff permission check.
|
||||
*/
|
||||
public static function canDelete(Model $record): bool
|
||||
{
|
||||
return (auth()->user()?->can('manage_staff') ?? false)
|
||||
&& auth()->id() !== $record->getKey();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Filament\Resources\Staff\Tables;
|
||||
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class StaffTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->with('roles'))
|
||||
->defaultSort('created_at', 'desc')
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('email')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('roles.name')
|
||||
->label('Roles')
|
||||
->badge(),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
DeleteAction::make(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity;
|
||||
|
||||
use Filament\Contracts\Plugin;
|
||||
use Filament\Panel;
|
||||
|
||||
/**
|
||||
* Thin Access-management plugin (see tickets.md T1.3) — Identity has no
|
||||
* customer-facing CRUD of its own, just the read-only AuditLogResource
|
||||
* (T6.2) discovered here.
|
||||
*/
|
||||
class IdentityPlugin implements Plugin
|
||||
{
|
||||
public function getId(): string
|
||||
{
|
||||
return 'identity';
|
||||
}
|
||||
|
||||
public function register(Panel $panel): void
|
||||
{
|
||||
$panel
|
||||
->discoverResources(
|
||||
in: __DIR__.'/Filament/Resources',
|
||||
for: 'Modules\Identity\Filament\Resources',
|
||||
)
|
||||
->discoverPages(
|
||||
in: __DIR__.'/Filament/Pages',
|
||||
for: 'Modules\Identity\Filament\Pages',
|
||||
)
|
||||
->discoverWidgets(
|
||||
in: __DIR__.'/Filament/Widgets',
|
||||
for: 'Modules\Identity\Filament\Widgets',
|
||||
);
|
||||
}
|
||||
|
||||
public function boot(Panel $panel): void {}
|
||||
|
||||
public static function make(): static
|
||||
{
|
||||
return app(static::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Policies;
|
||||
|
||||
use App\Models\User;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
/**
|
||||
* The audit trail (T6.2) is read-only from the admin panel — nothing ever
|
||||
* creates/edits/deletes an Activity row through Filament, only the
|
||||
* LogsActivity trait writes here. Gated by view_audit_log per domain.md §8.
|
||||
*/
|
||||
class AuditLogPolicy
|
||||
{
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->can('view_audit_log');
|
||||
}
|
||||
|
||||
public function view(User $user, Activity $activity): bool
|
||||
{
|
||||
return $user->can('view_audit_log');
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function update(User $user, Activity $activity): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function delete(User $user, Activity $activity): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user