From 03b9bce52ded0dd68b860813dafe89dc46ac14d7 Mon Sep 17 00:00:00 2001 From: ben Date: Fri, 6 Feb 2026 11:25:26 -0400 Subject: [PATCH] fix(zoho): Fix silent sync failures with API response validation and hash reset Zoho CRM sync appeared connected but silently failed to write data due to unvalidated API responses. Sync methods now validate Zoho responses before updating hashes, ensuring failed records re-sync on next run. Also fixes staging detection to use wp_parse_url hostname parsing instead of fragile strpos matching, adds admin UI for resetting sync hashes, and bumps HVAC_PLUGIN_VERSION to 2.2.11 to bust browser cache for updated JS. Co-Authored-By: Claude Opus 4.6 --- Status.md | 81 +++++++- assets/js/zoho-admin.js | 48 +++++ includes/admin/class-zoho-admin.php | 68 ++++++- includes/class-hvac-plugin.php | 7 +- includes/zoho/class-zoho-crm-auth.php | 51 +++-- includes/zoho/class-zoho-sync.php | 267 +++++++++++++++++++------- 6 files changed, 416 insertions(+), 106 deletions(-) diff --git a/Status.md b/Status.md index f72d4476..bbd2cb6a 100644 --- a/Status.md +++ b/Status.md @@ -1,8 +1,8 @@ # HVAC Community Events - Project Status **Last Updated:** February 6, 2026 -**Current Session:** Near Me Button Mobile Fix -**Version:** 2.2.6 (Deployed to Staging) +**Current Session:** Zoho CRM Sync Fix +**Version:** 2.2.6 (Deployed to Production) --- @@ -18,9 +18,84 @@ - Contact forms (trainer, venue) - Any other public-facing forms +### Post-Deploy Action Required +On production, go to **wp-admin > HVAC Community Events > Zoho CRM Sync**, click **"Force Full Re-sync (Reset Hashes)"**, then click each sync button or wait for the hourly scheduled sync. This clears poisoned hashes from prior failed syncs so all records re-sync with the new validated code. + --- -## 🎯 CURRENT SESSION - NEAR ME BUTTON MOBILE FIX (Feb 6, 2026) +## 🎯 CURRENT SESSION - ZOHO CRM SYNC FIX (Feb 6, 2026) + +### Status: ✅ **COMPLETE - Deployed to Production** + +**Objective:** Fix Zoho CRM integration that appeared connected but was silently failing to sync data (events, attendees, ticket sales not appearing in Zoho CRM). + +### Root Cause Analysis (4-model consensus: GPT-5, Gemini 3, Zen Code Review, Zen Debug) + +The sync pipeline had a "silent failure" architecture: +1. **Connection test only uses GET** - bypasses staging write block, giving false confidence +2. **Staging mode returned fake `'status' => 'success'`** for blocked writes - appeared successful +3. **Sync methods unconditionally updated `_zoho_sync_hash`** after any "sync" - poisoned hashes +4. **Subsequent syncs compared hashes, found matches, skipped all records** - permanent data loss +5. **No API response validation** - even real Zoho errors were silently ignored + +### Fixes Implemented + +1. **API Response Validation** (`includes/zoho/class-zoho-sync.php`) + - Added `validate_api_response()` helper method + - Checks for WP_Error, staging mode blocks, HTTP errors, Zoho error codes + - Confirms success with ID extraction before treating a sync as successful + +2. **Hash-Only-On-Success** (`includes/zoho/class-zoho-sync.php`) + - All 5 sync methods (events, users, attendees, rsvps, purchases) rewritten + - `_zoho_sync_hash` only updates when Zoho API confirms the write succeeded + - Failed syncs increment `$results['failed']` with error details + - Changed `catch (Exception)` to `catch (\Throwable)` for comprehensive error handling + +3. **Staging Mode Detection Fix** (`includes/zoho/class-zoho-crm-auth.php`) + - Replaced fragile `strpos()` substring matching with `wp_parse_url()` hostname comparison + - Production (`upskillhvac.com` / `www.upskillhvac.com`) explicitly whitelisted + - All other hostnames default to staging mode + - `HVAC_ZOHO_PRODUCTION_MODE` / `HVAC_ZOHO_STAGING_MODE` constants can override + - Staging fake responses now return `'skipped_staging'` instead of misleading `'success'` + +4. **Admin UI: Hash Reset & Staging Warning** (`includes/admin/class-zoho-admin.php`, `assets/js/zoho-admin.js`) + - Added `reset_sync_hashes()` AJAX handler - clears all `_zoho_sync_hash` from postmeta and usermeta + - Added "Force Full Re-sync (Reset Hashes)" button with confirmation dialog + - Connection test now surfaces staging warning when staging mode is active + +5. **Staging Environment Fix** (staging `wp-config.php`) + - Removed `HVAC_ZOHO_PRODUCTION_MODE` constant from staging wp-config.php + - Staging now correctly blocks all Zoho write operations via hostname detection + - GET requests (reads) still pass through for testing + +### Files Modified + +| File | Change | +|------|--------| +| `includes/zoho/class-zoho-sync.php` | Added `validate_api_response()`, rewrote all 5 sync methods for validated hashing | +| `includes/zoho/class-zoho-crm-auth.php` | Rewrote `is_staging_mode()` with hostname parsing, changed fake response status | +| `includes/admin/class-zoho-admin.php` | Added `reset_sync_hashes()` handler, reset button HTML, staging warning in connection test | +| `assets/js/zoho-admin.js` | Added reset hashes button handler, staging warning display in connection test | + +### Staging Verification + +| Test | Result | +|------|--------| +| Pre-deployment checks | All passed | +| Connection Test button | "Connection successful!" | +| Staging mode detection | Correctly reports hostname-based staging | +| Force Full Re-sync button | Cleared 3 post hashes + 66 user hashes | +| Events Sync | 41/41 synced, 0 failed | +| Staging write-block (after wp-config fix) | GET: PASS (95 modules), POST: PASS (blocked) | + +### Production Deployment +- Deployed successfully to https://upskillhvac.com/ +- All fix code verified present on production server +- Production correctly detected as non-staging (`upskillhvac.com` hostname match) + +--- + +## 📋 PREVIOUS SESSION - NEAR ME BUTTON MOBILE FIX (Feb 6, 2026) ### Status: ✅ **COMPLETE - Deployed to Staging** diff --git a/assets/js/zoho-admin.js b/assets/js/zoho-admin.js index c8c25707..e6c26e49 100644 --- a/assets/js/zoho-admin.js +++ b/assets/js/zoho-admin.js @@ -220,6 +220,13 @@ jQuery(document).ready(function ($) { successHtml += '

Refresh Token: ❌ Missing (OAuth required)

'; } + // Show staging warning if present + if (response.data.staging_warning) { + successHtml += '
'; + successHtml += '

Warning: ' + response.data.staging_warning + '

'; + successHtml += '
'; + } + // Show debug info if available if (response.data.debug) { successHtml += '
'; @@ -622,6 +629,47 @@ jQuery(document).ready(function ($) { }); }); + // ===================================================== + // Reset Sync Hashes Handler + // ===================================================== + $('#reset-sync-hashes').on('click', function () { + if (!confirm('This will clear all sync hashes and force every record to re-sync on the next run. Continue?')) { + return; + } + + var $button = $(this); + var $status = $('#reset-hashes-status'); + + $button.prop('disabled', true).text('Resetting...'); + $status.text(''); + + $.ajax({ + url: hvacZoho.ajaxUrl, + method: 'POST', + data: { + action: 'hvac_zoho_reset_sync_hashes', + nonce: hvacZoho.nonce + }, + success: function (response) { + if (response.success) { + $status.html( + '' + + response.data.posts_cleared + ' post hashes and ' + + response.data.users_cleared + ' user hashes cleared.' + ); + } else { + $status.html('Error: ' + response.data.message + ''); + } + }, + error: function () { + $status.html('Network error - please try again'); + }, + complete: function () { + $button.prop('disabled', false).text('Force Full Re-sync (Reset Hashes)'); + } + }); + }); + // ===================================================== // Diagnostic Test Handler // ===================================================== diff --git a/includes/admin/class-zoho-admin.php b/includes/admin/class-zoho-admin.php index ec285f9a..a410fe3b 100644 --- a/includes/admin/class-zoho-admin.php +++ b/includes/admin/class-zoho-admin.php @@ -47,6 +47,8 @@ class HVAC_Zoho_Admin { add_action('wp_ajax_hvac_zoho_run_scheduled_sync', array($this, 'run_scheduled_sync_now')); // Add simple test handler add_action('wp_ajax_hvac_zoho_simple_test', array($this, 'simple_test')); + // Add hash reset handler + add_action('wp_ajax_hvac_zoho_reset_sync_hashes', array($this, 'reset_sync_hashes')); // Add OAuth callback handler - only use one method to prevent duplicates add_action('init', array($this, 'add_oauth_rewrite_rule'), 5); add_filter('query_vars', array($this, 'add_oauth_query_vars'), 10, 1); @@ -297,6 +299,13 @@ class HVAC_Zoho_Admin {

Data Sync

+
+

Sync Maintenance

+

If records aren't syncing (e.g. after a failed sync or configuration change), reset sync hashes to force all records to re-sync on the next run.

+ + +
+

Events → Campaigns

Sync events from The Events Calendar to Zoho CRM Campaigns

@@ -897,20 +906,28 @@ class HVAC_Zoho_Admin { } // Success! - // Success! - wp_send_json_success(array( + $mode_info = HVAC_Zoho_CRM_Auth::get_debug_mode_info(); + $response_data = array( 'message' => 'Connection successful!', 'modules' => isset($response['modules']) ? count($response['modules']) . ' modules available' : 'API connected', 'client_id' => substr($client_id, 0, 10) . '...', 'client_secret_exists' => true, 'refresh_token_exists' => true, + 'is_staging' => $mode_info['is_staging'], + 'mode_info' => $mode_info, 'credentials_status' => array( 'client_id' => substr($client_id, 0, 10) . '...', 'client_secret_exists' => true, 'refresh_token_exists' => true, 'api_working' => true ) - )); + ); + + if ($mode_info['is_staging']) { + $response_data['staging_warning'] = 'WARNING: Staging mode is active. All write operations (sync) are blocked. Hostname: ' . ($mode_info['parsed_host'] ?? 'unknown'); + } + + wp_send_json_success($response_data); } catch (Exception $e) { $error_response = array( 'message' => 'Connection test failed due to exception', @@ -1048,18 +1065,18 @@ class HVAC_Zoho_Admin { */ public function run_scheduled_sync_now() { check_ajax_referer('hvac_zoho_nonce', 'nonce'); - + if (!current_user_can('manage_options')) { wp_send_json_error(array('message' => 'Unauthorized access')); return; } - + try { require_once HVAC_PLUGIN_DIR . 'includes/zoho/class-zoho-scheduled-sync.php'; $scheduled_sync = HVAC_Zoho_Scheduled_Sync::instance(); - + $result = $scheduled_sync->run_now(); - + wp_send_json_success(array( 'message' => 'Scheduled sync completed', 'result' => $result @@ -1072,5 +1089,42 @@ class HVAC_Zoho_Admin { } } + /** + * Reset all Zoho sync hashes to force a full re-sync + */ + public function reset_sync_hashes() { + check_ajax_referer('hvac_zoho_nonce', 'nonce'); + + if (!current_user_can('manage_options')) { + wp_send_json_error(array('message' => 'Unauthorized access')); + return; + } + + global $wpdb; + + // Delete all _zoho_sync_hash post meta + $posts_cleared = $wpdb->query( + "DELETE FROM {$wpdb->postmeta} WHERE meta_key = '_zoho_sync_hash'" + ); + + // Delete all _zoho_sync_hash user meta + $users_cleared = $wpdb->query( + "DELETE FROM {$wpdb->usermeta} WHERE meta_key = '_zoho_sync_hash'" + ); + + // Also clear last sync time so scheduled sync does a full run + delete_option('hvac_zoho_last_sync_time'); + + if (class_exists('HVAC_Logger')) { + HVAC_Logger::info("Sync hashes reset: {$posts_cleared} post hashes, {$users_cleared} user hashes cleared", 'ZohoAdmin'); + } + + wp_send_json_success(array( + 'message' => 'Sync hashes reset successfully', + 'posts_cleared' => (int) $posts_cleared, + 'users_cleared' => (int) $users_cleared, + )); + } + } ?> \ No newline at end of file diff --git a/includes/class-hvac-plugin.php b/includes/class-hvac-plugin.php index 571defb6..5af78387 100644 --- a/includes/class-hvac-plugin.php +++ b/includes/class-hvac-plugin.php @@ -112,10 +112,10 @@ final class HVAC_Plugin { */ private function defineConstants(): void { if (!defined('HVAC_PLUGIN_VERSION')) { - define('HVAC_PLUGIN_VERSION', '2.0.0'); + define('HVAC_PLUGIN_VERSION', '2.2.11'); } if (!defined('HVAC_VERSION')) { - define('HVAC_VERSION', '2.2.6'); + define('HVAC_VERSION', '2.2.11'); } if (!defined('HVAC_PLUGIN_FILE')) { define('HVAC_PLUGIN_FILE', dirname(__DIR__) . '/hvac-community-events.php'); @@ -175,6 +175,9 @@ final class HVAC_Plugin { // Core architecture includes require_once HVAC_PLUGIN_DIR . 'includes/class-hvac-browser-detection.php'; require_once HVAC_PLUGIN_DIR . 'includes/class-hvac-find-trainer-assets.php'; + + // reCAPTCHA integration for contact forms + require_once HVAC_PLUGIN_DIR . 'includes/class-hvac-recaptcha.php'; require_once HVAC_PLUGIN_DIR . 'includes/class-hvac-safari-debugger.php'; require_once HVAC_PLUGIN_DIR . 'includes/class-hvac-shortcodes.php'; diff --git a/includes/zoho/class-zoho-crm-auth.php b/includes/zoho/class-zoho-crm-auth.php index f8fa0377..bd3e9632 100644 --- a/includes/zoho/class-zoho-crm-auth.php +++ b/includes/zoho/class-zoho-crm-auth.php @@ -222,18 +222,21 @@ class HVAC_Zoho_CRM_Auth { return true; } + // 3. Parse hostname from site URL for accurate comparison $site_url = get_site_url(); - - // 3. Check for specific staging domains or keywords - if (strpos($site_url, 'staging') !== false || - strpos($site_url, 'dev') !== false || - strpos($site_url, 'test') !== false || - strpos($site_url, 'cloudwaysapps.com') !== false) { - return true; + $host = wp_parse_url($site_url, PHP_URL_HOST); + + if (empty($host)) { + return true; // Can't determine host, default to staging for safety } - // 4. Default check: Production only on upskillhvac.com - return strpos($site_url, 'upskillhvac.com') === false; + // 4. Production: upskillhvac.com or www.upskillhvac.com + if ($host === 'upskillhvac.com' || $host === 'www.upskillhvac.com') { + return false; + } + + // 5. Everything else is staging (including staging subdomains, cloudwaysapps, localhost, etc.) + return true; } /** @@ -242,8 +245,12 @@ class HVAC_Zoho_CRM_Auth { * @return array Debug information */ public static function get_debug_mode_info() { + $site_url = get_site_url(); + $host = wp_parse_url($site_url, PHP_URL_HOST); + $info = array( - 'site_url' => get_site_url(), + 'site_url' => $site_url, + 'parsed_host' => $host, 'is_staging' => self::is_staging_mode(), 'forced_production' => defined('HVAC_ZOHO_PRODUCTION_MODE') && HVAC_ZOHO_PRODUCTION_MODE, 'forced_staging' => defined('HVAC_ZOHO_STAGING_MODE') && HVAC_ZOHO_STAGING_MODE, @@ -255,20 +262,12 @@ class HVAC_Zoho_CRM_Auth { $info['detection_logic'][] = 'Forced PRODUCTION via HVAC_ZOHO_PRODUCTION_MODE constant'; } elseif ($info['forced_staging']) { $info['detection_logic'][] = 'Forced STAGING via HVAC_ZOHO_STAGING_MODE constant'; + } elseif (empty($host)) { + $info['detection_logic'][] = 'STAGING: Could not parse hostname from URL'; + } elseif ($host === 'upskillhvac.com' || $host === 'www.upskillhvac.com') { + $info['detection_logic'][] = 'PRODUCTION: Hostname matches upskillhvac.com'; } else { - $site_url = $info['site_url']; - if (strpos($site_url, 'staging') !== false) $info['detection_logic'][] = 'Matched "staging" in URL'; - if (strpos($site_url, 'dev') !== false) $info['detection_logic'][] = 'Matched "dev" in URL'; - if (strpos($site_url, 'test') !== false) $info['detection_logic'][] = 'Matched "test" in URL'; - if (strpos($site_url, 'cloudwaysapps.com') !== false) $info['detection_logic'][] = 'Matched "cloudwaysapps.com" in URL'; - - if (empty($info['detection_logic'])) { - if (strpos($site_url, 'upskillhvac.com') === false) { - $info['detection_logic'][] = 'Default STAGING: URL does not contain "upskillhvac.com"'; - } else { - $info['detection_logic'][] = 'Default PRODUCTION: URL contains "upskillhvac.com"'; - } - } + $info['detection_logic'][] = 'STAGING: Hostname "' . $host . '" is not upskillhvac.com'; } return $info; @@ -283,7 +282,7 @@ class HVAC_Zoho_CRM_Auth { // In staging mode, only allow read operations, no writes if ($is_staging && in_array($method, array('POST', 'PUT', 'DELETE', 'PATCH'))) { - $this->log_debug('STAGING MODE: Simulating ' . $method . ' request to ' . $endpoint); + $this->log_debug('STAGING MODE: Blocked ' . $method . ' request to ' . $endpoint); return array( 'data' => array( array( @@ -291,8 +290,8 @@ class HVAC_Zoho_CRM_Auth { 'details' => array( 'message' => 'Staging mode active. Write operations are disabled.' ), - 'message' => 'This would have been a ' . $method . ' request to: ' . $endpoint, - 'status' => 'success' + 'message' => 'Blocked ' . $method . ' request to: ' . $endpoint, + 'status' => 'skipped_staging' ) ) ); diff --git a/includes/zoho/class-zoho-sync.php b/includes/zoho/class-zoho-sync.php index 59c5b041..62d4aeb4 100644 --- a/includes/zoho/class-zoho-sync.php +++ b/includes/zoho/class-zoho-sync.php @@ -55,6 +55,86 @@ class HVAC_Zoho_Sync { // Use consistent logic from Auth class return !HVAC_Zoho_CRM_Auth::is_staging_mode(); } + + /** + * Validate a Zoho API response to determine if the operation succeeded + * + * @param mixed $response Response from make_api_request() + * @return array ['success' => bool, 'id' => string|null, 'error' => string|null] + */ + private function validate_api_response($response) { + // Check for WP_Error + if (is_wp_error($response)) { + return array( + 'success' => false, + 'id' => null, + 'error' => $response->get_error_message() + ); + } + + // Check for staging mode simulation + if (isset($response['data'][0]['code']) && $response['data'][0]['code'] === 'STAGING_MODE') { + return array( + 'success' => false, + 'id' => null, + 'error' => 'Staging mode: write operations blocked' + ); + } + + // Check for HTTP-level errors + if (isset($response['error'])) { + return array( + 'success' => false, + 'id' => null, + 'error' => $response['error'] + ); + } + + // Check for Zoho API error codes + if (isset($response['data'][0]['code']) && !in_array($response['data'][0]['code'], array('SUCCESS', 'DUPLICATE_DATA'))) { + $error_msg = isset($response['data'][0]['message']) ? $response['data'][0]['message'] : $response['data'][0]['code']; + return array( + 'success' => false, + 'id' => null, + 'error' => $error_msg + ); + } + + // Check for successful response with ID + if (isset($response['data'][0]['details']['id'])) { + return array( + 'success' => true, + 'id' => $response['data'][0]['details']['id'], + 'error' => null + ); + } + + // Check for duplicate record handling (also a success case) + if (isset($response['data'][0]['code']) && $response['data'][0]['code'] === 'DUPLICATE_DATA' + && isset($response['data'][0]['details']['duplicate_record']['id'])) { + return array( + 'success' => true, + 'id' => $response['data'][0]['details']['duplicate_record']['id'], + 'error' => null + ); + } + + // Check for success status without ID (e.g., PUT updates) + if (isset($response['data'][0]['status']) && $response['data'][0]['status'] === 'success') { + return array( + 'success' => true, + 'id' => isset($response['data'][0]['details']['id']) ? $response['data'][0]['details']['id'] : null, + 'error' => null + ); + } + + // Unknown response structure - treat as failure + return array( + 'success' => false, + 'id' => null, + 'error' => 'Unexpected API response: ' . json_encode(array_slice($response, 0, 3)) + ); + } /** * Generate a hash for sync data to detect changes @@ -202,37 +282,42 @@ class HVAC_Zoho_Sync { foreach ($events as $event) { try { $campaign_data = $this->prepare_campaign_data($event); - + // Check if data has changed using hash comparison if (!$this->should_sync($event->ID, $campaign_data)) { $results['skipped']++; continue; } - + $campaign_id = null; - + $sync_succeeded = false; + // FIRST: Check if we already have a stored Zoho Campaign ID $stored_campaign_id = get_post_meta($event->ID, '_zoho_campaign_id', true); - + if (!empty($stored_campaign_id)) { // We have a stored ID - try to update $update_response = $this->auth->make_api_request("/Campaigns/{$stored_campaign_id}", 'PUT', array( 'data' => array($campaign_data) )); - + $validated = $this->validate_api_response($update_response); + // Check if update failed due to invalid ID (e.g. campaign deleted in Zoho) - if (isset($update_response['code']) && $update_response['code'] === 'INVALID_DATA') { + if (!$validated['success'] && isset($update_response['code']) && $update_response['code'] === 'INVALID_DATA') { // Fallback: Create new campaign $create_response = $this->auth->make_api_request('/Campaigns', 'POST', array( 'data' => array($campaign_data) )); + $validated = $this->validate_api_response($create_response); $results['responses'][] = array('type' => 'create_fallback', 'id' => $event->ID, 'response' => $create_response); - - if (!empty($create_response['data'][0]['details']['id'])) { - $campaign_id = $create_response['data'][0]['details']['id']; + + if ($validated['success'] && $validated['id']) { + $campaign_id = $validated['id']; + $sync_succeeded = true; } - } else { + } elseif ($validated['success']) { $campaign_id = $stored_campaign_id; + $sync_succeeded = true; $results['responses'][] = array('type' => 'update', 'id' => $event->ID, 'response' => $update_response); } } else { @@ -240,25 +325,32 @@ class HVAC_Zoho_Sync { $create_response = $this->auth->make_api_request('/Campaigns', 'POST', array( 'data' => array($campaign_data) )); + $validated = $this->validate_api_response($create_response); $results['responses'][] = array('type' => 'create', 'id' => $event->ID, 'response' => $create_response); - - // Extract campaign ID from create response - if (!empty($create_response['data'][0]['details']['id'])) { - $campaign_id = $create_response['data'][0]['details']['id']; + + if ($validated['success'] && $validated['id']) { + $campaign_id = $validated['id']; + $sync_succeeded = true; } } - - $results['synced']++; - - // Update event meta with Zoho Campaign ID and sync hash - if (!empty($campaign_id)) { - update_post_meta($event->ID, '_zoho_campaign_id', $campaign_id); + + if ($sync_succeeded) { + $results['synced']++; + + // Only update hash and Zoho ID on confirmed success + if (!empty($campaign_id)) { + update_post_meta($event->ID, '_zoho_campaign_id', $campaign_id); + } + update_post_meta($event->ID, '_zoho_sync_hash', $this->generate_sync_hash($campaign_data)); + } else { + $results['failed']++; + $error_msg = isset($validated['error']) ? $validated['error'] : 'Unknown API error'; + $results['errors'][] = sprintf('Event %s: %s', $event->ID, $error_msg); } - update_post_meta($event->ID, '_zoho_sync_hash', $this->generate_sync_hash($campaign_data)); - - } catch (Exception $e) { + + } catch (\Throwable $e) { $results['failed']++; - $results['errors'][] = sprintf('Event %s: %s', $event->ID, $e->getMessage()); + $results['errors'][] = sprintf('Event %s: [%s] %s', $event->ID, get_class($e), $e->getMessage()); } } @@ -351,46 +443,59 @@ class HVAC_Zoho_Sync { foreach ($users as $user) { try { $contact_data = $this->prepare_contact_data($user); - + // Check if data has changed using hash comparison if (!$this->should_sync_user($user->ID, $contact_data)) { $results['skipped']++; continue; } - + + $contact_id = null; + $sync_succeeded = false; + // Check if contact already exists in Zoho $search_response = $this->auth->make_api_request('/Contacts/search', 'GET', array( 'criteria' => "(Email:equals:{$contact_data['Email']})" )); - + if (!empty($search_response['data'])) { // Update existing contact $contact_id = $search_response['data'][0]['id']; $update_response = $this->auth->make_api_request("/Contacts/{$contact_id}", 'PUT', array( 'data' => array($contact_data) )); + $validated = $this->validate_api_response($update_response); + $sync_succeeded = $validated['success']; } else { // Create new contact $create_response = $this->auth->make_api_request('/Contacts', 'POST', array( 'data' => array($contact_data) )); - - if (!empty($create_response['data'][0]['details']['id'])) { - $contact_id = $create_response['data'][0]['details']['id']; + $validated = $this->validate_api_response($create_response); + $sync_succeeded = $validated['success']; + + if ($validated['success'] && $validated['id']) { + $contact_id = $validated['id']; } } - - $results['synced']++; - - // Update user meta with Zoho ID and sync hash - if (isset($contact_id)) { - update_user_meta($user->ID, '_zoho_contact_id', $contact_id); + + if ($sync_succeeded) { + $results['synced']++; + + // Only update hash and Zoho ID on confirmed success + if (!empty($contact_id)) { + update_user_meta($user->ID, '_zoho_contact_id', $contact_id); + } + update_user_meta($user->ID, '_zoho_sync_hash', $this->generate_sync_hash($contact_data)); + } else { + $results['failed']++; + $error_msg = isset($validated['error']) ? $validated['error'] : 'Unknown API error'; + $results['errors'][] = sprintf('User %s: %s', $user->ID, $error_msg); } - update_user_meta($user->ID, '_zoho_sync_hash', $this->generate_sync_hash($contact_data)); - - } catch (Exception $e) { + + } catch (\Throwable $e) { $results['failed']++; - $results['errors'][] = sprintf('User %s: %s', $user->ID, $e->getMessage()); + $results['errors'][] = sprintf('User %s: [%s] %s', $user->ID, get_class($e), $e->getMessage()); } } @@ -489,13 +594,16 @@ class HVAC_Zoho_Sync { foreach ($orders as $order) { try { $invoice_data = $this->prepare_tc_invoice_data($order); - + // Check if data has changed using hash comparison if (!$this->should_sync($order->ID, $invoice_data)) { $results['skipped']++; continue; } + $invoice_id = null; + $sync_succeeded = false; + // Check if invoice already exists in Zoho (by WordPress Order ID) $search_response = $this->auth->make_api_request( '/Invoices/search?criteria=(WordPress_Order_ID:equals:' . $order->ID . ')', @@ -505,31 +613,41 @@ class HVAC_Zoho_Sync { if (!empty($search_response['data'])) { // Update existing invoice $invoice_id = $search_response['data'][0]['id']; - $this->auth->make_api_request("/Invoices/{$invoice_id}", 'PUT', array( + $update_response = $this->auth->make_api_request("/Invoices/{$invoice_id}", 'PUT', array( 'data' => array($invoice_data) )); + $validated = $this->validate_api_response($update_response); + $sync_succeeded = $validated['success']; } else { // Create new invoice $create_response = $this->auth->make_api_request('/Invoices', 'POST', array( 'data' => array($invoice_data) )); + $validated = $this->validate_api_response($create_response); + $sync_succeeded = $validated['success']; - if (!empty($create_response['data'][0]['details']['id'])) { - $invoice_id = $create_response['data'][0]['details']['id']; + if ($validated['success'] && $validated['id']) { + $invoice_id = $validated['id']; } } - $results['synced']++; + if ($sync_succeeded) { + $results['synced']++; - // Update order meta with Zoho ID and sync hash - if (isset($invoice_id)) { - update_post_meta($order->ID, '_zoho_invoice_id', $invoice_id); + // Only update hash and Zoho ID on confirmed success + if (!empty($invoice_id)) { + update_post_meta($order->ID, '_zoho_invoice_id', $invoice_id); + } + update_post_meta($order->ID, '_zoho_sync_hash', $this->generate_sync_hash($invoice_data)); + } else { + $results['failed']++; + $error_msg = isset($validated['error']) ? $validated['error'] : 'Unknown API error'; + $results['errors'][] = sprintf('Order %s: %s', $order->ID, $error_msg); } - update_post_meta($order->ID, '_zoho_sync_hash', $this->generate_sync_hash($invoice_data)); - } catch (Exception $e) { + } catch (\Throwable $e) { $results['failed']++; - $results['errors'][] = sprintf('Order %s: %s', $order->ID, $e->getMessage()); + $results['errors'][] = sprintf('Order %s: [%s] %s', $order->ID, get_class($e), $e->getMessage()); } } @@ -687,10 +805,20 @@ class HVAC_Zoho_Sync { $results['responses'][] = array('type' => 'debug', 'msg' => "Debug: Attendee {$attendee->ID} found Contact ID: " . $cid_debug); } + // If contact creation failed, count as failed and skip hash update + if (!$contact_id) { + $results['failed']++; + $error_detail = $this->last_contact_error ?: 'Unknown error'; + $results['errors'][] = sprintf('Attendee %s: No contact_id created. %s', $attendee->ID, $error_detail); + continue; + } + + $attendee_sync_ok = true; + // Step 2: Create Campaign Member (link Contact to Campaign) - if ($contact_id && !empty($attendee_data['event_id'])) { + if (!empty($attendee_data['event_id'])) { $campaign_id = get_post_meta($attendee_data['event_id'], '_zoho_campaign_id', true); - + // Debug: Log event_id and campaign_id for troubleshooting if (count($results['responses']) < 10) { $results['responses'][] = array( @@ -700,10 +828,10 @@ class HVAC_Zoho_Sync { 'campaign_id' => $campaign_id ?: 'NOT_SET' ); } - + if ($campaign_id) { $assoc_response = $this->create_campaign_member($contact_id, $campaign_id, 'Attended'); - + // ALWAYS capture the first link attempt for debugging if (!isset($results['first_link_attempt'])) { $results['first_link_attempt'] = array( @@ -713,7 +841,7 @@ class HVAC_Zoho_Sync { 'response' => $assoc_response ); } - + // Debug: Add responses (increased limit to 20) if (count($results['responses']) < 20) { $results['responses'][] = array('type' => 'link_attempt', 'id' => $attendee->ID, 'response' => $assoc_response); @@ -732,9 +860,6 @@ class HVAC_Zoho_Sync { } else { $results['errors'][] = sprintf('Attendee %s: Event %s has no Zoho Campaign ID', $attendee->ID, $attendee_data['event_id']); } - } elseif (!$contact_id) { - $error_detail = $this->last_contact_error ?: 'Unknown error'; - $results['errors'][] = sprintf('Attendee %s: No contact_id created. %s', $attendee->ID, $error_detail); } elseif (empty($attendee_data['event_id'])) { // Debug: Log when event_id is missing if (count($results['responses']) < 10) { @@ -742,9 +867,10 @@ class HVAC_Zoho_Sync { } } + // Contact was created/found successfully - count as synced $results['synced']++; - // Update attendee meta with Zoho Contact ID and sync hash + // Only update hash on confirmed contact success update_post_meta($attendee->ID, '_zoho_contact_id', $contact_id); update_post_meta($attendee->ID, '_zoho_sync_hash', $this->generate_sync_hash($attendee_data)); @@ -851,7 +977,7 @@ class HVAC_Zoho_Sync { foreach ($rsvps as $rsvp) { try { $rsvp_data = $this->prepare_rsvp_data($rsvp); - + // Check if data has changed using hash comparison if (!$this->should_sync($rsvp->ID, $rsvp_data)) { $results['skipped']++; @@ -866,12 +992,17 @@ class HVAC_Zoho_Sync { // Step 1: Create/Update Lead $lead_id = $this->ensure_lead_exists($rsvp_data); - if ($lead_id) { - $results['leads_created']++; + + if (!$lead_id) { + $results['failed']++; + $results['errors'][] = sprintf('RSVP %s: Failed to create/find lead', $rsvp->ID); + continue; } + $results['leads_created']++; + // Step 2: Create Campaign Member (link Lead to Campaign) - if ($lead_id && !empty($rsvp_data['event_id'])) { + if (!empty($rsvp_data['event_id'])) { $campaign_id = get_post_meta($rsvp_data['event_id'], '_zoho_campaign_id', true); if ($campaign_id) { $assoc_response = $this->create_campaign_member($lead_id, $campaign_id, 'Responded', 'Leads'); @@ -881,7 +1012,7 @@ class HVAC_Zoho_Sync { $results['campaign_members_created']++; } else { $results['errors'][] = sprintf('RSVP %s: Failed to link to campaign. Response: %s', $rsvp->ID, json_encode($assoc_response)); - if (isset($assoc_response['data'])) { + if (isset($assoc_response['data'])) { $results['responses'][] = array('type' => 'link_error', 'id' => $rsvp->ID, 'response' => $assoc_response); } } @@ -892,13 +1023,13 @@ class HVAC_Zoho_Sync { $results['synced']++; - // Update RSVP meta with Zoho Lead ID and sync hash + // Only update hash on confirmed lead creation success update_post_meta($rsvp->ID, '_zoho_lead_id', $lead_id); update_post_meta($rsvp->ID, '_zoho_sync_hash', $this->generate_sync_hash($rsvp_data)); - } catch (Exception $e) { + } catch (\Throwable $e) { $results['failed']++; - $results['errors'][] = sprintf('RSVP %s: %s', $rsvp->ID, $e->getMessage()); + $results['errors'][] = sprintf('RSVP %s: [%s] %s', $rsvp->ID, get_class($e), $e->getMessage()); } }