diff --git a/.env.example b/.env.example index 28c7af3..485c980 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,7 @@ APP_ENV=local APP_KEY= APP_DEBUG=true APP_URL=http://localhost:8000 +APP_TIMEZONE=Europe/Paris APP_LOCALE=fr APP_FALLBACK_LOCALE=fr @@ -87,4 +88,10 @@ LAVERIE_BOOKING_PENALTY_AMOUNT=3.00 LAVERIE_MACHINE_OFFLINE_THRESHOLD_MINUTES=10 # Paiements -LAVERIE_PAYMENT_PROVIDER=simulated +LAVERIE_PAYMENT_PROVIDER=stripe +LAVERIE_TOP_UP_MIN=5 +LAVERIE_TOP_UP_MAX=150 + +STRIPE_KEY=pk_test_51TpANRJRUgjTIwfBR9PoU4Lu201yD5R0JzvOv8Nmyva7ISX3GJPJ3IX4lSqnkg13siYwi3B9Qq0tIpEj6VCzeVFB00PSt9o0OA +STRIPE_SECRET=sk_test_51TpANRJRUgjTIwfB2jUa5nr7IpolnWNwwi0MQHbCG6JERu7npCmBrJPm6wbyTfo0lkZUh7PYyuHQ8qeecEfcfSXa0007BAKC7T +STRIPE_WEBHOOK_SECRET= diff --git a/app/Http/Controllers/Api/V1/HealthController.php b/app/Http/Controllers/Api/V1/HealthController.php index 0ba1b4e..2dc8065 100644 --- a/app/Http/Controllers/Api/V1/HealthController.php +++ b/app/Http/Controllers/Api/V1/HealthController.php @@ -17,6 +17,7 @@ class HealthController extends Controller return $this->success([ 'status' => 'ok', 'service' => 'laverie-api', + 'timezone' => config('app.timezone'), 'timestamp' => now()->toIso8601String(), ]); } diff --git a/app/Http/Controllers/Api/V1/WalletController.php b/app/Http/Controllers/Api/V1/WalletController.php index a139c83..ccfd7c9 100644 --- a/app/Http/Controllers/Api/V1/WalletController.php +++ b/app/Http/Controllers/Api/V1/WalletController.php @@ -22,7 +22,6 @@ class WalletController extends Controller public function __construct( private readonly WalletService $walletService, - private readonly PaymentService $paymentService, ) {} public function index(Request $request): JsonResponse @@ -63,9 +62,9 @@ class WalletController extends Controller ]); } - public function initiateTopUp(InitiateTopUpRequest $request): JsonResponse + public function initiateTopUp(InitiateTopUpRequest $request, PaymentService $paymentService): JsonResponse { - $payment = $this->paymentService->initiateTopUp( + $payment = $paymentService->initiateTopUp( $request->user(), (float) $request->input('amount'), $request->string('idempotency_key')->toString(), @@ -77,9 +76,9 @@ class WalletController extends Controller ], 'Rechargement initié.'); } - public function confirmTopUp(ConfirmTopUpRequest $request): JsonResponse + public function confirmTopUp(ConfirmTopUpRequest $request, PaymentService $paymentService): JsonResponse { - $payment = $this->paymentService->confirmTopUp( + $payment = $paymentService->confirmTopUp( $request->string('payment_uuid')->toString(), ); @@ -93,9 +92,20 @@ class WalletController extends Controller ], 'Rechargement confirmé.'); } - public function webhook(Request $request, string $provider): JsonResponse + public function webhook(Request $request, string $provider, PaymentService $paymentService): JsonResponse { - $this->paymentService->handleWebhook($provider, $request->all()); + if ($provider === 'stripe') { + try { + $paymentService->handleStripeWebhook( + $request->getContent(), + $request->header('Stripe-Signature', ''), + ); + } catch (\InvalidArgumentException $e) { + return $this->error($e->getMessage(), 400); + } + } else { + $paymentService->handleWebhook($provider, $request->all()); + } return $this->success(message: 'Webhook traité.'); } diff --git a/app/Http/Controllers/Supervisor/BookingPageController.php b/app/Http/Controllers/Supervisor/BookingPageController.php index 39e940d..45a7107 100644 --- a/app/Http/Controllers/Supervisor/BookingPageController.php +++ b/app/Http/Controllers/Supervisor/BookingPageController.php @@ -19,6 +19,8 @@ class BookingPageController extends Controller ->with(['user:id,first_name,last_name,email', 'machine:id,uuid,name,establishment_id', 'machine.establishment:id,name']); $this->scopeMachineRelationQuery($query); + $this->applyEstablishmentFilter($request, $query, 'machine'); + $this->applyUserSearchFilter($request, $query); if ($request->filled('status')) { $query->where('status', $request->string('status')); @@ -29,8 +31,14 @@ class BookingPageController extends Controller } $bookings = $query - ->orderByDesc('slot_start') - ->paginate(20) + ->tap(fn ($builder) => $this->applySort($request, $builder, [ + 'slot_start' => 'bookings.slot_start', + 'user' => fn ($builder, string $direction) => $this->sortByRelatedUser($builder, 'bookings', 'user_id', $direction), + 'machine' => fn ($builder, string $direction) => $this->sortByRelatedMachine($builder, 'bookings', $direction), + 'status' => 'bookings.status', + 'booking_fee' => 'bookings.booking_fee', + ], 'slot_start', 'desc')) + ->paginate($this->supervisorListPerPage($request)) ->withQueryString() ->through(fn (Booking $booking) => [ 'uuid' => $booking->uuid, @@ -54,9 +62,15 @@ class BookingPageController extends Controller return Inertia::render('Supervisor/Bookings/Index', [ 'bookings' => $bookings, + 'establishments' => $this->establishmentOptions(), 'filters' => [ + 'establishment_id' => $request->filled('establishment_id') ? (int) $request->input('establishment_id') : null, + 'user' => $request->string('user')->toString() ?: null, 'status' => $request->string('status')->toString() ?: null, 'date' => $request->string('date')->toString() ?: null, + 'sort' => $request->string('sort')->toString() ?: 'slot_start', + 'direction' => $request->string('direction')->toString() === 'asc' ? 'asc' : 'desc', + 'per_page' => $this->supervisorListPerPage($request), ], 'statusOptions' => $this->statusOptions(), ]); diff --git a/app/Http/Controllers/Supervisor/Concerns/ScopesSupervisorResources.php b/app/Http/Controllers/Supervisor/Concerns/ScopesSupervisorResources.php index 11ba01f..91d7c27 100644 --- a/app/Http/Controllers/Supervisor/Concerns/ScopesSupervisorResources.php +++ b/app/Http/Controllers/Supervisor/Concerns/ScopesSupervisorResources.php @@ -2,8 +2,10 @@ namespace App\Http\Controllers\Supervisor\Concerns; +use App\Models\Establishment; use App\Models\Supervisor; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; trait ScopesSupervisorResources @@ -16,6 +18,21 @@ trait ScopesSupervisorResources return $supervisor; } + protected function supervisorListPerPage(Request $request): int + { + $perPage = $request->integer('per_page', 10); + + return in_array($perPage, $this->supervisorPerPageOptions(), true) ? $perPage : 10; + } + + /** + * @return array + */ + protected function supervisorPerPageOptions(): array + { + return [10, 25, 50]; + } + protected function scopeEstablishmentQuery(Builder $query, string $establishmentColumn = 'establishment_id'): Builder { $supervisor = $this->supervisor(); @@ -47,4 +64,124 @@ trait ScopesSupervisorResources } }); } + + protected function applyEstablishmentFilter(Request $request, Builder $query, string $mode = 'direct'): void + { + if (! $request->filled('establishment_id')) { + return; + } + + $establishmentId = (int) $request->input('establishment_id'); + $this->assertEstablishmentAccessible($establishmentId); + + if ($mode === 'machine') { + $query->whereHas('machine', fn (Builder $query) => $query->where('establishment_id', $establishmentId)); + } else { + $query->where('establishment_id', $establishmentId); + } + } + + protected function applyUserSearchFilter(Request $request, Builder $query): void + { + if (! $request->filled('user')) { + return; + } + + $search = $request->string('user'); + + $query->whereHas('user', function (Builder $query) use ($search) { + $query->where(function (Builder $query) use ($search) { + $query->where('first_name', 'like', "%{$search}%") + ->orWhere('last_name', 'like', "%{$search}%") + ->orWhere('email', 'like', "%{$search}%"); + }); + }); + } + + protected function assertEstablishmentAccessible(int $establishmentId): void + { + $supervisor = $this->supervisor(); + + $exists = Establishment::query() + ->where('id', $establishmentId) + ->where('organization_id', $supervisor->organization_id) + ->when($supervisor->establishment_id, fn ($query) => $query->where('id', $supervisor->establishment_id)) + ->exists(); + + abort_unless($exists, 403); + } + + /** + * @return array + */ + protected function establishmentOptions(): array + { + $supervisor = $this->supervisor(); + + return Establishment::query() + ->where('organization_id', $supervisor->organization_id) + ->when($supervisor->establishment_id, fn ($query) => $query->where('id', $supervisor->establishment_id)) + ->orderBy('name') + ->get(['id', 'name']) + ->map(fn (Establishment $establishment) => [ + 'id' => $establishment->id, + 'name' => $establishment->name, + ]) + ->all(); + } + + /** + * @param array $sortableColumns + */ + protected function applySort( + Request $request, + Builder $query, + array $sortableColumns, + string $defaultColumn, + string $defaultDirection = 'asc', + ): void { + $sort = $request->string('sort')->toString(); + $direction = strtolower($request->string('direction')->toString()) === 'desc' ? 'desc' : 'asc'; + + if ($sort === '' || ! array_key_exists($sort, $sortableColumns)) { + $sort = $defaultColumn; + $direction = $defaultDirection; + } + + $handler = $sortableColumns[$sort]; + + if (is_callable($handler)) { + $handler($query, $direction); + + return; + } + + $query->orderBy($handler, $direction); + } + + protected function sortByRelatedUser(Builder $query, string $parentTable, string $foreignKey, string $direction): void + { + $query->leftJoin('users', "{$parentTable}.{$foreignKey}", '=', 'users.id') + ->orderBy('users.last_name', $direction) + ->orderBy('users.first_name', $direction) + ->select("{$parentTable}.*"); + } + + protected function sortByRelatedMachine(Builder $query, string $parentTable, string $direction): void + { + $query->leftJoin('machines', "{$parentTable}.machine_id", '=', 'machines.id') + ->orderBy('machines.name', $direction) + ->select("{$parentTable}.*"); + } + + protected function sortByRelatedEstablishment( + Builder $query, + string $parentTable, + string $foreignKeyColumn, + string $direction, + ): void { + $query->leftJoin('establishments', "{$parentTable}.{$foreignKeyColumn}", '=', 'establishments.id') + ->orderBy('establishments.name', $direction) + ->select("{$parentTable}.*"); + } } diff --git a/app/Http/Controllers/Supervisor/DashboardController.php b/app/Http/Controllers/Supervisor/DashboardController.php index 641512f..b469320 100644 --- a/app/Http/Controllers/Supervisor/DashboardController.php +++ b/app/Http/Controllers/Supervisor/DashboardController.php @@ -4,7 +4,9 @@ namespace App\Http\Controllers\Supervisor; use App\Http\Controllers\Controller; use App\Http\Controllers\Supervisor\Concerns\ScopesSupervisorResources; +use App\Models\Supervisor; use App\Services\DashboardService; +use Illuminate\Http\Request; use Inertia\Inertia; use Inertia\Response; @@ -12,14 +14,40 @@ class DashboardController extends Controller { use ScopesSupervisorResources; - public function index(DashboardService $dashboardService): Response + public function index(Request $request, DashboardService $dashboardService): Response { $supervisor = $this->supervisor(); + $establishmentFilter = $this->resolveDashboardEstablishmentFilter($request, $supervisor); + $establishments = $this->establishmentOptions(); return Inertia::render('Supervisor/Dashboard', [ - 'kpis' => $dashboardService->getKpis($supervisor), - 'alerts' => $dashboardService->getAlerts($supervisor), - 'machinesOverview' => $dashboardService->getMachinesOverview($supervisor), + 'kpis' => $dashboardService->getKpis($supervisor, $establishmentFilter), + 'alerts' => $dashboardService->getAlerts($supervisor, $establishmentFilter), + 'machinesOverview' => $dashboardService->getMachinesOverview($supervisor, $establishmentFilter), + 'machineStatusBreakdown' => $dashboardService->getMachineStatusBreakdown($supervisor, $establishmentFilter), + 'recentWashes' => $dashboardService->getRecentWashes($supervisor, $establishmentFilter), + 'context' => $dashboardService->getContext($supervisor, $establishmentFilter), + 'establishments' => $establishments, + 'showEstablishmentFilter' => $supervisor->establishment_id === null && count($establishments) > 1, + 'filters' => [ + 'establishment_id' => $establishmentFilter, + ], ]); } + + private function resolveDashboardEstablishmentFilter(Request $request, Supervisor $supervisor): ?int + { + if ($supervisor->establishment_id !== null) { + return $supervisor->establishment_id; + } + + if (! $request->filled('establishment_id')) { + return null; + } + + $establishmentId = (int) $request->input('establishment_id'); + $this->assertEstablishmentAccessible($establishmentId); + + return $establishmentId; + } } diff --git a/app/Http/Controllers/Supervisor/MachinePageController.php b/app/Http/Controllers/Supervisor/MachinePageController.php index 396b102..178514c 100644 --- a/app/Http/Controllers/Supervisor/MachinePageController.php +++ b/app/Http/Controllers/Supervisor/MachinePageController.php @@ -4,9 +4,17 @@ namespace App\Http\Controllers\Supervisor; use App\Http\Controllers\Controller; use App\Http\Controllers\Supervisor\Concerns\ScopesSupervisorResources; +use App\Models\Establishment; use App\Models\Machine; +use App\Models\MachineCommand; use App\Models\MachineEvent; +use App\Models\MachineIntegration; +use App\Models\MachineStatusHistory; +use App\Models\PricingRule; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Str; +use Illuminate\Validation\Rule; use Inertia\Inertia; use Inertia\Response; @@ -20,6 +28,7 @@ class MachinePageController extends Controller ->with('establishment:id,name,uuid'); $this->scopeEstablishmentQuery($query); + $this->applyEstablishmentFilter($request, $query); if ($request->filled('status')) { $query->where('status', $request->string('status')); @@ -38,23 +47,100 @@ class MachinePageController extends Controller } $machines = $query - ->orderBy('name') - ->paginate(20) + ->tap(fn ($builder) => $this->applySort($request, $builder, [ + 'name' => 'machines.name', + 'establishment' => fn ($builder, string $direction) => $builder + ->leftJoin('establishments', 'machines.establishment_id', '=', 'establishments.id') + ->orderBy('establishments.name', $direction) + ->select('machines.*'), + 'type' => 'machines.type', + 'status' => 'machines.status', + 'last_heartbeat_at' => 'machines.last_heartbeat_at', + ], 'name', 'asc')) + ->paginate($this->supervisorListPerPage($request)) ->withQueryString() ->through(fn (Machine $machine) => $this->formatMachineListItem($machine)); return Inertia::render('Supervisor/Machines/Index', [ 'machines' => $machines, + 'establishments' => $this->establishmentOptions(), + 'canManageMachines' => $this->canManageMachines(), 'filters' => [ + 'establishment_id' => $request->filled('establishment_id') ? (int) $request->input('establishment_id') : null, 'status' => $request->string('status')->toString() ?: null, 'type' => $request->string('type')->toString() ?: null, 'search' => $request->string('search')->toString() ?: null, + 'sort' => $request->string('sort')->toString() ?: 'name', + 'direction' => $request->string('direction')->toString() === 'desc' ? 'desc' : 'asc', + 'per_page' => $this->supervisorListPerPage($request), ], 'statusOptions' => $this->statusOptions(), 'typeOptions' => $this->typeOptions(), ]); } + public function store(Request $request): RedirectResponse + { + abort_unless($this->canManageMachines(), 403); + + $validated = $this->validateMachine($request); + $this->assertEstablishmentAccessible((int) $validated['establishment_id']); + + $validated['qr_code'] = $this->resolveQrCode($validated['qr_code'] ?? null); + $validated['status'] = $validated['status'] ?? 'available'; + + $machine = Machine::query()->create($validated); + $this->createSimulatedIntegration($machine); + + return redirect()->route('supervisor.machines.index') + ->with('success', 'Machine créée.'); + } + + public function update(Request $request, string $uuid): RedirectResponse + { + abort_unless($this->canManageMachines(), 403); + + $machine = $this->findAccessibleMachine($uuid); + $validated = $this->validateMachine($request, $machine); + $this->assertEstablishmentAccessible((int) $validated['establishment_id']); + + if (array_key_exists('qr_code', $validated) && blank($validated['qr_code'])) { + unset($validated['qr_code']); + } + + $machine->update($validated); + + return redirect()->route('supervisor.machines.index') + ->with('success', 'Machine mise à jour.'); + } + + public function destroy(string $uuid): RedirectResponse + { + abort_unless($this->canManageMachines(), 403); + + $machine = $this->findAccessibleMachine($uuid); + + if (in_array($machine->status, ['running', 'reserved'], true)) { + return redirect()->route('supervisor.machines.index') + ->withErrors(['delete' => 'Impossible de supprimer une machine en cours d\'utilisation.']); + } + + if ($machine->bookings()->exists() || $machine->washes()->exists()) { + return redirect()->route('supervisor.machines.index') + ->withErrors(['delete' => 'Impossible de supprimer une machine avec un historique de réservations ou de lavages.']); + } + + PricingRule::query()->where('machine_id', $machine->id)->update(['machine_id' => null]); + MachineCommand::query()->where('machine_id', $machine->id)->delete(); + MachineEvent::query()->where('machine_id', $machine->id)->delete(); + MachineStatusHistory::query()->where('machine_id', $machine->id)->delete(); + $machine->machineIntegration()?->delete(); + $machine->delete(); + + return redirect()->route('supervisor.machines.index') + ->with('success', 'Machine supprimée.'); + } + public function show(string $uuid): Response { $query = Machine::query()->where('uuid', $uuid); @@ -115,9 +201,11 @@ class MachinePageController extends Controller { return [ 'uuid' => $machine->uuid, + 'establishment_id' => $machine->establishment_id, 'name' => $machine->name, 'type' => $machine->type, 'type_label' => $this->typeLabel($machine->type), + 'qr_code' => $machine->qr_code, 'status' => $machine->status, 'status_label' => $this->statusLabel($machine->status), 'establishment_name' => $machine->establishment?->name, @@ -125,6 +213,68 @@ class MachinePageController extends Controller ]; } + /** + * @return array + */ + private function validateMachine(Request $request, ?Machine $machine = null): array + { + return $request->validate([ + 'establishment_id' => ['required', 'integer', 'exists:establishments,id'], + 'name' => ['required', 'string', 'max:100'], + 'type' => ['required', 'in:washer_small,washer_large,dryer_small,dryer_large'], + 'qr_code' => [ + 'nullable', + 'string', + 'max:255', + Rule::unique('machines', 'qr_code')->ignore($machine?->id), + ], + 'status' => ['required', 'in:available,reserved,running,maintenance,offline,error'], + ]); + } + + private function findAccessibleMachine(string $uuid): Machine + { + $query = Machine::query()->where('uuid', $uuid); + $this->scopeEstablishmentQuery($query); + + return $query->firstOrFail(); + } + + private function resolveQrCode(?string $qrCode): string + { + if (filled($qrCode)) { + return $qrCode; + } + + do { + $candidate = 'LAVERIE-'.strtoupper(Str::random(8)); + } while (Machine::query()->where('qr_code', $candidate)->exists()); + + return $candidate; + } + + private function createSimulatedIntegration(Machine $machine): void + { + $machine->load('establishment'); + + MachineIntegration::query()->create([ + 'machine_id' => $machine->id, + 'provider' => config('laverie.simulation.provider_name', 'simulated'), + 'external_machine_id' => 'SIM-'.$machine->uuid, + 'external_site_id' => 'SITE-'.$machine->establishment?->uuid, + 'mode' => 'simulated', + 'config' => [ + 'cycle_duration_seconds' => config('laverie.simulation.cycle_duration_seconds', 30), + ], + 'is_active' => true, + ]); + } + + private function canManageMachines(): bool + { + return $this->supervisor()->role !== 'viewer'; + } + /** * @return array */ diff --git a/app/Http/Controllers/Supervisor/OrganizationPageController.php b/app/Http/Controllers/Supervisor/OrganizationPageController.php new file mode 100644 index 0000000..952ec10 --- /dev/null +++ b/app/Http/Controllers/Supervisor/OrganizationPageController.php @@ -0,0 +1,276 @@ +supervisor(); + + $baseQuery = Establishment::query() + ->where('organization_id', $supervisor->organization_id); + + if ($supervisor->establishment_id !== null) { + $baseQuery->where('id', $supervisor->establishment_id); + } + + $totalCount = (clone $baseQuery)->count(); + $viewMode = $totalCount === 1 ? 'single' : 'list'; + + $canManageEstablishments = $this->canManageEstablishments(); + $canCreateEstablishments = $this->canCreateEstablishments(); + + if ($viewMode === 'single') { + $establishment = (clone $baseQuery)->withCount('machines')->first(); + + return Inertia::render('Supervisor/Organizations/Index', [ + 'establishments' => [$this->formatEstablishment($establishment)], + 'viewMode' => 'single', + 'canManageEstablishments' => $canManageEstablishments, + 'canCreateEstablishments' => $canCreateEstablishments, + 'filters' => [], + ]); + } + + $query = (clone $baseQuery)->withCount('machines'); + + if ($request->filled('search')) { + $search = $request->string('search'); + $query->where(function ($builder) use ($search) { + $builder->where('name', 'like', "%{$search}%") + ->orWhere('address', 'like', "%{$search}%") + ->orWhere('city', 'like', "%{$search}%") + ->orWhere('zip_code', 'like', "%{$search}%"); + }); + } + + if ($request->has('is_active') && $request->string('is_active')->toString() !== '') { + $query->where('is_active', $request->boolean('is_active')); + } + + $establishments = $query + ->tap(fn ($builder) => $this->applySort($request, $builder, [ + 'name' => 'name', + 'city' => 'city', + 'machines_count' => 'machines_count', + 'is_active' => 'is_active', + ], 'name', 'asc')) + ->paginate($this->supervisorListPerPage($request)) + ->withQueryString() + ->through(fn (Establishment $establishment) => $this->formatEstablishment($establishment)); + + return Inertia::render('Supervisor/Organizations/Index', [ + 'establishments' => $establishments, + 'viewMode' => 'list', + 'canManageEstablishments' => $canManageEstablishments, + 'canCreateEstablishments' => $canCreateEstablishments, + 'filters' => [ + 'search' => $request->string('search')->toString() ?: null, + 'is_active' => $request->has('is_active') && $request->string('is_active')->toString() !== '' + ? ($request->boolean('is_active') ? '1' : '0') + : null, + 'sort' => $request->string('sort')->toString() ?: 'name', + 'direction' => $request->string('direction')->toString() === 'desc' ? 'desc' : 'asc', + 'per_page' => $this->supervisorListPerPage($request), + ], + ]); + } + + public function searchAddresses(Request $request): JsonResponse + { + abort_unless($this->canManageEstablishments(), 403); + + $validated = $request->validate([ + 'q' => ['required', 'string', 'min:3', 'max:200'], + ]); + + return response()->json( + $this->geocoding->search($validated['q']), + ); + } + + public function storeEstablishment(Request $request): RedirectResponse + { + abort_unless($this->canManageEstablishments(), 403); + abort_if($this->supervisor()->establishment_id !== null, 403); + + $validated = $this->prepareEstablishmentData( + $request->validate($this->establishmentRules()), + ); + $validated['organization_id'] = $this->supervisor()->organization_id; + + Establishment::query()->create($validated); + + return redirect()->route('supervisor.organizations.index') + ->with('success', 'Enseigne créée.'); + } + + public function updateEstablishment(Request $request, int $id): RedirectResponse + { + abort_unless($this->canManageEstablishments(), 403); + + $establishment = $this->findAccessibleEstablishment($id); + $validated = $this->prepareEstablishmentData( + $request->validate($this->establishmentRules()), + ); + + if (! empty($validated['is_active']) && ! $establishment->is_active) { + $this->validateEstablishmentReadyForActivation($validated, $establishment); + } + + $establishment->update($validated); + + return redirect()->route('supervisor.organizations.index') + ->with('success', 'Enseigne mise à jour.'); + } + + public function toggleEstablishmentActive(int $id): RedirectResponse + { + abort_unless($this->canManageEstablishments(), 403); + + $establishment = $this->findAccessibleEstablishment($id); + $isActive = ! $establishment->is_active; + + if ($isActive) { + $this->validateEstablishmentReadyForActivation([ + 'name' => $establishment->name, + 'address' => $establishment->address, + 'timezone' => $establishment->timezone, + ], $establishment); + } + + $establishment->update(['is_active' => $isActive]); + + return redirect()->route('supervisor.organizations.index') + ->with('success', $isActive ? 'Enseigne réactivée.' : 'Enseigne désactivée.'); + } + + /** + * @return array + */ + private function formatEstablishment(Establishment $establishment): array + { + return [ + 'id' => $establishment->id, + 'uuid' => $establishment->uuid, + 'name' => $establishment->name, + 'address' => $establishment->address, + 'city' => $establishment->city, + 'zip_code' => $establishment->zip_code, + 'latitude' => $establishment->latitude !== null ? (float) $establishment->latitude : null, + 'longitude' => $establishment->longitude !== null ? (float) $establishment->longitude : null, + 'timezone' => $establishment->timezone, + 'is_active' => $establishment->is_active, + 'machines_count' => $establishment->machines_count, + ]; + } + + /** + * @return array + */ + private function establishmentRules(): array + { + return [ + 'name' => ['required', 'string', 'max:200'], + 'address' => ['required', 'string'], + 'city' => ['nullable', 'string', 'max:100'], + 'zip_code' => ['nullable', 'string', 'max:10'], + 'timezone' => ['required', 'string', 'max:50'], + 'is_active' => ['boolean'], + ]; + } + + /** + * @param array $validated + * @return array + */ + private function prepareEstablishmentData(array $validated): array + { + $coordinates = $this->geocoding->geocode( + $validated['address'], + $validated['city'] ?? null, + $validated['zip_code'] ?? null, + ); + + $validated['latitude'] = $coordinates['latitude'] ?? null; + $validated['longitude'] = $coordinates['longitude'] ?? null; + + return $validated; + } + + private function findAccessibleEstablishment(int $id): Establishment + { + $supervisor = $this->supervisor(); + + return Establishment::query() + ->where('id', $id) + ->where('organization_id', $supervisor->organization_id) + ->when($supervisor->establishment_id, fn ($query) => $query->where('id', $supervisor->establishment_id)) + ->firstOrFail(); + } + + private function canCreateEstablishments(): bool + { + $supervisor = $this->supervisor(); + + if ($supervisor->role === 'viewer') { + return false; + } + + return in_array($supervisor->role, ['owner', 'platform_admin'], true) + && $supervisor->establishment_id === null; + } + + private function canManageEstablishments(): bool + { + return $this->supervisor()->role !== 'viewer'; + } + + /** + * @param array $data + */ + private function validateEstablishmentReadyForActivation(array $data, Establishment $establishment): void + { + $validator = validator( + $data, + [ + 'name' => ['required', 'string', 'max:200'], + 'address' => ['required', 'string'], + 'timezone' => ['required', 'string', 'max:50'], + ], + [ + 'name.required' => 'Le nom est requis pour réactiver l\'enseigne.', + 'address.required' => 'L\'adresse est requise pour réactiver l\'enseigne.', + 'timezone.required' => 'Le fuseau horaire est requis pour réactiver l\'enseigne.', + ], + ); + + if ($validator->fails()) { + throw ValidationException::withMessages($validator->errors()->toArray()); + } + + $establishment->loadMissing('organization'); + + if (! $establishment->organization?->is_active) { + throw ValidationException::withMessages([ + 'is_active' => 'L\'organisation doit être active pour réactiver cette enseigne.', + ]); + } + } +} diff --git a/app/Http/Controllers/Supervisor/PricingPageController.php b/app/Http/Controllers/Supervisor/PricingPageController.php index 91eac3d..4001163 100644 --- a/app/Http/Controllers/Supervisor/PricingPageController.php +++ b/app/Http/Controllers/Supervisor/PricingPageController.php @@ -21,18 +21,60 @@ class PricingPageController extends Controller ->with(['establishment:id,name', 'machine:id,uuid,name']); $this->scopeEstablishmentQuery($query); + $this->applyEstablishmentFilter($request, $query); + + if ($request->filled('machine_type')) { + $query->where('machine_type', $request->string('machine_type')); + } + + if ($request->filled('day_type')) { + $query->where('day_type', $request->string('day_type')); + } + + if ($request->has('is_active') && $request->string('is_active')->toString() !== '') { + $query->where('is_active', $request->boolean('is_active')); + } + + if ($request->filled('search')) { + $search = $request->string('search'); + $query->where('label', 'like', "%{$search}%"); + } $rules = $query - ->orderBy('priority') - ->orderBy('slot_start') - ->get() - ->map(fn (PricingRule $rule) => $this->formatRule($rule)); + ->tap(fn ($builder) => $this->applySort($request, $builder, [ + 'establishment' => fn ($builder, string $direction) => $this->sortByRelatedEstablishment( + $builder, + 'pricing_rules', + 'establishment_id', + $direction, + ), + 'slot_start' => 'slot_start', + 'machine_type' => 'machine_type', + 'price' => 'price', + 'priority' => 'priority', + 'is_active' => 'is_active', + ], 'priority', 'asc')) + ->paginate($this->supervisorListPerPage($request)) + ->withQueryString() + ->through(fn (PricingRule $rule) => $this->formatRule($rule)); return Inertia::render('Supervisor/Pricing/Index', [ 'rules' => $rules, 'establishments' => $this->establishmentOptions(), 'machineTypes' => $this->machineTypeOptions(), 'dayTypes' => $this->dayTypeOptions(), + 'filters' => [ + 'establishment_id' => $request->filled('establishment_id') ? (int) $request->input('establishment_id') : null, + 'machine_type' => $request->string('machine_type')->toString() ?: null, + 'day_type' => $request->string('day_type')->toString() ?: null, + 'is_active' => $request->has('is_active') && $request->string('is_active')->toString() !== '' + ? ($request->boolean('is_active') ? '1' : '0') + : null, + 'search' => $request->string('search')->toString() ?: null, + 'sort' => $request->string('sort')->toString() ?: 'priority', + 'direction' => $request->string('direction')->toString() === 'desc' ? 'desc' : 'asc', + 'per_page' => $this->supervisorListPerPage($request), + ], ]); } @@ -134,22 +176,6 @@ class PricingPageController extends Controller ]; } - /** - * @return array - */ - private function establishmentOptions(): array - { - $supervisor = $this->supervisor(); - - return Establishment::query() - ->where('organization_id', $supervisor->organization_id) - ->when($supervisor->establishment_id, fn ($q) => $q->where('id', $supervisor->establishment_id)) - ->orderBy('name') - ->get(['id', 'name']) - ->map(fn (Establishment $e) => ['id' => $e->id, 'name' => $e->name]) - ->all(); - } - /** * @return array */ diff --git a/app/Http/Controllers/Supervisor/PromotionPageController.php b/app/Http/Controllers/Supervisor/PromotionPageController.php index 13f2762..61ff65c 100644 --- a/app/Http/Controllers/Supervisor/PromotionPageController.php +++ b/app/Http/Controllers/Supervisor/PromotionPageController.php @@ -15,21 +15,59 @@ class PromotionPageController extends Controller { use ScopesSupervisorResources; - public function index(): Response + public function index(Request $request): Response { $query = Promotion::query()->with('establishment:id,name'); $this->scopeEstablishmentQuery($query); + $this->applyEstablishmentFilter($request, $query); + + if ($request->filled('machine_type')) { + $query->where('machine_type', $request->string('machine_type')); + } + + if ($request->has('is_active') && $request->string('is_active')->toString() !== '') { + $query->where('is_active', $request->boolean('is_active')); + } + + if ($request->filled('search')) { + $search = $request->string('search'); + $query->where('description', 'like', "%{$search}%"); + } $promotions = $query - ->orderByDesc('starts_at') - ->get() - ->map(fn (Promotion $promotion) => $this->formatPromotion($promotion)); + ->tap(fn ($builder) => $this->applySort($request, $builder, [ + 'establishment' => fn ($builder, string $direction) => $this->sortByRelatedEstablishment( + $builder, + 'promotions', + 'establishment_id', + $direction, + ), + 'discount_value' => 'discount_value', + 'starts_at' => 'starts_at', + 'ends_at' => 'ends_at', + 'machine_type' => 'machine_type', + 'is_active' => 'is_active', + ], 'starts_at', 'desc')) + ->paginate($this->supervisorListPerPage($request)) + ->withQueryString() + ->through(fn (Promotion $promotion) => $this->formatPromotion($promotion)); return Inertia::render('Supervisor/Promotions/Index', [ 'promotions' => $promotions, 'establishments' => $this->establishmentOptions(), 'machineTypes' => $this->machineTypeOptions(), 'discountTypes' => $this->discountTypeOptions(), + 'filters' => [ + 'establishment_id' => $request->filled('establishment_id') ? (int) $request->input('establishment_id') : null, + 'machine_type' => $request->string('machine_type')->toString() ?: null, + 'is_active' => $request->has('is_active') && $request->string('is_active')->toString() !== '' + ? ($request->boolean('is_active') ? '1' : '0') + : null, + 'search' => $request->string('search')->toString() ?: null, + 'sort' => $request->string('sort')->toString() ?: 'starts_at', + 'direction' => $request->string('direction')->toString() === 'desc' ? 'desc' : 'asc', + 'per_page' => $this->supervisorListPerPage($request), + ], ]); } @@ -124,22 +162,6 @@ class PromotionPageController extends Controller ]; } - /** - * @return array - */ - private function establishmentOptions(): array - { - $supervisor = $this->supervisor(); - - return Establishment::query() - ->where('organization_id', $supervisor->organization_id) - ->when($supervisor->establishment_id, fn ($q) => $q->where('id', $supervisor->establishment_id)) - ->orderBy('name') - ->get(['id', 'name']) - ->map(fn (Establishment $e) => ['id' => $e->id, 'name' => $e->name]) - ->all(); - } - /** * @return array */ diff --git a/app/Http/Controllers/Supervisor/WashPageController.php b/app/Http/Controllers/Supervisor/WashPageController.php index d89746e..6c66644 100644 --- a/app/Http/Controllers/Supervisor/WashPageController.php +++ b/app/Http/Controllers/Supervisor/WashPageController.php @@ -19,6 +19,8 @@ class WashPageController extends Controller ->with(['user:id,first_name,last_name,email', 'machine:id,uuid,name,establishment_id', 'machine.establishment:id,name']); $this->scopeMachineRelationQuery($query); + $this->applyEstablishmentFilter($request, $query, 'machine'); + $this->applyUserSearchFilter($request, $query); if ($request->filled('status')) { $query->where('status', $request->string('status')); @@ -29,8 +31,15 @@ class WashPageController extends Controller } $washes = $query - ->orderByDesc('started_at') - ->paginate(20) + ->tap(fn ($builder) => $this->applySort($request, $builder, [ + 'started_at' => 'washes.started_at', + 'user' => fn ($builder, string $direction) => $this->sortByRelatedUser($builder, 'washes', 'user_id', $direction), + 'machine' => fn ($builder, string $direction) => $this->sortByRelatedMachine($builder, 'washes', $direction), + 'status' => 'washes.status', + 'duration_minutes' => 'washes.duration_minutes', + 'cost' => 'washes.cost', + ], 'started_at', 'desc')) + ->paginate($this->supervisorListPerPage($request)) ->withQueryString() ->through(fn (Wash $wash) => [ 'uuid' => $wash->uuid, @@ -56,9 +65,15 @@ class WashPageController extends Controller return Inertia::render('Supervisor/Washes/Index', [ 'washes' => $washes, + 'establishments' => $this->establishmentOptions(), 'filters' => [ + 'establishment_id' => $request->filled('establishment_id') ? (int) $request->input('establishment_id') : null, + 'user' => $request->string('user')->toString() ?: null, 'status' => $request->string('status')->toString() ?: null, 'date' => $request->string('date')->toString() ?: null, + 'sort' => $request->string('sort')->toString() ?: 'started_at', + 'direction' => $request->string('direction')->toString() === 'asc' ? 'asc' : 'desc', + 'per_page' => $this->supervisorListPerPage($request), ], 'statusOptions' => $this->statusOptions(), ]); diff --git a/app/Http/Requests/Api/V1/Wallet/InitiateTopUpRequest.php b/app/Http/Requests/Api/V1/Wallet/InitiateTopUpRequest.php index 1c12189..f2ac731 100644 --- a/app/Http/Requests/Api/V1/Wallet/InitiateTopUpRequest.php +++ b/app/Http/Requests/Api/V1/Wallet/InitiateTopUpRequest.php @@ -14,7 +14,7 @@ class InitiateTopUpRequest extends FormRequest public function rules(): array { return [ - 'amount' => ['required', 'numeric', 'min:1', 'max:500'], + 'amount' => ['required', 'numeric', 'min:5', 'max:150'], 'idempotency_key' => ['required', 'string', 'max:100'], 'return_url' => ['nullable', 'url', 'max:255'], ]; diff --git a/app/Http/Resources/PaymentTransactionResource.php b/app/Http/Resources/PaymentTransactionResource.php index 76db771..d23b1b4 100644 --- a/app/Http/Resources/PaymentTransactionResource.php +++ b/app/Http/Resources/PaymentTransactionResource.php @@ -9,7 +9,7 @@ class PaymentTransactionResource extends JsonResource { public function toArray(Request $request): array { - return [ + $data = [ 'uuid' => $this->uuid, 'provider' => $this->provider, 'provider_payment_id' => $this->provider_payment_id, @@ -19,5 +19,19 @@ class PaymentTransactionResource extends JsonResource 'return_url' => $this->return_url, 'created_at' => $this->created_at?->toIso8601String(), ]; + + if ($this->provider === 'stripe' && is_array($this->raw_payload)) { + $clientSecret = $this->raw_payload['client_secret'] ?? null; + + if (is_string($clientSecret) && $clientSecret !== '') { + $data['stripe'] = [ + 'payment_intent_id' => $this->provider_payment_id, + 'client_secret' => $clientSecret, + 'publishable_key' => config('services.stripe.key'), + ]; + } + } + + return $data; } } diff --git a/app/Http/Resources/WalletTransactionResource.php b/app/Http/Resources/WalletTransactionResource.php index 1197995..d8920c7 100644 --- a/app/Http/Resources/WalletTransactionResource.php +++ b/app/Http/Resources/WalletTransactionResource.php @@ -17,7 +17,7 @@ class WalletTransactionResource extends JsonResource 'balance_after' => (float) $this->balance_after, 'source_type' => $this->source_type, 'source_id' => $this->source_id, - 'metadata' => $this->metadata, + 'metadata' => $this->metadata ?? [], 'created_at' => $this->created_at?->toIso8601String(), ]; } diff --git a/app/Providers/IntegrationServiceProvider.php b/app/Providers/IntegrationServiceProvider.php index 58c9b88..c544cc1 100644 --- a/app/Providers/IntegrationServiceProvider.php +++ b/app/Providers/IntegrationServiceProvider.php @@ -9,6 +9,7 @@ use App\Services\BookingService; use App\Services\MachineIntegrationService; use App\Services\PaymentService; use App\Services\PricingService; +use App\Services\StripePaymentService; use App\Services\WalletService; use App\Services\WashService; use Illuminate\Support\ServiceProvider; @@ -27,6 +28,7 @@ class IntegrationServiceProvider extends ServiceProvider $this->app->singleton(AuditService::class); $this->app->singleton(BookingService::class); $this->app->singleton(WashService::class); + $this->app->singleton(StripePaymentService::class); $this->app->singleton(PaymentService::class); } diff --git a/app/Services/DashboardService.php b/app/Services/DashboardService.php index 42841bf..0fb40b3 100644 --- a/app/Services/DashboardService.php +++ b/app/Services/DashboardService.php @@ -3,6 +3,7 @@ namespace App\Services; use App\Models\Booking; +use App\Models\Establishment; use App\Models\Machine; use App\Models\MachineEvent; use App\Models\Supervisor; @@ -15,14 +16,14 @@ class DashboardService /** * @return array */ - public function getKpis(Supervisor $supervisor): array + public function getKpis(Supervisor $supervisor, ?int $establishmentFilter = null): array { $offlineThresholdMinutes = (int) config('laverie.machine.offline_threshold_minutes', 10); $today = Carbon::today(); - $washQuery = $this->washQuery($supervisor)->whereDate('started_at', $today); - $bookingQuery = $this->bookingQuery($supervisor)->whereDate('slot_start', $today); - $machineQuery = $this->machineQuery($supervisor); + $washQuery = $this->washQuery($supervisor, $establishmentFilter)->whereDate('started_at', $today); + $bookingQuery = $this->bookingQuery($supervisor, $establishmentFilter)->whereDate('slot_start', $today); + $machineQuery = $this->machineQuery($supervisor, $establishmentFilter); $offlineThreshold = now()->subMinutes($offlineThresholdMinutes); @@ -36,6 +37,8 @@ class DashboardService }) ->count(); + $machinesTotal = (clone $machineQuery)->count(); + return [ 'revenue_today' => (float) (clone $washQuery) ->where('status', 'completed') @@ -43,16 +46,85 @@ class DashboardService 'washes_today' => (clone $washQuery)->count(), 'bookings_today' => (clone $bookingQuery)->count(), 'offline_machines' => $offlineCount, - 'machines_total' => (clone $machineQuery)->count(), + 'machines_total' => $machinesTotal, + 'running_machines' => (clone $machineQuery)->where('status', 'running')->count(), + 'available_machines' => (clone $machineQuery)->where('status', 'available')->count(), + 'active_bookings' => $this->bookingQuery($supervisor, $establishmentFilter) + ->whereIn('status', ['confirmed', 'active']) + ->where('slot_end', '>=', now()) + ->count(), + ]; + } + + /** + * @return array + */ + public function getMachineStatusBreakdown(Supervisor $supervisor, ?int $establishmentFilter = null): array + { + $statuses = ['available', 'reserved', 'running', 'maintenance', 'offline', 'error']; + $counts = $this->machineQuery($supervisor, $establishmentFilter) + ->selectRaw('status, COUNT(*) as count') + ->groupBy('status') + ->pluck('count', 'status'); + + $breakdown = []; + foreach ($statuses as $status) { + $breakdown[$status] = (int) ($counts[$status] ?? 0); + } + + return $breakdown; + } + + /** + * @return Collection> + */ + public function getRecentWashes(Supervisor $supervisor, ?int $establishmentFilter = null, int $limit = 5): Collection + { + return $this->washQuery($supervisor, $establishmentFilter) + ->with(['machine:id,name,uuid', 'machine.establishment:id,name']) + ->orderByDesc('started_at') + ->limit($limit) + ->get() + ->map(fn (Wash $wash) => [ + 'uuid' => $wash->uuid, + 'machine_name' => $wash->machine?->name ?? '—', + 'establishment_name' => $wash->machine?->establishment?->name, + 'status' => $wash->status, + 'status_label' => $this->washStatusLabel($wash->status), + 'cost' => (float) $wash->cost, + 'started_at' => $wash->started_at?->toIso8601String(), + ]); + } + + /** + * @return array + */ + public function getContext(Supervisor $supervisor, ?int $establishmentFilter = null): array + { + $supervisor->loadMissing(['organization:id,name', 'establishment:id,name']); + + $establishmentName = $supervisor->establishment?->name; + + if ($establishmentName === null && $establishmentFilter !== null) { + $establishmentName = Establishment::query() + ->where('id', $establishmentFilter) + ->where('organization_id', $supervisor->organization_id) + ->value('name'); + } + + return [ + 'organization_name' => $supervisor->organization?->name, + 'establishment_name' => $establishmentName, + 'is_all_establishments' => $supervisor->establishment_id === null && $establishmentFilter === null, ]; } /** * @return Collection> */ - public function getAlerts(Supervisor $supervisor, int $limit = 10): Collection + public function getAlerts(Supervisor $supervisor, ?int $establishmentFilter = null, int $limit = 10): Collection { - $machineIds = $this->machineQuery($supervisor)->pluck('id'); + $machineIds = $this->machineQuery($supervisor, $establishmentFilter)->pluck('id'); if ($machineIds->isEmpty()) { return collect(); @@ -104,9 +176,9 @@ class DashboardService /** * @return Collection> */ - public function getMachinesOverview(Supervisor $supervisor): Collection + public function getMachinesOverview(Supervisor $supervisor, ?int $establishmentFilter = null): Collection { - return $this->machineQuery($supervisor) + return $this->machineQuery($supervisor, $establishmentFilter) ->with('establishment:id,name') ->orderBy('name') ->limit(12) @@ -123,41 +195,47 @@ class DashboardService ]); } - private function machineQuery(Supervisor $supervisor) + private function machineQuery(Supervisor $supervisor, ?int $establishmentFilter = null) { - $query = Machine::query()->whereHas('establishment', function ($query) use ($supervisor) { + $establishmentId = $supervisor->establishment_id ?? $establishmentFilter; + + $query = Machine::query()->whereHas('establishment', function ($query) use ($supervisor, $establishmentId) { $query->where('organization_id', $supervisor->organization_id); - if ($supervisor->establishment_id !== null) { - $query->where('id', $supervisor->establishment_id); + if ($establishmentId !== null) { + $query->where('id', $establishmentId); } }); - if ($supervisor->establishment_id !== null) { - $query->where('establishment_id', $supervisor->establishment_id); + if ($establishmentId !== null) { + $query->where('establishment_id', $establishmentId); } return $query; } - private function washQuery(Supervisor $supervisor) + private function washQuery(Supervisor $supervisor, ?int $establishmentFilter = null) { - return Wash::query()->whereHas('machine.establishment', function ($query) use ($supervisor) { + $establishmentId = $supervisor->establishment_id ?? $establishmentFilter; + + return Wash::query()->whereHas('machine.establishment', function ($query) use ($supervisor, $establishmentId) { $query->where('organization_id', $supervisor->organization_id); - if ($supervisor->establishment_id !== null) { - $query->where('id', $supervisor->establishment_id); + if ($establishmentId !== null) { + $query->where('id', $establishmentId); } }); } - private function bookingQuery(Supervisor $supervisor) + private function bookingQuery(Supervisor $supervisor, ?int $establishmentFilter = null) { - return Booking::query()->whereHas('machine.establishment', function ($query) use ($supervisor) { + $establishmentId = $supervisor->establishment_id ?? $establishmentFilter; + + return Booking::query()->whereHas('machine.establishment', function ($query) use ($supervisor, $establishmentId) { $query->where('organization_id', $supervisor->organization_id); - if ($supervisor->establishment_id !== null) { - $query->where('id', $supervisor->establishment_id); + if ($establishmentId !== null) { + $query->where('id', $establishmentId); } }); } @@ -195,4 +273,16 @@ class DashboardService default => $type, }; } + + private function washStatusLabel(string $status): string + { + return match ($status) { + 'pending_start' => 'En attente', + 'running' => 'En cours', + 'completed' => 'Terminé', + 'cancelled' => 'Annulé', + 'failed' => 'Échoué', + default => $status, + }; + } } diff --git a/app/Services/GeocodingService.php b/app/Services/GeocodingService.php new file mode 100644 index 0000000..63debd6 --- /dev/null +++ b/app/Services/GeocodingService.php @@ -0,0 +1,112 @@ +> + */ + public function search(string $query): array + { + $query = trim($query); + + if (strlen($query) < 3) { + return []; + } + + try { + $response = $this->client()->get(config('services.nominatim.url').'/search', [ + 'q' => $query, + 'format' => 'json', + 'addressdetails' => 1, + 'limit' => 5, + 'countrycodes' => 'fr', + ]); + } catch (ConnectionException|RequestException $exception) { + Log::warning('Geocoding search failed.', [ + 'query' => $query, + 'message' => $exception->getMessage(), + ]); + + return []; + } + + if (! $response->successful()) { + return []; + } + + return collect($response->json()) + ->map(fn (array $item) => $this->formatResult($item)) + ->filter(fn (array $item) => $item['address'] !== '') + ->values() + ->all(); + } + + /** + * @return array{latitude: float, longitude: float}|null + */ + public function geocode(string $address, ?string $city = null, ?string $zipCode = null): ?array + { + $query = collect([$address, $zipCode, $city, 'France']) + ->filter(fn (?string $part) => $part !== null && trim($part) !== '') + ->implode(', '); + + $results = $this->search($query); + + if ($results === [] || $results[0]['latitude'] === null || $results[0]['longitude'] === null) { + return null; + } + + return [ + 'latitude' => $results[0]['latitude'], + 'longitude' => $results[0]['longitude'], + ]; + } + + private function client(): PendingRequest + { + return Http::withHeaders([ + 'User-Agent' => config('services.nominatim.user_agent'), + 'Accept-Language' => 'fr', + ]) + ->withOptions([ + 'verify' => (bool) config('services.nominatim.verify_ssl', true), + ]) + ->timeout(8) + ->connectTimeout(4); + } + + /** + * @param array $item + * @return array + */ + private function formatResult(array $item): array + { + $addressParts = $item['address'] ?? []; + $street = trim(($addressParts['house_number'] ?? '').' '.($addressParts['road'] ?? '')); + + if ($street === '') { + $street = trim((string) ($item['name'] ?? '')); + } + + return [ + 'label' => (string) ($item['display_name'] ?? $street), + 'address' => $street, + 'city' => $addressParts['city'] + ?? $addressParts['town'] + ?? $addressParts['village'] + ?? $addressParts['municipality'] + ?? '', + 'zip_code' => (string) ($addressParts['postcode'] ?? ''), + 'latitude' => isset($item['lat']) ? (float) $item['lat'] : null, + 'longitude' => isset($item['lon']) ? (float) $item['lon'] : null, + ]; + } +} diff --git a/app/Services/PaymentService.php b/app/Services/PaymentService.php index 56eec67..07cde2f 100644 --- a/app/Services/PaymentService.php +++ b/app/Services/PaymentService.php @@ -6,12 +6,14 @@ use App\Models\PaymentTransaction; use App\Models\User; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; +use Stripe\Exception\ApiErrorException; class PaymentService { public function __construct( private readonly WalletService $walletService, private readonly AuditService $auditService, + private readonly StripePaymentService $stripePaymentService, ) {} public function initiateTopUp( @@ -20,8 +22,13 @@ class PaymentService string $idempotencyKey, ?string $returnUrl = null, ): PaymentTransaction { - if ($amount <= 0) { - throw new \InvalidArgumentException('Le montant doit être strictement positif.'); + $minAmount = (float) config('laverie.payment.top_up_min', 5); + $maxAmount = (float) config('laverie.payment.top_up_max', 150); + + if ($amount < $minAmount || $amount > $maxAmount) { + throw new \InvalidArgumentException( + "Le montant doit être compris entre {$minAmount} et {$maxAmount} EUR.", + ); } $existing = PaymentTransaction::query() @@ -32,21 +39,49 @@ class PaymentService return $existing; } - $payment = PaymentTransaction::query()->create([ - 'uuid' => (string) Str::uuid(), - 'user_id' => $user->id, - 'provider' => config('laverie.payment.default_provider', 'simulated'), - 'provider_payment_id' => 'sim-'.Str::random(16), - 'amount' => $amount, - 'currency' => 'EUR', - 'status' => 'initiated', - 'idempotency_key' => $idempotencyKey, - 'return_url' => $returnUrl, - ]); + $provider = config('laverie.payment.default_provider', 'simulated'); - $this->auditService->log($user, 'payment.initiated', $payment, null, $payment->toArray()); + if ($provider === 'stripe' && ! $this->stripePaymentService->isEnabled()) { + throw new \RuntimeException('Stripe n\'est pas configuré.'); + } - return $payment; + return DB::transaction(function () use ($user, $amount, $idempotencyKey, $returnUrl, $provider) { + $payment = PaymentTransaction::query()->create([ + 'uuid' => (string) Str::uuid(), + 'user_id' => $user->id, + 'provider' => $provider, + 'provider_payment_id' => null, + 'amount' => $amount, + 'currency' => 'EUR', + 'status' => 'initiated', + 'idempotency_key' => $idempotencyKey, + 'return_url' => $returnUrl, + ]); + + if ($provider === 'stripe') { + try { + $intent = $this->stripePaymentService->createPaymentIntent($user, $payment, $amount); + } catch (ApiErrorException $e) { + throw new \RuntimeException('Impossible de créer le paiement Stripe.', 0, $e); + } + + $payment->update([ + 'provider_payment_id' => $intent->id, + 'status' => 'pending', + 'raw_payload' => [ + 'client_secret' => $intent->client_secret, + ], + ]); + } else { + $payment->update([ + 'provider_payment_id' => 'sim-'.Str::random(16), + ]); + } + + $this->auditService->log($user, 'payment.initiated', $payment, null, $payment->fresh()->toArray()); + + return $payment->fresh(); + }); } public function confirmTopUp(string $paymentUuid): PaymentTransaction @@ -65,26 +100,29 @@ class PaymentService throw new \RuntimeException("Paiement non confirmable (statut : {$payment->status})."); } - $payment->update(['status' => 'succeeded']); + if ($payment->provider === 'stripe') { + if (blank($payment->provider_payment_id)) { + throw new \RuntimeException('Paiement Stripe introuvable.'); + } - $this->walletService->credit( - $payment->user, - (float) $payment->amount, - PaymentTransaction::class, - $payment->id, - "payment-credit:{$payment->uuid}", - ['provider' => $payment->provider], - ); + try { + $intent = $this->stripePaymentService->retrievePaymentIntent($payment->provider_payment_id); + } catch (ApiErrorException $e) { + throw new \RuntimeException('Impossible de vérifier le paiement Stripe.', 0, $e); + } - $this->auditService->log( - $payment->user, - 'payment.confirmed', - $payment, - null, - $payment->fresh()->toArray(), - ); + if (! $this->stripePaymentService->isPaymentSucceeded($intent)) { + throw new \RuntimeException('Le paiement Stripe n\'est pas encore confirmé.'); + } - return $payment->fresh(); + $payment->update([ + 'raw_payload' => array_merge($payment->raw_payload ?? [], [ + 'stripe' => $this->stripePaymentService->summarizePaymentIntent($intent), + ]), + ]); + } + + return $this->markPaymentSucceeded($payment->fresh()); }); } @@ -98,6 +136,10 @@ class PaymentService throw new \InvalidArgumentException('Webhook paiement invalide.'); } + if ($status === 'ignored') { + return; + } + DB::transaction(function () use ($provider, $payload, $providerPaymentId, $idempotencyKey, $status) { $query = PaymentTransaction::query()->lockForUpdate(); @@ -119,14 +161,90 @@ class PaymentService } $payment->update([ - 'raw_payload' => $payload, + 'raw_payload' => array_merge($payment->raw_payload ?? [], [ + 'webhook' => $payload, + ]), ]); if ($status === 'succeeded') { - $this->confirmTopUp($payment->uuid); + $this->markPaymentSucceeded($payment); } elseif (in_array($status, ['failed', 'cancelled', 'refunded'], true)) { $payment->update(['status' => $status]); } }); } + + public function handleStripeWebhook(string $payload, string $signature): void + { + $parsed = $this->stripePaymentService->parseWebhook($payload, $signature); + + if ($parsed['status'] === 'ignored') { + return; + } + + $this->handleWebhook('stripe', [ + 'provider_payment_id' => $parsed['provider_payment_id'], + 'status' => $parsed['status'], + 'payload' => $parsed['payload'], + ]); + } + + private function markPaymentSucceeded(PaymentTransaction $payment): PaymentTransaction + { + if ($payment->status === 'succeeded') { + return $payment; + } + + $payment->update(['status' => 'succeeded']); + + $this->walletService->credit( + $payment->user, + (float) $payment->amount, + PaymentTransaction::class, + $payment->id, + "payment-credit:{$payment->uuid}", + $this->buildWalletCreditMetadata($payment), + ); + + $this->auditService->log( + $payment->user, + 'payment.confirmed', + $payment, + null, + $payment->fresh()->toArray(), + ); + + return $payment->fresh(); + } + + /** + * @return array + */ + private function buildWalletCreditMetadata(PaymentTransaction $payment): array + { + $metadata = [ + 'operation' => 'top_up', + 'provider' => $payment->provider, + 'label' => match ($payment->provider) { + 'stripe' => 'Rechargement par carte', + 'simulated' => 'Rechargement (simulation)', + default => 'Rechargement portefeuille', + }, + 'payment_uuid' => $payment->uuid, + 'provider_payment_id' => $payment->provider_payment_id, + 'currency' => $payment->currency, + 'amount' => (float) $payment->amount, + ]; + + $raw = $payment->raw_payload ?? []; + + if ($payment->provider === 'stripe') { + $metadata['stripe'] = $raw['stripe'] ?? [ + 'payment_intent_id' => $payment->provider_payment_id, + 'status' => 'succeeded', + ]; + } + + return $metadata; + } } diff --git a/app/Services/StripePaymentService.php b/app/Services/StripePaymentService.php new file mode 100644 index 0000000..6bb7762 --- /dev/null +++ b/app/Services/StripePaymentService.php @@ -0,0 +1,151 @@ +configured) { + return; + } + + if (! class_exists(Stripe::class)) { + throw new \RuntimeException( + 'SDK Stripe manquant. Exécutez : composer require stripe/stripe-php', + ); + } + + Stripe::setApiKey(config('services.stripe.secret')); + $this->configured = true; + } + + public function isEnabled(): bool + { + return class_exists(Stripe::class) + && filled(config('services.stripe.secret')) + && filled(config('services.stripe.key')); + } + + public function publishableKey(): string + { + return (string) config('services.stripe.key'); + } + + public function createPaymentIntent( + User $user, + PaymentTransaction $payment, + float $amount, + ): PaymentIntent { + $this->ensureConfigured(); + + return PaymentIntent::create([ + 'amount' => (int) round($amount * 100), + 'currency' => strtolower($payment->currency), + 'metadata' => [ + 'payment_uuid' => $payment->uuid, + 'user_id' => (string) $user->id, + 'idempotency_key' => $payment->idempotency_key, + ], + ]); + } + + public function retrievePaymentIntent(string $paymentIntentId): PaymentIntent + { + $this->ensureConfigured(); + + return PaymentIntent::retrieve($paymentIntentId, [ + 'expand' => ['payment_method'], + ]); + } + + /** + * @return array + */ + public function summarizePaymentIntent(PaymentIntent $intent): array + { + $summary = [ + 'payment_intent_id' => $intent->id, + 'status' => $intent->status, + 'amount' => $intent->amount / 100, + 'currency' => strtoupper($intent->currency), + ]; + + $paymentMethod = $intent->payment_method; + if (is_object($paymentMethod)) { + $summary['payment_method_type'] = $paymentMethod->type ?? null; + + if (isset($paymentMethod->card)) { + $summary['card_brand'] = $paymentMethod->card->brand ?? null; + $summary['card_last4'] = $paymentMethod->card->last4 ?? null; + $summary['card_exp_month'] = $paymentMethod->card->exp_month ?? null; + $summary['card_exp_year'] = $paymentMethod->card->exp_year ?? null; + } + } + + return $summary; + } + + public function isPaymentSucceeded(PaymentIntent $paymentIntent): bool + { + return $paymentIntent->status === 'succeeded'; + } + + /** + * @return array{provider_payment_id: string, status: string, payload: array} + */ + public function parseWebhook(string $payload, string $signature): array + { + $this->ensureConfigured(); + + $secret = config('services.stripe.webhook_secret'); + + if (blank($secret)) { + throw new \RuntimeException('Webhook Stripe non configuré.'); + } + + try { + $event = Webhook::constructEvent($payload, $signature, $secret); + } catch (UnexpectedValueException|SignatureVerificationException $e) { + throw new \InvalidArgumentException('Signature webhook Stripe invalide.', 0, $e); + } + + $type = $event->type; + $object = $event->data->object; + + if (! in_array($type, [ + 'payment_intent.succeeded', + 'payment_intent.payment_failed', + 'payment_intent.canceled', + ], true)) { + return [ + 'provider_payment_id' => $object->id ?? '', + 'status' => 'ignored', + 'payload' => $event->toArray(), + ]; + } + + $status = match ($type) { + 'payment_intent.succeeded' => 'succeeded', + 'payment_intent.payment_failed' => 'failed', + 'payment_intent.canceled' => 'cancelled', + default => 'ignored', + }; + + return [ + 'provider_payment_id' => $object->id, + 'status' => $status, + 'payload' => $event->toArray(), + ]; + } +} diff --git a/composer.json b/composer.json index 6ee8da5..dc79180 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,7 @@ "laravel/framework": "^13.8", "laravel/sanctum": "^4.0", "laravel/tinker": "^3.0", + "stripe/stripe-php": "^20.3", "tightenco/ziggy": "^2.0" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 2b9ef3e..3ef68b4 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": "467a021a92214a0f1ddff604c3db2f6d", + "content-hash": "215a891aaf322e503ac2afec441182a6", "packages": [ { "name": "brick/math", @@ -3440,6 +3440,68 @@ }, "time": "2026-06-18T03:57:49+00:00" }, + { + "name": "stripe/stripe-php", + "version": "v20.3.0", + "source": { + "type": "git", + "url": "https://github.com/stripe/stripe-php.git", + "reference": "266f0b05890172184cca66a0688abaf1a96b08d8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/stripe/stripe-php/zipball/266f0b05890172184cca66a0688abaf1a96b08d8", + "reference": "266f0b05890172184cca66a0688abaf1a96b08d8", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "php": ">=7.2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "3.94.0", + "phpstan/phpstan": "^1.2", + "phpunit/phpunit": "^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "files": [ + "lib/version_check.php" + ], + "psr-4": { + "Stripe\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Stripe and contributors", + "homepage": "https://github.com/stripe/stripe-php/contributors" + } + ], + "description": "Stripe PHP Library", + "homepage": "https://stripe.com/", + "keywords": [ + "api", + "payment processing", + "stripe" + ], + "support": { + "issues": "https://github.com/stripe/stripe-php/issues", + "source": "https://github.com/stripe/stripe-php/tree/v20.3.0" + }, + "time": "2026-06-24T22:45:28+00:00" + }, { "name": "symfony/clock", "version": "v7.4.8", diff --git a/config/app.php b/config/app.php index 423eed5..b5037d4 100644 --- a/config/app.php +++ b/config/app.php @@ -65,7 +65,7 @@ return [ | */ - 'timezone' => 'UTC', + 'timezone' => env('APP_TIMEZONE', 'Europe/Paris'), /* |-------------------------------------------------------------------------- diff --git a/config/laverie.php b/config/laverie.php index 07af78f..539a665 100644 --- a/config/laverie.php +++ b/config/laverie.php @@ -20,6 +20,8 @@ return [ 'payment' => [ 'default_provider' => env('LAVERIE_PAYMENT_PROVIDER', 'simulated'), + 'top_up_min' => (float) env('LAVERIE_TOP_UP_MIN', 5), + 'top_up_max' => (float) env('LAVERIE_TOP_UP_MAX', 150), ], 'machine_api_key' => env('LAVERIE_MACHINE_API_KEY'), diff --git a/config/services.php b/config/services.php index 6a90eb8..3f55479 100644 --- a/config/services.php +++ b/config/services.php @@ -2,18 +2,6 @@ return [ - /* - |-------------------------------------------------------------------------- - | Third Party Services - |-------------------------------------------------------------------------- - | - | This file is for storing the credentials for third party services such - | as Mailgun, Postmark, AWS and more. This file provides the de facto - | location for this type of information, allowing packages to have - | a conventional file to locate the various service credentials. - | - */ - 'postmark' => [ 'key' => env('POSTMARK_API_KEY'), ], @@ -35,4 +23,16 @@ return [ ], ], + 'stripe' => [ + 'key' => env('STRIPE_KEY'), + 'secret' => env('STRIPE_SECRET'), + 'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'), + ], + + 'nominatim' => [ + 'url' => env('NOMINATIM_URL', 'https://nominatim.openstreetmap.org'), + 'user_agent' => env('NOMINATIM_USER_AGENT', 'LaverieBackOffice/1.0 (contact@laverie.local)'), + 'verify_ssl' => env('NOMINATIM_VERIFY_SSL', env('APP_ENV', 'production') !== 'local'), + ], + ]; diff --git a/resources/js/Components/ConfirmDeleteModal.vue b/resources/js/Components/ConfirmDeleteModal.vue new file mode 100644 index 0000000..2ac3eaa --- /dev/null +++ b/resources/js/Components/ConfirmDeleteModal.vue @@ -0,0 +1,67 @@ + + + \ No newline at end of file diff --git a/resources/js/Components/Dropdown.vue b/resources/js/Components/Dropdown.vue index ede3626..06ee2ea 100644 --- a/resources/js/Components/Dropdown.vue +++ b/resources/js/Components/Dropdown.vue @@ -1,5 +1,5 @@ diff --git a/resources/js/Components/Modal.vue b/resources/js/Components/Modal.vue index 4f847c4..7887bbf 100644 --- a/resources/js/Components/Modal.vue +++ b/resources/js/Components/Modal.vue @@ -70,6 +70,7 @@ const maxWidthClass = computed(() => { lg: 'sm:max-w-lg', xl: 'sm:max-w-xl', '2xl': 'sm:max-w-2xl', + '3xl': 'sm:max-w-3xl', }[props.maxWidth]; }); diff --git a/resources/js/Components/PrimaryButton.vue b/resources/js/Components/PrimaryButton.vue index 3bf8eb9..a4ca033 100644 --- a/resources/js/Components/PrimaryButton.vue +++ b/resources/js/Components/PrimaryButton.vue @@ -1,6 +1,6 @@