initial commit
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\BookingConflictException;
|
||||
use App\Models\Booking;
|
||||
use App\Models\Machine;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class BookingService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly WalletService $walletService,
|
||||
private readonly PricingService $pricingService,
|
||||
private readonly AuditService $auditService,
|
||||
) {}
|
||||
|
||||
public function createBooking(
|
||||
User $user,
|
||||
Machine $machine,
|
||||
Carbon $slotStart,
|
||||
Carbon $slotEnd,
|
||||
?string $idempotencyKey = null,
|
||||
): Booking {
|
||||
if ($slotEnd->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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user