lte($slotStart)) { throw new \InvalidArgumentException('La fin du créneau doit être postérieure au début.'); } $lockKey = sprintf( 'booking:machine:%d:%s', $machine->id, $slotStart->toIso8601String(), ); return Cache::lock($lockKey, 10)->block(5, function () use ($user, $machine, $slotStart, $slotEnd, $idempotencyKey) { return DB::transaction(function () use ($user, $machine, $slotStart, $slotEnd, $idempotencyKey) { if ($idempotencyKey !== null) { $existing = Booking::query() ->where('user_id', $user->id) ->where('machine_id', $machine->id) ->where('slot_start', $slotStart) ->where('status', '!=', 'cancelled') ->first(); if ($existing !== null) { return $existing; } } $conflict = Booking::query() ->where('machine_id', $machine->id) ->whereIn('status', ['pending', 'confirmed', 'active']) ->where('slot_start', '<', $slotEnd) ->where('slot_end', '>', $slotStart) ->lockForUpdate() ->exists(); if ($conflict) { throw new BookingConflictException(); } $washPrice = $this->pricingService->getActivePrice($machine, $slotStart); $bookingFee = (float) config('laverie.booking.fee', 1.00); $totalAmount = round($washPrice + $bookingFee, 2); $this->walletService->debit( $user, $totalAmount, Booking::class, null, $idempotencyKey ? "booking-debit:{$idempotencyKey}" : null, ['machine_id' => $machine->id, 'slot_start' => $slotStart->toIso8601String()], ); $booking = Booking::query()->create([ 'uuid' => (string) Str::uuid(), 'user_id' => $user->id, 'machine_id' => $machine->id, 'slot_start' => $slotStart, 'slot_end' => $slotEnd, 'booking_fee' => $bookingFee, 'reserved_amount' => $washPrice, 'status' => 'confirmed', ]); if ($machine->status === 'available' && $slotStart->lte(now()) && $slotEnd->gt(now())) { $machine->update(['status' => 'reserved']); } $this->auditService->log( $user, 'booking.created', $booking, null, $booking->toArray(), $machine->establishment?->organization_id, $machine->establishment_id, ); return $booking; }); }); } public function cancelBooking(Booking $booking, User $user): Booking { if ($booking->user_id !== $user->id) { throw new \RuntimeException('Réservation non autorisée.'); } if (! $booking->isCancellable()) { throw new \RuntimeException('Cette réservation ne peut pas être annulée.'); } return DB::transaction(function () use ($booking, $user) { $booking = Booking::query()->whereKey($booking->id)->lockForUpdate()->firstOrFail(); $graceHours = (int) config('laverie.booking.cancellation_grace_hours', 2); $withinGrace = $booking->slot_start->greaterThan(now()->addHours($graceHours)); $penaltyAmount = 0.0; $refundAmount = (float) $booking->reserved_amount + (float) $booking->booking_fee; if (! $withinGrace) { $penaltyAmount = (float) config('laverie.booking.penalty_amount', 3.00); $refundAmount = max(0, $refundAmount - $penaltyAmount); } if ($refundAmount > 0) { $this->walletService->credit( $user, $refundAmount, Booking::class, $booking->id, "booking-refund:{$booking->uuid}", ['reason' => $withinGrace ? 'cancellation_full' : 'cancellation_partial'], ); } $booking->update([ 'status' => 'cancelled', 'cancelled_at' => now(), 'penalty_amount' => $penaltyAmount, 'penalty_applied_at' => $penaltyAmount > 0 ? now() : null, ]); $machine = $booking->machine; if ($machine !== null && $machine->status === 'reserved') { $machine->update(['status' => 'available']); } $this->auditService->log($user, 'booking.cancelled', $booking, null, $booking->fresh()->toArray()); return $booking->fresh(); }); } public function moveBooking(Booking $booking, User $user, Carbon $newSlotStart, Carbon $newSlotEnd): Booking { if ($booking->user_id !== $user->id) { throw new \RuntimeException('Réservation non autorisée.'); } if (! in_array($booking->status, ['confirmed', 'pending'], true)) { throw new \RuntimeException('Cette réservation ne peut pas être déplacée.'); } return DB::transaction(function () use ($booking, $user, $newSlotStart, $newSlotEnd) { $machine = Machine::query()->findOrFail($booking->machine_id); $this->cancelBooking($booking, $user); return $this->createBooking( $user, $machine, $newSlotStart, $newSlotEnd, "booking-move:{$booking->uuid}:{$newSlotStart->timestamp}", ); }); } public function checkNoShows(): int { $graceMinutes = (int) config('laverie.booking.no_show_grace_minutes', 15); $cutoff = now()->subMinutes($graceMinutes); $processed = 0; $candidates = Booking::query() ->with(['user', 'machine', 'washes']) ->where('status', 'confirmed') ->where('slot_start', '<=', $cutoff) ->get(); foreach ($candidates as $booking) { if ($booking->washes->isNotEmpty()) { continue; } DB::transaction(function () use ($booking, &$processed) { $locked = Booking::query()->whereKey($booking->id)->lockForUpdate()->firstOrFail(); if ($locked->status !== 'confirmed' || $locked->washes()->exists()) { return; } $penaltyAmount = (float) config('laverie.booking.penalty_amount', 3.00); $locked->update([ 'status' => 'no_show', 'penalty_amount' => $penaltyAmount, 'penalty_applied_at' => now(), ]); $machine = $locked->machine; if ($machine !== null && $machine->status === 'reserved') { $machine->update(['status' => 'available']); } $this->auditService->log( $locked->user, 'booking.no_show', $locked, null, $locked->fresh()->toArray(), ); $processed++; }); } return $processed; } }