0) { $this->post = get_post($post); } elseif ($post instanceof \WP_Post) { $this->post = $post; } elseif ($post === null) { // New empty booking object $this->post = null; } else { global $post; $this->post = $post; } } /** * Pending in-memory property values not yet saved to the database. * * Cleared after a successful {@see save()} call. * * @var array */ private $props = []; /** * Stage multiple booking fields in memory before persisting. * * Keys should match logical meta names (e.g. `first_name`, `check_in`). * Values take precedence over stored meta when read via {@see get_prop()} * or {@see get_meta()} until {@see save()} clears this array. * * @param array $props Key-value pairs to merge into `$this->props`. * @return void */ public function set_props($props) { foreach ($props as $key => $value) { $this->props[$key] = $value; } } /** * Insert or update the booking post and all associated meta. * * For new bookings: * - Creates a `booking` CPT post with a title derived from guest + property name. * - Fires `uplisting_booking_submission_before_insert` and `_after_insert`. * * For all saves: * - Persists props as post meta (`_booking_{key}`). * - Applies filters `uplisting_insert_booking_data` and `uplisting_booking_meta_fields`. * - Fires `uplisting_booking_submission_custom_data` for extensions. * * @return int|false Booking post ID on success, false if post insertion fails. */ public function save() { $is_new = false; if (!$this->get_id()) { $is_new = true; $first_name = sanitize_text_field((string) ($this->props['first_name'] ?? '')); $last_name = sanitize_text_field((string) ($this->props['last_name'] ?? '')); $property_title = ''; if (!empty($this->props['property_id'])) { $property_title = sanitize_text_field((string) get_the_title(absint($this->props['property_id']))); } $booking_title = trim($first_name . ' ' . $last_name . ' - ' . $property_title); if ($booking_title === '' || $booking_title === '-') { $booking_title = 'Booking Order'; } $booking_data = [ 'post_title' => sanitize_text_field($booking_title), 'post_type' => 'bizdexul_booking', 'post_status' => 'publish', 'post_author' => 1, ]; // Filter booking post data $booking_data = apply_filters('bizdex_uplisting_insert_booking_data', $booking_data, $this->props); // Action before insertion do_action('bizdex_uplisting_booking_submission_before_insert', $booking_data, $this->props); $booking_id = wp_insert_post($booking_data); if (is_wp_error($booking_id)) { bizdex_uplisting_log('Booking insert failed: ' . $booking_id->get_error_message()); return false; } if (empty($booking_id)) { return false; } $this->post = get_post($booking_id); if (!isset($this->props['created_at'])) { $this->props['created_at'] = current_time('mysql'); } // Action after post insertion do_action('bizdex_uplisting_booking_submission_after_insert', $booking_id, $this->props); } // Save meta from sanitized booking props only (never raw $_POST). $meta_fields = apply_filters('bizdex_uplisting_booking_meta_fields', $this->props, $this->get_id()); foreach ($meta_fields as $key => $value) { $key = sanitize_key((string) $key); if ($key === '') { continue; } update_post_meta($this->get_id(), bizdex_uplisting_meta_key('_booking_' . $key), self::sanitize_meta_value($key, $value)); } // Action for custom booking data — receive sanitized props, not raw request. do_action('bizdex_uplisting_booking_submission_custom_data', $this->get_id(), $this->props); $this->props = []; // Clear props after save return $this->get_id(); } /** * Sanitize a booking meta value by known field type. * * @param string $key Meta key without `_booking_` prefix. * @param mixed $value Raw value. * @return mixed */ public static function sanitize_meta_value(string $key, $value) { $int_keys = [ 'property_id', 'adults', 'kids', 'nights_count', 'pms_booking_id', ]; $float_keys = [ 'total_amount', 'booking_total', 'nights_total', 'nights_total_after_discount', 'addons_total', 'cleaning_fee', 'extra_fees', 'discount_amount', 'taxes', 'tax_amount', 'security_deposit', ]; $email_keys = ['email']; $textarea_keys = ['trip_details', 'special_request', 'admin_notes', 'pms_api_error']; $ymd_keys = ['check_in', 'check_out']; $referral_allowed = ['google', 'facebook', 'instagram', 'friend', 'other']; if (in_array($key, $int_keys, true)) { return absint($value); } if (in_array($key, $float_keys, true)) { return is_numeric($value) ? (float) $value : 0.0; } if (in_array($key, $email_keys, true)) { $email = sanitize_email((string) $value); return is_email($email) ? $email : ''; } if (in_array($key, $textarea_keys, true)) { return sanitize_textarea_field((string) $value); } if (in_array($key, $ymd_keys, true)) { $date = sanitize_text_field((string) $value); return Request::is_ymd_date($date) ? $date : ''; } if ($key === 'referral') { $referral = sanitize_key((string) $value); return in_array($referral, $referral_allowed, true) ? $referral : ''; } if (is_array($value)) { return map_deep($value, static function ($item) { if (is_bool($item) || is_numeric($item)) { return $item; } return sanitize_text_field((string) $item); }); } return sanitize_text_field((string) $value); } /** * Get the WordPress post ID for this booking. * * @return int Post ID, or `0` when no post is loaded (new/empty booking). */ public function get_id() { return $this->post ? $this->post->ID : 0; } /** * Read a booking field from in-memory props or persisted post meta. * * Checks `$this->props` first (unsaved changes), then delegates to * {@see get_meta()}. Use this in forms/checkout where staged data * may not yet be written to the database. * * @param string $key Logical meta key without the `_booking_` prefix. * @param mixed $default Value returned when the key is missing or empty. * @return mixed */ public function get_prop($key, $default = '') { if (isset($this->props[$key])) { return $this->props[$key]; } return $this->get_meta($key, $default); } /** * Read a booking meta value directly from the database. * * Stored as post meta key `_booking_{$key}` (e.g. `_booking_email`). * In-memory props override the database value when present. * * @param string $key Logical meta key without prefix (e.g. `first_name`). * @param mixed $default Fallback when no post ID exists or value is empty string. * @return mixed */ public function get_meta($key, $default = '') { if (isset($this->props[$key])) { return $this->props[$key]; } if (!$this->get_id()) { return $default; } return bizdex_uplisting_get_post_meta((int) $this->get_id(), '_booking_' . $key, $default); } /** * Get the linked property post ID. * * Meta key: `_booking_property_id`. * * @return int|string Property post ID, or empty string if unset. */ public function get_property_id() { return $this->get_meta('property_id'); } /** * Get the guest's first name. * * Meta key: `_booking_first_name`. * * @return string */ public function get_first_name() { return $this->get_meta('first_name'); } /** * Get the guest's last name. * * Meta key: `_booking_last_name`. * * @return string */ public function get_last_name() { return $this->get_meta('last_name'); } /** * Get the guest's email address. * * Meta key: `_booking_email`. * * @return string */ public function get_email() { return $this->get_meta('email'); } /** * Get the guest's phone number. * * Meta key: `_booking_phone`. * * @return string */ public function get_phone() { return $this->get_meta('phone'); } /** * Get the check-in date string submitted with the booking. * * Meta key: `_booking_check_in`. Format depends on frontend input (typically Y-m-d). * * @return string */ public function get_check_in() { return $this->get_meta('check_in'); } /** * Get the check-out date string submitted with the booking. * * Meta key: `_booking_check_out`. Checkout day is exclusive in night calculations. * * @return string */ public function get_check_out() { return $this->get_meta('check_out'); } /** * Human-readable stay date range from saved booking meta. * * @return string */ public function get_stay_date_range_display(): string { $dates = (string) $this->get_meta('dates'); if ($dates !== '') { return $dates; } $check_in = (string) $this->get_check_in(); $check_out = (string) $this->get_check_out(); if ($check_in !== '' && $check_out !== '') { return $check_in . ' to ' . $check_out; } return ''; } /** * Create and persist a one-time confirmation key for the success page. * * @return string Empty when the booking has not been saved yet. */ public function create_confirmation_key(): string { $booking_id = (int) $this->get_id(); if ($booking_id <= 0) { return ''; } $key = wp_generate_password(32, false, false); update_post_meta($booking_id, bizdex_uplisting_meta_key('_booking_confirmation_key'), $key); return $key; } /** * Validate a booking confirmation key from the success page query string. * * @param int $booking_id Booking post ID. * @param string $key Confirmation key from the redirect URL. */ public static function validate_confirmation_key(int $booking_id, string $key): bool { if ($booking_id <= 0 || !preg_match('/^[A-Za-z0-9]{32}$/', $key) || get_post_type($booking_id) !== 'bizdexul_booking') { return false; } $stored = bizdex_uplisting_get_post_meta($booking_id, '_booking_confirmation_key', true); return is_string($stored) && $stored !== '' && hash_equals($stored, $key); } /** * Get the number of adult guests. * * Meta key: `_booking_adults`. * * @return int|string */ public function get_adults() { return $this->get_meta('adults'); } /** * Get the number of child guests. * * Meta key: `_booking_kids`. * * @return int|string */ public function get_kids() { return $this->get_meta('kids'); } /** * Get the stored night count for this booking. * * Meta key: `_booking_nights_count`. May differ from {@see get_effective_nights()} * when calculated live from a date range string. * * @return int|string */ public function get_nights_count() { return $this->get_meta('nights_count'); } /** * Get free-text trip details from the reservation form. * * Meta key: `_booking_trip_details`. * * @return string */ public function get_trip_details() { return $this->get_meta('trip_details'); } /** * Get the guest's special request message. * * Meta key: `_booking_special_request`. * * @return string */ public function get_special_request() { return $this->get_meta('special_request'); } /** * Get how the guest heard about the property. * * Meta key: `_booking_referral`. * * @return string */ public function get_referral() { return $this->get_meta('referral'); } /** * Get the final grand total charged or quoted for the booking. * * Meta key: `_booking_total_amount`. Stored as a numeric string/float. * * @return float|string */ public function get_total_amount() { return $this->get_meta('total_amount'); } /** * Grand total for admin list/detail, using the same live rules as the thank-you page. * * Older bookings may only have stored nights-after-discount. Recalculating from * stored nights + current property fees keeps admin in sync with checkout. */ public function get_display_total_amount(): float { $breakdown = $this->get_display_pricing_breakdown(); if (!empty($breakdown['valid'])) { return (float) ($breakdown['grand_total'] ?? 0); } $booking_total = $this->get_meta('booking_total'); if (is_numeric($booking_total) && (float) $booking_total > 0) { return (float) $booking_total; } return (float) $this->get_total_amount(); } /** * Length-of-stay discount stored on this booking. * * Meta key: `_booking_discount_amount`. */ public function get_discount_amount() { return $this->get_meta('discount_amount'); } /** * Extra (non-cleaning) fees stored on this booking. * * Meta key: `_booking_extra_fees`. */ public function get_extra_fees() { return $this->get_meta('extra_fees'); } /** * Get the accommodation subtotal before fees, taxes, and discounts. * * Meta key: `_booking_nights_total`. * * @return float|string */ public function get_nights_total() { return $this->get_meta('nights_total'); } /** * Get the combined total of selected add-ons at time of booking. * * Meta key: `_booking_addons_total`. * * @return float|string */ public function get_addons_total() { return $this->get_meta('addons_total'); } /** * Get the cleaning fee applied to this booking. * * Meta key: `_booking_cleaning_fee`. * * @return float|string */ public function get_cleaning_fee() { return $this->get_meta('cleaning_fee'); } /** * Get the total tax amount applied to this booking. * * Meta key: `_booking_taxes`. * * @return float|string */ public function get_taxes() { return $this->get_meta('taxes'); } /** * Pricing rows for admin screens, using stored nights plus current property rules. * * Matches the thank-you page (checkout extra charges, discounts, tax) without * calling the Uplisting calendar API. * * @return array */ public function get_display_pricing_breakdown(): array { $stored = [ 'valid' => false, 'error' => '', 'nights' => (int) $this->get_nights_count(), 'nights_total' => (float) $this->get_nights_total(), 'total_day_rate' => (float) $this->get_nights_total(), 'cleaning_fee' => (float) $this->get_cleaning_fee(), 'addons_total' => (float) $this->get_addons_total(), 'extra_fees' => (float) $this->get_extra_fees(), 'discounts' => (float) $this->get_discount_amount(), 'discount_type' => null, 'discount_rate' => 0.0, 'applied_discount_rule' => null, 'average_day_rate' => 0.0, 'security_deposit' => (float) $this->get_meta('security_deposit'), 'subtotal' => 0.0, 'taxes_total' => (float) $this->get_taxes(), 'grand_total' => (float) $this->get_total_amount(), ]; $nights = (int) $stored['nights']; $nights_total = (float) $stored['nights_total']; if ($nights > 0 && $nights_total > 0) { $stored['average_day_rate'] = $nights_total / $nights; } $property_id = (int) $this->get_property_id(); if ($property_id <= 0 || $nights < 1 || $nights_total <= 0) { $stored['valid'] = $nights_total > 0 || $stored['grand_total'] > 0; return $stored; } $property = new PropertyDetails($property_id); if (!$property->get_id()) { $stored['valid'] = $nights_total > 0 || $stored['grand_total'] > 0; return $stored; } $breakdown = $this->get_pricing_breakdown($property, [ 'nights' => $nights, 'nights_total' => $nights_total, 'for_checkout' => true, ]); if (empty($breakdown['valid'])) { $stored['valid'] = $nights_total > 0 || $stored['grand_total'] > 0; return $stored; } return $this->apply_extra_charges_policy($breakdown, $property, [ 'for_checkout' => true, ]); } /** * Get the payment status slug for this booking. * * Meta key: `_booking_payment_status`. Common values: `enquiry`, `pending`, `completed`, * `failed`, `cancelled`. * * @return string */ public function get_payment_status() { return $this->get_meta('payment_status'); } /** * Get the payment gateway slug used for this booking. * * Meta key: `_booking_payment_method`. Examples: `stripe`, `offline`. * * @return string */ public function get_payment_method() { return $this->get_meta('payment_method'); } /** * Get the Stripe charge or payment intent ID for reconciliation. * * Meta key: `_booking_stripe_charge_id`. Empty when paid offline or unpaid. * * @return string */ public function get_stripe_charge_id() { return $this->get_meta('stripe_charge_id'); } /** * Get the MySQL datetime when the booking record was created. * * Meta key: `_booking_created_at`. Set automatically on first {@see save()}. * * @return string MySQL datetime (site timezone). */ public function get_created_at() { return $this->get_meta('created_at'); } /** * * Meta key: `_booking_notes`. Each note is an array with keys: * `note` (string), `user` (display name), `date` (MySQL datetime). * * @return array */ public function get_notes() { $notes = $this->get_meta('notes'); return is_array($notes) ? array_reverse($notes) : []; } /** * Load the {@see Property} for the booked property. * * @return Property|null Property model instance, or null when no property ID is set. */ public function get_property() { $property_id = $this->get_property_id(); if ($property_id) { return new PropertyDetails($property_id); } return null; } /** * Derive the number of nights from a human-readable date range. * * Accepts separators ` to ` or ` - ` (e.g. `2024-06-01 to 2024-06-05`). * Minimum return value is `1`. Result passes through filter * `uplisting_calculate_effective_nights`. * * @param string $selected_dates Date range from the booking form or session. * @return int Night count (ceil of day difference). */ public function get_effective_nights($selected_dates = '') { $nights = 0; if (!empty($selected_dates)) { $parsed = self::parse_date_range($selected_dates); if (!empty($parsed['start_date']) && !empty($parsed['end_date'])) { $nights = self::count_booking_nights($parsed['start_date'], $parsed['end_date']); } } $nights = $nights > 0 ? $nights : 1; return apply_filters('bizdex_uplisting_calculate_effective_nights', $nights, $this, $selected_dates); } /** * Sum property-level extra fees (fixed or percentage of accommodation). */ public function get_calculated_fees($property, $nights, $nights_total) { $fees = []; if (is_array($property)) { if (isset($property['uplisting_fees']) && is_array($property['uplisting_fees'])) { $fees = $property['uplisting_fees']; } elseif (isset($property['fees']) && is_array($property['fees'])) { $fees = $property['fees']; } } elseif (is_object($property) && method_exists($property, 'get_checkout_fees')) { $fees = $property->get_checkout_fees(); } elseif (is_object($property) && method_exists($property, 'get_fees')) { $fees = $property->get_fees(); } $total_fees = 0.0; foreach ($fees as $fee) { if (!is_array($fee)) { continue; } if (is_object($property) && method_exists($property, 'is_charge_item_enabled_at_checkout') && !$property::is_charge_item_enabled_at_checkout($fee)) { continue; } $label = strtolower((string) ($fee['label'] ?? '')); if ($label === 'cleaning_fee' || $label === 'cleaning') { continue; } $amount = floatval($fee['amount'] ?? 0); $type = $fee['type'] ?? 'fixed'; if ($type === 'percentage') { $total_fees += $nights_total * ($amount / 100); } else { $total_fees += $amount; } } return apply_filters('bizdex_uplisting_calculate_extra_fees', $total_fees, $this, $property, $nights, $nights_total); } /** * Find the best eligible discount for the stay. * * @return array{amount: float, rule: array|null, type: string|null, rate: float} */ private function resolve_discount($property, $nights, $nights_total) { $rules = []; if (is_array($property) && !empty($property['discounts'])) { $rules = $property['discounts']; } elseif (is_object($property) && method_exists($property, 'get_discounts')) { $rules = (array) $property->get_discounts(); } usort($rules, function ($a, $b) { return ($b['days'] ?? 0) <=> ($a['days'] ?? 0); }); foreach ($rules as $discount) { if (!is_array($discount)) { continue; } if (is_object($property) && method_exists($property, 'is_charge_item_enabled_at_checkout') && !$property::is_charge_item_enabled_at_checkout($discount)) { continue; } $required_days = (int) ($discount['days'] ?? 0); if ($required_days <= 0 || $nights < $required_days) { continue; } $rate = (float) ($discount['amount'] ?? 0); $type = strtolower($discount['type'] ?? 'percentage') === 'percentage' ? 'percentage' : 'fixed'; if ($type === 'percentage') { $amount = $nights_total * ($rate / 100); } else { $amount = $rate; } return [ 'amount' => $amount, 'rule' => array_merge($discount, ['type' => $type]), 'type' => $type, 'rate' => $rate, ]; } return [ 'amount' => 0.0, 'rule' => null, 'type' => null, 'rate' => 0.0, ]; } /** * Return the calculated discount amount. Filtered by `uplisting_calculate_discounts`. */ public function get_calculated_discounts($property, $nights, $nights_total) { $discount = $this->resolve_discount($property, $nights, $nights_total); return apply_filters( 'bizdex_uplisting_calculate_discounts', $discount['amount'], $this, $property, $nights, $nights_total ); } /** * Build a human-readable label for an applied discount rule. */ public function format_discount_rule_label($rule, $calculated_amount = null) { if (empty($rule) || !is_array($rule)) { return ''; } $name = $rule['name'] ?? $rule['label'] ?? __('Discount', 'advanced-connector-for-uplisting'); $type = strtolower($rule['type'] ?? 'percentage') === 'percentage' ? 'percentage' : 'fixed'; $rate = (float) ($rule['amount'] ?? 0); $rate_text = $type === 'percentage' ? $rate . '%' : bizdex_uplisting_format_price($rate); $label = sprintf('%s (%s)', $name, $rate_text); if ($calculated_amount !== null && $calculated_amount > 0) { $label .= ' -' . bizdex_uplisting_format_price($calculated_amount); } return $label; } /** * Resolve and return the property cleaning fee. */ public function get_calculated_cleaning_fee($property) { if (is_object($property) && method_exists($property, 'is_cleaning_fee_enabled_at_checkout') && !$property->is_cleaning_fee_enabled_at_checkout()) { return 0.0; } $fee = 0.0; if (is_array($property)) { $fee = floatval($property['cleaning_fee'] ?? ($property['uplisting_cleaning_fee'] ?? 0)); } elseif (is_object($property) && method_exists($property, 'get_cleaning_fee')) { $fee = floatval($property->get_cleaning_fee()); } return apply_filters('bizdex_uplisting_calculate_cleaning_fee', $fee, $this, $property); } /** * Parse a date range string into check-in and last-night dates. * * Flatpickr range selection uses the second date as the last booked night * (inclusive), not the morning checkout day. * * @param string $dates Date string such as "2026-07-06 to 2026-07-10". * @return array{start_date: string, end_date: string} */ public static function parse_date_range(string $dates): array { if (strpos($dates, ' to ') !== false) { $parts = explode(' to ', $dates, 2); } elseif (strpos($dates, ' - ') !== false) { $parts = explode(' - ', $dates, 2); } else { return ['start_date' => '', 'end_date' => '']; } if (count($parts) < 2) { return ['start_date' => '', 'end_date' => '']; } return [ 'start_date' => Request::normalize_ymd_value(trim($parts[0])), 'end_date' => Request::normalize_ymd_value(trim($parts[1])), ]; } /** * Count booked nights when the end date is the last night stayed (inclusive). * * Example: 2026-07-23 to 2026-07-24 = 2 nights (23rd and 24th). */ public static function count_booking_nights(string $check_in, string $last_night): int { if ('' === $check_in || '' === $last_night) { return 0; } try { $start = new \DateTime($check_in); $end = new \DateTime($last_night); if ($end < $start) { return 0; } return (int) $start->diff($end)->days + 1; } catch (\Exception $e) { return 0; } } /** * Whether any night in a stay range overlaps synced busy dates. * * @param string $check_in Check-in (Y-m-d). * @param string $last_night Last booked night, inclusive (Y-m-d). * @param string[] $busy_dates Blocked dates (Y-m-d). */ public static function stay_overlaps_busy_dates(string $check_in, string $last_night, array $busy_dates): bool { if (empty($busy_dates) || '' === $check_in || '' === $last_night) { return false; } $start_ts = strtotime($check_in); $end_ts = strtotime($last_night); if (!$start_ts || !$end_ts || $end_ts < $start_ts) { return false; } $busy_lookup = array_fill_keys($busy_dates, true); for ($day_ts = $start_ts; $day_ts <= $end_ts; $day_ts = strtotime('+1 day', $day_ts)) { if (isset($busy_lookup[gmdate('Y-m-d', $day_ts)])) { return true; } } return false; } /** * Convert the last booked night to the guest checkout date. */ public static function last_night_to_checkout(string $last_night): string { if ('' === $last_night) { return ''; } return gmdate('Y-m-d', strtotime($last_night . ' +1 day')); } /** * Convert a guest checkout date to the last booked night. */ public static function checkout_to_last_night(string $checkout_date): string { if ('' === $checkout_date) { return ''; } return gmdate('Y-m-d', strtotime($checkout_date . ' -1 day')); } /** * Normalize calendar day entries from an Uplisting API response. * * Supports both `{ calendar: { days: [] } }` and `{ days: [] }` shapes. * * @param array|\WP_Error|null $response Raw API response. * @return array> */ public static function extract_calendar_days($response) { if (!is_array($response)) { return []; } if (!empty($response['calendar']['days']) && is_array($response['calendar']['days'])) { return $response['calendar']['days']; } if (!empty($response['days']) && is_array($response['days'])) { return $response['days']; } return []; } /** * Exact nightly rate for a single calendar date (in-memory only). * * @param array>|array|\WP_Error|null $response * @param string|null $date Target date (Y-m-d); defaults to today (site timezone). */ public static function get_calendar_day_rate_for_date($response, ?string $date = null): float { $days = is_array($response) && isset($response[0]['date']) ? $response : self::extract_calendar_days($response); $date = $date ?: wp_date('Y-m-d'); foreach ($days as $day) { if (is_object($day)) { $day = (array) $day; } if (!is_array($day) || empty($day['date'])) { continue; } $rate = null; if (isset($day['day_rate'])) { $rate = (float) $day['day_rate']; } elseif (isset($day['price'])) { $rate = (float) $day['price']; } if ($rate === null || $rate <= 0) { continue; } if ((string) $day['date'] !== $date) { continue; } return $rate; } return 0.0; } /** * Aggregate calendar day rates and availability for a date range. * * Sums each `day_rate` where `start_date <= date <= end_date` (last night inclusive). * * @param array> $days Calendar day objects from API. * @param string $start_date Check-in date (Y-m-d). * @param string $end_date Last booked night (Y-m-d, inclusive). * @return array{ * nights_total: float, * total_day_rate: float, * nights_count: int, * available: bool, * minimum_length_of_stay: int, * average_day_rate: float * } */ public function get_calculated_calendar_totals($days, $start_date, $end_date) { $nights_total = 0.0; $nights_count = 0; $available = true; $minimum_length_of_stay = 1; try { $start = new \DateTime($start_date); $end = new \DateTime($end_date); $end_formatted = $end->format('Y-m-d'); $start_formatted = $start->format('Y-m-d'); foreach ($days as $day) { if ($day['date'] === $start_formatted && isset($day['minimum_length_of_stay'])) { $minimum_length_of_stay = $day['minimum_length_of_stay']; } $current_date = $day['date']; if ($current_date >= $start_formatted && $current_date <= $end_formatted) { if (isset($day['day_rate'])) { $nights_total += floatval($day['day_rate']); $nights_count++; } if (isset($day['available']) && !$day['available']) { $available = false; } } } } catch (\Exception $e) { $available = false; bizdex_uplisting_log('Calendar totals calculation failed: ' . $e->getMessage()); } $average_day_rate = $nights_count > 0 ? ($nights_total / $nights_count) : 0.0; return [ 'nights_total' => $nights_total, 'total_day_rate' => $nights_total, 'nights_count' => $nights_count, 'available' => $available, 'minimum_length_of_stay' => $minimum_length_of_stay, 'average_day_rate' => $average_day_rate, ]; } /** * Resolve the security deposit hold amount for display/charging. */ public function get_calculated_security_deposit($property) { if (is_object($property) && method_exists($property, 'is_security_deposit_enabled_at_checkout') && !$property->is_security_deposit_enabled_at_checkout()) { return 0.0; } $deposit_data = []; if (is_array($property) && isset($property['security_deposit'])) { $deposit_data = $property['security_deposit']; } elseif (is_object($property) && method_exists($property, 'get_security_deposit')) { $deposit_data = $property->get_security_deposit(); } $deposit_amount = 0.0; if (!empty($deposit_data['enabled']) && !empty($deposit_data['amount'])) { $deposit_amount = floatval($deposit_data['amount']); } return apply_filters('bizdex_uplisting_calculate_security_deposit', $deposit_amount, $this, $property); } /** * Calculate total tax from base percentage and additional tax rules. * * Applies `per_booking_percentage` plus each entry in `uplisting_taxes` * (fixed or percentage). Filtered by `uplisting_calculate_taxes`. * * @param float $subtotal Pre-tax booking subtotal. * @param Property|array $property Tax configuration source. * @return float Combined tax amount. */ public function get_calculated_taxes($subtotal, $property) { $tax_percentage = 0.0; $extra_taxes = []; if (is_array($property)) { $tax_percentage = floatval( $property['per_booking_percentage'] ?? $property['tax_percentage'] ?? $property['uplisting_tax_percentage'] ?? 0 ); if (isset($property['uplisting_taxes']) && is_array($property['uplisting_taxes'])) { $extra_taxes = $property['uplisting_taxes']; } elseif (isset($property['taxes']) && is_array($property['taxes'])) { $extra_taxes = $property['taxes']; } } elseif (is_object($property) && method_exists($property, 'get_tax_percentage')) { $tax_percentage = $property->is_per_booking_tax_enabled_at_checkout() ? floatval($property->get_tax_percentage()) : 0.0; $extra_taxes = method_exists($property, 'get_checkout_taxes') ? $property->get_checkout_taxes() : $property->get_taxes(); } $taxes = $subtotal * ($tax_percentage / 100); foreach ($extra_taxes as $tax) { if (!is_array($tax)) { continue; } if (is_object($property) && method_exists($property, 'is_charge_item_enabled_at_checkout') && !$property::is_charge_item_enabled_at_checkout($tax)) { continue; } $label = strtolower((string) ($tax['label'] ?? '')); if ($label === 'per_booking_percentage') { continue; } $amount = floatval($tax['amount'] ?? 0); $type = $tax['type'] ?? 'percentage'; if ($type === 'percentage') { $taxes += $subtotal * ($amount / 100); } else { $taxes += $amount; } } return apply_filters('bizdex_uplisting_calculate_taxes', $taxes, $this, $subtotal, $property); } /** * Default pricing breakdown shape used for errors and empty states. * * @param string $error Optional error message. * @return array */ private function empty_pricing_breakdown($error = '') { return [ 'valid' => $error === '', 'error' => $error, 'nights' => 0, 'nights_total' => 0.0, 'total_day_rate' => 0.0, 'cleaning_fee' => 0.0, 'addons_total' => 0.0, 'extra_fees' => 0.0, 'discounts' => 0.0, 'discount_type' => null, 'discount_rate' => 0.0, 'applied_discount_rule' => null, 'average_day_rate' => 0.0, 'security_deposit' => 0.0, 'subtotal' => 0.0, 'taxes_total' => 0.0, 'grand_total' => 0.0, ]; } /** * Whether extra charges should be included in a pricing breakdown. * * @param Property|object|array|null $property * @param array $args `for_checkout`, `include_extra_charges`. */ public function should_include_extra_charges($property, array $args = []): bool { if (!empty($args['include_extra_charges'])) { return true; } if (empty($args['for_checkout'])) { return false; } if ($property instanceof PropertyDetails) { return $property->applies_extra_charges_at_checkout(); } if (is_object($property) && method_exists($property, 'applies_extra_charges_at_checkout')) { return (bool) $property->applies_extra_charges_at_checkout(); } return false; } /** * Strip cleaning, fees, taxes, and deposit when extra charges are off. * Length-of-stay discounts stay on the total. * * @param array $breakdown * @param Property|object|array|null $property * @param array $args * @return array */ public function apply_extra_charges_policy(array $breakdown, $property, array $args = []): array { if ($this->should_include_extra_charges($property, $args) || empty($breakdown['valid'])) { return $breakdown; } $nights_total = (float) ($breakdown['nights_total'] ?? $breakdown['total_day_rate'] ?? 0); $addons_total = (float) ($breakdown['addons_total'] ?? 0); $discount_amount = (float) ($breakdown['discounts'] ?? 0); $subtotal = max(0.0, $nights_total + $addons_total - $discount_amount); $breakdown['cleaning_fee'] = 0.0; $breakdown['extra_fees'] = 0.0; $breakdown['taxes_total'] = 0.0; $breakdown['security_deposit'] = 0.0; $breakdown['subtotal'] = $subtotal; $breakdown['grand_total'] = $subtotal; return $breakdown; } /** * Build pricing breakdown from API calendar totals and property fees. */ public function get_pricing_breakdown($property, $args = []) { try { $selected_dates = $args['selected_dates'] ?? $this->get_prop('dates', ''); $nights = isset($args['nights']) ? (int) $args['nights'] : $this->get_effective_nights($selected_dates); if ($nights < 1) { return $this->empty_pricing_breakdown( __('A valid stay length is required to calculate pricing.', 'advanced-connector-for-uplisting') ); } $nights_total = 0.0; if (isset($args['nights_total'])) { $nights_total = (float) $args['nights_total']; } elseif (isset($args['total_day_rate'])) { $nights_total = (float) $args['total_day_rate']; } $has_calendar_args = !empty($args['calendar_days']) && !empty($args['start_date']) && !empty($args['end_date']); if ($nights_total <= 0 && $has_calendar_args) { $start_date = sanitize_text_field($args['start_date']); $end_date = sanitize_text_field($args['end_date']); if (strtotime($start_date) === false || strtotime($end_date) === false) { return $this->empty_pricing_breakdown( __('Invalid check-in or check-out date.', 'advanced-connector-for-uplisting') ); } $calendar = $this->get_calculated_calendar_totals($args['calendar_days'], $start_date, $end_date); if (!$calendar['available']) { return $this->empty_pricing_breakdown( __('Selected dates are not available.', 'advanced-connector-for-uplisting') ); } if ($calendar['nights_count'] < 1) { return $this->empty_pricing_breakdown( __('No nightly rates were found for the selected dates.', 'advanced-connector-for-uplisting') ); } $nights = (int) $calendar['nights_count']; $nights_total = (float) $calendar['nights_total']; } if ($nights_total <= 0) { return $this->empty_pricing_breakdown( __('Accommodation total is missing. Select dates to load pricing from the calendar.', 'advanced-connector-for-uplisting') ); } if (isset($args['average_day_rate'])) { $average_day_rate = (float) $args['average_day_rate']; } else { $average_day_rate = $nights_total / $nights; } $cleaning_fee = 0.0; $extra_fees = 0.0; $taxes_total = 0.0; $security_deposit = 0.0; if ($this->should_include_extra_charges($property, $args)) { $cleaning_fee = $this->get_calculated_cleaning_fee($property); $extra_fees = $this->get_calculated_fees($property, $nights, $nights_total); $security_deposit = $this->get_calculated_security_deposit($property); } $addons_total = 0.0; $discount = $this->resolve_discount($property, $nights, $nights_total); $discount_amount = (float) $discount['amount']; $applied_rule = $discount['rule']; $discount_type = $discount['type']; $discount_rate = (float) $discount['rate']; $discount_amount = (float) apply_filters( 'bizdex_uplisting_calculate_discounts', $discount_amount, $this, $property, $nights, $nights_total ); if ($discount_amount < 0) { $discount_amount = 0.0; } if ($discount_amount > $nights_total) { $discount_amount = $nights_total; } $security_deposit = $security_deposit ?? 0.0; $pre_tax_total = $nights_total + $cleaning_fee + $addons_total + $extra_fees; $pre_tax_total = $pre_tax_total - $discount_amount; if ($pre_tax_total < 0) { $pre_tax_total = 0.0; } $subtotal = (float) apply_filters( 'bizdex_uplisting_calculate_subtotal', $pre_tax_total, $this ); if ($this->should_include_extra_charges($property, $args)) { $taxes_total = $this->get_calculated_taxes($subtotal, $property); } $pre_grand_total = $subtotal + $taxes_total; $grand_total = (float) apply_filters( 'bizdex_uplisting_calculate_grand_total', $pre_grand_total, $this, $subtotal, $taxes_total ); if ($grand_total < 0) { $grand_total = 0.0; } return [ 'valid' => true, 'error' => '', 'nights' => $nights, 'nights_total' => $nights_total, 'total_day_rate' => $nights_total, 'cleaning_fee' => $cleaning_fee, 'addons_total' => $addons_total, 'extra_fees' => $extra_fees, 'discounts' => $discount_amount, 'discount_type' => $discount_type, 'discount_rate' => $discount_rate, 'applied_discount_rule' => $applied_rule, 'average_day_rate' => $average_day_rate, 'security_deposit' => $security_deposit, 'subtotal' => $subtotal, 'taxes_total' => $taxes_total, 'grand_total' => $grand_total, ]; } catch (\Throwable $e) { bizdex_uplisting_log('Pricing breakdown error: ' . $e->getMessage()); return $this->empty_pricing_breakdown( __('Unable to calculate pricing. Please try again.', 'advanced-connector-for-uplisting') ); } } /** * Build labeled total rows for the reservation review UI. */ public function get_booking_item_totals($property, $args = []) { $b = $this->get_pricing_breakdown($property, $args); $total_rows = [ 'nights' => [ 'label' => '' . $b['nights'] . ' ' . esc_html__('nights', 'advanced-connector-for-uplisting') . '', 'value' => '' . bizdex_uplisting_format_price($b['nights_total']) . '', ], ]; if ($b['cleaning_fee'] > 0) { $total_rows['cleaning'] = [ 'label' => esc_html__('Cleaning fee', 'advanced-connector-for-uplisting'), 'value' => '' . bizdex_uplisting_format_price($b['cleaning_fee']) . '', ]; } if ($b['extra_fees'] > 0) { $total_rows['fees'] = [ 'label' => esc_html__('Extra Fees', 'advanced-connector-for-uplisting'), 'value' => '' . bizdex_uplisting_format_price($b['extra_fees']) . '', ]; } if ($b['discounts'] > 0) { $discount_label = $b['applied_discount_rule'] ? esc_html($this->format_discount_rule_label($b['applied_discount_rule'])) : esc_html__('Discounts', 'advanced-connector-for-uplisting'); $total_rows['discounts'] = [ 'label' => $discount_label, 'value' => '-' . bizdex_uplisting_format_price($b['discounts']) . '', ]; } if ($b['addons_total'] > 0) { $total_rows['addons'] = [ 'label' => esc_html__('Add-ons', 'advanced-connector-for-uplisting'), 'value' => '' . bizdex_uplisting_format_price($b['addons_total']) . '', ]; } if ($b['taxes_total'] > 0) { $total_rows['taxes'] = [ 'label' => esc_html__('Taxes', 'advanced-connector-for-uplisting'), 'value' => '' . bizdex_uplisting_format_price($b['taxes_total']) . '', ]; } if ($b['security_deposit'] > 0) { $total_rows['security_deposit'] = [ 'label' => esc_html__('Security Deposit (Hold)', 'advanced-connector-for-uplisting'), 'value' => '' . bizdex_uplisting_format_price($b['security_deposit']) . '', ]; } $total_rows['booking_total'] = [ 'label' => '' . esc_html((string) $property->get_meta('total_text', __('Estimated total *', 'advanced-connector-for-uplisting'))) . '', 'value' => '' . bizdex_uplisting_format_price($b['grand_total']) . '', ]; return apply_filters('bizdex_uplisting_reservation_review_totals', $total_rows, $property, $this); } /** * Resolve property fees, taxes, and discounts from synced post meta when possible. * * @param string $property_source_id Uplisting property source ID. * @param int $property_post_id Optional WordPress property post ID. * @return Property|array|null */ public static function resolve_property_for_pricing($property_source_id, $property_post_id = 0) { static $resolved = []; $cache_key = $property_source_id . ':' . (int) $property_post_id; if (array_key_exists($cache_key, $resolved)) { return $resolved[$cache_key]; } if ($property_post_id > 0) { $model = new PropertyDetails($property_post_id); if ($model->get_id()) { $resolved[$cache_key] = $model; return $model; } } $posts = get_posts([ 'post_type' => 'bizdexul_property', 'post_status' => 'publish', 'posts_per_page' => 1, 'fields' => 'ids', 'no_found_rows' => true, 'update_post_meta_cache' => true, 'update_post_term_cache' => false, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Resolve WP property from Uplisting source id. 'meta_query' => [ 'relation' => 'OR', [ 'key' => bizdex_uplisting_meta_key('property_source_id'), 'value' => sanitize_text_field($property_source_id), ], [ 'key' => 'property_source_id', 'value' => sanitize_text_field($property_source_id), ], ], ]); if (!empty($posts)) { $model = new PropertyDetails((int) $posts[0]); $resolved[$cache_key] = $model; return $model; } $api_client = new UplistingClient(); $property_data = $api_client->get_property($property_source_id); if (is_wp_error($property_data)) { bizdex_uplisting_log('Property resolve failed: ' . $property_data->get_error_message()); $resolved[$cache_key] = null; return null; } $resolved[$cache_key] = is_array($property_data) ? $property_data : null; return $resolved[$cache_key]; } /** * Fetch live pricing from the Uplisting calendar API. * * @param string $property_source_id Uplisting property source ID. * @param string $start_date Check-in date (Y-m-d). * @param string $end_date Last booked night (Y-m-d, inclusive). * @param int|null $property_post_id Optional WordPress property post ID. * @param array $args Optional calendar_end and other breakdown context. */ public function calculate_pricing($property_source_id, $start_date, $end_date, $property_post_id = null, $args = []) { $check_in = $start_date; $last_night = $end_date; $calendar_end = $args['calendar_end'] ?? self::last_night_to_checkout($last_night); $api_client = new UplistingClient(); $response = $api_client->get_calendar($property_source_id, $check_in, $calendar_end); if (is_wp_error($response)) { return $response; } $days = self::extract_calendar_days($response); $calendar_totals = $this->get_calculated_calendar_totals($days, $check_in, $last_night); $nights_total = $calendar_totals['nights_total']; $nights_count = $calendar_totals['nights_count']; $minimum_length_of_stay = $calendar_totals['minimum_length_of_stay']; $available = $calendar_totals['available']; $average_day_rate = $calendar_totals['average_day_rate']; $day_rate = $average_day_rate > 0 ? $average_day_rate : ($days[0]['day_rate'] ?? null); $property_data = null; if ($nights_count > 0) { $property_data = self::resolve_property_for_pricing( $property_source_id, $property_post_id ? (int) $property_post_id : 0 ); } $pricing_breakdown = $this->get_pricing_breakdown($property_data, [ 'nights' => $nights_count, 'nights_total' => $nights_total, 'average_day_rate' => $average_day_rate, ]); return [ 'day_rate' => $day_rate, 'average_day_rate' => $average_day_rate, 'available' => $available, 'minimum_length_of_stay' => $minimum_length_of_stay, 'nights_total' => $nights_total, 'total_day_rate' => $nights_total, 'total_price' => $nights_total, 'nights_count' => $nights_count, 'property_data' => $property_data, 'pricing_breakdown' => $pricing_breakdown, 'days' => $days, ]; } /** * Calculate pricing from local property meta and synced busy dates. * * Used for `uplisting_booking_form` inquiry properties, and as a meta fallback * when a PMS (`booking_form`) property has no live API source ID. * * Prefers synced `uplisting_calendar_day_rates` when present, otherwise the * property display/base price × nights. * * @param PropertyDetails $property Property model. * @param string $start_date Check-in (Y-m-d). * @param string $end_date Last booked night (Y-m-d, inclusive). * @param array $args Optional breakdown context. * @return array */ public function calculate_local_pricing(PropertyDetails $property, string $start_date, string $end_date, array $args = []) { $busy_dates = $property->get_busy_dates(); $nights_count = self::count_booking_nights($start_date, $end_date); $available = $nights_count > 0 && !self::stay_overlaps_busy_dates($start_date, $end_date, $busy_dates); $minimum_length_of_stay = $property->get_effective_min_stay(); $maximum_length_of_stay = $property->get_effective_max_stay(); if ($available && $maximum_length_of_stay > 0 && $nights_count > $maximum_length_of_stay) { $available = false; } $rate_totals = self::sum_synced_calendar_day_rates($property, $start_date, $end_date); if ($rate_totals['nights_count'] === $nights_count && $rate_totals['nights_total'] > 0) { $nights_total = $rate_totals['nights_total']; $average_day_rate = $nights_count > 0 ? ($nights_total / $nights_count) : 0.0; $base_price = $average_day_rate; } else { $base_price = (float) $property->get_display_price(); if ($base_price <= 0) { $base_price = (float) $property->get_price(); } $nights_total = $base_price * max(0, $nights_count); $average_day_rate = $nights_count > 0 ? ($nights_total / $nights_count) : $base_price; } $pricing_breakdown = $this->get_pricing_breakdown($property, [ 'nights' => $nights_count, 'nights_total' => $nights_total, 'average_day_rate' => $average_day_rate, ]); return [ 'day_rate' => $base_price, 'average_day_rate' => $average_day_rate, 'available' => $available, 'minimum_length_of_stay' => $minimum_length_of_stay, 'maximum_length_of_stay' => $maximum_length_of_stay, 'nights_total' => $nights_total, 'total_day_rate' => $nights_total, 'total_price' => $nights_total, 'nights_count' => $nights_count, 'property_data' => $property, 'pricing_breakdown' => $pricing_breakdown, 'days' => [], 'source' => 'local', ]; } /** * Sum nightly rates from synced `uplisting_calendar_day_rates` meta for a stay. * * @param PropertyDetails $property Property model. * @param string $start_date Check-in (Y-m-d). * @param string $end_date Last booked night, inclusive (Y-m-d). * @return array{nights_total: float, nights_count: int} */ public static function sum_synced_calendar_day_rates(PropertyDetails $property, string $start_date, string $end_date): array { $rates = $property->get_meta('uplisting_calendar_day_rates', []); if (!is_array($rates) || empty($rates) || $start_date === '' || $end_date === '') { return ['nights_total' => 0.0, 'nights_count' => 0]; } $nights_total = 0.0; $nights_count = 0; try { $cursor = new \DateTime($start_date); $end = new \DateTime($end_date); if ($end < $cursor) { return ['nights_total' => 0.0, 'nights_count' => 0]; } while ($cursor <= $end) { $key = $cursor->format('Y-m-d'); if (isset($rates[$key]) && is_numeric($rates[$key])) { $nights_total += (float) $rates[$key]; $nights_count++; } $cursor->modify('+1 day'); } } catch (\Exception $e) { return ['nights_total' => 0.0, 'nights_count' => 0]; } return [ 'nights_total' => $nights_total, 'nights_count' => $nights_count, ]; } }