From 46f9b8d5a38a86d3535ea99631141641d6791135 Mon Sep 17 00:00:00 2001 From: Nyan Lin Paing <117423022+LinPaing21@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:59:00 +0700 Subject: [PATCH 1/2] Phase 6: security & ops hardening (T6.1-T6.5) - T6.1: named rate limiters (api-read/api-write/api-auth/api-webhooks), applied per module route group with tighter limits on booking/payment writes and the KBZ webhook than read-only catalog/routing endpoints. - T6.2: install spatie/laravel-activitylog; LogsActivity on Booking/ Payment/Refund status transitions and catalog/pricing admin CRUD (EvCompany, Destination, DepartureTimeSlot, EvRoute, RoutePricing). New IdentityPlugin with a read-only AuditLogResource gated by view_audit_log. - T6.3: JSON error envelope for api/* in bootstrap/app.php (401/403/404/ 405/429/500 fallback), plus PaymentGatewayException (422 declined / 502 unavailable). - T6.4: feature tests proving the FastAPI agent token gets 403 on refund/cancel-not-owned and 405 (no write handler) on catalog/routing writes. - T6.5: install gboquizosanchez/filament-log-viewer with a custom Filament admin theme (required for its views' Tailwind classes to compile), LOG_CHANNEL/FILAMENT_LOG_VIEWER_DRIVER=daily, registered under Operations in the sidebar. 252 tests passing. --- .env.example | 3 +- AGENTS.md | 7 + CLAUDE.md | 7 + app-modules/booking/routes/booking-routes.php | 2 +- app-modules/booking/src/Models/Booking.php | 17 +- app-modules/catalog/routes/catalog-routes.php | 2 +- .../catalog/src/Models/DepartureTimeSlot.php | 18 +- .../catalog/src/Models/Destination.php | 18 +- app-modules/catalog/src/Models/EvCompany.php | 18 +- .../identity/routes/identity-routes.php | 2 +- .../identity/src/Filament/Pages/.gitkeep | 0 .../Resources/AuditLogs/AuditLogResource.php | 51 ++++ .../AuditLogs/Pages/ListAuditLogs.php | 18 ++ .../AuditLogs/Pages/ViewAuditLog.php | 17 ++ .../AuditLogs/Schemas/AuditLogInfolist.php | 41 +++ .../AuditLogs/Tables/AuditLogsTable.php | 62 +++++ .../identity/src/Filament/Widgets/.gitkeep | 0 app-modules/identity/src/IdentityPlugin.php | 43 ++++ .../identity/src/Policies/AuditLogPolicy.php | 39 +++ .../src/Providers/IdentityServiceProvider.php | 8 +- .../tests/Feature/AgentAbilityAuditTest.php | 82 ++++++ .../identity/tests/Feature/AuditLogTest.php | 67 +++++ app-modules/payment/routes/payment-routes.php | 4 +- .../Exceptions/PaymentGatewayException.php | 57 +++++ app-modules/payment/src/Models/Payment.php | 16 +- app-modules/payment/src/Models/Refund.php | 16 +- app-modules/routing/routes/routing-routes.php | 2 +- app-modules/routing/src/Models/EvRoute.php | 18 +- .../routing/src/Models/RoutePricing.php | 18 +- .../tests/Feature/ApiErrorEnvelopeTest.php | 36 +++ .../tests/Feature/ApiRateLimitingTest.php | 36 +++ app/Providers/AppServiceProvider.php | 30 ++- app/Providers/Filament/AdminPanelProvider.php | 14 ++ bootstrap/app.php | 72 +++++- composer.json | 2 + composer.lock | 238 +++++++++++++++++- config/activitylog.php | 73 ++++++ ...08_09_093457_create_activity_log_table.php | 23 ++ package-lock.json | 6 +- package.json | 4 +- resources/css/filament/admin/theme.css | 6 + tickets.md | 6 + vite.config.js | 2 +- 43 files changed, 1176 insertions(+), 25 deletions(-) create mode 100644 app-modules/identity/src/Filament/Pages/.gitkeep create mode 100644 app-modules/identity/src/Filament/Resources/AuditLogs/AuditLogResource.php create mode 100644 app-modules/identity/src/Filament/Resources/AuditLogs/Pages/ListAuditLogs.php create mode 100644 app-modules/identity/src/Filament/Resources/AuditLogs/Pages/ViewAuditLog.php create mode 100644 app-modules/identity/src/Filament/Resources/AuditLogs/Schemas/AuditLogInfolist.php create mode 100644 app-modules/identity/src/Filament/Resources/AuditLogs/Tables/AuditLogsTable.php create mode 100644 app-modules/identity/src/Filament/Widgets/.gitkeep create mode 100644 app-modules/identity/src/IdentityPlugin.php create mode 100644 app-modules/identity/src/Policies/AuditLogPolicy.php create mode 100644 app-modules/identity/tests/Feature/AgentAbilityAuditTest.php create mode 100644 app-modules/identity/tests/Feature/AuditLogTest.php create mode 100644 app-modules/payment/src/Exceptions/PaymentGatewayException.php create mode 100644 app-modules/shared/tests/Feature/ApiErrorEnvelopeTest.php create mode 100644 app-modules/shared/tests/Feature/ApiRateLimitingTest.php create mode 100644 config/activitylog.php create mode 100644 database/migrations/2026_08_09_093457_create_activity_log_table.php create mode 100644 resources/css/filament/admin/theme.css diff --git a/.env.example b/.env.example index 376249d..b13bdb7 100644 --- a/.env.example +++ b/.env.example @@ -15,7 +15,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 diff --git a/AGENTS.md b/AGENTS.md index 37bcf67..76959ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index a8290a1..b5c0d9d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/app-modules/booking/routes/booking-routes.php b/app-modules/booking/routes/booking-routes.php index 79af426..b385706 100644 --- a/app-modules/booking/routes/booking-routes.php +++ b/app-modules/booking/routes/booking-routes.php @@ -3,7 +3,7 @@ use Illuminate\Support\Facades\Route; use Modules\Booking\Http\Controllers\BookingController; -Route::prefix('api/v1')->middleware(['api', 'auth:sanctum', 'throttle:60,1'])->group(function () { +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'); diff --git a/app-modules/booking/src/Models/Booking.php b/app-modules/booking/src/Models/Booking.php index 771b15f..61a4e27 100644 --- a/app-modules/booking/src/Models/Booking.php +++ b/app-modules/booking/src/Models/Booking.php @@ -13,11 +13,26 @@ 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 */ - use HasFactory; + use HasFactory, LogsActivity; + + /** + * 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 diff --git a/app-modules/catalog/routes/catalog-routes.php b/app-modules/catalog/routes/catalog-routes.php index 76821d8..6f452e5 100644 --- a/app-modules/catalog/routes/catalog-routes.php +++ b/app-modules/catalog/routes/catalog-routes.php @@ -4,7 +4,7 @@ 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:60,1'])->group(function () { +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'); }); diff --git a/app-modules/catalog/src/Models/DepartureTimeSlot.php b/app-modules/catalog/src/Models/DepartureTimeSlot.php index a55dfae..cb488a6 100644 --- a/app-modules/catalog/src/Models/DepartureTimeSlot.php +++ b/app-modules/catalog/src/Models/DepartureTimeSlot.php @@ -7,6 +7,8 @@ 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 @@ -15,7 +17,21 @@ use Modules\Routing\Models\EvRoute; class DepartureTimeSlot extends Model { /** @use HasFactory */ - 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 diff --git a/app-modules/catalog/src/Models/Destination.php b/app-modules/catalog/src/Models/Destination.php index 15bb2cc..9ede316 100644 --- a/app-modules/catalog/src/Models/Destination.php +++ b/app-modules/catalog/src/Models/Destination.php @@ -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 */ - 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 diff --git a/app-modules/catalog/src/Models/EvCompany.php b/app-modules/catalog/src/Models/EvCompany.php index d589be6..baaebf3 100644 --- a/app-modules/catalog/src/Models/EvCompany.php +++ b/app-modules/catalog/src/Models/EvCompany.php @@ -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 */ - 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 diff --git a/app-modules/identity/routes/identity-routes.php b/app-modules/identity/routes/identity-routes.php index f57b773..20cee1a 100644 --- a/app-modules/identity/routes/identity-routes.php +++ b/app-modules/identity/routes/identity-routes.php @@ -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'); }); diff --git a/app-modules/identity/src/Filament/Pages/.gitkeep b/app-modules/identity/src/Filament/Pages/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app-modules/identity/src/Filament/Resources/AuditLogs/AuditLogResource.php b/app-modules/identity/src/Filament/Resources/AuditLogs/AuditLogResource.php new file mode 100644 index 0000000..8f97d84 --- /dev/null +++ b/app-modules/identity/src/Filament/Resources/AuditLogs/AuditLogResource.php @@ -0,0 +1,51 @@ + ListAuditLogs::route('/'), + 'view' => ViewAuditLog::route('/{record}'), + ]; + } +} diff --git a/app-modules/identity/src/Filament/Resources/AuditLogs/Pages/ListAuditLogs.php b/app-modules/identity/src/Filament/Resources/AuditLogs/Pages/ListAuditLogs.php new file mode 100644 index 0000000..7ea14a2 --- /dev/null +++ b/app-modules/identity/src/Filament/Resources/AuditLogs/Pages/ListAuditLogs.php @@ -0,0 +1,18 @@ +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(), + ]), + ]); + } +} diff --git a/app-modules/identity/src/Filament/Resources/AuditLogs/Tables/AuditLogsTable.php b/app-modules/identity/src/Filament/Resources/AuditLogs/Tables/AuditLogsTable.php new file mode 100644 index 0000000..87f566c --- /dev/null +++ b/app-modules/identity/src/Filament/Resources/AuditLogs/Tables/AuditLogsTable.php @@ -0,0 +1,62 @@ +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(), + ]); + } +} diff --git a/app-modules/identity/src/Filament/Widgets/.gitkeep b/app-modules/identity/src/Filament/Widgets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app-modules/identity/src/IdentityPlugin.php b/app-modules/identity/src/IdentityPlugin.php new file mode 100644 index 0000000..42a6034 --- /dev/null +++ b/app-modules/identity/src/IdentityPlugin.php @@ -0,0 +1,43 @@ +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); + } +} diff --git a/app-modules/identity/src/Policies/AuditLogPolicy.php b/app-modules/identity/src/Policies/AuditLogPolicy.php new file mode 100644 index 0000000..256f9ac --- /dev/null +++ b/app-modules/identity/src/Policies/AuditLogPolicy.php @@ -0,0 +1,39 @@ +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; + } +} diff --git a/app-modules/identity/src/Providers/IdentityServiceProvider.php b/app-modules/identity/src/Providers/IdentityServiceProvider.php index 1624202..8e4224c 100644 --- a/app-modules/identity/src/Providers/IdentityServiceProvider.php +++ b/app-modules/identity/src/Providers/IdentityServiceProvider.php @@ -2,11 +2,17 @@ namespace Modules\Identity\Providers; +use Illuminate\Contracts\Auth\Access\Gate; use Illuminate\Support\ServiceProvider; +use Modules\Identity\Policies\AuditLogPolicy; +use Spatie\Activitylog\Models\Activity; class IdentityServiceProvider extends ServiceProvider { public function register(): void {} - public function boot(): void {} + public function boot(Gate $gate): void + { + $gate->policy(Activity::class, AuditLogPolicy::class); + } } diff --git a/app-modules/identity/tests/Feature/AgentAbilityAuditTest.php b/app-modules/identity/tests/Feature/AgentAbilityAuditTest.php new file mode 100644 index 0000000..2ca19d2 --- /dev/null +++ b/app-modules/identity/tests/Feature/AgentAbilityAuditTest.php @@ -0,0 +1,82 @@ +agent = User::factory()->create(); + $this->agentToken = $this->agent->createToken('fastapi-agent', TokenAbility::fastApiAgentAbilities())->plainTextToken; +}); + +test('the agent token cannot refund a confirmed booking', 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-AGENT-AUDIT-1', + ]); + + $this->withHeader('Authorization', "Bearer {$this->agentToken}") + ->postJson("/api/v1/bookings/{$booking->booking_ref}/refund", [ + 'amount' => 15000, + 'reason' => 'agent should never reach this', + ]) + ->assertForbidden(); + + expect($booking->refresh()->status)->toBe(BookingStatus::Confirmed); +}); + +test('the agent token cannot cancel a booking it does not own', function () { + $owner = User::factory()->create(); + $booking = Booking::factory()->create(['user_id' => $owner->id, 'status' => BookingStatus::PendingPayment]); + + $this->withHeader('Authorization', "Bearer {$this->agentToken}") + ->postJson("/api/v1/bookings/{$booking->booking_ref}/cancel") + ->assertForbidden(); + + expect($booking->refresh()->status)->toBe(BookingStatus::PendingPayment); +}); + +test('catalog writes have no customer-facing route at all', function () { + // These paths only exist as GET (read) routes — a POST to them is + // rejected as 405 (method not allowed), not routed to any write + // handler, proving no write endpoint was ever registered. + $this->withHeader('Authorization', "Bearer {$this->agentToken}") + ->postJson('/api/v1/companies', ['name' => 'Should Not Exist']) + ->assertStatus(405); + + $this->withHeader('Authorization', "Bearer {$this->agentToken}") + ->postJson('/api/v1/destinations', ['name' => 'Should Not Exist']) + ->assertStatus(405); +}); + +test('routing/pricing writes have no customer-facing route at all', function () { + $this->withHeader('Authorization', "Bearer {$this->agentToken}") + ->postJson('/api/v1/routes', ['ev_company_id' => 1]) + ->assertStatus(405); +}); + +test('the agent token can still read routes and create/read bookings', function () { + $this->withHeader('Authorization', "Bearer {$this->agentToken}") + ->getJson('/api/v1/routes') + ->assertSuccessful(); + + $booking = Booking::factory()->create(['user_id' => $this->agent->id]); + + $this->withHeader('Authorization', "Bearer {$this->agentToken}") + ->getJson('/api/v1/bookings') + ->assertSuccessful() + ->assertJsonPath('data.0.id', $booking->id); +}); diff --git a/app-modules/identity/tests/Feature/AuditLogTest.php b/app-modules/identity/tests/Feature/AuditLogTest.php new file mode 100644 index 0000000..9a73631 --- /dev/null +++ b/app-modules/identity/tests/Feature/AuditLogTest.php @@ -0,0 +1,67 @@ +create(['status' => BookingStatus::PendingPayment]); + + $booking->update(['status' => BookingStatus::Confirmed]); + + $activity = Activity::where('subject_type', Booking::class) + ->where('subject_id', $booking->id) + ->where('log_name', 'booking') + ->latest('id') + ->first(); + + expect($activity)->not->toBeNull(); + expect($activity->attribute_changes->get('attributes'))->toMatchArray(['status' => BookingStatus::Confirmed->value]); +}); + +test('a catalog CRUD write is recorded in the audit log', function () { + $company = EvCompany::factory()->create(['name' => 'Original Name']); + + $company->update(['name' => 'Renamed Company']); + + $activity = Activity::where('subject_type', EvCompany::class) + ->where('subject_id', $company->id) + ->where('log_name', 'catalog') + ->latest('id') + ->first(); + + expect($activity)->not->toBeNull(); + expect($activity->attribute_changes->get('attributes')['name'])->toBe('Renamed Company'); +}); + +test('a user without view_audit_log cannot list the audit log via Filament', function () { + Permission::findOrCreate('view_audit_log', 'web'); + $role = Role::findOrCreate('support', 'web'); + $user = User::factory()->create(); + $user->assignRole($role); + + $this->actingAs($user) + ->get('/admin/audit-logs') + ->assertForbidden(); +}); + +test('a user with view_audit_log can list the audit log via Filament', function () { + Permission::findOrCreate('view_audit_log', 'web'); + $role = Role::findOrCreate('support', 'web'); + $role->givePermissionTo('view_audit_log'); + $user = User::factory()->create(); + $user->assignRole($role); + + $this->actingAs($user) + ->get('/admin/audit-logs') + ->assertSuccessful(); +}); diff --git a/app-modules/payment/routes/payment-routes.php b/app-modules/payment/routes/payment-routes.php index d1d63c0..2005b5f 100644 --- a/app-modules/payment/routes/payment-routes.php +++ b/app-modules/payment/routes/payment-routes.php @@ -5,7 +5,7 @@ use Modules\Payment\Http\Controllers\PaymentController; use Modules\Payment\Http\Controllers\PaymentWebhookController; use Modules\Payment\Http\Controllers\RefundController; -Route::prefix('api/v1')->middleware(['api', 'auth:sanctum', 'throttle:60,1'])->group(function () { +Route::prefix('api/v1')->middleware(['api', 'auth:sanctum', 'throttle:api-write'])->group(function () { Route::post('/payments/{booking:booking_ref}/initiate', [PaymentController::class, 'initiate'])->name('payment.payments.initiate'); Route::post('/bookings/{booking:booking_ref}/refund', [RefundController::class, 'refund'])->name('payment.bookings.refund'); }); @@ -13,6 +13,6 @@ Route::prefix('api/v1')->middleware(['api', 'auth:sanctum', 'throttle:60,1'])->g // No auth:sanctum — the gateway authenticates itself via its own signed // payload (verified inside each gateway's handleWebhook()), not a bearer // token (domain.md §6). -Route::prefix('api/v1')->middleware(['api', 'throttle:60,1'])->group(function () { +Route::prefix('api/v1')->middleware(['api', 'throttle:api-webhooks'])->group(function () { Route::post('/webhooks/{method}/{encryptBookingId?}', [PaymentWebhookController::class, 'handle'])->name('payment.webhooks.handle'); }); diff --git a/app-modules/payment/src/Exceptions/PaymentGatewayException.php b/app-modules/payment/src/Exceptions/PaymentGatewayException.php new file mode 100644 index 0000000..e84585c --- /dev/null +++ b/app-modules/payment/src/Exceptions/PaymentGatewayException.php @@ -0,0 +1,57 @@ +expectsJson()) { + return response()->json([ + 'message' => $this->getMessage(), + 'gateway_code' => $this->gatewayCode, + ], $this->statusCode); + } + + return null; + } +} diff --git a/app-modules/payment/src/Models/Payment.php b/app-modules/payment/src/Models/Payment.php index b40c557..1a4a025 100644 --- a/app-modules/payment/src/Models/Payment.php +++ b/app-modules/payment/src/Models/Payment.php @@ -10,6 +10,8 @@ use Modules\Booking\Models\Booking; use Modules\Payment\Database\Factories\PaymentFactory; use Modules\Payment\Enums\PaymentMethod; use Modules\Payment\Enums\PaymentStatus; +use Spatie\Activitylog\Models\Concerns\LogsActivity; +use Spatie\Activitylog\Support\LogOptions; /** * One attempt to pay for a Booking through a gateway — a Booking can have @@ -19,7 +21,19 @@ use Modules\Payment\Enums\PaymentStatus; class Payment extends Model { /** @use HasFactory */ - use HasFactory; + use HasFactory, LogsActivity; + + /** + * Audit trail on status transitions only (domain.md §6; T6.2). + */ + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->logOnly(['status']) + ->logOnlyDirty() + ->dontLogEmptyChanges() + ->useLogName('payment'); + } /** * @var list diff --git a/app-modules/payment/src/Models/Refund.php b/app-modules/payment/src/Models/Refund.php index c8160ff..9e3e4a4 100644 --- a/app-modules/payment/src/Models/Refund.php +++ b/app-modules/payment/src/Models/Refund.php @@ -8,6 +8,8 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Modules\Payment\Database\Factories\RefundFactory; use Modules\Payment\Enums\RefundStatus; +use Spatie\Activitylog\Models\Concerns\LogsActivity; +use Spatie\Activitylog\Support\LogOptions; /** * A reversal against a specific successful Payment (not against the Booking @@ -17,7 +19,19 @@ use Modules\Payment\Enums\RefundStatus; class Refund extends Model { /** @use HasFactory */ - use HasFactory; + use HasFactory, LogsActivity; + + /** + * Audit trail on status transitions only (domain.md §6; T6.2). + */ + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->logOnly(['status']) + ->logOnlyDirty() + ->dontLogEmptyChanges() + ->useLogName('refund'); + } /** * @var list diff --git a/app-modules/routing/routes/routing-routes.php b/app-modules/routing/routes/routing-routes.php index 8db0a72..ebdf42c 100644 --- a/app-modules/routing/routes/routing-routes.php +++ b/app-modules/routing/routes/routing-routes.php @@ -3,7 +3,7 @@ use Illuminate\Support\Facades\Route; use Modules\Routing\Http\Controllers\EvRouteController; -Route::prefix('api/v1')->middleware(['api', 'auth:sanctum', 'throttle:60,1'])->group(function () { +Route::prefix('api/v1')->middleware(['api', 'auth:sanctum', 'throttle:api-read'])->group(function () { Route::get('/routes', [EvRouteController::class, 'index'])->name('routing.routes.index'); Route::get('/routes/{route}', [EvRouteController::class, 'show'])->name('routing.routes.show'); Route::get('/routes/{route}/pricing', [EvRouteController::class, 'pricing'])->name('routing.routes.pricing'); diff --git a/app-modules/routing/src/Models/EvRoute.php b/app-modules/routing/src/Models/EvRoute.php index c3ca942..aef23b6 100644 --- a/app-modules/routing/src/Models/EvRoute.php +++ b/app-modules/routing/src/Models/EvRoute.php @@ -12,11 +12,27 @@ use Modules\Catalog\Models\DepartureTimeSlot; use Modules\Catalog\Models\Destination; use Modules\Catalog\Models\EvCompany; use Modules\Routing\Database\Factories\EvRouteFactory; +use Spatie\Activitylog\Models\Concerns\LogsActivity; +use Spatie\Activitylog\Support\LogOptions; class EvRoute extends Model { /** @use HasFactory */ - use HasFactory; + use HasFactory, LogsActivity; + + /** + * Full CRUD audit trail — route 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('routing'); + } /** * @var list diff --git a/app-modules/routing/src/Models/RoutePricing.php b/app-modules/routing/src/Models/RoutePricing.php index f2b3a94..806440c 100644 --- a/app-modules/routing/src/Models/RoutePricing.php +++ b/app-modules/routing/src/Models/RoutePricing.php @@ -7,11 +7,27 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Modules\Routing\Database\Factories\RoutePricingFactory; use Modules\Shared\Enums\VehicleOption; +use Spatie\Activitylog\Models\Concerns\LogsActivity; +use Spatie\Activitylog\Support\LogOptions; class RoutePricing extends Model { /** @use HasFactory */ - use HasFactory; + use HasFactory, LogsActivity; + + /** + * Full CRUD audit trail — pricing changes must never silently reprice + * existing bookings (domain.md §3), so every edit here is traceable to + * who changed it and when (T6.2). + */ + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->logFillable() + ->logOnlyDirty() + ->dontLogEmptyChanges() + ->useLogName('routing'); + } protected $table = 'route_pricing'; diff --git a/app-modules/shared/tests/Feature/ApiErrorEnvelopeTest.php b/app-modules/shared/tests/Feature/ApiErrorEnvelopeTest.php new file mode 100644 index 0000000..ab2538c --- /dev/null +++ b/app-modules/shared/tests/Feature/ApiErrorEnvelopeTest.php @@ -0,0 +1,36 @@ +postJson('/api/v1/bookings/EVB-DOES-NOT-EXIST/cancel') + ->assertUnauthorized() + ->assertJsonStructure(['message']); +}); + +test('a route model binding miss on an api route gets a 404 JSON envelope', function () { + $user = User::factory()->create(); + $token = $user->createToken('test')->plainTextToken; + + $this->withHeader('Authorization', "Bearer {$token}") + ->getJson('/api/v1/bookings/EVB-DOES-NOT-EXIST') + ->assertNotFound() + ->assertJsonStructure(['message']); +}); + +test('an unknown api route gets a 404 JSON envelope, not an HTML page', function () { + $this->getJson('/api/v1/this-route-does-not-exist') + ->assertNotFound() + ->assertJsonStructure(['message']); +}); + +test('an unsupported HTTP method on a known api route gets a 405 JSON envelope', function () { + $this->putJson('/api/v1/companies') + ->assertStatus(405) + ->assertJsonStructure(['message']); +}); diff --git a/app-modules/shared/tests/Feature/ApiRateLimitingTest.php b/app-modules/shared/tests/Feature/ApiRateLimitingTest.php new file mode 100644 index 0000000..1e17547 --- /dev/null +++ b/app-modules/shared/tests/Feature/ApiRateLimitingTest.php @@ -0,0 +1,36 @@ +create(); + $token = $user->createToken('test')->plainTextToken; + + $readResponses = collect(range(1, 25))->map( + fn () => $this->withHeader('Authorization', "Bearer {$token}")->getJson('/api/v1/companies') + ); + expect($readResponses->every(fn ($response) => $response->status() !== 429))->toBeTrue(); + + $writeResponses = collect(range(1, 25))->map( + fn () => $this->withHeader('Authorization', "Bearer {$token}")->postJson('/api/v1/bookings', []) + ); + expect($writeResponses->contains(fn ($response) => $response->status() === 429))->toBeTrue(); +}); + +test('a rate-limited api request gets a 429 JSON envelope', function () { + $user = User::factory()->create(); + $token = $user->createToken('test')->plainTextToken; + + $responses = collect(range(1, 25))->map( + fn () => $this->withHeader('Authorization', "Bearer {$token}")->postJson('/api/v1/bookings', []) + ); + + $limited = $responses->first(fn ($response) => $response->status() === 429); + + expect($limited)->not->toBeNull(); + $limited->assertJsonStructure(['message']); +}); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b6..2812886 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,9 @@ namespace App\Providers; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -19,6 +22,31 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { - // + $this->configureRateLimiting(); + } + + /** + * Named api/* rate limiters (T6.1, domain.md §8) — read-only catalog/ + * routing endpoints get a looser limit than the write-heavy booking/ + * payment endpoints; auth/token issuance and the inbound KBZ webhook + * each get their own tighter limiter. + */ + private function configureRateLimiting(): void + { + RateLimiter::for('api-read', function (Request $request) { + return Limit::perMinute(120)->by($request->user()?->id ?: $request->ip()); + }); + + RateLimiter::for('api-write', function (Request $request) { + return Limit::perMinute(20)->by($request->user()?->id ?: $request->ip()); + }); + + RateLimiter::for('api-auth', function (Request $request) { + return Limit::perMinute(10)->by($request->ip()); + }); + + RateLimiter::for('api-webhooks', function (Request $request) { + return Limit::perMinute(30)->by($request->ip()); + }); } } diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 4731363..37f8b5e 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -2,6 +2,7 @@ namespace App\Providers\Filament; +use Boquizo\FilamentLogViewer\FilamentLogViewerPlugin; use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\DisableBladeIconComponents; @@ -11,6 +12,7 @@ use Filament\Pages\Dashboard; use Filament\Panel; use Filament\PanelProvider; use Filament\Support\Colors\Color; +use Filament\Support\Icons\Heroicon; use Filament\Widgets\AccountWidget; use Filament\Widgets\FilamentInfoWidget; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; @@ -21,6 +23,7 @@ use Illuminate\Session\Middleware\StartSession; use Illuminate\View\Middleware\ShareErrorsFromSession; use Modules\Booking\BookingPlugin; use Modules\Catalog\CatalogPlugin; +use Modules\Identity\IdentityPlugin; use Modules\Payment\PaymentPlugin; use Modules\Routing\RoutingPlugin; @@ -32,6 +35,7 @@ class AdminPanelProvider extends PanelProvider ->default() ->id('admin') ->path('admin') + ->viteTheme('resources/css/filament/admin/theme.css') ->login() ->colors([ 'primary' => Color::Amber, @@ -47,6 +51,16 @@ class AdminPanelProvider extends PanelProvider RoutingPlugin::make(), BookingPlugin::make(), PaymentPlugin::make(), + IdentityPlugin::make(), + // T6.5 — ops convenience for browsing storage/logs/*.log + // in-browser; distinct from the structured, per-model audit + // trail (AuditLogResource, T6.2). No extra permission gate: + // the panel login itself already restricts to admin-tier + // roles (domain.md §8). + FilamentLogViewerPlugin::make() + ->navigationGroup('Operations') + ->navigationIcon(Heroicon::OutlinedDocumentText) + ->navigationLabel('Log Viewer'), ]) ->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources') ->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages') diff --git a/bootstrap/app.php b/bootstrap/app.php index f588264..2c9bd05 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,9 +1,19 @@ withRouting( @@ -18,5 +28,65 @@ return Application::configure(basePath: dirname(__DIR__)) ]); }) ->withExceptions(function (Exceptions $exceptions): void { - // + // api/* always gets a JSON error envelope regardless of the + // client's Accept header (T6.3) — module exceptions that define + // their own render() (app-modules/*/src/Exceptions) still win, + // since Laravel checks those before these fallback callbacks. + $exceptions->shouldRenderJsonWhen(fn (Request $request, Throwable $e) => $request->is('api/*') || $request->expectsJson()); + + $exceptions->render(function (AuthenticationException $e, Request $request) { + if ($request->is('api/*')) { + return response()->json(['message' => 'Unauthenticated.'], 401); + } + }); + + $exceptions->render(function (AuthorizationException $e, Request $request) { + if ($request->is('api/*')) { + return response()->json(['message' => $e->getMessage() ?: 'This action is unauthorized.'], 403); + } + }); + + $exceptions->render(function (ModelNotFoundException $e, Request $request) { + if ($request->is('api/*')) { + return response()->json(['message' => 'The requested resource was not found.'], 404); + } + }); + + $exceptions->render(function (NotFoundHttpException $e, Request $request) { + if ($request->is('api/*')) { + return response()->json(['message' => 'The requested resource was not found.'], 404); + } + }); + + $exceptions->render(function (MethodNotAllowedHttpException $e, Request $request) { + if ($request->is('api/*')) { + return response()->json(['message' => 'This method is not allowed for the requested route.'], 405); + } + }); + + $exceptions->render(function (TooManyRequestsHttpException $e, Request $request) { + if ($request->is('api/*')) { + return response()->json(['message' => 'Too many requests.'], 429); + } + }); + + // Last-resort fallback: anything reaching here on api/* is an + // exception with no render() of its own and no more specific + // handler above — never let it leak a raw trace or fall through to + // a bare, unenveloped 500 (T6.3). ValidationException/ + // HttpResponseException are excluded — Laravel's default handling + // of those (after renderable callbacks run) already produces the + // right JSON envelope, this fallback would only get in the way. + $exceptions->render(function (Throwable $e, Request $request) { + if (! $request->is('api/*') + || $e instanceof HttpExceptionInterface + || $e instanceof ValidationException + || $e instanceof HttpResponseException) { + return null; + } + + return response()->json([ + 'message' => app()->hasDebugModeEnabled() ? $e->getMessage() : 'Server Error', + ], 500); + }); })->create(); diff --git a/composer.json b/composer.json index f37d819..05bb611 100644 --- a/composer.json +++ b/composer.json @@ -8,6 +8,7 @@ "require": { "php": "^8.3", "filament/filament": "^4.0", + "gboquizosanchez/filament-log-viewer": "^2.3", "laravel/framework": "^13.0", "laravel/sanctum": "^4.0", "laravel/tinker": "^3.0", @@ -17,6 +18,7 @@ "modules/payment": "*", "modules/routing": "*", "modules/shared": "*", + "spatie/laravel-activitylog": "^5.0", "spatie/laravel-permission": "^8.3" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 3f2fc2a..04ea963 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d93096263e7b6887bbb3e53c47ee6e90", + "content-hash": "5ec2c4de349d84433a04f4f044d7f7ed", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -1547,6 +1547,69 @@ ], "time": "2025-12-03T09:33:47+00:00" }, + { + "name": "gboquizosanchez/filament-log-viewer", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/gboquizosanchez/filament-log-viewer.git", + "reference": "a32df2ae9d9512c166ac1a93eed57c9677294024" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/gboquizosanchez/filament-log-viewer/zipball/a32df2ae9d9512c166ac1a93eed57c9677294024", + "reference": "a32df2ae9d9512c166ac1a93eed57c9677294024", + "shasum": "" + }, + "require": { + "ext-zip": "*", + "php": "^8.2|^8.3|^8.4", + "symfony/polyfill-php83": "^1.33" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.64", + "hermes/dependencies": "^1.1", + "larastan/larastan": "^2.9", + "orchestra/testbench": "^9.1", + "pestphp/pest": "^3.5" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Boquizo\\FilamentLogViewer\\FilamentLogViewerServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Boquizo\\FilamentLogViewer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Germán Boquizo Sánchez", + "email": "germanboquizosanchez@gmail.com", + "role": "Developer" + } + ], + "description": "Filament Log Viewer", + "homepage": "https://github.com/gboquizosanchez", + "keywords": [ + "filament", + "laravel", + "log-viewer" + ], + "support": { + "issues": "https://github.com/gboquizosanchez/filament-log-viewer/issues", + "source": "https://github.com/gboquizosanchez/filament-log-viewer/tree/2.3.0" + }, + "time": "2026-04-07T12:29:16+00:00" + }, { "name": "graham-campbell/result-type", "version": "v1.1.4", @@ -5415,6 +5478,99 @@ ], "time": "2024-05-17T09:06:10+00:00" }, + { + "name": "spatie/laravel-activitylog", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-activitylog.git", + "reference": "0e00fe74fd071cc572a045459f6d4c9de33130bd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-activitylog/zipball/0e00fe74fd071cc572a045459f6d4c9de33130bd", + "reference": "0e00fe74fd071cc572a045459f6d4c9de33130bd", + "shasum": "" + }, + "require": { + "illuminate/config": "^12.0 || ^13.0", + "illuminate/database": "^12.0 || ^13.0", + "illuminate/support": "^12.0 || ^13.0", + "php": "^8.4", + "spatie/laravel-package-tools": "^1.6.3" + }, + "require-dev": { + "ext-json": "*", + "larastan/larastan": "^3.0", + "laravel/pint": "^1.29", + "orchestra/testbench": "^10.0 || ^11.0", + "pestphp/pest": "^4.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\Activitylog\\ActivitylogServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Spatie\\Activitylog\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + }, + { + "name": "Sebastian De Deyne", + "email": "sebastian@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + }, + { + "name": "Tom Witkowski", + "email": "dev.gummibeer@gmail.com", + "homepage": "https://gummibeer.de", + "role": "Developer" + } + ], + "description": "A very simple activity logger to monitor the users of your website or application", + "homepage": "https://github.com/spatie/activitylog", + "keywords": [ + "activity", + "laravel", + "log", + "spatie", + "user" + ], + "support": { + "issues": "https://github.com/spatie/laravel-activitylog/issues", + "source": "https://github.com/spatie/laravel-activitylog/tree/5.0.0" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-03-25T10:04:54+00:00" + }, { "name": "spatie/laravel-package-tools", "version": "1.93.1", @@ -7196,6 +7352,86 @@ ], "time": "2026-04-10T16:19:22+00:00" }, + { + "name": "symfony/polyfill-php83", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-01T12:47:55+00:00" + }, { "name": "symfony/polyfill-php84", "version": "v1.38.1", diff --git a/config/activitylog.php b/config/activitylog.php new file mode 100644 index 0000000..27cd6aa --- /dev/null +++ b/config/activitylog.php @@ -0,0 +1,73 @@ + env('ACTIVITYLOG_ENABLED', true), + + /* + * When the clean command is executed, all recording activities older than + * the number of days specified here will be deleted. + */ + 'clean_after_days' => 365, + + /* + * If no log name is passed to the activity() helper + * we use this default log name. + */ + 'default_log_name' => 'default', + + /* + * You can specify an auth driver here that gets user models. + * If this is null we'll use the current Laravel auth driver. + */ + 'default_auth_driver' => null, + + /* + * If set to true, the subject relationship on activities + * will include soft deleted models. + */ + 'include_soft_deleted_subjects' => false, + + /* + * This model will be used to log activity. + * It should implement the Spatie\Activitylog\Contracts\Activity interface + * and extend Illuminate\Database\Eloquent\Model. + */ + 'activity_model' => Activity::class, + + /* + * These attributes will be excluded from logging for all models. + * Model-specific exclusions via logExcept() are merged with these. + */ + 'default_except_attributes' => [], + + /* + * When enabled, activities are buffered in memory and inserted in a + * single bulk query after the response has been sent to the client. + * This can significantly reduce the number of database queries when + * many activities are logged during a single request. + * + * Only enable this if your application logs a high volume of activities + * per request. Buffered activities will not have an ID until the + * buffer is flushed. + */ + 'buffer' => [ + 'enabled' => env('ACTIVITYLOG_BUFFER_ENABLED', false), + ], + + /* + * These action classes can be overridden to customize how activities + * are logged and cleaned. Your custom classes must extend the originals. + */ + 'actions' => [ + 'log_activity' => LogActivityAction::class, + 'clean_log' => CleanActivityLogAction::class, + ], +]; diff --git a/database/migrations/2026_08_09_093457_create_activity_log_table.php b/database/migrations/2026_08_09_093457_create_activity_log_table.php new file mode 100644 index 0000000..5c17c24 --- /dev/null +++ b/database/migrations/2026_08_09_093457_create_activity_log_table.php @@ -0,0 +1,23 @@ +id(); + $table->string('log_name')->nullable()->index(); + $table->text('description'); + $table->nullableMorphs('subject', 'subject'); + $table->string('event')->nullable(); + $table->nullableMorphs('causer', 'causer'); + $table->json('attribute_changes')->nullable(); + $table->json('properties')->nullable(); + $table->timestamps(); + }); + } +}; diff --git a/package-lock.json b/package-lock.json index 14ad6cb..1a91d73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { - "name": "famous_linnyone4_ev", + "name": "html", "lockfileVersion": 3, "requires": true, "packages": { "": { "devDependencies": { - "@tailwindcss/vite": "^4.0.0", + "@tailwindcss/vite": "^4.3.3", "axios": "^1.11.0", "concurrently": "^9.0.1", "laravel-vite-plugin": "^2.0.0", - "tailwindcss": "^4.0.0", + "tailwindcss": "^4.3.3", "vite": "^7.0.7" } }, diff --git a/package.json b/package.json index 7686b29..1429d87 100644 --- a/package.json +++ b/package.json @@ -7,11 +7,11 @@ "dev": "vite" }, "devDependencies": { - "@tailwindcss/vite": "^4.0.0", + "@tailwindcss/vite": "^4.3.3", "axios": "^1.11.0", "concurrently": "^9.0.1", "laravel-vite-plugin": "^2.0.0", - "tailwindcss": "^4.0.0", + "tailwindcss": "^4.3.3", "vite": "^7.0.7" } } diff --git a/resources/css/filament/admin/theme.css b/resources/css/filament/admin/theme.css new file mode 100644 index 0000000..3361b48 --- /dev/null +++ b/resources/css/filament/admin/theme.css @@ -0,0 +1,6 @@ +@import '../../../../vendor/filament/filament/resources/css/theme.css'; + +@source '../../../../app/Filament/**/*'; +@source '../../../../resources/views/filament/**/*'; +@source '../../../../app-modules/*/src/Filament/**/*'; +@source '../../../../vendor/gboquizosanchez/filament-log-viewer/resources/views/**/*'; diff --git a/tickets.md b/tickets.md index 47e6c9d..c99206d 100644 --- a/tickets.md +++ b/tickets.md @@ -330,6 +330,12 @@ Pickup & Dropoff Locations was originally scoped here. The real business model i - **Description**: Review every endpoint against domain.md §8's access boundaries; add feature tests proving the FastAPI agent token **cannot** hit refund/catalog-write endpoints (expect 403), and that catalog/pricing writes have no customer-facing route at all. - **Domain reference**: domain.md §8 (re-read before writing these tests) +### T6.5 — Filament log viewer +- **Module**: Shared +- **Depends on**: T6.2 +- **Description**: Install `gboquizosanchez/filament-log-viewer` to browse application/error log files (`storage/logs/laravel-*.log`) from the admin panel, on top of the `daily` log channel. This is a dev/ops convenience for reading raw log output in-browser — distinct from T6.2's structured, per-model audit trail (`spatie/laravel-activitylog`), which this does not replace. Gate visibility to admin-tier roles only (same access boundary as the rest of the Filament panel, domain.md §8). +- **Domain reference**: — (ops convenience, no business rule) + --- ## Phase 7 — Dashboard & Polish diff --git a/vite.config.js b/vite.config.js index f35b4e7..3b3bc05 100644 --- a/vite.config.js +++ b/vite.config.js @@ -5,7 +5,7 @@ import tailwindcss from '@tailwindcss/vite'; export default defineConfig({ plugins: [ laravel({ - input: ['resources/css/app.css', 'resources/js/app.js'], + input: ['resources/css/app.css', 'resources/js/app.js', 'resources/css/filament/admin/theme.css'], refresh: true, }), tailwindcss(), From fd3a1954538e2eeb15bfb95daa230041fd5437ff Mon Sep 17 00:00:00 2001 From: Nyan Lin Paing <117423022+LinPaing21@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:21:11 +0700 Subject: [PATCH 2/2] Add Access group admin surfaces, booking soft deletes, refund crash fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Access group (Filament): - StaffResource: manage users with an admin-tier role, gated by manage_staff - CustomerResource: read-only view of role-less users, gated by view_customers - RoleResource: edit permissions per role (fixed role set), gated by manage_roles - ManageAppSettings: tabbed General/Booking settings page that reads/writes real .env keys via new EnvFileWriter (no parallel DB settings table, so BookingService/config('booking.*') stay unchanged) - Moved Access above Catalog in the nav group order - New permissions: manage_staff, manage_roles, view_customers, manage_settings Booking soft deletes: - bookings.deleted_at + SoftDeletes on the Booking model - BookingPolicy::delete (manage_bookings, cancelled/expired only) and ::restore (manage_bookings) - DeleteBookingTableAction/RestoreBookingTableAction + TrashedFilter on BookingsTable, using authorize() so the policy is enforced at call time, not just cosmetically hidden Refund crash fix: - ProcessRefundAction passed a nullable $payment->booking into RefundBookingAction's non-nullable Booking param — a soft-deleted booking's payment reaching the refund picker was an uncaught TypeError. Excluded such payments from the picker and added a defensive guard. - Same unguarded $event->payment->booking / $event->refund->payment->booking pattern fixed in the MarkBookingPaid/MarkBookingRefunded queued listeners. 289 tests passing. --- .env.example | 5 + ...13753_add_deleted_at_to_bookings_table.php | 33 ++++ .../Actions/DeleteBookingTableAction.php | 23 +++ .../Actions/RestoreBookingTableAction.php | 20 +++ .../Bookings/Tables/BookingsTable.php | 9 ++ app-modules/booking/src/Models/Booking.php | 3 +- .../booking/src/Policies/BookingPolicy.php | 22 +++ .../tests/Feature/BookingPolicyTest.php | 51 ++++++ .../tests/Feature/BookingResourceTest.php | 93 +++++++++++ .../database/seeders/RolePermissionSeeder.php | 11 ++ .../pages/manage-app-settings.blade.php | 3 + .../src/Filament/Pages/ManageAppSettings.php | 151 ++++++++++++++++++ .../Resources/Customers/CustomerResource.php | 63 ++++++++ .../Customers/Pages/ListCustomers.php | 18 +++ .../Customers/Pages/ViewCustomer.php | 11 ++ .../Customers/Schemas/CustomerInfolist.php | 30 ++++ .../Customers/Tables/CustomersTable.php | 35 ++++ .../Resources/Roles/Pages/EditRole.php | 28 ++++ .../Resources/Roles/Pages/ListRoles.php | 17 ++ .../Filament/Resources/Roles/RoleResource.php | 71 ++++++++ .../Resources/Roles/Schemas/RoleForm.php | 25 +++ .../Resources/Roles/Tables/RolesTable.php | 29 ++++ .../Resources/Staff/Pages/CreateStaff.php | 11 ++ .../Resources/Staff/Pages/EditStaff.php | 19 +++ .../Resources/Staff/Pages/ListStaff.php | 19 +++ .../Resources/Staff/Schemas/StaffForm.php | 45 ++++++ .../Resources/Staff/StaffResource.php | 90 +++++++++++ .../Resources/Staff/Tables/StaffTable.php | 37 +++++ .../tests/Feature/CustomerResourceTest.php | 35 ++++ .../tests/Feature/ManageAppSettingsTest.php | 71 ++++++++ .../tests/Feature/RoleResourceTest.php | 36 +++++ .../tests/Feature/StaffResourceTest.php | 59 +++++++ .../Refunds/Actions/ProcessRefundAction.php | 18 +++ .../payment/src/Listeners/MarkBookingPaid.php | 8 + .../src/Listeners/MarkBookingRefunded.php | 8 + .../tests/Feature/MarkBookingPaidTest.php | 9 ++ .../tests/Feature/MarkBookingRefundedTest.php | 40 +++++ .../tests/Feature/RefundResourceTest.php | 27 ++++ .../shared/src/Support/EnvFileWriter.php | 77 +++++++++ .../shared/tests/Unit/EnvFileWriterTest.php | 60 +++++++ app/Models/User.php | 12 ++ app/Providers/Filament/AdminPanelProvider.php | 2 +- config/app.php | 19 ++- 43 files changed, 1450 insertions(+), 3 deletions(-) create mode 100644 app-modules/booking/database/migrations/2026_08_09_213753_add_deleted_at_to_bookings_table.php create mode 100644 app-modules/booking/src/Filament/Resources/Bookings/Actions/DeleteBookingTableAction.php create mode 100644 app-modules/booking/src/Filament/Resources/Bookings/Actions/RestoreBookingTableAction.php create mode 100644 app-modules/identity/resources/views/filament/pages/manage-app-settings.blade.php create mode 100644 app-modules/identity/src/Filament/Pages/ManageAppSettings.php create mode 100644 app-modules/identity/src/Filament/Resources/Customers/CustomerResource.php create mode 100644 app-modules/identity/src/Filament/Resources/Customers/Pages/ListCustomers.php create mode 100644 app-modules/identity/src/Filament/Resources/Customers/Pages/ViewCustomer.php create mode 100644 app-modules/identity/src/Filament/Resources/Customers/Schemas/CustomerInfolist.php create mode 100644 app-modules/identity/src/Filament/Resources/Customers/Tables/CustomersTable.php create mode 100644 app-modules/identity/src/Filament/Resources/Roles/Pages/EditRole.php create mode 100644 app-modules/identity/src/Filament/Resources/Roles/Pages/ListRoles.php create mode 100644 app-modules/identity/src/Filament/Resources/Roles/RoleResource.php create mode 100644 app-modules/identity/src/Filament/Resources/Roles/Schemas/RoleForm.php create mode 100644 app-modules/identity/src/Filament/Resources/Roles/Tables/RolesTable.php create mode 100644 app-modules/identity/src/Filament/Resources/Staff/Pages/CreateStaff.php create mode 100644 app-modules/identity/src/Filament/Resources/Staff/Pages/EditStaff.php create mode 100644 app-modules/identity/src/Filament/Resources/Staff/Pages/ListStaff.php create mode 100644 app-modules/identity/src/Filament/Resources/Staff/Schemas/StaffForm.php create mode 100644 app-modules/identity/src/Filament/Resources/Staff/StaffResource.php create mode 100644 app-modules/identity/src/Filament/Resources/Staff/Tables/StaffTable.php create mode 100644 app-modules/identity/tests/Feature/CustomerResourceTest.php create mode 100644 app-modules/identity/tests/Feature/ManageAppSettingsTest.php create mode 100644 app-modules/identity/tests/Feature/RoleResourceTest.php create mode 100644 app-modules/identity/tests/Feature/StaffResourceTest.php create mode 100644 app-modules/payment/tests/Feature/MarkBookingRefundedTest.php create mode 100644 app-modules/shared/src/Support/EnvFileWriter.php create mode 100644 app-modules/shared/tests/Unit/EnvFileWriterTest.php diff --git a/.env.example b/.env.example index b13bdb7..9c86413 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app-modules/booking/database/migrations/2026_08_09_213753_add_deleted_at_to_bookings_table.php b/app-modules/booking/database/migrations/2026_08_09_213753_add_deleted_at_to_bookings_table.php new file mode 100644 index 0000000..ffef75c --- /dev/null +++ b/app-modules/booking/database/migrations/2026_08_09_213753_add_deleted_at_to_bookings_table.php @@ -0,0 +1,33 @@ +softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('bookings', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + } +}; diff --git a/app-modules/booking/src/Filament/Resources/Bookings/Actions/DeleteBookingTableAction.php b/app-modules/booking/src/Filament/Resources/Bookings/Actions/DeleteBookingTableAction.php new file mode 100644 index 0000000..444e3d3 --- /dev/null +++ b/app-modules/booking/src/Filament/Resources/Bookings/Actions/DeleteBookingTableAction.php @@ -0,0 +1,23 @@ +authorize('delete'); + } +} diff --git a/app-modules/booking/src/Filament/Resources/Bookings/Actions/RestoreBookingTableAction.php b/app-modules/booking/src/Filament/Resources/Bookings/Actions/RestoreBookingTableAction.php new file mode 100644 index 0000000..21c5bb1 --- /dev/null +++ b/app-modules/booking/src/Filament/Resources/Bookings/Actions/RestoreBookingTableAction.php @@ -0,0 +1,20 @@ +authorize('restore'); + } +} diff --git a/app-modules/booking/src/Filament/Resources/Bookings/Tables/BookingsTable.php b/app-modules/booking/src/Filament/Resources/Bookings/Tables/BookingsTable.php index 6b028c3..9868b6c 100644 --- a/app-modules/booking/src/Filament/Resources/Bookings/Tables/BookingsTable.php +++ b/app-modules/booking/src/Filament/Resources/Bookings/Tables/BookingsTable.php @@ -7,11 +7,14 @@ 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; @@ -109,11 +112,17 @@ class BookingsTable $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(), ]); } } diff --git a/app-modules/booking/src/Models/Booking.php b/app-modules/booking/src/Models/Booking.php index 61a4e27..f1da7cb 100644 --- a/app-modules/booking/src/Models/Booking.php +++ b/app-modules/booking/src/Models/Booking.php @@ -7,6 +7,7 @@ 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; @@ -19,7 +20,7 @@ use Spatie\Activitylog\Support\LogOptions; class Booking extends Model { /** @use HasFactory */ - use HasFactory, LogsActivity; + use HasFactory, LogsActivity, SoftDeletes; /** * Audit trail on status transitions and driver/vehicle assignment only — diff --git a/app-modules/booking/src/Policies/BookingPolicy.php b/app-modules/booking/src/Policies/BookingPolicy.php index b6cd470..e1ffe57 100644 --- a/app-modules/booking/src/Policies/BookingPolicy.php +++ b/app-modules/booking/src/Policies/BookingPolicy.php @@ -63,4 +63,26 @@ class BookingPolicy { 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'); + } } diff --git a/app-modules/booking/tests/Feature/BookingPolicyTest.php b/app-modules/booking/tests/Feature/BookingPolicyTest.php index 27c214f..c09b175 100644 --- a/app-modules/booking/tests/Feature/BookingPolicyTest.php +++ b/app-modules/booking/tests/Feature/BookingPolicyTest.php @@ -1,6 +1,7 @@ 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(); +}); diff --git a/app-modules/booking/tests/Feature/BookingResourceTest.php b/app-modules/booking/tests/Feature/BookingResourceTest.php index 8fb26f3..7484141 100644 --- a/app-modules/booking/tests/Feature/BookingResourceTest.php +++ b/app-modules/booking/tests/Feature/BookingResourceTest.php @@ -243,3 +243,96 @@ test('the detail page\'s assign driver action is hidden for a pending_payment bo ->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); +}); diff --git a/app-modules/identity/database/seeders/RolePermissionSeeder.php b/app-modules/identity/database/seeders/RolePermissionSeeder.php index 15a7464..d40d413 100644 --- a/app-modules/identity/database/seeders/RolePermissionSeeder.php +++ b/app-modules/identity/database/seeders/RolePermissionSeeder.php @@ -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', ], ]; diff --git a/app-modules/identity/resources/views/filament/pages/manage-app-settings.blade.php b/app-modules/identity/resources/views/filament/pages/manage-app-settings.blade.php new file mode 100644 index 0000000..6d969da --- /dev/null +++ b/app-modules/identity/resources/views/filament/pages/manage-app-settings.blade.php @@ -0,0 +1,3 @@ + + {{ $this->form }} + diff --git a/app-modules/identity/src/Filament/Pages/ManageAppSettings.php b/app-modules/identity/src/Filament/Pages/ManageAppSettings.php new file mode 100644 index 0000000..c449005 --- /dev/null +++ b/app-modules/identity/src/Filament/Pages/ManageAppSettings.php @@ -0,0 +1,151 @@ +|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(); + } +} diff --git a/app-modules/identity/src/Filament/Resources/Customers/CustomerResource.php b/app-modules/identity/src/Filament/Resources/Customers/CustomerResource.php new file mode 100644 index 0000000..2cd9438 --- /dev/null +++ b/app-modules/identity/src/Filament/Resources/Customers/CustomerResource.php @@ -0,0 +1,63 @@ +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; + } +} diff --git a/app-modules/identity/src/Filament/Resources/Customers/Pages/ListCustomers.php b/app-modules/identity/src/Filament/Resources/Customers/Pages/ListCustomers.php new file mode 100644 index 0000000..3decfa2 --- /dev/null +++ b/app-modules/identity/src/Filament/Resources/Customers/Pages/ListCustomers.php @@ -0,0 +1,18 @@ +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(), + ), + ]), + ]), + ]); + } +} diff --git a/app-modules/identity/src/Filament/Resources/Customers/Tables/CustomersTable.php b/app-modules/identity/src/Filament/Resources/Customers/Tables/CustomersTable.php new file mode 100644 index 0000000..b8c49ec --- /dev/null +++ b/app-modules/identity/src/Filament/Resources/Customers/Tables/CustomersTable.php @@ -0,0 +1,35 @@ +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(), + ]); + } +} diff --git a/app-modules/identity/src/Filament/Resources/Roles/Pages/EditRole.php b/app-modules/identity/src/Filament/Resources/Roles/Pages/EditRole.php new file mode 100644 index 0000000..1f17c44 --- /dev/null +++ b/app-modules/identity/src/Filament/Resources/Roles/Pages/EditRole.php @@ -0,0 +1,28 @@ +forgetCachedPermissions(); + } +} diff --git a/app-modules/identity/src/Filament/Resources/Roles/Pages/ListRoles.php b/app-modules/identity/src/Filament/Resources/Roles/Pages/ListRoles.php new file mode 100644 index 0000000..4cdcab7 --- /dev/null +++ b/app-modules/identity/src/Filament/Resources/Roles/Pages/ListRoles.php @@ -0,0 +1,17 @@ + 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; + } +} diff --git a/app-modules/identity/src/Filament/Resources/Roles/Schemas/RoleForm.php b/app-modules/identity/src/Filament/Resources/Roles/Schemas/RoleForm.php new file mode 100644 index 0000000..fb70edc --- /dev/null +++ b/app-modules/identity/src/Filament/Resources/Roles/Schemas/RoleForm.php @@ -0,0 +1,25 @@ +components([ + TextInput::make('name') + ->disabled() + ->dehydrated(false), + CheckboxList::make('permissions') + ->relationship(name: 'permissions', titleAttribute: 'name') + ->columns(2) + ->bulkToggleable() + ->columnSpanFull(), + ]); + } +} diff --git a/app-modules/identity/src/Filament/Resources/Roles/Tables/RolesTable.php b/app-modules/identity/src/Filament/Resources/Roles/Tables/RolesTable.php new file mode 100644 index 0000000..8f33373 --- /dev/null +++ b/app-modules/identity/src/Filament/Resources/Roles/Tables/RolesTable.php @@ -0,0 +1,29 @@ +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(), + ]); + } +} diff --git a/app-modules/identity/src/Filament/Resources/Staff/Pages/CreateStaff.php b/app-modules/identity/src/Filament/Resources/Staff/Pages/CreateStaff.php new file mode 100644 index 0000000..4ad58d5 --- /dev/null +++ b/app-modules/identity/src/Filament/Resources/Staff/Pages/CreateStaff.php @@ -0,0 +1,11 @@ +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.'), + ]); + } +} diff --git a/app-modules/identity/src/Filament/Resources/Staff/StaffResource.php b/app-modules/identity/src/Filament/Resources/Staff/StaffResource.php new file mode 100644 index 0000000..a9807b7 --- /dev/null +++ b/app-modules/identity/src/Filament/Resources/Staff/StaffResource.php @@ -0,0 +1,90 @@ +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(); + } +} diff --git a/app-modules/identity/src/Filament/Resources/Staff/Tables/StaffTable.php b/app-modules/identity/src/Filament/Resources/Staff/Tables/StaffTable.php new file mode 100644 index 0000000..e62354a --- /dev/null +++ b/app-modules/identity/src/Filament/Resources/Staff/Tables/StaffTable.php @@ -0,0 +1,37 @@ +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(), + ]); + } +} diff --git a/app-modules/identity/tests/Feature/CustomerResourceTest.php b/app-modules/identity/tests/Feature/CustomerResourceTest.php new file mode 100644 index 0000000..faa169e --- /dev/null +++ b/app-modules/identity/tests/Feature/CustomerResourceTest.php @@ -0,0 +1,35 @@ +seed(RolePermissionSeeder::class); + + $this->staff = User::factory()->create(); + $this->staff->assignRole('support'); +}); + +test('staff with view_customers can list customers', function () { + $this->actingAs($this->staff)->get('/admin/customers')->assertSuccessful(); +}); + +test('the customer resource only lists users without any role', function () { + $customer = User::factory()->create(); + + $this->actingAs($this->staff); + + Livewire::test(ListCustomers::class) + ->assertCanSeeTableRecords([$customer]) + ->assertCanNotSeeTableRecords([$this->staff]); +}); + +test('a customer view page loads for staff', function () { + $customer = User::factory()->create(); + + $this->actingAs($this->staff) + ->get("/admin/customers/{$customer->id}") + ->assertSuccessful(); +}); diff --git a/app-modules/identity/tests/Feature/ManageAppSettingsTest.php b/app-modules/identity/tests/Feature/ManageAppSettingsTest.php new file mode 100644 index 0000000..49184ad --- /dev/null +++ b/app-modules/identity/tests/Feature/ManageAppSettingsTest.php @@ -0,0 +1,71 @@ +seed(RolePermissionSeeder::class); + + // Never let a test write to the real project .env — bind the writer to + // a throwaway temp file instead. + $this->envPath = sys_get_temp_dir().'/manage-app-settings-test-'.uniqid().'.env'; + file_put_contents($this->envPath, "APP_NAME=Laravel\n"); + app()->instance(EnvFileWriter::class, new EnvFileWriter($this->envPath)); +}); + +afterEach(function () { + @unlink($this->envPath); +}); + +test('an admin without manage_settings is forbidden from the app settings page', function () { + $support = User::factory()->create(); + $support->assignRole('support'); + + $this->actingAs($support)->get('/admin/manage-app-settings')->assertForbidden(); +}); + +test('a super_admin can view and save app settings, writing them to .env', function () { + $superAdmin = User::factory()->create(); + $superAdmin->assignRole('super_admin'); + $this->actingAs($superAdmin); + + Livewire::test(ManageAppSettings::class) + ->assertOk() + ->fillForm([ + 'site_name' => 'EV Booking Co', + 'support_email' => 'help@evbooking.test', + 'support_phone' => '+95912345678', + 'timezone' => 'Asia/Yangon', + 'currency' => 'MMK', + 'back_seat_enabled' => false, + 'whole_vehicle_enabled' => true, + 'front_seat_max_per_booking' => 2, + ]) + ->call('save') + ->assertHasNoFormErrors(); + + $contents = file_get_contents($this->envPath); + + expect($contents) + ->toContain('APP_NAME="EV Booking Co"') + ->toContain('SUPPORT_EMAIL=help@evbooking.test') + ->toContain('APP_TIMEZONE=Asia/Yangon') + ->toContain('APP_CURRENCY=MMK') + ->toContain('BOOKING_BACK_SEAT_ENABLED=false') + ->toContain('BOOKING_WHOLE_VEHICLE_ENABLED=true') + ->toContain('BOOKING_FRONT_SEAT_MAX_PER_BOOKING=2'); +}); + +test('front seat max per booking must be at least 1', function () { + $superAdmin = User::factory()->create(); + $superAdmin->assignRole('super_admin'); + $this->actingAs($superAdmin); + + Livewire::test(ManageAppSettings::class) + ->fillForm(['front_seat_max_per_booking' => 0]) + ->call('save') + ->assertHasFormErrors(['front_seat_max_per_booking']); +}); diff --git a/app-modules/identity/tests/Feature/RoleResourceTest.php b/app-modules/identity/tests/Feature/RoleResourceTest.php new file mode 100644 index 0000000..4beac4f --- /dev/null +++ b/app-modules/identity/tests/Feature/RoleResourceTest.php @@ -0,0 +1,36 @@ +seed(RolePermissionSeeder::class); + + $this->superAdmin = User::factory()->create(); + $this->superAdmin->assignRole('super_admin'); +}); + +test('a super_admin can list and edit roles', function () { + $this->actingAs($this->superAdmin)->get('/admin/roles')->assertSuccessful(); + + $role = Role::where('name', 'support')->firstOrFail(); + + $this->actingAs($this->superAdmin)->get("/admin/roles/{$role->id}/edit")->assertSuccessful(); +}); + +test('an admin without manage_roles is forbidden from the role resource', function () { + $admin = User::factory()->create(); + $admin->assignRole('admin'); + + $this->actingAs($admin)->get('/admin/roles')->assertForbidden(); +}); + +test('roles cannot be created or deleted from the resource', function () { + expect(RoleResource::canCreate())->toBeFalse(); + + $role = Role::where('name', 'support')->firstOrFail(); + + expect(RoleResource::canDelete($role))->toBeFalse(); +}); diff --git a/app-modules/identity/tests/Feature/StaffResourceTest.php b/app-modules/identity/tests/Feature/StaffResourceTest.php new file mode 100644 index 0000000..5a1910e --- /dev/null +++ b/app-modules/identity/tests/Feature/StaffResourceTest.php @@ -0,0 +1,59 @@ +seed(RolePermissionSeeder::class); + + $this->superAdmin = User::factory()->create(); + $this->superAdmin->assignRole('super_admin'); +}); + +test('a super_admin can list, create, and edit staff', function () { + $this->actingAs($this->superAdmin)->get('/admin/staff')->assertSuccessful(); + $this->actingAs($this->superAdmin)->get('/admin/staff/create')->assertSuccessful(); + + $other = User::factory()->create(); + $other->assignRole('support'); + + $this->actingAs($this->superAdmin)->get("/admin/staff/{$other->id}/edit")->assertSuccessful(); +}); + +test('an admin without manage_staff is forbidden from the staff resource', function () { + $admin = User::factory()->create(); + $admin->assignRole('admin'); + + $this->actingAs($admin)->get('/admin/staff')->assertForbidden(); +}); + +test('the staff resource only lists users carrying an admin-tier role', function () { + $support = User::factory()->create(); + $support->assignRole('support'); + + $customer = User::factory()->create(); + + $this->actingAs($this->superAdmin); + + Livewire::test(ListStaff::class) + ->assertCanSeeTableRecords([$support]) + ->assertCanNotSeeTableRecords([$customer]); +}); + +test('a super_admin cannot delete their own staff account', function () { + $this->actingAs($this->superAdmin); + + expect(StaffResource::canDelete($this->superAdmin))->toBeFalse(); +}); + +test('a super_admin can delete another staff account', function () { + $other = User::factory()->create(); + $other->assignRole('support'); + + $this->actingAs($this->superAdmin); + + expect(StaffResource::canDelete($other))->toBeTrue(); +}); diff --git a/app-modules/payment/src/Filament/Resources/Refunds/Actions/ProcessRefundAction.php b/app-modules/payment/src/Filament/Resources/Refunds/Actions/ProcessRefundAction.php index 2c5ab98..56950bc 100644 --- a/app-modules/payment/src/Filament/Resources/Refunds/Actions/ProcessRefundAction.php +++ b/app-modules/payment/src/Filament/Resources/Refunds/Actions/ProcessRefundAction.php @@ -33,6 +33,10 @@ class ProcessRefundAction ->label('Payment') ->options(fn () => Payment::query() ->where('status', PaymentStatus::Completed->value) + // A soft-deleted booking excludes itself from this + // belongsTo by default — never offer a payment whose + // booking is gone (Booking now uses SoftDeletes). + ->whereHas('booking') ->with('booking') ->get() ->mapWithKeys(fn (Payment $payment) => [ @@ -50,6 +54,20 @@ class ProcessRefundAction ->action(function (array $data): void { $payment = Payment::with('booking')->findOrFail($data['payment_id']); + // Defense in depth against the options list going stale + // between render and submit (e.g. the booking gets deleted + // mid-form) — $payment->booking is nullable, but + // RefundBookingAction requires a real Booking. + if ($payment->booking === null) { + Notification::make() + ->title('Refund failed') + ->body('This payment\'s booking no longer exists.') + ->danger() + ->send(); + + return; + } + try { app(RefundBookingAction::class)->handle( $payment->booking, diff --git a/app-modules/payment/src/Listeners/MarkBookingPaid.php b/app-modules/payment/src/Listeners/MarkBookingPaid.php index c4bf839..1dc7824 100644 --- a/app-modules/payment/src/Listeners/MarkBookingPaid.php +++ b/app-modules/payment/src/Listeners/MarkBookingPaid.php @@ -18,6 +18,14 @@ class MarkBookingPaid implements ShouldQueue { $booking = $event->payment->booking; + // Booking uses SoftDeletes — normally unreachable here (a + // pending_payment booking is never deletable, BookingPolicy::delete), + // but this listener is queued, so it's worth guarding against a + // booking that vanished between dispatch and execution regardless. + if ($booking === null) { + return; + } + if ($booking->status === BookingStatus::PendingPayment) { $booking->update(['status' => BookingStatus::Confirmed]); } diff --git a/app-modules/payment/src/Listeners/MarkBookingRefunded.php b/app-modules/payment/src/Listeners/MarkBookingRefunded.php index 952500d..44f12e3 100644 --- a/app-modules/payment/src/Listeners/MarkBookingRefunded.php +++ b/app-modules/payment/src/Listeners/MarkBookingRefunded.php @@ -18,6 +18,14 @@ class MarkBookingRefunded implements ShouldQueue { $booking = $event->refund->payment->booking; + // Booking uses SoftDeletes — normally unreachable here (a confirmed + // booking is never deletable, BookingPolicy::delete), but this + // listener is queued, so it's worth guarding against a booking that + // vanished between dispatch and execution regardless. + if ($booking === null) { + return; + } + if ($booking->status === BookingStatus::Confirmed) { $booking->update(['status' => BookingStatus::Cancelled]); } diff --git a/app-modules/payment/tests/Feature/MarkBookingPaidTest.php b/app-modules/payment/tests/Feature/MarkBookingPaidTest.php index 67037e0..690d833 100644 --- a/app-modules/payment/tests/Feature/MarkBookingPaidTest.php +++ b/app-modules/payment/tests/Feature/MarkBookingPaidTest.php @@ -24,3 +24,12 @@ test('does not touch a booking that already moved on for another reason', functi expect($booking->refresh()->status)->toBe(BookingStatus::Cancelled); }); + +test('does not crash if the booking was soft-deleted before this queued listener ran', function () { + $booking = Booking::factory()->create(['status' => BookingStatus::PendingPayment]); + $payment = Payment::factory()->completed()->create(['booking_id' => $booking->id]); + $booking->delete(); + + expect(fn () => (new MarkBookingPaid)->handle(new PaymentCompleted($payment->fresh()))) + ->not->toThrow(Throwable::class); +}); diff --git a/app-modules/payment/tests/Feature/MarkBookingRefundedTest.php b/app-modules/payment/tests/Feature/MarkBookingRefundedTest.php new file mode 100644 index 0000000..bebab92 --- /dev/null +++ b/app-modules/payment/tests/Feature/MarkBookingRefundedTest.php @@ -0,0 +1,40 @@ +create(['status' => BookingStatus::Confirmed]); + $payment = Payment::factory()->completed()->create(['booking_id' => $booking->id, 'gateway' => PaymentMethod::KbzMiniApp]); + $refund = Refund::factory()->create(['payment_id' => $payment->id, 'status' => RefundStatus::Completed]); + + (new MarkBookingRefunded)->handle(new RefundProcessed($refund)); + + expect($booking->refresh()->status)->toBe(BookingStatus::Cancelled); +}); + +test('does not touch a booking that already moved on for another reason', function () { + $booking = Booking::factory()->create(['status' => BookingStatus::Cancelled]); + $payment = Payment::factory()->completed()->create(['booking_id' => $booking->id, 'gateway' => PaymentMethod::KbzMiniApp]); + $refund = Refund::factory()->create(['payment_id' => $payment->id, 'status' => RefundStatus::Completed]); + + (new MarkBookingRefunded)->handle(new RefundProcessed($refund)); + + expect($booking->refresh()->status)->toBe(BookingStatus::Cancelled); +}); + +test('does not crash if the booking was soft-deleted before this queued listener ran', function () { + $booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]); + $payment = Payment::factory()->completed()->create(['booking_id' => $booking->id, 'gateway' => PaymentMethod::KbzMiniApp]); + $refund = Refund::factory()->create(['payment_id' => $payment->id, 'status' => RefundStatus::Completed]); + $booking->delete(); + + expect(fn () => (new MarkBookingRefunded)->handle(new RefundProcessed($refund->fresh()))) + ->not->toThrow(Throwable::class); +}); diff --git a/app-modules/payment/tests/Feature/RefundResourceTest.php b/app-modules/payment/tests/Feature/RefundResourceTest.php index a12c84e..645b09e 100644 --- a/app-modules/payment/tests/Feature/RefundResourceTest.php +++ b/app-modules/payment/tests/Feature/RefundResourceTest.php @@ -124,3 +124,30 @@ test('a non-completed payment is not offered in the process action\'s payment se expect(Refund::where('payment_id', $pendingPayment->id)->exists())->toBeFalse(); }); + +test('a payment whose booking has been soft-deleted is not offered in the process action\'s payment select', function () { + $admin = User::factory()->create()->givePermissionTo(['view_payments', 'process_refunds']); + $this->actingAs($admin); + + $booking = Booking::factory()->create(['status' => BookingStatus::Cancelled, 'price' => 15000]); + $payment = Payment::factory()->completed()->create([ + 'booking_id' => $booking->id, + 'gateway' => PaymentMethod::KbzMiniApp, + 'amount' => 15000, + 'gateway_transaction_id' => 'EVB-FILAMENT-DELETED-1', + ]); + $booking->delete(); + + // Regression: a payment whose booking is gone must never crash the + // refund action (RefundBookingAction requires a non-null Booking) — it + // simply isn't offered as an option at all. + Livewire::test(ListRefunds::class) + ->callAction('process', data: [ + 'payment_id' => $payment->id, + 'amount' => 1000, + 'reason' => 'reason', + ]) + ->assertHasFormErrors(['payment_id']); + + expect(Refund::where('payment_id', $payment->id)->exists())->toBeFalse(); +}); diff --git a/app-modules/shared/src/Support/EnvFileWriter.php b/app-modules/shared/src/Support/EnvFileWriter.php new file mode 100644 index 0000000..1d3d6e8 --- /dev/null +++ b/app-modules/shared/src/Support/EnvFileWriter.php @@ -0,0 +1,77 @@ +path = $path ?? base_path('.env'); + } + + /** + * @param array $values + */ + public function write(array $values): void + { + $contents = File::exists($this->path) ? File::get($this->path) : ''; + + foreach ($values as $key => $value) { + $contents = $this->setKey($contents, $key, $value); + } + + File::put($this->path, $contents); + } + + private function setKey(string $contents, string $key, bool|int|string|null $value): string + { + $line = $key.'='.$this->formatValue($value); + $pattern = '/^'.preg_quote($key, '/').'=.*$/m'; + + if (preg_match($pattern, $contents) === 1) { + return (string) preg_replace($pattern, $line, $contents, 1); + } + + return rtrim($contents, "\n")."\n".$line."\n"; + } + + private function formatValue(bool|int|string|null $value): string + { + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + + if ($value === null || $value === '') { + return ''; + } + + if (is_int($value)) { + return (string) $value; + } + + // Quote values containing whitespace or characters that would + // otherwise break .env parsing (matches the convention already used + // by hand-written entries in this project's .env.example). + return Str::contains($value, [' ', '#', '"']) + ? '"'.str_replace('"', '\\"', $value).'"' + : $value; + } +} diff --git a/app-modules/shared/tests/Unit/EnvFileWriterTest.php b/app-modules/shared/tests/Unit/EnvFileWriterTest.php new file mode 100644 index 0000000..caf82f6 --- /dev/null +++ b/app-modules/shared/tests/Unit/EnvFileWriterTest.php @@ -0,0 +1,60 @@ +path = sys_get_temp_dir().'/env-file-writer-test-'.uniqid().'.env'; +}); + +afterEach(function () { + @unlink($this->path); +}); + +test('it replaces an existing key in place without touching other lines', function () { + file_put_contents($this->path, "APP_NAME=Laravel\nAPP_ENV=local\n"); + + (new EnvFileWriter($this->path))->write(['APP_NAME' => 'New Name']); + + expect(file_get_contents($this->path))->toBe("APP_NAME=\"New Name\"\nAPP_ENV=local\n"); +}); + +test('it appends a missing key at the end of the file', function () { + file_put_contents($this->path, "APP_NAME=Laravel\n"); + + (new EnvFileWriter($this->path))->write(['SUPPORT_EMAIL' => 'support@example.com']); + + expect(file_get_contents($this->path))->toBe("APP_NAME=Laravel\nSUPPORT_EMAIL=support@example.com\n"); +}); + +test('it formats booleans as bare true/false', function () { + file_put_contents($this->path, ''); + + (new EnvFileWriter($this->path))->write(['BOOKING_BACK_SEAT_ENABLED' => false]); + + expect(file_get_contents($this->path))->toContain('BOOKING_BACK_SEAT_ENABLED=false'); +}); + +test('it quotes values containing whitespace', function () { + file_put_contents($this->path, ''); + + (new EnvFileWriter($this->path))->write(['APP_NAME' => 'My Company']); + + expect(file_get_contents($this->path))->toContain('APP_NAME="My Company"'); +}); + +test('it writes multiple keys in one call', function () { + file_put_contents($this->path, "APP_NAME=Laravel\n"); + + (new EnvFileWriter($this->path))->write([ + 'APP_NAME' => 'Renamed', + 'APP_CURRENCY' => 'MMK', + 'BOOKING_FRONT_SEAT_MAX_PER_BOOKING' => 2, + ]); + + $contents = file_get_contents($this->path); + + expect($contents) + ->toContain('APP_NAME=Renamed') + ->toContain('APP_CURRENCY=MMK') + ->toContain('BOOKING_FRONT_SEAT_MAX_PER_BOOKING=2'); +}); diff --git a/app/Models/User.php b/app/Models/User.php index ca8ffaa..704534a 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -7,9 +7,11 @@ use Database\Factories\UserFactory; use Filament\Models\Contracts\FilamentUser; use Filament\Panel; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Sanctum\HasApiTokens; +use Modules\Booking\Models\Booking; use Spatie\Permission\Traits\HasRoles; class User extends Authenticatable implements FilamentUser @@ -29,6 +31,16 @@ class User extends Authenticatable implements FilamentUser return $this->hasAnyRole(self::ADMIN_TIER_ROLES); } + /** + * A customer's bookings (domain.md §1) — inverse of Booking::user(). + * Staff (admin-tier role) users don't create bookings, so this is + * effectively customer-only in practice, but not enforced here. + */ + public function bookings(): HasMany + { + return $this->hasMany(Booking::class); + } + /** * The attributes that are mass assignable. * diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 37f8b5e..71f9e9f 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -41,10 +41,10 @@ class AdminPanelProvider extends PanelProvider 'primary' => Color::Amber, ]) ->navigationGroups([ + NavigationGroup::make()->label('Access'), NavigationGroup::make()->label('Catalog'), NavigationGroup::make()->label('Routing'), NavigationGroup::make()->label('Operations'), - NavigationGroup::make()->label('Access'), ]) ->plugins([ CatalogPlugin::make(), diff --git a/config/app.php b/config/app.php index 423eed5..e3d6f9a 100644 --- a/config/app.php +++ b/config/app.php @@ -65,7 +65,7 @@ return [ | */ - 'timezone' => 'UTC', + 'timezone' => env('APP_TIMEZONE', 'UTC'), /* |-------------------------------------------------------------------------- @@ -84,6 +84,23 @@ return [ 'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'), + /* + |-------------------------------------------------------------------------- + | Site / Support Details + |-------------------------------------------------------------------------- + | + | Editable via the Access group's "App Settings" page in Filament, which + | writes these back into .env directly (Modules\Shared\Support\ + | EnvFileWriter) rather than a separate DB-backed settings table. + | + */ + + 'support_email' => env('SUPPORT_EMAIL'), + + 'support_phone' => env('SUPPORT_PHONE'), + + 'currency' => env('APP_CURRENCY', 'MMK'), + /* |-------------------------------------------------------------------------- | Encryption Key