where('businessId', $business->id)->whereIn('id', $serviceIds)->where('isActive', true)->get(); if ($services->count() !== count($serviceIds)) { throw new RuntimeException('One or more selected services are no longer available.'); } $start = CarbonImmutable::parse($input['startAt'])->setTimezone($business->timezone); $end = $start->addMinutes($services->sum('durationMinutes') + $services->sum('bufferBeforeMinutes') + $services->sum('bufferAfterMinutes')); $available = $this->availability->slots($business, $start, $serviceIds, $input['staffId'])->first(fn ($slot) => $slot['startAt'] === $start->toIso8601String()); if (! $available) { throw new RuntimeException('That time has just been booked. Please choose another slot.'); } $rawToken = Str::random(64); $appointment = DB::transaction(function () use ($business, $input, $services, $start, $end, $rawToken): Appointment { $conflict = Appointment::query() ->where('businessId', $business->id) ->where('staffId', $input['staffId']) ->whereNotIn('status', [AppointmentStatus::Cancelled->value, AppointmentStatus::NoShow->value]) ->where('startAt', '<', $end->utc()) ->where('endAt', '>', $start->utc()) ->lockForUpdate() ->exists(); if ($conflict) { throw new RuntimeException('That time has just been booked. Please choose another slot.'); } $email = strtolower(trim($input['email'])); $customer = CustomerProfile::query()->where('businessId', $business->id)->where('email', $email)->first(); if (! $customer) $customer = new CustomerProfile(['businessId' => $business->id, 'email' => $email]); $customer->firstName = trim($input['firstName']); $customer->lastName = trim($input['lastName']); $customer->phone = trim($input['phone'] ?? '') ?: $customer->phone; $customer->isGuest = ! $customer->userId; $customer->save(); $appointment = Appointment::create([ 'businessId' => $business->id, 'customerId' => $customer->id, 'staffId' => $input['staffId'], 'status' => $business->bookingRule?->requireApproval ? AppointmentStatus::Pending : AppointmentStatus::Confirmed, 'startAt' => $start->utc(), 'endAt' => $end->utc(), 'notes' => $input['notes'] ?? null, ]); foreach ($services as $service) { AppointmentService::create(['appointmentId' => $appointment->id, 'serviceId' => $service->id, 'priceAtBooking' => $service->price, 'durationAtBookingMinutes' => $service->durationMinutes]); } GuestAppointmentAccessToken::create(['appointmentId' => $appointment->id, 'tokenHash' => hash('sha256', $rawToken), 'expiresAt' => now()->addMonths(6)]); return $appointment; }); $appointment->load(['customer', 'staff.user', 'services.service']); $notificationType = $appointment->status === AppointmentStatus::Pending ? NotificationType::BookingPendingApproval : NotificationType::BookingConfirmed; $this->notifications->create( $business, $notificationType, $appointment->status === AppointmentStatus::Pending ? 'Your booking request is waiting for the shop to approve.' : 'Your appointment is confirmed.', "booking-created:{$appointment->id}", $appointment->customer->userId, $appointment->customer->id, $appointment->id, "/book/confirmation", $business->name, ); return ['appointment' => $appointment, 'accessToken' => $rawToken]; } }