}|null Cached setup verification result. */ private static $setup_cache = null; /** @var string[] Required keys in the property-page display payload. */ const REQUIRED_PROPERTY_DISPLAY_KEYS = [ 'total_day_rate', 'raw_total_day_rate', 'addons_total', 'raw_addons_total', 'discount_amount', 'raw_discount_amount', 'booking_total', 'raw_booking_total', ]; /** @var string[] Required keys in the reservation display payload sent to JavaScript. */ const REQUIRED_DISPLAY_KEYS = [ 'nights_count', 'nights_count_label', 'total_day_rate', 'raw_total_day_rate', 'cleaning_fee', 'raw_cleaning_fee', 'addons_total', 'raw_addons_total', 'discount_label', 'discount_amount', 'raw_discount_amount', 'tax_label', 'taxes_total', 'raw_taxes_total', 'booking_total', 'raw_booking_total', ]; /** * Row definitions shared with the frontend (labels and keys only). * * @return array */ public static function get_row_definitions() { $rows = [ [ 'key' => 'average_day_rate', 'label' => __('Approx. daily price', 'advanced-connector-for-uplisting'), ], [ 'key' => 'nights_count', 'label' => __('Nights', 'advanced-connector-for-uplisting'), ], [ 'key' => 'total_day_rate', 'label' => __('Total Day Rate', 'advanced-connector-for-uplisting'), ], [ 'key' => 'cleaning_fee', 'label' => __('Cleaning Fee', 'advanced-connector-for-uplisting'), ], [ 'key' => 'extra_fees', 'label' => __('Extra Fees', 'advanced-connector-for-uplisting'), ], [ 'key' => 'addons', 'label' => __('Add-ons', 'advanced-connector-for-uplisting'), ], [ 'key' => 'discount', 'label' => __('Discount', 'advanced-connector-for-uplisting'), ], [ 'key' => 'taxes', 'label' => __('Taxes', 'advanced-connector-for-uplisting'), ], [ 'key' => 'booking_total', 'label' => __('Booking Total', 'advanced-connector-for-uplisting'), 'is_total' => true, ], ]; /** * Filter pricing breakdown row definitions before they are sent to JavaScript. * * @param array $rows */ return apply_filters('bizdex_uplisting_pricing_breakdown_rows', $rows); } /** * Row definitions for the single property page (day rate, add-ons, discount, total only). * * @return array */ public static function get_property_row_definitions() { $rows = [ [ 'key' => 'average_day_rate', 'label' => __('Approx. daily price', 'advanced-connector-for-uplisting'), ], [ 'key' => 'nights_count', 'label' => __('Nights', 'advanced-connector-for-uplisting'), ], [ 'key' => 'total_day_rate', 'label' => __('Total Day Rate', 'advanced-connector-for-uplisting'), ], [ 'key' => 'addons', 'label' => __('Add-ons', 'advanced-connector-for-uplisting'), ], [ 'key' => 'discount', 'label' => __('Discount', 'advanced-connector-for-uplisting'), ], [ 'key' => 'booking_total', 'label' => __('Total', 'advanced-connector-for-uplisting'), 'is_total' => true, ], ]; /** * Filter property-page pricing breakdown row definitions. * * @param array $rows */ return apply_filters('bizdex_uplisting_property_pricing_breakdown_rows', $rows); } /** * Resolve the pricing breakdown template file (theme override or plugin default). * * @return string Absolute path, or empty string when not found. */ public static function get_template_path() { $theme_template = locate_template([ 'wp-uplisting-connector/' . self::TEMPLATE_SLUG . '.php', self::TEMPLATE_SLUG . '.php', ]); if ($theme_template) { return $theme_template; } $plugin_template = BIZDEX_UPLISTING_PLUGIN_DIR . 'templates/' . self::TEMPLATE_SLUG . '.php'; return file_exists($plugin_template) ? $plugin_template : ''; } /** * Verify the template file exists and contains required markup. * * @return array{valid: bool, errors: string[]} */ public static function verify_template_file() { $errors = []; $path = self::get_template_path(); if (!$path) { $errors[] = __('Pricing breakdown template file is missing.', 'advanced-connector-for-uplisting'); return ['valid' => false, 'errors' => $errors]; } $contents = file_get_contents($path); if ($contents === false) { $errors[] = __('Pricing breakdown template file could not be read.', 'advanced-connector-for-uplisting'); return ['valid' => false, 'errors' => $errors]; } $has_mount_element = ( strpos($contents, 'id="' . self::MOUNT_ID . '"') !== false || strpos($contents, 'PricingBreakdown::MOUNT_ID') !== false || strpos($contents, self::MOUNT_ID) !== false ); if (!$has_mount_element) { $errors[] = sprintf( /* translators: %s: DOM mount element ID */ __('Template is missing required mount element: %s', 'advanced-connector-for-uplisting'), self::MOUNT_ID ); } if (strpos($contents, self::TEMPLATE_ID) === false) { $errors[] = __('Template is missing pricing breakdown markup.', 'advanced-connector-for-uplisting'); } return [ 'valid' => empty($errors), 'errors' => $errors, ]; } /** * Verify full frontend setup: template file and row definitions. * * @return array{valid: bool, errors: string[], checks: array} */ public static function verify_setup() { if (self::$setup_cache !== null) { return self::$setup_cache; } $template_check = self::verify_template_file(); $rows = self::get_row_definitions(); $checks = [ 'template_file' => !empty(self::get_template_path()), 'template_id' => $template_check['valid'], 'row_definitions' => !empty($rows), ]; $errors = $template_check['errors']; if (!$checks['row_definitions']) { $errors[] = __('Pricing breakdown row definitions are empty.', 'advanced-connector-for-uplisting'); } self::$setup_cache = [ 'valid' => !in_array(false, $checks, true), 'errors' => array_values(array_unique($errors)), 'checks' => $checks, ]; return self::$setup_cache; } /** * Build the formatted pricing breakdown payload for the frontend template. * * Total Day Rate is always the gross sum of nightly rates — discounts are shown separately. * * @param array $pricing_breakdown Raw breakdown from {@see BookingService::get_pricing_breakdown()}. * @param float|null $booking_total Optional grand total override. * @param array $context Optional context (`nights`). * @return array>> */ public static function prepare_display_payload($pricing_breakdown, $booking_total = null, $context = []) { $total_day_rate = (float) ($pricing_breakdown['total_day_rate'] ?? $pricing_breakdown['nights_total'] ?? 0); $cleaning_fee = (float) ($pricing_breakdown['cleaning_fee'] ?? 0); $extra_fees = (float) ($pricing_breakdown['extra_fees'] ?? 0); $addons_total = (float) ($pricing_breakdown['addons_total'] ?? 0); $discount_amount = (float) ($pricing_breakdown['discounts'] ?? 0); $taxes_total = (float) ($pricing_breakdown['taxes_total'] ?? 0); $nights = (int) ($context['nights'] ?? $pricing_breakdown['nights'] ?? 0); if ($booking_total === null) { $booking_total = (float) ($pricing_breakdown['grand_total'] ?? 0); } else { $booking_total = (float) $booking_total; } return [ 'nights_count' => $nights, 'nights_count_label' => self::format_nights_label($nights), 'average_day_rate' => bizdex_uplisting_format_price((float) ($pricing_breakdown['average_day_rate'] ?? ($nights > 0 ? $total_day_rate / $nights : 0))), 'raw_average_day_rate'=> (float) ($pricing_breakdown['average_day_rate'] ?? ($nights > 0 ? $total_day_rate / $nights : 0)), 'total_day_rate' => bizdex_uplisting_format_price($total_day_rate), 'raw_total_day_rate' => $total_day_rate, 'cleaning_fee' => bizdex_uplisting_format_price($cleaning_fee), 'raw_cleaning_fee' => $cleaning_fee, 'extra_fees' => bizdex_uplisting_format_price($extra_fees), 'raw_extra_fees' => $extra_fees, 'addons_total' => bizdex_uplisting_format_price($addons_total), 'raw_addons_total' => $addons_total, 'addon_items' => [], 'discount_label' => self::format_discount_label($pricing_breakdown), 'discount_amount' => bizdex_uplisting_format_price($discount_amount), 'raw_discount_amount' => $discount_amount, 'tax_label' => self::format_tax_label($context['property'] ?? null), 'taxes_total' => bizdex_uplisting_format_price($taxes_total), 'raw_taxes_total' => $taxes_total, 'booking_total' => bizdex_uplisting_format_price($booking_total), 'raw_booking_total' => $booking_total, ]; } /** * Build the property-page pricing payload. * * Includes day rate, add-ons, and length-of-stay discounts. Synced extra * charges (cleaning, fees, taxes) stay checkout-only when enabled. * * @param array $pricing_breakdown Raw breakdown from {@see BookingService::get_pricing_breakdown()}. * @param float|null $booking_total Unused; kept for signature compatibility. * @param array $context Optional context (`nights`). * @return array>> */ public static function prepare_property_display_payload($pricing_breakdown, $booking_total = null, $context = []) { $total_day_rate = (float) ($pricing_breakdown['total_day_rate'] ?? $pricing_breakdown['nights_total'] ?? 0); $addons_total = (float) ($pricing_breakdown['addons_total'] ?? 0); $discount_amount = (float) ($pricing_breakdown['discounts'] ?? 0); $nights = (int) ($context['nights'] ?? $pricing_breakdown['nights'] ?? 0); $average_day_rate = (float) ($pricing_breakdown['average_day_rate'] ?? ($nights > 0 ? $total_day_rate / $nights : 0)); $property_total = $total_day_rate + $addons_total - $discount_amount; if ($property_total < 0) { $property_total = 0.0; } return [ 'nights_count' => $nights, 'nights_count_label' => self::format_nights_label($nights), 'average_day_rate' => bizdex_uplisting_format_price($average_day_rate), 'raw_average_day_rate' => $average_day_rate, 'total_day_rate' => bizdex_uplisting_format_price($total_day_rate), 'raw_total_day_rate' => $total_day_rate, 'addons_total' => bizdex_uplisting_format_price($addons_total), 'raw_addons_total' => $addons_total, 'addon_items' => [], 'discount_label' => self::format_discount_label($pricing_breakdown), 'discount_amount' => bizdex_uplisting_format_price($discount_amount), 'raw_discount_amount' => $discount_amount, 'booking_total' => bizdex_uplisting_format_price($property_total), 'raw_booking_total' => $property_total, ]; } /** * Checkout sidebar rows — omit synced extra charges unless enabled in property meta. * * @return array */ public static function get_checkout_row_definitions(PropertyDetails $property): array { $rows = self::get_row_definitions(); if ($property->applies_extra_charges_at_checkout()) { return $rows; } $hidden = ['cleaning_fee', 'extra_fees', 'taxes']; return array_values(array_filter($rows, static function ($row) use ($hidden) { return !in_array($row['key'] ?? '', $hidden, true); })); } /** * Validate the property-page display payload before it is sent to JavaScript. * * @param array $payload Payload from {@see prepare_property_display_payload()}. * @return array{valid: bool, errors: string[]} */ public static function validate_property_display_payload($payload) { $errors = []; foreach (self::REQUIRED_PROPERTY_DISPLAY_KEYS as $key) { if (!array_key_exists($key, $payload)) { $errors[] = sprintf( /* translators: %s: pricing breakdown field key */ __('Missing pricing breakdown field: %s', 'advanced-connector-for-uplisting'), $key ); } } $numeric_keys = [ 'raw_total_day_rate', 'raw_addons_total', 'raw_discount_amount', 'raw_booking_total', ]; foreach ($numeric_keys as $key) { if (array_key_exists($key, $payload) && !is_numeric($payload[$key])) { $errors[] = sprintf( /* translators: %s: pricing breakdown field key */ __('Invalid numeric value for: %s', 'advanced-connector-for-uplisting'), $key ); } } if (array_key_exists('raw_booking_total', $payload) && (float) $payload['raw_booking_total'] < 0) { $errors[] = __('Booking total cannot be negative.', 'advanced-connector-for-uplisting'); } if ( array_key_exists('raw_booking_total', $payload) && array_key_exists('raw_total_day_rate', $payload) ) { $expected_total = (float) $payload['raw_total_day_rate'] + (float) ($payload['raw_addons_total'] ?? 0) - (float) ($payload['raw_discount_amount'] ?? 0); if (abs($expected_total - (float) $payload['raw_booking_total']) > 0.02) { $errors[] = __('Property pricing total does not match day rate, add-ons, and discount.', 'advanced-connector-for-uplisting'); } } return [ 'valid' => empty($errors), 'errors' => $errors, ]; } /** * Build the full pricing response for reservation/checkout templates. * * @param array $pricing_breakdown Raw breakdown from {@see BookingService::get_pricing_breakdown()}. * @param array $context Optional context (`nights`, `property`). * @return array{ * pricing_breakdown: array, * pricing_breakdown_valid: bool, * pricing_breakdown_errors: string[] * } */ public static function build_reservation_pricing_response(array $pricing_breakdown, array $context = []) { $booking_total = (float) ($pricing_breakdown['grand_total'] ?? 0); $display = self::prepare_display_payload($pricing_breakdown, $booking_total, $context); $validation = self::validate_display_payload($display); return [ 'pricing_breakdown' => $display, 'pricing_breakdown_valid' => $validation['valid'], 'pricing_breakdown_errors' => $validation['errors'], ]; } /** * Build the discount row label from the applied rule (e.g. "Weekly discount (15%)"). * * @param array $pricing_breakdown Raw breakdown from {@see BookingService::get_pricing_breakdown()}. * @return string */ public static function format_discount_label($pricing_breakdown) { $rule = $pricing_breakdown['applied_discount_rule'] ?? null; if (empty($rule) || !is_array($rule)) { return __('Discount', 'advanced-connector-for-uplisting'); } $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); return sprintf('%s (%s)', $name, $rate_text); } /** * Build the nights row label (e.g. "4 nights"). * * @param int $nights Stay length. * @return string */ public static function format_nights_label($nights) { $nights = (int) $nights; if ($nights <= 0) { return ''; } return sprintf( /* translators: %d: number of nights */ _n('%d night', '%d nights', $nights, 'advanced-connector-for-uplisting'), $nights ); } /** * Build the tax row label from property tax rules (e.g. "Occupancy Tax (11%)"). * * @param PropertyDetails|array|null $property Property data. * @return string */ public static function format_tax_label($property) { $rules = self::get_property_tax_rules($property); if (empty($rules)) { return __('Taxes', 'advanced-connector-for-uplisting'); } $labels = []; foreach ($rules as $rule) { if (!is_array($rule)) { continue; } $amount = (float) ($rule['amount'] ?? 0); if ($amount <= 0) { continue; } $name = $rule['name'] ?? $rule['label'] ?? __('Tax', 'advanced-connector-for-uplisting'); $type = strtolower((string) ($rule['type'] ?? 'percentage')) === 'percentage' ? 'percentage' : 'fixed'; $rate_text = $type === 'percentage' ? rtrim(rtrim(number_format($amount, 2, '.', ''), '0'), '.') . '%' : bizdex_uplisting_format_price($amount); $labels[] = sprintf('%s (%s)', $name, $rate_text); } if (empty($labels)) { return __('Taxes', 'advanced-connector-for-uplisting'); } return implode(', ', $labels); } /** * Resolve tax rules from a property model or API array. * * @param PropertyDetails|array|null $property Property data. * @return array> */ private static function get_property_tax_rules($property) { if ($property instanceof PropertyDetails) { $rules = $property->get_taxes(); if (!empty($rules)) { return $rules; } $percentage = (float) $property->get_tax_percentage(); if ($percentage > 0) { return [ [ 'name' => __('Tax', 'advanced-connector-for-uplisting'), 'type' => 'percentage', 'amount' => $percentage, ], ]; } return []; } if (is_array($property)) { $rules = $property['uplisting_taxes'] ?? ($property['taxes'] ?? []); if (!empty($rules) && is_array($rules)) { return $rules; } $percentage = (float) ($property['per_booking_percentage'] ?? ($property['tax_percentage'] ?? 0)); if ($percentage > 0) { return [ [ 'name' => __('Tax', 'advanced-connector-for-uplisting'), 'type' => 'percentage', 'amount' => $percentage, ], ]; } } return []; } /** * Validate the display payload before it is sent to JavaScript. * * @param array $payload Payload from {@see prepare_display_payload()}. * @return array{valid: bool, errors: string[]} */ public static function validate_display_payload($payload) { $errors = []; foreach (self::REQUIRED_DISPLAY_KEYS as $key) { if (!array_key_exists($key, $payload)) { $errors[] = sprintf( /* translators: %s: pricing breakdown field key */ __('Missing pricing breakdown field: %s', 'advanced-connector-for-uplisting'), $key ); } } $numeric_keys = [ 'raw_total_day_rate', 'raw_cleaning_fee', 'raw_extra_fees', 'raw_addons_total', 'raw_discount_amount', 'raw_taxes_total', 'raw_booking_total', ]; foreach ($numeric_keys as $key) { if (array_key_exists($key, $payload) && !is_numeric($payload[$key])) { $errors[] = sprintf( /* translators: %s: pricing breakdown field key */ __('Invalid numeric value for: %s', 'advanced-connector-for-uplisting'), $key ); } } if (array_key_exists('raw_booking_total', $payload) && (float) $payload['raw_booking_total'] < 0) { $errors[] = __('Booking total cannot be negative.', 'advanced-connector-for-uplisting'); } if ( array_key_exists('raw_booking_total', $payload) && array_key_exists('raw_total_day_rate', $payload) ) { $expected_total = (float) $payload['raw_total_day_rate'] + (float) ($payload['raw_cleaning_fee'] ?? 0) + (float) ($payload['raw_extra_fees'] ?? 0) + (float) ($payload['raw_addons_total'] ?? 0) - (float) ($payload['raw_discount_amount'] ?? 0) + (float) ($payload['raw_taxes_total'] ?? 0); if (abs($expected_total - (float) $payload['raw_booking_total']) > 0.02) { $errors[] = __('Pricing breakdown totals do not match booking total.', 'advanced-connector-for-uplisting'); } } return [ 'valid' => empty($errors), 'errors' => $errors, ]; } /** * Localize the pricing breakdown script (`bizdexUplistingPricingBreakdown`). * * @param string $context `property` (single property card), `checkout` (reservation/success), or `generic`. * @param string $handle Script handle that includes the pricing-breakdown code. */ public static function localize_script( ?PropertyDetails $property = null, string $context = 'property', string $handle = 'bizdex-uplisting-pricing-breakdown' ): void { if ( !wp_script_is($handle, 'registered') && !wp_script_is($handle, 'enqueued') ) { return; } if ($context === 'checkout' && $property instanceof PropertyDetails) { $rows = self::get_checkout_row_definitions($property); } elseif ($context === 'generic') { $rows = self::get_row_definitions(); } else { $rows = self::get_property_row_definitions(); } wp_localize_script($handle, 'bizdexUplistingPricingBreakdown', [ 'mountId' => self::MOUNT_ID, 'rows' => $rows, 'setup' => (defined('WP_DEBUG') && WP_DEBUG) ? self::verify_setup() : ['valid' => true, 'errors' => [], 'checks' => []], 'i18n' => [ 'approxDailyPrice' => __('Approx. daily price', 'advanced-connector-for-uplisting'), 'nights' => __('Nights', 'advanced-connector-for-uplisting'), 'totalDayRate' => __('Total Day Rate', 'advanced-connector-for-uplisting'), 'addons' => __('Add-ons', 'advanced-connector-for-uplisting'), 'discount' => __('Discount', 'advanced-connector-for-uplisting'), 'total' => __('Total', 'advanced-connector-for-uplisting'), ], ]); } }