'Wrong restaurant' ]); } // Check service exists $service = alexr_get_service($serviceId); if (!$service) { wp_send_json_error(['message' => __eva('Service not found.')]); } // Check nonce if (!$this->verify_nonce($restaurantId)) { wp_send_json_error(['message' => __eva('Could not create a booking. Invalid nonce. Please try again.')]); } // Common data extraction $date = sanitize_text_field($_REQUEST['dateSelected']); $time = intval($_REQUEST['timeSelected']); $party = intval($_REQUEST['party']); $uuid = sanitize_text_field($_REQUEST['uuid']); // Verificar si el servicio bloquea mesas $is_service_blocking_tables = $service->isBlockingTables(); // Get the booking that's going to be modified or created $booking = \Alexr\Models\Booking::where('uuid', $uuid) ->where('status', '!=', \Alexr\Enums\BookingStatus::DELETED) ->first(); // Handle based on operation type if ($is_modify_booking) { $result = $this->processBookingModification($booking, $service, $restaurant, $is_service_blocking_tables, $date, $time, $party, $uuid); } else { $result = $this->processNewBooking($booking, $service, $restaurant, $is_service_blocking_tables, $date, $time, $party); } if ($result === false) { // Errors are already sent in the specific methods return; } // Response is handled in the specific methods } /** * Process a booking modification * * @param \Alexr\Models\Booking $booking Current booking object * @param object $service Service object * @param \Alexr\Models\Restaurant $restaurant Restaurant object * @param bool $is_service_blocking_tables Whether service blocks tables * @param string $date Date for the booking * @param int $time Time for the booking * @param int $party Number of guests * @param string $uuid UUID of the booking * @return bool|array False if error, booking data if successful */ protected function processBookingModification($booking, $service, $restaurant, $is_service_blocking_tables, $date, $time, $party, $uuid) { // Get the UUID of the booking to modify $modify_booking_uuid = sanitize_text_field($_REQUEST['modify_booking_uuid']); // Find the original booking to modify $booking = \Alexr\Models\Booking::where('uuid', $modify_booking_uuid) ->where('status', '!=', \Alexr\Enums\BookingStatus::DELETED) ->first(); if (!$booking) { wp_send_json_error(['message' => __eva('Booking does not exist.')]); return false; } // Save original attributes and state for event handling $old_attributes = \Alexr\Listeners\ListenBookingEvents::getOriginal($booking); $old_tables = $booking->tablesList; $old_status = $booking->status; // Preserve original status $old_party = $booking->party; $old_date = $booking->date; $old_time = $booking->time; // Check date availability if ($restaurant->isDateClosed($date)) { wp_send_json_error(['message' => __eva('Sorry, this date is closed for reservations.')]); return false; } if ($service->isSlotClosed($date, $time)) { wp_send_json_error(['message' => __eva('Sorry, this slot is already closed.')]); return false; } // Create a new booking record to store modification history $new_booking = $this->saveModificationHistory($booking, $uuid, $modify_booking_uuid); // Update booking with new data $booking->date = $date; $booking->party = $party; $booking->time = $time; $booking->widget_id = intval($_REQUEST['widgetId']); $booking->shift_event_id = $service->id; $booking->shift_event_name = $service->name; // Always check for the best table assignment when modifying a booking $exclude_booking_id = $booking->id; $isAvailable = $service->isAvailable($date, $time, $party, $exclude_booking_id); if (!$isAvailable) { wp_send_json_error(['message' => __eva('Sorry, this time is not available for the new number of guests.')]); return false; } $assignedTables = []; // If specific tables are available, assign them if (is_array($isAvailable) && !empty($isAvailable)) { $tables_id = array_map(function($table) { return $table['id']; }, $isAvailable); $booking->tables()->sync($tables_id); $assignedTables = $isAvailable; } // Calcular duración según el tipo de disponibilidad y mesa asignada if ($service->availability_type === 'tables_schedule' && !empty($assignedTables)) { $booking->duration = $this->calculateBookingDuration( $assignedTables, $service, $date, $time, $party ); } else { // Para otros tipos de disponibilidad, usar duración estándar $booking->duration = $service->getDuration($party); } // Determine status based on service rules (same as new bookings) $status = $service->getBookingStatusForNewReservation($date, $time, $party, $booking->email, $exclude_booking_id); $booking->status = $status; // Save area_selected and table_selected $this->attachAreaTableSelected($booking, $service, $date, $time, $party); // Apply specific rules that might modify status $service->applyRulePendingForSpecificTables($booking); // Save booking $booking->save(); // Verify booking was saved correctly $new_booking_saved = \Alexr\Models\Booking::find($booking->id); if (!$new_booking_saved || $new_booking_saved->status == \Alexr\Enums\BookingStatus::DELETED) { $data = json_encode($booking->toArray()); evavel_log_to_file(\Evavel\Log\Log::AJAX_ERROR_SAVE_BOKING, $data); wp_send_json_error(['message' => __eva('Sorry, something went wrong, please try again.')]); return false; } // Modified bookings don't need payment again $message = $this->sendBookingNotifications($booking, false, false, true, [ 'old_date' => $old_date, 'old_time' => $old_time, 'old_party' => $old_party ]); $payment = null; // Trigger modification events evavel_event(new \Alexr\Events\EventBookingModified($booking, null, $old_attributes)); // Send successful response $this->sendSuccessResponse($booking, $message, $payment); return true; } /** * Process a new booking * * @param \Alexr\Models\Booking $booking Booking object * @param object $service Service object * @param \Alexr\Models\Restaurant $restaurant Restaurant object * @param bool $is_service_blocking_tables Whether service blocks tables * @param string $date Date for the booking * @param int $time Time for the booking * @param int $party Number of guests * @return bool|array False if error, booking data if successful */ protected function processNewBooking($booking, $service, $restaurant, $is_service_blocking_tables, $date, $time, $party) { // If holded booking doesn't exist, it was deleted (time expired) if (!$booking) { wp_send_json_error(['message' => __eva('Time expired, please try again.')]); return false; } // Anti-spam: honeypot, minimum time since the hold and rate limit per IP if (!$this->antispamCheckNewBooking($booking, $restaurant)) { return false; } // Check for duplicate bookings if ($this->isBookingDuplicated()) { wp_send_json_error(['message' => __eva('You cannot reserve again for this date.')]); return false; } // If tables haven't been blocked, assign available tables now $tablesList = $booking->tablesList; $assignedTables = []; if (!$is_service_blocking_tables && empty($tablesList)) { // Comprobar disponibilidad por seguridad, pero excluyendo la reserva actual $isAvailable = $service->isAvailable($date, $time, $party, $booking->id); //ray('MESAS ASIGNADAS'); //ray($isAvailable); if (!$isAvailable) { wp_send_json_error(['message' => __eva('Sorry, this time is not available any more.')]); return false; } if (is_array($isAvailable) && !empty($isAvailable)) { $tables_id = array_map(function($table) { return $table['id']; }, $isAvailable); $booking->tables()->sync($tables_id); $assignedTables = $isAvailable; $booking->save(); } } else { // Si ya hay mesas asignadas, obtenerlas para calcular la duración foreach ($tablesList as $tableId) { $table = \Alexr\Models\Table::find($tableId); if ($table) { $assignedTables[] = [ 'id' => $table->id, 'name' => $table->name, 'area_id' => $table->area_id, 'min_seats' => $table->min_seats, 'max_seats' => $table->max_seats ]; } } } // Set status and update booking details $booking->status = \Alexr\Enums\BookingStatus::PENDING; $this->updateBookingValuesFromRequest($booking); $this->assignOrCreateNewCustomerIfNeeded($booking); // Capture and persist the offer menu selection (if any). The widget // can send `offer_id`, `offer_rule_id`, `menu_lines` and `menu_subtotal`. // The server recomputes the authoritative subtotal from the offer record // to avoid trusting client-side prices. $this->attachOfferMenuSelection($booking, $service); // Determine final status based on service rules $status = $service->getBookingStatusForNewReservation($date, $time, $party, $booking->email); // Calcular duración según el tipo de disponibilidad y mesa asignada // Calcular duración según el tipo de disponibilidad y mesa asignada if ($service->availability_type === 'tables_schedule' && !empty($assignedTables)) { // Si hay más de una mesa, podría ser una combinación /*if (count($assignedTables) > 1) { // Intentar calcular duración como combinación $tableIds = array_map(function($table) { return $table['id']; }, $assignedTables); $booking->duration = $this->calculateDurationForTablesCombination( $tableIds, $service, $date, $time, $party, $service->list_of_tables_with_schedule['durationType'] ?? 'end_of_slot' ); } else { // Se extiende hasta la siguiente mesa o no // end_of_slot = hasta el final del schedule // next_slot = hasta la siguiente reserva // end_of_slot, next_slot $duration_type = $service->list_of_tables_with_schedule['durationType'] ?? 'end_of_slot'; // Si es una sola mesa, determinar duración según su schedule $tableId = $assignedTables[0]['id']; $booking->duration = $this->calculateDurationForTableSchedule( $tableId, $service, $date, $time, $party, $duration_type ); }*/ $booking->duration = $this->calculateBookingDuration( $assignedTables, $service, $date, $time, $party ); } else { // Para otros tipos de disponibilidad, usar duración estándar $booking->duration = $service->getDuration($party); } /*if ($service->availability_type === 'tables_schedule' && !empty($assignedTables)) { // Si hay más de una mesa, podría ser una combinación if (count($assignedTables) > 1) { // Intentar calcular duración como combinación $tableIds = array_map(function($table) { return $table['id']; }, $assignedTables); $booking->duration = $this->calculateDurationForTablesCombination( $tableIds, $service, $date, $time, $party, $service->list_of_tables_with_schedule['durationType'] ?? 'end_of_slot' ); } else { // Se extiende hasta la siguiente mesa o no // end_of_slot = hasta el final del schedule // next_slot = hasta la siguiente reserva // end_of_slot, next_slot $duration_type = $service->list_of_tables_with_schedule['durationType'] ?? 'end_of_slot'; // Si es una sola mesa, determinar duración según su schedule $tableId = $assignedTables[0]['id']; $booking->duration = $this->calculateDurationForTableSchedule( $tableId, $service, $date, $time, $party, $duration_type ); } } else { // Para otros tipos de disponibilidad, usar duración estándar $booking->duration = $service->getDuration($party); }*/ /*if ($service->availability_type === 'tables_schedule' && !empty($assignedTables)) { // Si hay más de una mesa, usar duración estándar if (count($assignedTables) > 1) { $booking->duration = $service->getDuration($party); } else { // Se extiende hasta la siguiente mesa o no // end_of_slot = hasta el final del schedule // next_slot = hasta la siguiente reserva // end_of_slot, next_slot $duration_type = $service->list_of_tables_with_schedule['durationType'] ?? 'end_of_slot'; // Si es una sola mesa, determinar duración según su schedule $tableId = $assignedTables[0]['id']; $booking->duration = $this->calculateDurationForTableSchedule( $tableId, $service, $date, $time, $party, $duration_type ); } } else { // Para otros tipos de disponibilidad, usar duración estándar $booking->duration = $service->getDuration($party); }*/ $booking->status = $status; // Save area_selected and table_selected $this->attachAreaTableSelected($booking, $service, $date, $time, $party); // Apply specific rules $service->applyRulePendingForSpecificTables($booking); // Save booking $booking->save(); // Verify booking was saved correctly $new_booking_saved = \Alexr\Models\Booking::find($booking->id); if (!$new_booking_saved || $new_booking_saved->status == \Alexr\Enums\BookingStatus::DELETED) { $data = json_encode($booking->toArray()); evavel_log_to_file(\Evavel\Log\Log::AJAX_ERROR_SAVE_BOKING, $data); wp_send_json_error(['message' => __eva('Sorry, something went wrong, please try again.')]); return false; } $this->antispamRecordBooking($restaurant); // Check if payment is needed // No es suficiente cuando se usan tables_layouts porque solo comprueba el precio segun las reglas pero no el precio de la mesa en si $isPaymentNeeded = $this->isPaymentNeeded($service, $booking); //*************** PRICE PASADO DESDE EL FORMULARIO +++++++++++++++++++++++++++++++++++++ $price_table = isset($_REQUEST['price']) ? intval($_REQUEST['price']) : null; // When an offer rule matches the slot, its payment configuration is // authoritative. Override $price_table so the menu-based pricing wins // over any value the widget sent. $matched_offer_rule = $service->getOfferRuleMatched($date, $time, $party); if ($matched_offer_rule !== null) { $price_table = null; } // After-reservation offers: only `percentage_of_menu` defers payment to // menu-submit time (the amount depends on the menu, unknown at booking). // `fixed` / `per_seat` amounts ARE known at booking → charge normally now. $is_after_reservation = $matched_offer_rule !== null && isset($matched_offer_rule['display_position']) && $matched_offer_rule['display_position'] === 'after_reservation'; $offer_payment_type = ($matched_offer_rule !== null && isset($matched_offer_rule['payment_type'])) ? $matched_offer_rule['payment_type'] : null; $defer_payment = $is_after_reservation && $offer_payment_type === 'percentage_of_menu'; if ($defer_payment) { $isPaymentNeeded = false; } if (!$defer_payment && ($price_table > 0 || $isPaymentNeeded)) { // Charge now. For fixed/per_seat after_reservation the "pick your // menu" message is sent AFTER payment succeeds (see sendBookingEmail // → notifyAfterReservationMenuPending). $booking->status = \Alexr\Enums\BookingStatus::PENDING_PAYMENT; $booking->save(); $payment = $this->gatewayPaymentDetails($service, $booking, $price_table); $message = $service->getPaymentMessage(); } else if ($is_after_reservation) { // After-reservation with no payment at booking (percentage_of_menu, // or payment not required): send the rule's custom "pick your menu" // email/WhatsApp to the customer instead of the default status // notification (admin + dashboard notifications are kept). $message = $this->sendAfterReservationMenuNotifications($booking, $matched_offer_rule); $payment = null; } else { // Send notifications $message = $this->sendBookingNotifications($booking, false, false, false); $payment = null; } // Trigger creation event evavel_event(new \Alexr\Events\EventBookingCreated($booking)); // Save tracking data $this->saveBookingTracking($booking); // Link booking to missed call log if applicable $this->linkMissedCallLog($booking); // Send successful response $this->sendSuccessResponse($booking, $message, $payment); return true; } /** * Save modification history for a booking * Creates a copy of the original booking with DELETED status and sets the parent_booking_id * * @param \Alexr\Models\Booking $booking Original booking * @param string $uuid UUID of new booking (temporary hold) * @param string $modify_booking_uuid UUID of booking being modified * @return \Alexr\Models\Booking|null The history booking record or null */ protected function saveModificationHistory($booking, $uuid, $modify_booking_uuid) { // If the UUIDs are different, we need to create a historical record if ($uuid != $modify_booking_uuid) { $historical_booking = \Alexr\Models\Booking::where('uuid', $uuid) ->where('status', '!=', \Alexr\Enums\BookingStatus::DELETED) ->first(); if ($historical_booking) { // Copy the current booking data to the historical record $historical_booking->date = $booking->date; $historical_booking->party = $booking->party; $historical_booking->time = $booking->time; $historical_booking->widget_id = $booking->widget_id; $historical_booking->shift_event_id = $booking->shift_event_id; $historical_booking->shift_event_name = $booking->shift_event_name; $historical_booking->parent_booking_id = $booking->id; $historical_booking->customer_id = $booking->customer_id; $historical_booking->status = \Alexr\Enums\BookingStatus::DELETED; // If the original booking had custom fields, copy them if (!empty($booking->custom_fields)) { $historical_booking->custom_fields = $booking->custom_fields; } // Save other important booking data if (!empty($booking->first_name)) $historical_booking->first_name = $booking->first_name; if (!empty($booking->last_name)) $historical_booking->last_name = $booking->last_name; if (!empty($booking->email)) $historical_booking->email = $booking->email; if (!empty($booking->phone)) $historical_booking->phone = $booking->phone; if (!empty($booking->notes)) $historical_booking->notes = $booking->notes; $historical_booking->save(); // If there were tables assigned to the original booking, we should copy that information if (!empty($booking->tablesList)) { $historical_booking->tables()->sync($booking->tablesList); } return $historical_booking; } } return null; } /** * Send success response for booking operation * * @param \Alexr\Models\Booking $booking Booking object * @param string $message Message to display * @param array|null $payment Payment details */ protected function sendSuccessResponse($booking, $message, $payment) { // After-reservation menu flow: the customer still has to pick the menu // from the booking page. Flag it so the widget shows the "Menu Pending" // view and auto-redirects to the booking link (with the menu selector // opened directly via select_menu=1). $settings = is_array($booking->settings) ? $booking->settings : []; $offer_menu = isset($settings['offer_menu']) ? $settings['offer_menu'] : null; $menu_pending = is_array($offer_menu) && isset($offer_menu['menu_status']) && $offer_menu['menu_status'] === 'pending'; $menu_redirect_url = null; if ($menu_pending && function_exists('evavel_view_booking_url')) { $menu_redirect_url = add_query_arg( ['select_menu' => 1], evavel_view_booking_url($booking->uuid) ); } wp_send_json_success([ 'success' => true, 'message' => $message, 'payment' => $payment, 'booking' => [ 'id' => $booking->id, 'status' => $booking->status, 'uuid' => $booking->uuid, 'email' => $booking->email, 'shift' => $booking->shiftName, 'date' => $booking->date, 'time' => $booking->time, 'guests' => $booking->party, 'duration' => $booking->duration, 'language' => $booking->language, 'first_name' => $booking->first_name, 'last_name' => $booking->last_name, 'phone' => $booking->dial_code . $booking->phone, 'calendar_links' => $booking->getCalendarLinks(), 'menu_pending' => $menu_pending, 'menu_redirect_url' => $menu_redirect_url, ], ]); } /** * Link booking to a missed call log if the booking came from a missed call * * @param \Alexr\Models\Booking $booking * @return void */ protected function linkMissedCallLog($booking) { $missedCallId = isset($_REQUEST['missed_call']) ? intval($_REQUEST['missed_call']) : null; if (!$missedCallId) { return; } try { $log = \Alexr\Models\MissedCallLog::where('id', $missedCallId) ->where('restaurant_id', $booking->restaurant_id) ->first(); if ($log && !$log->hasBooking()) { $log->linkBooking($booking->id); $booking->missed_call_log_id = $log->id; $booking->save(); } } catch (\Exception $e) { error_log('[MissedCall] Error linking booking: ' . $e->getMessage()); } } /** * Capture the offer menu selection sent from the widget and persist it on * the booking record. The server recomputes the authoritative subtotal * from the offer items in DB so client-side prices cannot be tampered with. * * Stored under `$booking->settings['offer_menu']`: * - offer_id * - offer_rule_id * - lines (sanitised, with server prices) * - subtotal (recomputed) * - client_subtotal (informational, what the widget reported) * * @param \Alexr\Models\Booking $booking * @param object $service * * @return void */ /** * Notifications for the after-reservation offer flow. * * Replaces the default CUSTOMER status notification (email/SMS/WhatsApp) * with the per-rule custom email + custom WhatsApp template that carry the * "pick your menu" call to action and the booking link. Admin email/SMS and * the dashboard notification are kept exactly as the default flow. * * @param \Alexr\Models\Booking $booking * @param array $rule the matched after_reservation offer rule * * @return string|array widget message for the success response */ protected function sendAfterReservationMenuNotifications($booking, $rule) { $lang = isset($_REQUEST['lang']) ? sanitize_text_field($_REQUEST['lang']) : 'en'; // 1. Customer — custom "pick your menu" email + WhatsApp (shared method, // sets the pending_cta_sent idempotency guard). Falls back to the // default status email when no custom email is configured. $booking->notifyAfterReservationMenuPending($lang); // 2. Admin email + SMS (same as default flow, per status). The widget // success message uses the dedicated "Menu Pending" message that tells // the customer to pick their menu + carries the booking link. if ($booking->status == \Alexr\Enums\BookingStatus::PENDING) { $booking->sendEmailAdminPending(); $booking->sendSmsPendingAdmin(null); } else if ($booking->status == \Alexr\Enums\BookingStatus::BOOKED) { $booking->sendEmailAdminBooked(); $booking->sendSmsBookedAdmin(null); } $message = $booking->messageMenuPending($lang); // 3. Dashboard notification (same as default flow). if ($booking->restaurant) { $booking->restaurant->notify(new \Alexr\Notifications\BookingOnlineReceived($booking)); } return $message; } protected function attachOfferMenuSelection($booking, $service) { $offer_id = isset($_REQUEST['offer_id']) ? intval($_REQUEST['offer_id']) : 0; if ($offer_id <= 0) return; if (!method_exists($service, 'sanitiseMenuLines')) return; $rule_id = isset($_REQUEST['offer_rule_id']) ? sanitize_text_field($_REQUEST['offer_rule_id']) : null; $menu_pending = isset($_REQUEST['menu_pending']) && $_REQUEST['menu_pending'] === 'yes'; $settings = $booking->settings; if (!is_array($settings)) $settings = []; // After-reservation flow: the customer picks the menu later from the // booking link. Persist a pending marker + which offer applies, with // no lines/price yet. Payment (if any) is deferred to submit time. if ($menu_pending) { $settings['offer_menu'] = [ 'offer_id' => $offer_id, 'offer_rule_id' => $rule_id, 'lines' => [], 'subtotal' => 0, 'menu_status' => 'pending', ]; $booking->settings = $settings; return; } // In-booking flow (before/after form): the customer already chose the // menu during the reservation. Persist the validated lines + subtotal. $raw_lines = isset($_REQUEST['menu_lines']) ? $_REQUEST['menu_lines'] : null; if (is_string($raw_lines)) { $decoded = json_decode(stripslashes($raw_lines), true); $raw_lines = is_array($decoded) ? $decoded : []; } if (!is_array($raw_lines)) $raw_lines = []; $clean_lines = $service->sanitiseMenuLines($offer_id, $raw_lines, $booking->party); // Use the subtotal sent from the widget. The user verified the total // before pressing Continue, so it is the authoritative value here. $client_subtotal = isset($_REQUEST['menu_subtotal']) ? floatval($_REQUEST['menu_subtotal']) : 0; $settings['offer_menu'] = [ 'offer_id' => $offer_id, 'offer_rule_id' => $rule_id, 'lines' => $clean_lines, 'subtotal' => $client_subtotal, 'menu_status' => 'submitted', ]; $booking->settings = $settings; } }