Intégration fonctionnalites V1

This commit is contained in:
bastien
2026-07-04 22:30:18 +02:00
parent 55a558b536
commit 10ee859602
54 changed files with 4802 additions and 1018 deletions
@@ -17,6 +17,7 @@ class HealthController extends Controller
return $this->success([
'status' => 'ok',
'service' => 'laverie-api',
'timezone' => config('app.timezone'),
'timestamp' => now()->toIso8601String(),
]);
}
@@ -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é.');
}
@@ -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(),
]);
@@ -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<int, int>
*/
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<int, array{id: int, name: string}>
*/
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<string, string|callable(Builder, string): void> $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}.*");
}
}
@@ -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;
}
}
@@ -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<string, mixed>
*/
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<string, string>
*/
@@ -0,0 +1,276 @@
<?php
namespace App\Http\Controllers\Supervisor;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Supervisor\Concerns\ScopesSupervisorResources;
use App\Models\Establishment;
use App\Services\GeocodingService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class OrganizationPageController extends Controller
{
use ScopesSupervisorResources;
public function __construct(private GeocodingService $geocoding) {}
public function index(Request $request): Response
{
$supervisor = $this->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<string, mixed>
*/
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<string, mixed>
*/
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<string, mixed> $validated
* @return array<string, mixed>
*/
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<string, mixed> $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.',
]);
}
}
}
@@ -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<int, array{id: int, name: string}>
*/
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<string, string>
*/
@@ -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<int, array{id: int, name: string}>
*/
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<string, string>
*/
@@ -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(),
]);
@@ -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'],
];
@@ -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;
}
}
@@ -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(),
];
}
@@ -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);
}
+113 -23
View File
@@ -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<string, mixed>
*/
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<string, int>
*/
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<int, array<string, mixed>>
*/
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<string, string|null>
*/
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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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,
};
}
}
+112
View File
@@ -0,0 +1,112 @@
<?php
namespace App\Services;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class GeocodingService
{
/**
* @return array<int, array<string, mixed>>
*/
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<string, mixed> $item
* @return array<string, mixed>
*/
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,
];
}
}
+152 -34
View File
@@ -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<string, mixed>
*/
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;
}
}
+151
View File
@@ -0,0 +1,151 @@
<?php
namespace App\Services;
use App\Models\PaymentTransaction;
use App\Models\User;
use Stripe\Exception\SignatureVerificationException;
use Stripe\PaymentIntent;
use Stripe\Stripe;
use Stripe\Webhook;
use UnexpectedValueException;
class StripePaymentService
{
private bool $configured = false;
private function ensureConfigured(): void
{
if ($this->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<string, mixed>
*/
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<string, mixed>}
*/
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(),
];
}
}