api_key = $api_key ? $api_key : get_option('bizdex_uplisting_api_key', ''); $this->client_id = get_option('bizdex_uplisting_client_id', ''); } /** * Execute an authenticated HTTP request against the Uplisting API. * * Uses `wp_remote_request()` with 30s timeout. Accepts HTTP 200/201/202. * Returns decoded JSON array, `true` for empty success bodies, or `WP_Error`. * * @param string $endpoint Path appended to base URL (e.g. `/properties`). * @param string $method HTTP verb (GET, POST, PATCH, DELETE). * @param array|string|null $body Request body; arrays are JSON-encoded. * @param array $args Extra args merged into wp_remote_request(). * @return array|true|\WP_Error */ private function request($endpoint, $method = 'GET', $body = null, $args = []) { if (empty($this->api_key)) { return new \WP_Error('no_api_key', 'API key is missing.'); } $url = $this->base_url . $endpoint; $encoded_api = base64_encode($this->api_key); $default_args = [ 'timeout' => 30, 'method' => strtoupper($method), 'headers' => [ 'Content-Type' => 'application/json', 'Authorization' => 'Basic ' . $encoded_api, ], ]; if (!empty($this->client_id)) { $default_args['headers']['X-Uplisting-Client-Id'] = $this->client_id; } if ($body !== null) { $default_args['body'] = is_string($body) ? $body : wp_json_encode($body); } $request_args = wp_parse_args($args, $default_args); $response = wp_remote_request($url, $request_args); if (is_wp_error($response)) { return $response; } $http_code = wp_remote_retrieve_response_code($response); $response_body = wp_remote_retrieve_body($response); if (!in_array($http_code, [200, 201, 202])) { $body_snippet = is_string($response_body) ? substr($response_body, 0, 200) : ''; return new \WP_Error('api_error', 'API Error: HTTP ' . $http_code, ['body' => $body_snippet]); } if (empty($response_body)) { return true; } $data = json_decode($response_body, true); if (json_last_error() !== JSON_ERROR_NONE) { return new \WP_Error('json_error', 'Invalid JSON response from API.'); } return $data; } /** * Verify API credentials by fetching the properties index. * * @return true|string `true` on success, or an error message string. */ public function test_connection() { $response = $this->request('/properties'); if (is_wp_error($response)) { return $response->get_error_message(); } if (isset($response['data']) && !empty($response['data'])) { return true; } return 'No properties found or API response error.'; } /** * Fetch and normalize all properties from the Uplisting account. * * @return array>|string Normalized property list, or error message. */ public function get_properties() { $response = $this->request('/properties'); if (is_wp_error($response)) { return $response->get_error_message(); } if (isset($response['data']) && !empty($response['data'])) { return $this->process_properties_response($response); } return 'No properties found or API response error.'; } /** * Fetch calendar day rates and availability for a property. * * Always requests live data from the API (no caching) so availability * cannot become stale and allow overlapping same-day bookings. * * @param string $property_id Uplisting property source ID. * @param string|null $from Start date (Y-m-d) query param. * @param string|null $to End date (Y-m-d) query param. * @return array|\WP_Error Decoded API response with `calendar.days`. */ public function get_calendar($property_id, $from = null, $to = null) { $endpoint = '/calendar/' . $property_id; $params = []; if ($from) { $params['from'] = $from; } if ($to) { $params['to'] = $to; } if (!empty($params)) { $endpoint .= '?' . http_build_query($params); } $response = $this->request($endpoint); if (is_wp_error($response)) { return $response; } return $response; } /** * Fetch a single property by Uplisting ID with included relationships. * * @param string $id Uplisting property source ID. * @return array|false Normalized property array, or false on failure. */ public function get_property($id, $force_refresh = false) { $cache_key = 'bizdex_uplisting_prop_' . $id; if (!$force_refresh) { $cached = get_transient($cache_key); if (is_array($cached)) { return $cached; } } $response = $this->request('/properties/' . $id); if (is_wp_error($response)) { return $response; } if (isset($response['data']) && !empty($response['data'])) { $properties = $this->process_properties_response($response); $property = !empty($properties) ? $properties[0] : false; if (is_array($property)) { set_transient($cache_key, $property, HOUR_IN_SECONDS); return $property; } } return new \WP_Error('not_found', __('Property not found.', 'advanced-connector-for-uplisting')); } /** * Fetch ordered property photos for gallery import. * * @param string $property_id Uplisting property source ID. * @param bool $force_refresh When true, bypasses property transient cache. * @return array|\WP_Error */ public function get_property_photos($property_id, $force_refresh = true) { $property_id = sanitize_text_field((string) $property_id); if ('' === $property_id) { return new \WP_Error('invalid_property_id', __('Property source ID is required.', 'advanced-connector-for-uplisting')); } if ($force_refresh) { $response = $this->request('/properties/' . $property_id); } else { $property = $this->get_property($property_id); if (is_wp_error($property)) { return $property; } if (!is_array($property) || empty($property['response']) || !is_array($property['response'])) { return new \WP_Error('invalid_response', __('Invalid property response.', 'advanced-connector-for-uplisting')); } $response = $property['response']; } if (is_wp_error($response)) { return $response; } return $this->extract_property_photos_from_response($response); } /** * Extract ordered photo records from an Uplisting API property response. * * @param array $response * @return array|\WP_Error */ private function extract_property_photos_from_response(array $response) { $property = $response['data'] ?? null; if (!is_array($property) || ($property['type'] ?? '') !== 'properties') { return new \WP_Error('no_data', __('Error fetching images from the API.', 'advanced-connector-for-uplisting')); } if (empty($property['relationships']['photos']['data'])) { return new \WP_Error('no_photos', __('No photos found in API response.', 'advanced-connector-for-uplisting')); } $photos_map = []; foreach ($response['included'] ?? [] as $item) { if (!is_array($item) || ($item['type'] ?? '') !== 'photos') { continue; } $url = $item['attributes']['url'] ?? ''; if ('' === $url) { continue; } $order = $item['attributes']['position'] ?? $item['attributes']['order'] ?? 999; $photos_map[$item['id']] = [ 'url' => $url, 'order' => (int) $order, ]; } $ordered_photos = []; foreach ($property['relationships']['photos']['data'] as $photo_ref) { if (!is_array($photo_ref)) { continue; } $photo_id = $photo_ref['id'] ?? ''; if ('' === $photo_id || !isset($photos_map[$photo_id])) { continue; } $ordered_photos[] = [ 'id' => (string) $photo_id, 'url' => $photos_map[$photo_id]['url'], 'order' => $photos_map[$photo_id]['order'], ]; } if (empty($ordered_photos)) { return new \WP_Error('no_photos_included', __('No photos found in API included data.', 'advanced-connector-for-uplisting')); } usort($ordered_photos, static function ($a, $b) { return $a['order'] <=> $b['order']; }); return $ordered_photos; } /** * List bookings for a property within an optional date range. * * @param string $property_id Uplisting property source ID. * @param string|null $from Filter start date (Y-m-d). * @param string|null $to Filter end date (Y-m-d). * @return array|true|\WP_Error */ public function get_bookings($property_id, $from = null, $to = null) { $endpoint = '/bookings/' . $property_id; $params = []; if ($from) $params['from'] = $from; if ($to) $params['to'] = $to; if (!empty($params)) { $endpoint .= '?' . http_build_query($params); } return $this->request($endpoint); } /** * Push calendar updates (rates, availability) to Uplisting. * * @param string $property_id Uplisting property source ID. * @param array $data Calendar payload for POST body. * @return array|true|\WP_Error */ public function update_calendar($property_id, $data) { return $this->request('/calendar/' . $property_id, 'POST', $data); } /** * Query global availability across properties. * * @param array $args Query string parameters. * @return array|true|\WP_Error */ public function get_availability($args = []) { $endpoint = '/availability'; if (!empty($args)) { $endpoint .= '?' . http_build_query($args); } return $this->request($endpoint); } /** * List registered outbound webhooks. * * @return array|true|\WP_Error */ public function get_webhooks() { return $this->request('/hooks'); } /** * Register a new outbound webhook subscription. * * @param string $target_url Callback URL on this site. * @param string $event Uplisting event name to subscribe to. * @return array|true|\WP_Error */ public function register_webhook($target_url, $event) { $data = [ 'target_url' => $target_url, 'event' => $event, ]; return $this->request('/hooks', 'POST', $data); } /** * Remove a webhook by its Uplisting ID. * * @param string|int $id Webhook resource ID. * @return array|true|\WP_Error */ public function delete_webhook($id) { return $this->request('/hooks/' . $id, 'DELETE'); } /** * List custom booking attribute definitions (v2). * * @return array|true|\WP_Error */ public function get_custom_attributes() { return $this->request('/v2/custom_booking_attributes'); } /** * Create a custom booking attribute definition (v2). * * @param array $data Attribute definition payload. * @return array|true|\WP_Error */ public function create_custom_attribute($data) { return $this->request('/v2/custom_booking_attributes', 'POST', $data); } /** * Flatten JSON:API property response(s) into import-ready arrays. * * Resolves included address, suitability, policy, amenities, photos, fees, * taxes, discounts, and security deposit relationships. Output keys include * `id`, `name`, `guests`, `photos`, `fees`, `discounts`, `house_rules`, etc. * * @param array $response Raw decoded API response with `data` and `included`. * @return array> */ private function process_properties_response($response) { $properties = []; $data = isset($response['data']['type']) ? [$response['data']] : ($response['data'] ?? []); foreach ($data as $property) { if (!isset($property['type']) || $property['type'] !== 'properties') { continue; } $propertyAttributes = $property['attributes']; $propertyId = $property['id']; // Find related address $address = array_filter($response['included'] ?? [], function ($item) use ($property) { return isset($property['relationships']['address']['data']['id']) && $item['id'] == $property['relationships']['address']['data']['id'] && $item['type'] == 'addresses'; }); $address = reset($address); // Find related suitability $suitability = array_filter($response['included'] ?? [], function ($item) use ($property) { return isset($property['relationships']['suitability']['data']['id']) && $item['id'] == $property['relationships']['suitability']['data']['id'] && $item['type'] == 'suitabilities'; }); $suitability = reset($suitability); // Find related policy $policy = array_filter($response['included'] ?? [], function ($item) use ($property) { return isset($property['relationships']['policy']['data']['id']) && $item['id'] == $property['relationships']['policy']['data']['id'] && $item['type'] == 'policies'; }); $policy = reset($policy); // Find related amenities $amenities = []; if (!empty($property['relationships']['amenities']['data'])) { $amenities = array_filter($response['included'] ?? [], function ($item) use ($property) { $amenity_ids = array_column($property['relationships']['amenities']['data'], 'id'); return in_array($item['id'], $amenity_ids) && $item['type'] == 'amenities'; }); } // Find related photos $photos = []; if (!empty($property['relationships']['photos']['data'])) { $photos = array_filter($response['included'] ?? [], function ($item) use ($property) { $photo_ids = array_column($property['relationships']['photos']['data'], 'id'); return in_array($item['id'], $photo_ids) && $item['type'] == 'photos'; }); } // Sort photos by position if available usort($photos, function ($a, $b) { $posA = $a['attributes']['position'] ?? 999; $posB = $b['attributes']['position'] ?? 999; return $posA <=> $posB; }); $photoUrls = array_column(array_column($photos, 'attributes'), 'url'); $suitability_attrs = $suitability ? $suitability['attributes'] : []; $fees_data = $this->extract_fees($property, $response); $taxes_data = $this->extract_taxes($property, $response); $discounts_data = $this->extract_discounts($property, $response); $security_deposit_data = $this->extract_security_deposit($property, $response); // Fees used by import / pricing. $cleaning_fee = 0; foreach ($fees_data as $fee) { if ($fee['label'] === 'cleaning_fee') { $cleaning_fee = $fee['amount']; break; } } $tax_percentage = 0; foreach ($taxes_data as $tax) { $tax_percentage += floatval($tax['amount']); } // Build house rules exactly like the old plugin $house_rules = []; if (!empty($propertyAttributes['check_in_time'])) { $house_rules[] = "Check-in: " . gmdate("g:iA", strtotime($propertyAttributes['check_in_time'] . ":00")); } if (!empty($propertyAttributes['check_out_time'])) { $house_rules[] = "reservation: " . gmdate("g:iA", strtotime($propertyAttributes['check_out_time'] . ":00")); } if ($suitability) { $house_rules[] = !empty($suitability_attrs['children']) ? "Children allowed" : "Children not allowed"; $house_rules[] = !empty($suitability_attrs['pets']) ? "Pets allowed" : "Pets not allowed"; $house_rules[] = !empty($suitability_attrs['events']) ? "Events allowed" : "Events not allowed"; $house_rules[] = !empty($suitability_attrs['smoking']) ? "Smoking allowed" : "Smoking not allowed"; } if ($policy) { if (!empty($policy['attributes']['type'])) $house_rules[] = $policy['attributes']['type']; if (!empty($policy['attributes']['description'])) $house_rules[] = $policy['attributes']['description']; } $house_rules = array_filter($house_rules); $properties[] = [ 'id' => $propertyId, 'name' => $propertyAttributes['name'] ?? '', 'description' => $propertyAttributes['description'] ?? '', 'title' => $address ? trim(($address['attributes']['street'] ?? $address['attributes']['address_line_1'] ?? '') . ', ' . ($address['attributes']['city'] ?? '') . ', ' . ($address['attributes']['country'] ?? '')) : '', 'address' => $address ? $address['attributes'] : [], 'guests' => $propertyAttributes['maximum_capacity'] ?? $suitability_attrs['max_guests'] ?? '', 'bedrooms' => $propertyAttributes['bedrooms'] ?? $suitability_attrs['bedrooms'] ?? '', 'baths' => $propertyAttributes['bathrooms'] ?? $suitability_attrs['bathrooms'] ?? '', 'beds' => $propertyAttributes['beds'] ?? '', 'check_in_time' => $propertyAttributes['check_in_time'] ?? '', 'reservation_time' => $propertyAttributes['check_out_time'] ?? '', 'license' => $propertyAttributes['license'] ?? '', 'suitability' => $suitability_attrs, 'policy' => $policy ? $policy['attributes'] : [], 'security_deposit' => $security_deposit_data, 'house_rules' => $house_rules, 'amenities' => array_column(array_column($amenities, 'attributes'), 'name'), 'photos' => $photoUrls, 'images' => $photoUrls, 'cleaning_fee' => $cleaning_fee, 'tax_percentage' => $tax_percentage, 'default_daily_rate' => $propertyAttributes['default_daily_rate'] ?? '', 'fees' => $fees_data, 'taxes' => $taxes_data, 'discounts' => $discounts_data, 'property' => $property, 'response' => $response, ]; } return $properties; } /** * Parse `property_fees` included resources with amount > 0. * * @param array $property JSON:API property resource. * @param array $response Full response including `included`. * @return array */ private function extract_fees($property, $response) { $extracted = []; if (!empty($property['relationships']['fees']['data'])) { $ids = array_column($property['relationships']['fees']['data'], 'id'); $items = array_filter($response['included'] ?? [], function ($item) use ($ids) { return in_array($item['id'], $ids) && $item['type'] == 'property_fees'; }); foreach ($items as $item) { $amount = $item['attributes']['amount'] ?? 0; if (floatval($amount) > 0) { $extracted[] = [ 'name' => $item['attributes']['name'] ?? '', 'label' => $item['attributes']['label'] ?? '', 'amount' => $amount, 'type' => $item['attributes']['type'] ?? '', ]; } } } return $extracted; } /** * Extract tax data from a JSON:API property response. * * @param array $property Property data from the API. * @param array $response Full API response including included resources. * @return array */ private function extract_taxes($property, $response) { $extracted = []; if (!empty($property['relationships']['taxes']['data'])) { $ids = array_column($property['relationships']['taxes']['data'], 'id'); $items = array_filter($response['included'] ?? [], function ($item) use ($ids) { return in_array($item['id'], $ids) && $item['type'] == 'property_taxes'; }); foreach ($items as $item) { $amount = $item['attributes']['amount'] ?? 0; if (floatval($amount) > 0) { $extracted[] = [ 'name' => $item['attributes']['name'] ?? '', 'label' => $item['attributes']['label'] ?? '', 'amount' => $amount, 'type' => $item['attributes']['type'] ?? '', ]; } } } return $extracted; } /** * Extract discount data from a JSON:API property response. * * @param array $property Property data from the API. * @param array $response Full API response including included resources. * @return array */ private function extract_discounts($property, $response) { $extracted = []; if (!empty($property['relationships']['discounts']['data'])) { $ids = array_column($property['relationships']['discounts']['data'], 'id'); $items = array_filter($response['included'] ?? [], function ($item) use ($ids) { return in_array($item['id'], $ids) && $item['type'] == 'property_discounts'; }); foreach ($items as $item) { $type = strtolower($item['attributes']['type'] ?? ''); if (!in_array($type, ['percentage', 'fixed'], true)) { continue; } $amount = $item['attributes']['amount'] ?? 0; if (floatval($amount) > 0) { $extracted[] = [ 'name' => $item['attributes']['name'] ?? '', 'label' => $item['attributes']['label'] ?? '', 'days' => $item['attributes']['days'] ?? 0, 'amount' => $amount, 'type' => $type, ]; } } } return $extracted; } /** * Extract security deposit data from a JSON:API property response. * * @param array $property Property data from the API. * @param array $response Full API response including included resources. * @return array */ private function extract_security_deposit($property, $response) { $extracted = []; $security_deposit = array_filter($response['included'] ?? [], function ($item) use ($property) { return isset($property['relationships']['protect_security_deposit_setting']['data']['id']) && $item['id'] == $property['relationships']['protect_security_deposit_setting']['data']['id'] && $item['type'] == 'protect_security_deposit_settings'; }); $security_deposit = reset($security_deposit); if ($security_deposit && isset($security_deposit['attributes'])) { $attrs = $security_deposit['attributes']; $is_enabled = !empty($attrs['enabled']) || (isset($attrs['amount']) && floatval($attrs['amount']) > 0); if ($is_enabled) { $extracted = $attrs; } } return $extracted; } }