initial commit
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class AuditService
|
||||
{
|
||||
public function log(
|
||||
Model $actor,
|
||||
string $action,
|
||||
Model|string $target,
|
||||
?array $before = null,
|
||||
?array $after = null,
|
||||
?int $organizationId = null,
|
||||
?int $establishmentId = null,
|
||||
?string $ip = null,
|
||||
): AuditLog {
|
||||
[$targetType, $targetId] = $this->resolveTarget($target);
|
||||
|
||||
return AuditLog::query()->create([
|
||||
'actor_type' => $actor->getMorphClass(),
|
||||
'actor_id' => $actor->getKey(),
|
||||
'organization_id' => $organizationId,
|
||||
'establishment_id' => $establishmentId,
|
||||
'action' => $action,
|
||||
'target_type' => $targetType,
|
||||
'target_id' => $targetId,
|
||||
'before_data' => $before,
|
||||
'after_data' => $after,
|
||||
'ip_address' => $ip,
|
||||
'created_at' => Carbon::now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: int|null}
|
||||
*/
|
||||
private function resolveTarget(Model|string $target): array
|
||||
{
|
||||
if ($target instanceof Model) {
|
||||
return [$target->getMorphClass(), $target->getKey()];
|
||||
}
|
||||
|
||||
return [$target, null];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Booking;
|
||||
use App\Models\Machine;
|
||||
use App\Models\MachineEvent;
|
||||
use App\Models\Supervisor;
|
||||
use App\Models\Wash;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class DashboardService
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getKpis(Supervisor $supervisor): 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);
|
||||
|
||||
$offlineThreshold = now()->subMinutes($offlineThresholdMinutes);
|
||||
|
||||
$offlineCount = (clone $machineQuery)
|
||||
->where(function ($query) use ($offlineThreshold) {
|
||||
$query->where('status', 'offline')
|
||||
->orWhere(function ($query) use ($offlineThreshold) {
|
||||
$query->where('last_heartbeat_at', '<', $offlineThreshold)
|
||||
->orWhereNull('last_heartbeat_at');
|
||||
});
|
||||
})
|
||||
->count();
|
||||
|
||||
return [
|
||||
'revenue_today' => (float) (clone $washQuery)
|
||||
->where('status', 'completed')
|
||||
->sum('cost'),
|
||||
'washes_today' => (clone $washQuery)->count(),
|
||||
'bookings_today' => (clone $bookingQuery)->count(),
|
||||
'offline_machines' => $offlineCount,
|
||||
'machines_total' => (clone $machineQuery)->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, array<string, mixed>>
|
||||
*/
|
||||
public function getAlerts(Supervisor $supervisor, int $limit = 10): Collection
|
||||
{
|
||||
$machineIds = $this->machineQuery($supervisor)->pluck('id');
|
||||
|
||||
if ($machineIds->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$errorMachines = Machine::query()
|
||||
->whereIn('id', $machineIds)
|
||||
->whereIn('status', ['error', 'maintenance', 'offline'])
|
||||
->with('establishment:id,name')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn (Machine $machine) => [
|
||||
'type' => 'machine_status',
|
||||
'severity' => $machine->status === 'error' ? 'high' : 'medium',
|
||||
'message' => sprintf(
|
||||
'%s (%s) — %s',
|
||||
$machine->name,
|
||||
$machine->establishment?->name ?? '—',
|
||||
$this->machineStatusLabel($machine->status),
|
||||
),
|
||||
'occurred_at' => $machine->updated_at?->toIso8601String(),
|
||||
]);
|
||||
|
||||
$recentEvents = MachineEvent::query()
|
||||
->whereIn('machine_id', $machineIds)
|
||||
->whereIn('event_type', ['error_reported', 'machine_offline', 'cycle_failed'])
|
||||
->with('machine:id,name,establishment_id', 'machine.establishment:id,name')
|
||||
->orderByDesc('occurred_at')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn (MachineEvent $event) => [
|
||||
'type' => 'machine_event',
|
||||
'severity' => $event->event_type === 'error_reported' ? 'high' : 'medium',
|
||||
'message' => sprintf(
|
||||
'%s — %s',
|
||||
$event->machine?->name ?? 'Machine',
|
||||
$this->eventTypeLabel($event->event_type),
|
||||
),
|
||||
'occurred_at' => $event->occurred_at?->toIso8601String(),
|
||||
]);
|
||||
|
||||
return $errorMachines
|
||||
->concat($recentEvents)
|
||||
->sortByDesc('occurred_at')
|
||||
->take($limit)
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, array<string, mixed>>
|
||||
*/
|
||||
public function getMachinesOverview(Supervisor $supervisor): Collection
|
||||
{
|
||||
return $this->machineQuery($supervisor)
|
||||
->with('establishment:id,name')
|
||||
->orderBy('name')
|
||||
->limit(12)
|
||||
->get()
|
||||
->map(fn (Machine $machine) => [
|
||||
'uuid' => $machine->uuid,
|
||||
'name' => $machine->name,
|
||||
'type' => $machine->type,
|
||||
'type_label' => $this->machineTypeLabel($machine->type),
|
||||
'status' => $machine->status,
|
||||
'status_label' => $this->machineStatusLabel($machine->status),
|
||||
'establishment_name' => $machine->establishment?->name,
|
||||
'last_heartbeat_at' => $machine->last_heartbeat_at?->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function machineQuery(Supervisor $supervisor)
|
||||
{
|
||||
$query = Machine::query()->whereHas('establishment', function ($query) use ($supervisor) {
|
||||
$query->where('organization_id', $supervisor->organization_id);
|
||||
|
||||
if ($supervisor->establishment_id !== null) {
|
||||
$query->where('id', $supervisor->establishment_id);
|
||||
}
|
||||
});
|
||||
|
||||
if ($supervisor->establishment_id !== null) {
|
||||
$query->where('establishment_id', $supervisor->establishment_id);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function washQuery(Supervisor $supervisor)
|
||||
{
|
||||
return Wash::query()->whereHas('machine.establishment', function ($query) use ($supervisor) {
|
||||
$query->where('organization_id', $supervisor->organization_id);
|
||||
|
||||
if ($supervisor->establishment_id !== null) {
|
||||
$query->where('id', $supervisor->establishment_id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function bookingQuery(Supervisor $supervisor)
|
||||
{
|
||||
return Booking::query()->whereHas('machine.establishment', function ($query) use ($supervisor) {
|
||||
$query->where('organization_id', $supervisor->organization_id);
|
||||
|
||||
if ($supervisor->establishment_id !== null) {
|
||||
$query->where('id', $supervisor->establishment_id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function machineStatusLabel(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
'available' => 'Disponible',
|
||||
'reserved' => 'Réservée',
|
||||
'running' => 'En cours',
|
||||
'maintenance' => 'Maintenance',
|
||||
'offline' => 'Hors ligne',
|
||||
'error' => 'Erreur',
|
||||
default => $status,
|
||||
};
|
||||
}
|
||||
|
||||
private function machineTypeLabel(string $type): string
|
||||
{
|
||||
return match ($type) {
|
||||
'washer_small' => 'Lave-linge petit',
|
||||
'washer_large' => 'Lave-linge grand',
|
||||
'dryer_small' => 'Sèche-linge petit',
|
||||
'dryer_large' => 'Sèche-linge grand',
|
||||
default => $type,
|
||||
};
|
||||
}
|
||||
|
||||
private function eventTypeLabel(string $type): string
|
||||
{
|
||||
return match ($type) {
|
||||
'error_reported' => 'Erreur signalée',
|
||||
'machine_offline' => 'Machine hors ligne',
|
||||
'cycle_failed' => 'Cycle échoué',
|
||||
default => $type,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Domain\Integration\MachineCommandResult;
|
||||
use App\Domain\Integration\MachineStatusSnapshot;
|
||||
use App\Domain\Integration\MachineProviderInterface;
|
||||
use App\Domain\Integration\SimulatedMachineProvider;
|
||||
use App\Models\Machine;
|
||||
use App\Models\MachineIntegration;
|
||||
use App\Models\User;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class MachineIntegrationService
|
||||
{
|
||||
/** @var array<string, class-string<MachineProviderInterface>> */
|
||||
private array $providerMap;
|
||||
|
||||
public function __construct(
|
||||
private readonly SimulatedMachineProvider $simulatedProvider,
|
||||
) {
|
||||
$this->providerMap = [
|
||||
config('laverie.simulation.provider_name', 'simulated') => SimulatedMachineProvider::class,
|
||||
'simulated' => SimulatedMachineProvider::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function startCycle(Machine $machine, array $payload = [], ?User $user = null): MachineCommandResult
|
||||
{
|
||||
if ($user !== null) {
|
||||
$payload['requested_by_user_id'] = $user->id;
|
||||
}
|
||||
|
||||
return $this->resolveProvider($machine)->startCycle($machine, $payload);
|
||||
}
|
||||
|
||||
public function stopCycle(Machine $machine, array $payload = []): MachineCommandResult
|
||||
{
|
||||
return $this->resolveProvider($machine)->stopCycle($machine, $payload);
|
||||
}
|
||||
|
||||
public function refreshStatus(Machine $machine): MachineStatusSnapshot
|
||||
{
|
||||
return $this->resolveProvider($machine)->refreshStatus($machine);
|
||||
}
|
||||
|
||||
public function handleInboundEvent(string $provider, array $payload): void
|
||||
{
|
||||
$this->resolveProviderByName($provider)->handleInboundEvent($payload);
|
||||
}
|
||||
|
||||
private function resolveProvider(Machine $machine): MachineProviderInterface
|
||||
{
|
||||
$integration = $machine->relationLoaded('integration')
|
||||
? $machine->integration
|
||||
: $machine->integration()->first();
|
||||
|
||||
if ($integration === null) {
|
||||
$integration = MachineIntegration::query()
|
||||
->where('machine_id', $machine->id)
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
}
|
||||
|
||||
$providerName = $integration?->provider ?? config('laverie.simulation.provider_name', 'simulated');
|
||||
|
||||
return $this->resolveProviderByName($providerName);
|
||||
}
|
||||
|
||||
private function resolveProviderByName(string $providerName): MachineProviderInterface
|
||||
{
|
||||
$class = $this->providerMap[$providerName] ?? null;
|
||||
|
||||
if ($class === null) {
|
||||
throw new InvalidArgumentException("Fournisseur machine inconnu : {$providerName}");
|
||||
}
|
||||
|
||||
return match ($class) {
|
||||
SimulatedMachineProvider::class => $this->simulatedProvider,
|
||||
default => app($class),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\PaymentTransaction;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PaymentService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly WalletService $walletService,
|
||||
private readonly AuditService $auditService,
|
||||
) {}
|
||||
|
||||
public function initiateTopUp(
|
||||
User $user,
|
||||
float $amount,
|
||||
string $idempotencyKey,
|
||||
?string $returnUrl = null,
|
||||
): PaymentTransaction {
|
||||
if ($amount <= 0) {
|
||||
throw new \InvalidArgumentException('Le montant doit être strictement positif.');
|
||||
}
|
||||
|
||||
$existing = PaymentTransaction::query()
|
||||
->where('idempotency_key', $idempotencyKey)
|
||||
->first();
|
||||
|
||||
if ($existing !== null) {
|
||||
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,
|
||||
]);
|
||||
|
||||
$this->auditService->log($user, 'payment.initiated', $payment, null, $payment->toArray());
|
||||
|
||||
return $payment;
|
||||
}
|
||||
|
||||
public function confirmTopUp(string $paymentUuid): PaymentTransaction
|
||||
{
|
||||
return DB::transaction(function () use ($paymentUuid) {
|
||||
$payment = PaymentTransaction::query()
|
||||
->where('uuid', $paymentUuid)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
if ($payment->status === 'succeeded') {
|
||||
return $payment;
|
||||
}
|
||||
|
||||
if (! in_array($payment->status, ['initiated', 'pending'], true)) {
|
||||
throw new \RuntimeException("Paiement non confirmable (statut : {$payment->status}).");
|
||||
}
|
||||
|
||||
$payment->update(['status' => 'succeeded']);
|
||||
|
||||
$this->walletService->credit(
|
||||
$payment->user,
|
||||
(float) $payment->amount,
|
||||
PaymentTransaction::class,
|
||||
$payment->id,
|
||||
"payment-credit:{$payment->uuid}",
|
||||
['provider' => $payment->provider],
|
||||
);
|
||||
|
||||
$this->auditService->log(
|
||||
$payment->user,
|
||||
'payment.confirmed',
|
||||
$payment,
|
||||
null,
|
||||
$payment->fresh()->toArray(),
|
||||
);
|
||||
|
||||
return $payment->fresh();
|
||||
});
|
||||
}
|
||||
|
||||
public function handleWebhook(string $provider, array $payload): void
|
||||
{
|
||||
$providerPaymentId = $payload['provider_payment_id'] ?? $payload['payment_id'] ?? null;
|
||||
$idempotencyKey = $payload['idempotency_key'] ?? null;
|
||||
$status = $payload['status'] ?? 'succeeded';
|
||||
|
||||
if ($providerPaymentId === null && $idempotencyKey === null) {
|
||||
throw new \InvalidArgumentException('Webhook paiement invalide.');
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($provider, $payload, $providerPaymentId, $idempotencyKey, $status) {
|
||||
$query = PaymentTransaction::query()->lockForUpdate();
|
||||
|
||||
if ($idempotencyKey !== null) {
|
||||
$payment = $query->where('idempotency_key', $idempotencyKey)->first();
|
||||
} else {
|
||||
$payment = $query
|
||||
->where('provider', $provider)
|
||||
->where('provider_payment_id', $providerPaymentId)
|
||||
->first();
|
||||
}
|
||||
|
||||
if ($payment === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($payment->status === 'succeeded') {
|
||||
return;
|
||||
}
|
||||
|
||||
$payment->update([
|
||||
'raw_payload' => $payload,
|
||||
]);
|
||||
|
||||
if ($status === 'succeeded') {
|
||||
$this->confirmTopUp($payment->uuid);
|
||||
} elseif (in_array($status, ['failed', 'cancelled', 'refunded'], true)) {
|
||||
$payment->update(['status' => $status]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Machine;
|
||||
use App\Models\PricingRule;
|
||||
use App\Models\Promotion;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonInterface;
|
||||
|
||||
class PricingService
|
||||
{
|
||||
public function getActivePrice(Machine $machine, ?CarbonInterface $at = null): float
|
||||
{
|
||||
$at = Carbon::instance($at ?? now());
|
||||
$establishment = $machine->establishment;
|
||||
|
||||
if ($establishment === null) {
|
||||
$machine->loadMissing('establishment');
|
||||
$establishment = $machine->establishment;
|
||||
}
|
||||
|
||||
if ($establishment === null) {
|
||||
throw new \RuntimeException('Établissement introuvable pour la machine.');
|
||||
}
|
||||
|
||||
$dayType = $this->resolveDayType($at);
|
||||
$time = $at->format('H:i:s');
|
||||
|
||||
$rules = PricingRule::query()
|
||||
->where('establishment_id', $establishment->id)
|
||||
->where('is_active', true)
|
||||
->where(function ($query) use ($machine) {
|
||||
$query->whereNull('machine_id')
|
||||
->orWhere('machine_id', $machine->id);
|
||||
})
|
||||
->where(function ($query) use ($machine) {
|
||||
$query->whereNull('machine_type')
|
||||
->orWhere('machine_type', $machine->type);
|
||||
})
|
||||
->where(function ($query) use ($dayType) {
|
||||
$query->where('day_type', 'all')
|
||||
->orWhere('day_type', $dayType);
|
||||
})
|
||||
->where('slot_start', '<=', $time)
|
||||
->where('slot_end', '>', $time)
|
||||
->orderBy('priority')
|
||||
->orderByRaw('CASE WHEN machine_id IS NOT NULL THEN 0 WHEN machine_type IS NOT NULL THEN 1 ELSE 2 END')
|
||||
->get();
|
||||
|
||||
$rule = $rules->first();
|
||||
|
||||
if ($rule === null) {
|
||||
throw new \RuntimeException('Aucune règle tarifaire active pour cette machine.');
|
||||
}
|
||||
|
||||
$basePrice = (float) $rule->price;
|
||||
|
||||
return $this->applyPromotion($establishment->id, $machine->type, $at, $basePrice);
|
||||
}
|
||||
|
||||
private function resolveDayType(CarbonInterface $at): string
|
||||
{
|
||||
if ($at->isWeekend()) {
|
||||
return 'weekend';
|
||||
}
|
||||
|
||||
return 'weekday';
|
||||
}
|
||||
|
||||
private function applyPromotion(int $establishmentId, string $machineType, CarbonInterface $at, float $basePrice): float
|
||||
{
|
||||
$promotion = Promotion::query()
|
||||
->where('establishment_id', $establishmentId)
|
||||
->where('is_active', true)
|
||||
->where('starts_at', '<=', $at)
|
||||
->where('ends_at', '>=', $at)
|
||||
->where(function ($query) use ($machineType) {
|
||||
$query->where('machine_type', 'all')
|
||||
->orWhere('machine_type', $machineType);
|
||||
})
|
||||
->orderByDesc('discount_value')
|
||||
->first();
|
||||
|
||||
if ($promotion === null) {
|
||||
return round($basePrice, 2);
|
||||
}
|
||||
|
||||
$discount = $promotion->discount_type === 'percent'
|
||||
? $basePrice * ((float) $promotion->discount_value / 100)
|
||||
: (float) $promotion->discount_value;
|
||||
|
||||
return round(max(0, $basePrice - $discount), 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\WalletInsufficientFundsException;
|
||||
use App\Models\User;
|
||||
use App\Models\Wallet;
|
||||
use App\Models\WalletTransaction;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class WalletService
|
||||
{
|
||||
public function getBalance(User $user): float
|
||||
{
|
||||
$wallet = $this->findOrCreateWallet($user);
|
||||
|
||||
return (float) $wallet->current_balance;
|
||||
}
|
||||
|
||||
public function credit(
|
||||
User $user,
|
||||
float $amount,
|
||||
string $sourceType,
|
||||
?int $sourceId = null,
|
||||
?string $idempotencyKey = null,
|
||||
?array $metadata = null,
|
||||
): WalletTransaction {
|
||||
return $this->applyMovement($user, 'credit', $amount, $sourceType, $sourceId, $idempotencyKey, $metadata);
|
||||
}
|
||||
|
||||
public function debit(
|
||||
User $user,
|
||||
float $amount,
|
||||
string $sourceType,
|
||||
?int $sourceId = null,
|
||||
?string $idempotencyKey = null,
|
||||
?array $metadata = null,
|
||||
): WalletTransaction {
|
||||
return $this->applyMovement($user, 'debit', $amount, $sourceType, $sourceId, $idempotencyKey, $metadata);
|
||||
}
|
||||
|
||||
private function applyMovement(
|
||||
User $user,
|
||||
string $type,
|
||||
float $amount,
|
||||
string $sourceType,
|
||||
?int $sourceId,
|
||||
?string $idempotencyKey,
|
||||
?array $metadata,
|
||||
): WalletTransaction {
|
||||
if ($amount <= 0) {
|
||||
throw new \InvalidArgumentException('Le montant doit être strictement positif.');
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($user, $type, $amount, $sourceType, $sourceId, $idempotencyKey, $metadata) {
|
||||
if ($idempotencyKey !== null) {
|
||||
$existing = WalletTransaction::query()
|
||||
->where('idempotency_key', $idempotencyKey)
|
||||
->first();
|
||||
|
||||
if ($existing !== null) {
|
||||
return $existing;
|
||||
}
|
||||
}
|
||||
|
||||
$wallet = Wallet::query()
|
||||
->where('user_id', $user->id)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($wallet === null) {
|
||||
$wallet = $this->createWallet($user);
|
||||
$wallet = Wallet::query()->whereKey($wallet->id)->lockForUpdate()->firstOrFail();
|
||||
}
|
||||
|
||||
if (! $wallet->isActive()) {
|
||||
throw new \RuntimeException('Le porte-monnaie n\'est pas actif.');
|
||||
}
|
||||
|
||||
$balanceBefore = (float) $wallet->current_balance;
|
||||
|
||||
if ($type === 'debit' && $balanceBefore < $amount) {
|
||||
throw new WalletInsufficientFundsException($amount, $balanceBefore);
|
||||
}
|
||||
|
||||
$balanceAfter = $type === 'credit'
|
||||
? round($balanceBefore + $amount, 2)
|
||||
: round($balanceBefore - $amount, 2);
|
||||
|
||||
$wallet->update(['current_balance' => $balanceAfter]);
|
||||
|
||||
return WalletTransaction::query()->create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'wallet_id' => $wallet->id,
|
||||
'type' => $type,
|
||||
'amount' => $amount,
|
||||
'balance_before' => $balanceBefore,
|
||||
'balance_after' => $balanceAfter,
|
||||
'source_type' => $sourceType,
|
||||
'source_id' => $sourceId,
|
||||
'idempotency_key' => $idempotencyKey,
|
||||
'metadata' => $metadata,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
private function findOrCreateWallet(User $user): Wallet
|
||||
{
|
||||
return Wallet::query()->firstOrCreate(
|
||||
['user_id' => $user->id],
|
||||
['currency' => 'EUR', 'current_balance' => 0, 'status' => 'active'],
|
||||
);
|
||||
}
|
||||
|
||||
private function createWallet(User $user): Wallet
|
||||
{
|
||||
return Wallet::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'currency' => 'EUR',
|
||||
'current_balance' => 0,
|
||||
'status' => 'active',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\MachineNotAvailableException;
|
||||
use App\Models\Booking;
|
||||
use App\Models\Machine;
|
||||
use App\Models\User;
|
||||
use App\Models\Wash;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class WashService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly WalletService $walletService,
|
||||
private readonly PricingService $pricingService,
|
||||
private readonly MachineIntegrationService $machineIntegrationService,
|
||||
private readonly AuditService $auditService,
|
||||
) {}
|
||||
|
||||
public function startWash(
|
||||
User $user,
|
||||
Machine $machine,
|
||||
string $triggerMethod,
|
||||
?int $bookingId = null,
|
||||
?string $program = null,
|
||||
): Wash {
|
||||
return DB::transaction(function () use ($user, $machine, $triggerMethod, $bookingId, $program) {
|
||||
$machine = Machine::query()->whereKey($machine->id)->lockForUpdate()->firstOrFail();
|
||||
|
||||
if (! in_array($machine->status, ['available', 'reserved'], true)) {
|
||||
throw new MachineNotAvailableException("Statut actuel : {$machine->status}");
|
||||
}
|
||||
|
||||
$booking = null;
|
||||
if ($bookingId !== null) {
|
||||
$booking = Booking::query()
|
||||
->whereKey($bookingId)
|
||||
->where('user_id', $user->id)
|
||||
->where('machine_id', $machine->id)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
if (! in_array($booking->status, ['confirmed', 'active'], true)) {
|
||||
throw new \RuntimeException('La réservation n\'est pas valide pour démarrer un lavage.');
|
||||
}
|
||||
}
|
||||
|
||||
$cost = $booking !== null
|
||||
? (float) $booking->reserved_amount
|
||||
: $this->pricingService->getActivePrice($machine);
|
||||
|
||||
if ($booking === null) {
|
||||
$this->walletService->debit(
|
||||
$user,
|
||||
$cost,
|
||||
Wash::class,
|
||||
null,
|
||||
'wash-debit:'.Str::uuid(),
|
||||
['machine_id' => $machine->id],
|
||||
);
|
||||
}
|
||||
|
||||
$wash = Wash::query()->create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'user_id' => $user->id,
|
||||
'machine_id' => $machine->id,
|
||||
'booking_id' => $booking?->id,
|
||||
'trigger_method' => $triggerMethod,
|
||||
'status' => 'pending_start',
|
||||
'program' => $program,
|
||||
'cost' => $cost,
|
||||
]);
|
||||
|
||||
$result = $this->machineIntegrationService->startCycle($machine, [
|
||||
'wash_id' => $wash->id,
|
||||
'wash_uuid' => $wash->uuid,
|
||||
'program' => $program,
|
||||
'requested_by_user_id' => $user->id,
|
||||
], $user);
|
||||
|
||||
if (! $result->success) {
|
||||
throw new \RuntimeException('Échec du démarrage du cycle machine.');
|
||||
}
|
||||
|
||||
$command = $machine->commands()->latest('id')->first();
|
||||
|
||||
$wash->update([
|
||||
'status' => 'running',
|
||||
'machine_command_id' => $command?->id,
|
||||
'started_at' => now(),
|
||||
]);
|
||||
|
||||
if ($booking !== null) {
|
||||
$booking->update(['status' => 'active']);
|
||||
}
|
||||
|
||||
$machine->update(['current_user_id' => $user->id]);
|
||||
|
||||
$this->auditService->log(
|
||||
$user,
|
||||
'wash.started',
|
||||
$wash,
|
||||
null,
|
||||
$wash->fresh()->toArray(),
|
||||
$machine->establishment?->organization_id,
|
||||
$machine->establishment_id,
|
||||
);
|
||||
|
||||
return $wash->fresh(['machine', 'booking']);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user