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()); } }