boot_gateway_modules(); $this->manual_payment = $this->resolve_manual_payment(); $this->init_gateways(); add_action('bizdex_uplisting_reservation_payment', [$this, 'output_payment_fields'], 10); add_action('bizdex_uplisting_reservation_form_start', [$this, 'output_form_meta'], 5); add_action('wp_enqueue_scripts', [$this, 'enqueue_gateway_scripts'], 20); add_action('admin_init', [$this, 'register_gateway_settings'], 5); } /** * Create a reservation session and redirect URL to checkout (REST). * * @param array $input Prepared request input. * @return array{success: bool, data: array, status?: int} */ public function create_reservation_session(array $input = []): array { $property_id = isset($input['property_id']) ? absint($input['property_id']) : 0; $dates = isset($input['dates']) ? sanitize_text_field((string) $input['dates']) : ''; $adults = max(0, isset($input['adults']) && is_numeric($input['adults']) ? (int) $input['adults'] : 0); $kids = max(0, isset($input['kids']) && is_numeric($input['kids']) ? (int) $input['kids'] : 0); if ($property_id <= 0 || get_post_type($property_id) !== 'bizdexul_property') { return RestEndpointHelper::build_error_result(['message' => __('Invalid property.', 'advanced-connector-for-uplisting')]); } if ($dates === '') { return RestEndpointHelper::build_error_result(['message' => __('Please select valid check-in and check-out dates first.', 'advanced-connector-for-uplisting')]); } if (($adults + $kids) < 1) { return RestEndpointHelper::build_error_result(['message' => __('Please select guests before continuing.', 'advanced-connector-for-uplisting')]); } $parsed = BookingDetails::parse_date_range($dates); if ( empty($parsed['start_date']) || empty($parsed['end_date']) || !$this->is_ymd_date((string) $parsed['start_date']) || !$this->is_ymd_date((string) $parsed['end_date']) ) { return RestEndpointHelper::build_error_result(['message' => __('Please select valid check-in and check-out dates.', 'advanced-connector-for-uplisting')]); } $property = new PropertyDetails($property_id); if ($property->uses_uplisting_api_for_pricing()) { // PMS integration (booking_form): validate via Uplisting API. $source_id = $property->get_property_source_id(); if (!empty($source_id)) { $pricing = (new BookingDetails())->calculate_pricing( (string) $source_id, $parsed['start_date'], $parsed['end_date'], $property_id ); } else { // Missing source ID — fall back to synced calendar rates / meta. $pricing = (new BookingDetails())->calculate_local_pricing( $property, $parsed['start_date'], $parsed['end_date'] ); } } elseif ($property->uses_local_booking_fields()) { // Inquiry form (uplisting_booking_form): property meta + iCal. $pricing = (new BookingDetails())->calculate_local_pricing( $property, $parsed['start_date'], $parsed['end_date'] ); } else { return RestEndpointHelper::build_error_result(['message' => __('This property does not support online booking.', 'advanced-connector-for-uplisting')]); } if (is_wp_error($pricing)) { return RestEndpointHelper::build_error_result(['message' => $pricing->get_error_message()]); } if (empty($pricing['available'])) { return RestEndpointHelper::build_error_result(['message' => __('Selected dates are not fully available.', 'advanced-connector-for-uplisting')]); } $minimum_stay = (int) ($pricing['minimum_length_of_stay'] ?? 1); $nights_count = (int) ($pricing['nights_count'] ?? 0); if ($nights_count > 0 && $nights_count < $minimum_stay) { return RestEndpointHelper::build_error_result([ 'message' => sprintf( /* translators: %d: minimum nights required */ __('Minimum stay is %d nights.', 'advanced-connector-for-uplisting'), $minimum_stay ), ]); } $session_data = [ 'property_id' => $property_id, 'dates' => $dates, 'start_date' => $parsed['start_date'], 'end_date' => $parsed['end_date'], 'check_in' => $parsed['start_date'], 'check_out' => $parsed['end_date'], 'adults' => $adults, 'kids' => $kids, 'base_price' => $property->uses_uplisting_api_for_pricing() ? (float) ($pricing['average_day_rate'] ?? 0) : (float) $property->get_display_price(), 'created' => time(), ]; SessionHandler::instance()->set_all($session_data); return RestEndpointHelper::build_success_result([ 'redirect_url' => FrontendManager::get_reservation_page_url(), 'session_data' => $session_data, ]); } /** * Process reservation form submission and create booking (REST). * * @param array $input Prepared request input. * @return array{success: bool, data: array, status?: int} */ public function handle_booking_submission(array $input = []): array { if ($input === []) { return RestEndpointHelper::build_error_result( ['message' => __('Invalid request.', 'advanced-connector-for-uplisting')], 400 ); } $validation_error = $this->validate_submission_input($input); if ($validation_error !== '') { return RestEndpointHelper::build_error_result(['message' => $validation_error]); } $property = new PropertyDetails((int) $input['property_id']); if ($property->creates_local_site_booking_only()) { $input['payment_method'] = 'offline'; } $pricing = $this->build_submission_booking_props($input); if (is_wp_error($pricing)) { return RestEndpointHelper::build_error_result(['message' => $pricing->get_error_message()]); } $booking = new BookingDetails(); $booking->set_props($pricing['booking_props']); $booking_id = $booking->save(); if (!$booking_id) { return RestEndpointHelper::build_error_result(['message' => __('Failed to create booking. Please try again.', 'advanced-connector-for-uplisting')]); } if ($property->creates_local_site_booking_only()) { update_post_meta($booking_id, bizdex_uplisting_meta_key('_booking_submission_source'), 'local'); update_post_meta($booking_id, bizdex_uplisting_meta_key('_booking_option'), 'uplisting_booking_form'); } $checkout_gateway = $this->get_checkout_gateway($booking, $property); $payment_method = $property->creates_local_site_booking_only() ? 'offline' : $input['payment_method']; $payment_result = apply_filters( 'bizdex_uplisting_process_booking_payment', null, $payment_method, array_merge($input['payment_data'], [ 'booking_id' => $booking_id, 'total_amount' => $pricing['booking_total'], ]), $booking_id, $booking ); if ($payment_result === null) { $payment_result = $this->process_booking_payment( $payment_method, array_merge($input['payment_data'], [ 'booking_id' => $booking_id, 'total_amount' => $pricing['booking_total'], ]), $booking_id ); } if (empty($payment_result['success'])) { update_post_meta($booking_id, bizdex_uplisting_meta_key('_booking_payment_status'), 'failed'); return RestEndpointHelper::build_error_result([ 'message' => $payment_result['message'] ?? __('Payment failed.', 'advanced-connector-for-uplisting'), ]); } $gateway = $checkout_gateway ?: $this->get_gateway($payment_method); $payment_status = $payment_result['payment_status'] ?? null; if (!$payment_status && $gateway) { $payment_status = $this->resolve_gateway_payment_status($gateway); } if (!$payment_status) { $payment_status = !empty($payment_result['meta']['charge_id']) ? 'completed' : 'enquiry'; } update_post_meta($booking_id, bizdex_uplisting_meta_key('_booking_payment_status'), sanitize_text_field($payment_status)); if (!empty($payment_result['meta']['charge_id'])) { update_post_meta($booking_id, bizdex_uplisting_meta_key('_booking_stripe_charge_id'), sanitize_text_field($payment_result['meta']['charge_id'])); } /** * Fires after a booking payment has been processed. * * @param int $booking_id * @param string $payment_method * @param array $payment_result * @param BookingDetails $booking */ do_action('bizdex_uplisting_booking_payment_processed', $booking_id, $payment_method, $payment_result, $booking); /** * Fires after a reservation is successfully created and payment processed. * EmailManager listens and sends customer confirmation + admin new booking. * * @param int $booking_id * @param BookingDetails $booking * @param array $payment_result */ do_action('bizdex_uplisting_after_reservation', $booking_id, $booking, $payment_result); $confirmation_key = ''; $redirect_url = ''; $saved_booking = new BookingDetails($booking_id); $success_page_id = FrontendManager::get_reservation_success_page_id(); if ($success_page_id) { $confirmation_key = $saved_booking->create_confirmation_key(); if ($confirmation_key !== '') { $redirect_url = FrontendManager::get_reservation_success_page_url($booking_id, $confirmation_key); } } SessionHandler::instance()->destroy(); $success_message = $gateway ? $this->resolve_gateway_success_message($gateway) : __('Booking created successfully!', 'advanced-connector-for-uplisting'); $success_message = apply_filters( 'bizdex_uplisting_booking_submission_success_message', $success_message, $booking_id, $payment_method, $booking ); $response_data = [ 'message' => $success_message, 'booking_id' => $booking_id, ]; if ($redirect_url !== '') { $response_data['redirect_url'] = $redirect_url; } return RestEndpointHelper::build_success_result($response_data); } /** * Called at admin_init. * Registers each gateway's WP options and hooks its admin_fields() * method into the 'uplisting_gateway_settings_{id}' action so the * payment-settings.php view can fire it without knowing the gateway type. */ public function register_gateway_settings(): void { register_setting('bizdex_uplisting_settings_group', 'bizdex_uplisting_active_gateway', [ 'sanitize_callback' => [$this, 'sanitize_active_gateway_option'], ]); foreach ($this->gateways as $id => $gateway) { if (method_exists($gateway, 'register_settings')) { $gateway->register_settings(); } if (method_exists($gateway, 'admin_fields')) { add_action( 'bizdex_uplisting_gateway_settings_' . $id, [$gateway, 'admin_fields'], 10); } } } /** * Sanitize the active payment gateway option value. * * @param mixed $value Raw option value from settings form. */ public function sanitize_active_gateway_option($value): string { $value = sanitize_text_field((string) $value); if ($this->is_manual_gateway_id($value)) { return ''; } if ($value !== '' && !$this->get_gateway($value)) { return ''; } return $value; } /** * Load optional online payment gateways. * * Filter `uplisting_payment_gateways` receives gateway class names (strings) * or pre-built {@see AbstractPaymentGateway} instances. */ protected function init_gateways(): void { $load_gateways = apply_filters('bizdex_uplisting_payment_gateways', []); foreach ($load_gateways as $gateway) { if (is_string($gateway)) { if (!class_exists($gateway)) { continue; } $gateway = new $gateway(); } if (!$gateway instanceof AbstractPaymentGateway) { continue; } $this->gateways[$gateway->get_id()] = $gateway; } } /** * Return optional online gateways registered for admin/checkout. * * @return AbstractPaymentGateway[] */ public function get_gateways(): array { return $this->gateways; } /** * Built-in manual payment handler. */ public function get_manual_payment(): AbstractPaymentGateway { return $this->manual_payment; } /** * Resolve the built-in manual payment module. */ protected function resolve_manual_payment(): AbstractPaymentGateway { $handler = apply_filters('bizdex_uplisting_manual_payment_handler', null); if ($handler instanceof AbstractPaymentGateway) { return $handler; } return new ManualPayment(); } /** * Whether checkout should use built-in manual payment (no online gateway). */ public function uses_manual_payment(): bool { return $this->get_active_online_gateway_id() === ''; } /** * Stored online gateway ID (empty string = manual payment). */ public function get_active_online_gateway_id(): string { $active = get_option('bizdex_uplisting_active_gateway', ''); if ($this->is_manual_gateway_id($active)) { if ($active !== '') { update_option('bizdex_uplisting_active_gateway', ''); } return ''; } if ($active !== '' && $this->get_gateway($active)) { return (string) $active; } if ($active !== '') { update_option('bizdex_uplisting_active_gateway', ''); } return ''; } /** * Whether the gateway ID represents the manual/offline payment method. * * @param string $id Gateway slug. */ private function is_manual_gateway_id(string $id): bool { return in_array($id, ['', 'offline', 'manual', 'enquiry'], true); } /** * Return a registered gateway by ID. */ public function get_gateway(string $id): ?AbstractPaymentGateway { if ($this->is_manual_gateway_id($id)) { return $this->manual_payment; } return $this->gateways[$id] ?? null; } /** * Process payment for a saved booking through the matching gateway. * * @param string $payment_method Gateway slug from the form. * @param array $posted_data Sanitized payment payload. * @param int $booking_id Saved booking post ID. * @return array{success: bool, message: string, payment_status: string, meta: array} */ public function process_booking_payment(string $payment_method, array $posted_data, int $booking_id): array { if (empty($payment_method) || $payment_method === 'none') { return $this->default_payment_result('pending'); } $gateway = $this->get_gateway($payment_method); if (!$gateway) { return [ 'success' => false, 'message' => __('Invalid payment method selected.', 'advanced-connector-for-uplisting'), 'payment_status' => 'failed', 'meta' => [], ]; } $posted_data['booking_id'] = $booking_id; $result = $gateway->process_payment($posted_data); if (empty($result['success'])) { return [ 'success' => false, 'message' => $result['message'] ?? __('Payment failed.', 'advanced-connector-for-uplisting'), 'payment_status' => 'failed', 'meta' => is_array($result['data'] ?? null) ? $result['data'] : [], ]; } $payment_status = 'pending'; if (!empty($result['data']['payment_status'])) { $payment_status = (string) $result['data']['payment_status']; } elseif (!empty($result['data']['charge_id'])) { $payment_status = 'completed'; } elseif ($gateway) { $payment_status = $this->resolve_gateway_payment_status($gateway); } return [ 'success' => true, 'message' => $result['message'] ?? '', 'payment_status' => $payment_status, 'meta' => is_array($result['data'] ?? null) ? $result['data'] : [], ]; } /** * Resolve the checkout gateway ID exposed to templates and JavaScript. * * Returns `offline` for built-in manual payment. * * Filter: uplisting_default_active_gateway */ public function get_active_gateway_id(): string { $online_id = $this->get_active_online_gateway_id(); if ($online_id !== '') { return $online_id; } return (string) apply_filters('bizdex_uplisting_default_active_gateway', 'offline'); } /** * Return booking payment statuses available in admin. * * Filter: uplisting_booking_payment_statuses * * @return array */ public static function get_booking_payment_statuses(): array { return apply_filters('bizdex_uplisting_booking_payment_statuses', [ 'enquiry' => __('Enquiry', 'advanced-connector-for-uplisting'), 'pending' => __('Pending', 'advanced-connector-for-uplisting'), 'completed' => __('Completed', 'advanced-connector-for-uplisting'), 'failed' => __('Failed', 'advanced-connector-for-uplisting'), 'cancelled' => __('Cancelled', 'advanced-connector-for-uplisting'), ]); } /** * Dashicon slug for a booking payment status badge. * * Filter: uplisting_booking_payment_status_icons */ public static function get_booking_payment_status_icon(string $status): string { $icons = apply_filters('bizdex_uplisting_booking_payment_status_icons', [ 'enquiry' => 'email-alt', 'completed' => 'yes-alt', 'pending' => 'clock', 'failed' => 'warning', 'cancelled' => 'dismiss', ]); return (string) ($icons[$status] ?? 'marker'); } /** * Whether checkout should render visible payment UI for the active gateway. * * @param BookingDetails|null $booking */ public function checkout_shows_payment_section($booking = null): bool { $gateway = $this->get_checkout_gateway($booking); if (!$gateway) { return false; } return (bool) apply_filters( 'bizdex_uplisting_show_payment_section', $gateway->shows_checkout_section(), $gateway, $booking ); } /** * Gateway used on checkout for a property/booking context. * * `uplisting_booking_form` always uses offline (local site booking only). * * @param BookingDetails|null $booking * @param PropertyDetails|null $property */ public function get_checkout_gateway($booking = null, ?PropertyDetails $property = null): ?AbstractPaymentGateway { if (!$property instanceof PropertyDetails || !$property->get_id()) { $property_id = 0; if ($booking instanceof BookingDetails) { $property_id = (int) $booking->get_prop('property_id'); } if ($property_id > 0) { $property = new PropertyDetails($property_id); } } if ($property instanceof PropertyDetails && $property->get_id() && $property->creates_local_site_booking_only()) { return apply_filters( 'bizdex_uplisting_local_booking_checkout_gateway', $this->manual_payment, $property, $booking ); } $online_id = $this->get_active_online_gateway_id(); if ($online_id !== '') { $gateway = $this->get_gateway($online_id); if ($gateway && $gateway->is_enabled()) { return $gateway; } } return $this->manual_payment; } /** * Build a default payment result payload for the given payment status. * * @return array{success: bool, message: string, payment_status: string, meta: array} */ private function default_payment_result(string $payment_status): array { return [ 'success' => true, 'message' => '', 'payment_status' => $payment_status, 'meta' => [], ]; } /** * Return the single active/enabled gateway. * * @return AbstractPaymentGateway|null */ public function get_active_gateway(): ?AbstractPaymentGateway { return $this->get_checkout_gateway(); } /** * Output hidden meta fields at form open (nonce, token, IDs). * Fires on: uplisting_reservation_form_start * * @param BookingDetails|null $booking */ public function output_form_meta($booking = null) { if ($booking instanceof BookingDetails) { $property_id = (int) $booking->get_prop('property_id'); if ($property_id > 0) { echo ''; } } $gateway = $this->get_checkout_gateway($booking); if ($gateway && method_exists($gateway, 'output_form_meta')) { $gateway->output_form_meta($booking); } } /** * Output the payment fields for the active gateway. * Fires on: uplisting_reservation_payment * * @param BookingDetails|null $booking */ public function output_payment_fields($booking = null) { $gateway = $this->get_checkout_gateway($booking); if (!$gateway) { return; } $show_section = (bool) apply_filters( 'bizdex_uplisting_show_payment_section', $gateway->shows_checkout_section(), $gateway, $booking ); if (!$show_section) { do_action('bizdex_uplisting_payment_section_hidden', $gateway, $booking); $this->output_gateway_hidden_fields($gateway, $booking); return; } if (!method_exists($gateway, 'payment_fields')) { return; } bizdex_uplisting_get_template_part('payment/payment-section', '', [ 'gateway' => $gateway, 'booking' => $booking, ]); } /** * Enqueue scripts for all enabled gateways. */ public function enqueue_gateway_scripts() { if (!FrontendManager::is_reservation_page()) { return; } $checkout_gateway = $this->get_checkout_gateway(); if ($checkout_gateway && method_exists($checkout_gateway, 'enqueue_scripts')) { $checkout_gateway->enqueue_scripts(); } } /** * Require each gateway subdirectory bootstrap.php once. */ private function boot_gateway_modules(): void { static $booted = false; if ($booted) { return; } $booted = true; $gateways_path = BIZDEX_UPLISTING_PLUGIN_DIR . 'src/Gateways/'; if (!is_dir($gateways_path)) { return; } $dirs = glob($gateways_path . '*', GLOB_ONLYDIR); if (!$dirs) { return; } foreach ($dirs as $dir) { $bootstrap = $dir . '/bootstrap.php'; if (is_readable($bootstrap)) { require_once $bootstrap; } } } /** * Resolve booking payment status for a gateway via filter. */ private function resolve_gateway_payment_status(AbstractPaymentGateway $gateway): string { return (string) apply_filters( 'bizdex_uplisting_gateway_booking_payment_status_' . $gateway->get_id(), $gateway->booking_payment_status(), $gateway ); } /** * Resolve booking submission success message for a gateway via filter. */ private function resolve_gateway_success_message(AbstractPaymentGateway $gateway): string { return (string) apply_filters( 'bizdex_uplisting_gateway_booking_success_message_' . $gateway->get_id(), $gateway->booking_success_message(), $gateway ); } /** * Output hidden payment fields when the gateway hides its checkout section. * * @param BookingDetails|null $booking */ private function output_gateway_hidden_fields(AbstractPaymentGateway $gateway, $booking = null): void { echo ''; do_action('bizdex_uplisting_gateway_hidden_payment_fields', $gateway, $booking); } /** * Parse and sanitize booking form data from a raw input array. * * Nonce must be verified by the caller before invoking this method. * * @param array $raw Raw POST or prepared input values. * @return array */ private function parse_submission_input(array $raw): array { $property_id = isset($raw['property_id']) && is_numeric($raw['property_id']) ? absint($raw['property_id']) : 0; $property = null; if ($property_id > 0 && get_post_type($property_id) === 'bizdexul_property') { $property = new PropertyDetails($property_id); } $default_gateway = $property && $property->creates_local_site_booking_only() ? 'offline' : $this->get_active_gateway_id(); $allowed_gateways = array_values(array_unique(array_filter(array_merge( ['offline', $this->get_manual_payment()->get_id()], array_keys($this->get_gateways()) )))); $raw_payment_method = $this->raw_scalar($raw, 'payment_method'); $payment_method_key = $raw_payment_method !== null ? sanitize_key($raw_payment_method) : ''; $payment_method = ($payment_method_key !== '' && in_array($payment_method_key, $allowed_gateways, true)) ? $payment_method_key : $default_gateway; $payment_method_id = $this->text_from_raw($raw, 'payment_method_id'); $email = $this->email_from_raw($raw, 'email'); return [ 'property_id' => $property_id, 'first_name' => $this->text_from_raw($raw, 'first_name'), 'last_name' => $this->text_from_raw($raw, 'last_name'), 'email' => $email, 'phone' => $this->text_from_raw($raw, 'phone'), 'check_in' => $this->ymd_from_raw($raw, 'check_in'), 'check_out' => $this->ymd_from_raw($raw, 'check_out'), 'adults' => max(0, $this->int_from_raw($raw, 'adults')), 'kids' => max(0, $this->int_from_raw($raw, 'kids')), 'trip_details' => $this->textarea_from_raw($raw, 'trip_details'), 'special_request' => $this->textarea_from_raw($raw, 'special_request'), 'referral' => BookingDetails::sanitize_meta_value('referral', $this->text_from_raw($raw, 'referral')), 'total_amount' => max(0.0, $this->float_from_raw($raw, 'total_amount')), 'nights_count' => max(0, $this->int_from_raw($raw, 'nights_count')), 'nights_total' => max(0.0, $this->float_from_raw($raw, 'nights_total')), 'addons_total' => max(0.0, $this->float_from_raw($raw, 'addons_total')), 'cleaning_fee' => max(0.0, $this->float_from_raw($raw, 'cleaning_fee')), 'taxes' => max(0.0, $this->float_from_raw($raw, 'taxes')), 'payment_method' => $payment_method !== '' ? $payment_method : $default_gateway, 'payment_method_id' => $payment_method_id, 'payment_data' => [ 'payment_method_id' => $payment_method_id, 'email' => $email, 'property_id' => $property_id, ], ]; } /** * Validate booking submission input and return an error message, or empty on success. * * @param array $input */ private function validate_submission_input(array $input): string { if ( empty($input['property_id']) || $input['first_name'] === '' || $input['last_name'] === '' || $input['email'] === '' || $input['phone'] === '' ) { return __('Please fill in all required fields.', 'advanced-connector-for-uplisting'); } if (get_post_type((int) $input['property_id']) !== 'bizdexul_property') { return __('Invalid property.', 'advanced-connector-for-uplisting'); } if (!is_email((string) $input['email'])) { return __('Please enter a valid email address.', 'advanced-connector-for-uplisting'); } if ( empty($input['check_in']) || empty($input['check_out']) || !$this->is_ymd_date((string) $input['check_in']) || !$this->is_ymd_date((string) $input['check_out']) ) { return __('Please select check-in and check-out dates.', 'advanced-connector-for-uplisting'); } if (strtotime((string) $input['check_out']) <= strtotime((string) $input['check_in'])) { return __('Check-out must be after check-in.', 'advanced-connector-for-uplisting'); } if (((int) $input['adults'] + (int) $input['kids']) < 1) { return __('Please select guests before continuing.', 'advanced-connector-for-uplisting'); } $allowed_gateways = array_merge( ['offline', $this->get_manual_payment()->get_id()], array_keys($this->get_gateways()) ); $allowed_gateways = array_values(array_unique(array_filter($allowed_gateways))); if (!in_array((string) $input['payment_method'], $allowed_gateways, true)) { return __('Invalid payment method.', 'advanced-connector-for-uplisting'); } return ''; } /** * Build booking props from submitted form data. * * `booking_form` uses Uplisting API pricing; `uplisting_booking_form` uses local meta. * * @param array $input * @return array|\WP_Error */ private function build_submission_booking_props(array $input) { if (empty($input['property_id']) || get_post_type($input['property_id']) !== 'bizdexul_property') { return new \WP_Error('invalid_property', __('Invalid property.', 'advanced-connector-for-uplisting')); } $property = new PropertyDetails((int) $input['property_id']); if ($property->creates_local_site_booking_only()) { return $this->build_local_submission_booking_props($input, $property); } if ($property->uses_uplisting_api_for_pricing()) { return $this->build_api_submission_booking_props($input, $property); } return $this->build_local_submission_booking_props($input, $property); } /** * Build booking props using Uplisting API calendar pricing (PMS mode). * * @param array $input * @param PropertyDetails $property * @return array|\WP_Error */ private function build_api_submission_booking_props(array $input, PropertyDetails $property) { $property_source_id = $property->get_meta('property_source_id'); if (empty($property_source_id)) { return new \WP_Error('missing_source_id', __('Property is missing Uplisting source ID.', 'advanced-connector-for-uplisting')); } $max_guests = (int) $property->get_meta('guests'); $requested_guests = (int) $input['adults'] + (int) $input['kids']; if ($max_guests > 0 && $requested_guests > $max_guests) { return new \WP_Error( 'capacity_exceeded', sprintf( /* translators: %d: maximum number of guests allowed */ __('Maximum capacity is %d guests.', 'advanced-connector-for-uplisting'), $max_guests ) ); } $booking_helper = new BookingDetails(); $api_pricing = $booking_helper->calculate_pricing( (string) $property_source_id, $input['check_in'], $input['check_out'], $property->get_id() ); if (is_wp_error($api_pricing)) { return $api_pricing; } if (empty($api_pricing['available'])) { return new \WP_Error('unavailable', __('Selected dates are not fully available.', 'advanced-connector-for-uplisting')); } $nights_count = (int) ($api_pricing['nights_count'] ?? 0); $minimum_stay = (int) ($api_pricing['minimum_length_of_stay'] ?? 1); if ($nights_count > 0 && $nights_count < $minimum_stay) { return new \WP_Error( 'minimum_stay', sprintf( /* translators: %d: minimum nights required */ __('Minimum stay is %d nights.', 'advanced-connector-for-uplisting'), $minimum_stay ) ); } $pricing_breakdown = $api_pricing['pricing_breakdown'] ?? []; $nights_total = (float) ($api_pricing['nights_total'] ?? 0); $nights_count = (int) ($api_pricing['nights_count'] ?? 0); $average_day_rate = (float) ($api_pricing['average_day_rate'] ?? ($nights_count > 0 ? $nights_total / $nights_count : 0)); if (empty($pricing_breakdown['valid']) && $nights_count > 0 && $nights_total > 0) { $pricing_breakdown = $booking_helper->get_pricing_breakdown($property, [ 'nights' => $nights_count, 'nights_total' => $nights_total, 'average_day_rate' => $average_day_rate, 'for_checkout' => true, ]); } if (!empty($pricing_breakdown['valid'])) { $pricing_breakdown = $booking_helper->apply_extra_charges_policy($pricing_breakdown, $property, [ 'for_checkout' => true, ]); } $booking_total = (float) ($pricing_breakdown['grand_total'] ?? $nights_total); $discount_amount = (float) ($pricing_breakdown['discounts'] ?? 0); $booking_props = [ 'property_id' => $input['property_id'], 'first_name' => $input['first_name'], 'last_name' => $input['last_name'], 'email' => $input['email'], 'phone' => $input['phone'], 'check_in' => $input['check_in'], 'check_out' => $input['check_out'], 'adults' => $input['adults'], 'kids' => $input['kids'], 'trip_details' => $input['trip_details'], 'special_request' => $input['special_request'], 'referral' => $input['referral'], 'total_amount' => $booking_total, 'nights_count' => $nights_count, 'nights_total' => $nights_total, 'nights_total_after_discount' => $nights_total - $discount_amount, 'discount_amount' => $discount_amount, 'booking_total' => $booking_total, 'payment_method' => $input['payment_method'], ]; $booking_props = array_merge($booking_props, $this->pricing_charge_meta_from_breakdown($pricing_breakdown)); return [ 'booking_total' => $booking_total, 'booking_props' => $booking_props, ]; } /** * Build booking props using local property meta and synced busy dates. * * @param array $input * @param PropertyDetails $property * @return array|\WP_Error */ private function build_local_submission_booking_props(array $input, PropertyDetails $property) { $availability = $this->check_local_availability( $property->get_id(), $input['check_in'], $input['check_out'] ); if (is_wp_error($availability)) { return $availability; } $max_guests = (int) $property->get_meta('guests'); $requested_guests = (int) $input['adults'] + (int) $input['kids']; if ($max_guests > 0 && $requested_guests > $max_guests) { return new \WP_Error( 'capacity_exceeded', sprintf( /* translators: %d: maximum number of guests allowed */ __('Maximum capacity is %d guests.', 'advanced-connector-for-uplisting'), $max_guests ) ); } $nights_count = (int) $input['nights_count']; if ($nights_count <= 0) { $nights_count = BookingDetails::count_booking_nights($input['check_in'], $input['check_out']); } $booking_helper = new BookingDetails(); $local_pricing = $booking_helper->calculate_local_pricing( $property, $input['check_in'], $input['check_out'] ); if (empty($local_pricing['available'])) { return new \WP_Error('unavailable', __('Selected dates are not fully available.', 'advanced-connector-for-uplisting')); } $nights_total = (float) ($local_pricing['nights_total'] ?? 0); $nights_count = (int) ($local_pricing['nights_count'] ?? $nights_count); $average_day_rate = (float) ($local_pricing['average_day_rate'] ?? 0); $pricing_breakdown = $booking_helper->get_pricing_breakdown($property, [ 'nights' => $nights_count, 'nights_total' => $nights_total, 'average_day_rate' => $average_day_rate, 'for_checkout' => true, ]); if (!empty($pricing_breakdown['valid'])) { $pricing_breakdown = $booking_helper->apply_extra_charges_policy($pricing_breakdown, $property, [ 'for_checkout' => true, ]); } $nights_total = (float) ($pricing_breakdown['nights_total'] ?? $nights_total); $booking_total = (float) ($pricing_breakdown['grand_total'] ?? 0); $discount_amount = (float) ($pricing_breakdown['discounts'] ?? 0); if ($booking_total <= 0 || $nights_total <= 0) { return new \WP_Error( 'invalid_pricing', __('Unable to calculate booking total for the selected dates.', 'advanced-connector-for-uplisting') ); } $nights_total_after_discount = $nights_total - $discount_amount; $booking_props = [ 'property_id' => $input['property_id'], 'first_name' => $input['first_name'], 'last_name' => $input['last_name'], 'email' => $input['email'], 'phone' => $input['phone'], 'check_in' => $input['check_in'], 'check_out' => $input['check_out'], 'adults' => $input['adults'], 'kids' => $input['kids'], 'trip_details' => $input['trip_details'], 'special_request' => $input['special_request'], 'referral' => $input['referral'], 'total_amount' => $booking_total, 'nights_count' => $nights_count, 'nights_total' => $nights_total, 'nights_total_after_discount' => $nights_total_after_discount, 'discount_amount' => $discount_amount, 'booking_total' => $booking_total, 'payment_method' => 'offline', 'submission_source' => 'local', ]; $booking_props = array_merge($booking_props, $this->pricing_charge_meta_from_breakdown($pricing_breakdown)); return [ 'booking_total' => $booking_total, 'booking_props' => $booking_props, ]; } /** * Persist server-calculated fees even when they are zero so admin matches checkout. * * @param array $pricing_breakdown * @return array */ private function pricing_charge_meta_from_breakdown(array $pricing_breakdown): array { return [ 'addons_total' => (float) ($pricing_breakdown['addons_total'] ?? 0), 'cleaning_fee' => (float) ($pricing_breakdown['cleaning_fee'] ?? 0), 'extra_fees' => (float) ($pricing_breakdown['extra_fees'] ?? 0), 'taxes' => (float) ($pricing_breakdown['taxes_total'] ?? 0), 'security_deposit' => (float) ($pricing_breakdown['security_deposit'] ?? 0), ]; } /** * Check availability using locally synced busy dates (no Uplisting API call). * * @param int $property_post_id WordPress property post ID. * @param string $check_in Y-m-d * @param string $check_out Y-m-d * @return true|\WP_Error */ private function check_local_availability(int $property_post_id, string $check_in, string $check_out) { $property = new PropertyDetails($property_post_id); if (!$property->get_id()) { return new \WP_Error('invalid_property', __('Invalid property.', 'advanced-connector-for-uplisting')); } if (BookingDetails::stay_overlaps_busy_dates($check_in, $check_out, $property->get_busy_dates())) { return new \WP_Error('unavailable', __('Selected dates are not fully available.', 'advanced-connector-for-uplisting')); } return true; } /** * Send booking confirmation emails to guest and admin. * * @param int $booking_id * @return bool True when both emails were accepted by wp_mail(). */ public function send_booking_emails($booking_id): bool { return EmailManager::instance()->send_booking_emails((int) $booking_id); } /** * Send booking cancelled email to the guest. * * @param int $booking_id * @return bool */ public function send_cancelled_email($booking_id): bool { return EmailManager::instance()->send_cancelled_email((int) $booking_id); } /** * Send payment received email to the guest. * * @param int $booking_id * @return bool */ public function send_payment_received_email($booking_id): bool { return EmailManager::instance()->send_payment_received_email((int) $booking_id); } /** * Validate YYYY-MM-DD date string. */ private function is_ymd_date(string $date): bool { if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { return false; } $parts = explode('-', $date); return checkdate((int) $parts[1], (int) $parts[2], (int) $parts[0]); } /** * Read a raw scalar string from a request bag, or null when missing. * * @param array $raw */ private function raw_scalar(array $raw, string $key): ?string { if (!array_key_exists($key, $raw)) { return null; } $value = $raw[$key]; if ($value === null || is_array($value) || is_object($value)) { return null; } return (string) wp_unslash((string) $value); } /** * Sanitize a single-line text field from a raw request bag. * * @param array $raw */ private function text_from_raw(array $raw, string $key, string $default = ''): string { $value = $this->raw_scalar($raw, $key); if ($value === null) { return $default; } return sanitize_text_field($value); } /** * Sanitize a multi-line text field from a raw request bag. * * @param array $raw */ private function textarea_from_raw(array $raw, string $key, string $default = ''): string { $value = $this->raw_scalar($raw, $key); if ($value === null) { return $default; } return sanitize_textarea_field($value); } /** * Sanitize an email field from a raw request bag. * * @param array $raw */ private function email_from_raw(array $raw, string $key, string $default = ''): string { $value = $this->raw_scalar($raw, $key); if ($value === null) { return $default; } $email = sanitize_email($value); if ($email === '' || !is_email($email)) { return $default; } return $email; } /** * Sanitize an integer field from a raw request bag. * * @param array $raw */ private function int_from_raw(array $raw, string $key, int $default = 0): int { $value = $this->raw_scalar($raw, $key); if ($value === null || !is_numeric($value)) { return $default; } return (int) $value; } /** * Sanitize a float field from a raw request bag. * * @param array $raw */ private function float_from_raw(array $raw, string $key, float $default = 0.0): float { $value = $this->raw_scalar($raw, $key); if ($value === null || !is_numeric($value)) { return $default; } return (float) $value; } /** * Sanitize a Y-m-d date field from a raw request bag. * * @param array $raw */ private function ymd_from_raw(array $raw, string $key, string $default = ''): string { $value = $this->text_from_raw($raw, $key, ''); if ($value === '' || !$this->is_ymd_date($value)) { return $default; } return $value; } }