/** * Descomplicar® Crescimento Digital * https://descomplicar.pt */ CI = &get_instance(); // Load base model for local use if (method_exists($this->CI, 'load')) { $this->CI->load->model('desk_moloni/desk_moloni_sync_log_model', 'desk_moloni_sync_log_model'); $this->model = $this->CI->desk_moloni_sync_log_model; } // Initialize dependencies for QueueProcessor $this->CI->load->model('desk_moloni/desk_moloni_model'); $model = $this->CI->desk_moloni_model; // Redis initialization if (!extension_loaded('redis')) { throw new \Exception('Redis extension not loaded'); } $redis = new \Redis(); $redis_host = get_option('desk_moloni_redis_host', '127.0.0.1'); $redis_port = (int)get_option('desk_moloni_redis_port', 6379); $redis_password = get_option('desk_moloni_redis_password', ''); $redis_db = (int)get_option('desk_moloni_redis_db', 0); if (!$redis->connect($redis_host, $redis_port, 2.5)) { throw new \Exception('Failed to connect to Redis server'); } if (!empty($redis_password)) { $redis->auth($redis_password); } $redis->select($redis_db); // Instantiate services $this->entity_mapping = new EntityMappingService(); $this->error_handler = new ErrorHandler(); $retry_handler = new RetryHandler(); // Instantiate QueueProcessor with dependencies $this->queue_processor = new QueueProcessor( $redis, $model, $this->entity_mapping, $this->error_handler, $retry_handler ); $this->register_hooks(); log_activity('PerfexHooks initialized and registered with DI'); } /** * Register all Perfex CRM hooks */ protected function register_hooks() { // Client/Customer hooks hooks()->add_action('after_client_added', [$this, 'handle_client_added']); hooks()->add_action('after_client_updated', [$this, 'handle_client_updated']); hooks()->add_action('before_client_deleted', [$this, 'handle_client_before_delete']); // Invoice hooks hooks()->add_action('after_invoice_added', [$this, 'handle_invoice_added']); hooks()->add_action('after_invoice_updated', [$this, 'handle_invoice_updated']); hooks()->add_action('invoice_status_changed', [$this, 'handle_invoice_status_changed']); hooks()->add_action('invoice_payment_recorded', [$this, 'handle_invoice_payment_recorded']); // Estimate hooks hooks()->add_action('after_estimate_added', [$this, 'handle_estimate_added']); hooks()->add_action('after_estimate_updated', [$this, 'handle_estimate_updated']); hooks()->add_action('estimate_status_changed', [$this, 'handle_estimate_status_changed']); // Credit Note hooks hooks()->add_action('after_credit_note_added', [$this, 'handle_credit_note_added']); hooks()->add_action('after_credit_note_updated', [$this, 'handle_credit_note_updated']); // Item/Product hooks hooks()->add_action('after_item_added', [$this, 'handle_item_added']); hooks()->add_action('after_item_updated', [$this, 'handle_item_updated']); hooks()->add_action('before_item_deleted', [$this, 'handle_item_before_delete']); // Contact hooks hooks()->add_action('after_contact_added', [$this, 'handle_contact_added']); hooks()->add_action('after_contact_updated', [$this, 'handle_contact_updated']); // Payment hooks hooks()->add_action('after_payment_added', [$this, 'handle_payment_added']); hooks()->add_action('after_payment_updated', [$this, 'handle_payment_updated']); // Custom hooks for Moloni integration hooks()->add_action('desk_moloni_webhook_received', [$this, 'handle_moloni_webhook']); hooks()->add_action('desk_moloni_manual_sync_requested', [$this, 'handle_manual_sync']); log_activity('Perfex CRM hooks registered successfully'); } /** * Handle client added event * * @param int $client_id */ public function handle_client_added($client_id) { if (!$this->should_sync_entity('customers')) { return; } try { $priority = $this->get_sync_priority('customer', 'create'); $delay = $this->get_sync_delay('customer', 'create'); $job_id = $this->queue_processor->add_to_queue( EntityMappingService::ENTITY_CUSTOMER, $client_id, 'create', 'perfex_to_moloni', $priority, ['trigger' => 'client_added'], $delay ); if ($job_id) { log_activity("Client #{$client_id} queued for sync to Moloni (Job: {$job_id})"); } } catch (\Exception $e) { $this->error_handler->log_error( ErrorHandler::CATEGORY_SYNC, 'CLIENT_ADDED_HOOK_FAILED', $e->getMessage(), ['client_id' => $client_id] ); } } /** * Handle client updated event * * @param int $client_id * @param array $data */ public function handle_client_updated($client_id, $data = []) { if (!$this->should_sync_entity('customers')) { return; } try { // Check if significant fields were changed if (!$this->has_significant_changes('customer', $data)) { log_activity("Client #{$client_id} updated but no significant changes detected"); return; } $priority = $this->get_sync_priority('customer', 'update'); $delay = $this->get_sync_delay('customer', 'update'); $job_id = $this->queue_processor->add_to_queue( EntityMappingService::ENTITY_CUSTOMER, $client_id, 'update', 'perfex_to_moloni', $priority, [ 'trigger' => 'client_updated', 'changed_fields' => array_keys($data) ], $delay ); if ($job_id) { log_activity("Client #{$client_id} queued for update sync to Moloni (Job: {$job_id})"); } } catch (\Exception $e) { $this->error_handler->log_error( ErrorHandler::CATEGORY_SYNC, 'CLIENT_UPDATED_HOOK_FAILED', $e->getMessage(), ['client_id' => $client_id, 'data' => $data] ); } } /** * Handle client before delete event * * @param int $client_id */ public function handle_client_before_delete($client_id) { if (!$this->should_sync_entity('customers')) { return; } try { // Check if client is mapped to Moloni $mapping = $this->entity_mapping->get_mapping_by_perfex_id( EntityMappingService::ENTITY_CUSTOMER, $client_id ); if (!$mapping) { return; // No mapping, nothing to sync } $priority = QueueProcessor::PRIORITY_HIGH; // High priority for deletions $job_id = $this->queue_processor->add_to_queue( EntityMappingService::ENTITY_CUSTOMER, $client_id, 'delete', 'perfex_to_moloni', $priority, [ 'trigger' => 'client_before_delete', 'moloni_id' => $mapping->moloni_id ], 0 // No delay for deletions ); if ($job_id) { log_activity("Client #{$client_id} queued for deletion sync to Moloni (Job: {$job_id})"); } } catch (\Exception $e) { $this->error_handler->log_error( ErrorHandler::CATEGORY_SYNC, 'CLIENT_DELETE_HOOK_FAILED', $e->getMessage(), ['client_id' => $client_id] ); } } /** * Handle invoice added event * * @param int $invoice_id */ public function handle_invoice_added($invoice_id) { if (!$this->should_sync_entity('invoices')) { return; } try { $priority = QueueProcessor::PRIORITY_HIGH; // Invoices are high priority $delay = $this->get_sync_delay('invoice', 'create'); $job_id = $this->queue_processor->add_to_queue( EntityMappingService::ENTITY_INVOICE, $invoice_id, 'create', 'perfex_to_moloni', $priority, ['trigger' => 'invoice_added'], $delay ); if ($job_id) { log_activity("Invoice #{$invoice_id} queued for sync to Moloni (Job: {$job_id})"); // Also sync client if not already synced $this->ensure_client_synced_for_invoice($invoice_id); } } catch (\Exception $e) { $this->error_handler->log_error( ErrorHandler::CATEGORY_SYNC, 'INVOICE_ADDED_HOOK_FAILED', $e->getMessage(), ['invoice_id' => $invoice_id] ); } } /** * Handle invoice updated event * * @param int $invoice_id * @param array $data */ public function handle_invoice_updated($invoice_id, $data = []) { if (!$this->should_sync_entity('invoices')) { return; } try { // Get invoice status to determine sync behavior $this->CI->load->model('invoices_model'); $invoice = $this->CI->invoices_model->get($invoice_id); if (!$invoice) { return; } $priority = $this->get_invoice_update_priority($invoice, $data); $delay = $this->get_sync_delay('invoice', 'update'); $job_id = $this->queue_processor->add_to_queue( EntityMappingService::ENTITY_INVOICE, $invoice_id, 'update', 'perfex_to_moloni', $priority, [ 'trigger' => 'invoice_updated', 'invoice_status' => $invoice->status, 'changed_fields' => array_keys($data) ], $delay ); if ($job_id) { log_activity("Invoice #{$invoice_id} queued for update sync to Moloni (Job: {$job_id})"); } } catch (\Exception $e) { $this->error_handler->log_error( ErrorHandler::CATEGORY_SYNC, 'INVOICE_UPDATED_HOOK_FAILED', $e->getMessage(), ['invoice_id' => $invoice_id, 'data' => $data] ); } } /** * Handle invoice status changed event * * @param int $invoice_id * @param int $old_status * @param int $new_status */ public function handle_invoice_status_changed($invoice_id, $old_status, $new_status) { if (!$this->should_sync_entity('invoices')) { return; } try { // Critical status changes should sync immediately $critical_statuses = [2, 3, 4, 5]; // Sent, Paid, Overdue, Cancelled $priority = in_array($new_status, $critical_statuses) ? QueueProcessor::PRIORITY_CRITICAL : QueueProcessor::PRIORITY_HIGH; $delay = $priority === QueueProcessor::PRIORITY_CRITICAL ? 0 : self::CRITICAL_SYNC_DELAY; $job_id = $this->queue_processor->add_to_queue( EntityMappingService::ENTITY_INVOICE, $invoice_id, 'update', 'perfex_to_moloni', $priority, [ 'trigger' => 'invoice_status_changed', 'old_status' => $old_status, 'new_status' => $new_status ], $delay ); if ($job_id) { log_activity("Invoice #{$invoice_id} status change queued for sync (Status: {$old_status} -> {$new_status}, Job: {$job_id})"); } } catch (\Exception $e) { $this->error_handler->log_error( ErrorHandler::CATEGORY_SYNC, 'INVOICE_STATUS_HOOK_FAILED', $e->getMessage(), ['invoice_id' => $invoice_id, 'old_status' => $old_status, 'new_status' => $new_status] ); } } /** * Handle invoice payment recorded event * * @param int $payment_id * @param int $invoice_id */ public function handle_invoice_payment_recorded($payment_id, $invoice_id) { if (!$this->should_sync_entity('payments')) { return; } try { // Payment recording is critical for financial accuracy $priority = QueueProcessor::PRIORITY_CRITICAL; $job_id = $this->queue_processor->add_to_queue( EntityMappingService::ENTITY_INVOICE, $invoice_id, 'update', 'perfex_to_moloni', $priority, [ 'trigger' => 'payment_recorded', 'payment_id' => $payment_id ], 0 // No delay for payments ); if ($job_id) { log_activity("Invoice #{$invoice_id} payment recorded, queued for sync (Payment: #{$payment_id}, Job: {$job_id})"); } } catch (\Exception $e) { $this->error_handler->log_error( ErrorHandler::CATEGORY_SYNC, 'PAYMENT_RECORDED_HOOK_FAILED', $e->getMessage(), ['payment_id' => $payment_id, 'invoice_id' => $invoice_id] ); } } /** * Handle estimate added event * * @param int $estimate_id */ public function handle_estimate_added($estimate_id) { if (!$this->should_sync_entity('estimates')) { return; } try { $priority = QueueProcessor::PRIORITY_NORMAL; $delay = $this->get_sync_delay('estimate', 'create'); $job_id = $this->queue_processor->add_to_queue( EntityMappingService::ENTITY_ESTIMATE, $estimate_id, 'create', 'perfex_to_moloni', $priority, ['trigger' => 'estimate_added'], $delay ); if ($job_id) { log_activity("Estimate #{$estimate_id} queued for sync to Moloni (Job: {$job_id})"); // Ensure client is synced $this->ensure_client_synced_for_estimate($estimate_id); } } catch (\Exception $e) { $this->error_handler->log_error( ErrorHandler::CATEGORY_SYNC, 'ESTIMATE_ADDED_HOOK_FAILED', $e->getMessage(), ['estimate_id' => $estimate_id] ); } } /** * Handle item/product added event * * @param int $item_id */ public function handle_item_added($item_id) { if (!$this->should_sync_entity('products')) { return; } try { $priority = QueueProcessor::PRIORITY_NORMAL; $delay = $this->get_sync_delay('product', 'create'); $job_id = $this->queue_processor->add_to_queue( EntityMappingService::ENTITY_PRODUCT, $item_id, 'create', 'perfex_to_moloni', $priority, ['trigger' => 'item_added'], $delay ); if ($job_id) { log_activity("Item #{$item_id} queued for sync to Moloni (Job: {$job_id})"); } } catch (\Exception $e) { $this->error_handler->log_error( ErrorHandler::CATEGORY_SYNC, 'ITEM_ADDED_HOOK_FAILED', $e->getMessage(), ['item_id' => $item_id] ); } } /** * Handle Moloni webhook events * * @param array $webhook_data */ public function handle_moloni_webhook($webhook_data) { try { $entity_type = $webhook_data['entity_type'] ?? null; $entity_id = $webhook_data['entity_id'] ?? null; $action = $webhook_data['action'] ?? null; if (!$entity_type || !$entity_id || !$action) { throw new \Exception('Invalid webhook data structure'); } // Determine priority based on entity type and action $priority = $this->get_webhook_priority($entity_type, $action); $job_id = $this->queue_processor->add_to_queue( $entity_type, $entity_id, $action, 'moloni_to_perfex', $priority, [ 'trigger' => 'moloni_webhook', 'webhook_data' => $webhook_data ], 0 // No delay for webhooks ); if ($job_id) { log_activity("Moloni webhook processed: {$entity_type} #{$entity_id} {$action} (Job: {$job_id})"); } } catch (\Exception $e) { $this->error_handler->log_error( ErrorHandler::CATEGORY_SYNC, 'MOLONI_WEBHOOK_HOOK_FAILED', $e->getMessage(), ['webhook_data' => $webhook_data] ); } } /** * Handle manual sync requests * * @param array $sync_request */ public function handle_manual_sync($sync_request) { try { $entity_type = $sync_request['entity_type']; $entity_ids = $sync_request['entity_ids']; $direction = $sync_request['direction'] ?? 'bidirectional'; $force_update = $sync_request['force_update'] ?? false; foreach ($entity_ids as $entity_id) { $job_id = $this->queue_processor->add_to_queue( $entity_type, $entity_id, $force_update ? 'update' : 'create', $direction, QueueProcessor::PRIORITY_HIGH, [ 'trigger' => 'manual_sync', 'force_update' => $force_update, 'requested_by' => get_staff_user_id() ], 0 // No delay for manual sync ); if ($job_id) { log_activity("Manual sync requested: {$entity_type} #{$entity_id} (Job: {$job_id})"); } } } catch (\Exception $e) { $this->error_handler->log_error( ErrorHandler::CATEGORY_SYNC, 'MANUAL_SYNC_HOOK_FAILED', $e->getMessage(), ['sync_request' => $sync_request] ); } } /** * Check if entity type should be synced * * @param string $entity_type * @return bool */ protected function should_sync_entity($entity_type) { $sync_enabled = get_option('desk_moloni_sync_enabled') == '1'; $entity_sync_enabled = get_option("desk_moloni_sync_{$entity_type}") == '1'; return $sync_enabled && $entity_sync_enabled; } /** * Get sync priority for entity and action * * @param string $entity_type * @param string $action * @return int */ protected function get_sync_priority($entity_type, $action) { // High priority entities $high_priority_entities = ['invoice', 'payment']; if (in_array($entity_type, $high_priority_entities)) { return QueueProcessor::PRIORITY_HIGH; } // Critical actions if ($action === 'delete') { return QueueProcessor::PRIORITY_HIGH; } return QueueProcessor::PRIORITY_NORMAL; } /** * Get sync delay for entity and action * * @param string $entity_type * @param string $action * @return int */ protected function get_sync_delay($entity_type, $action) { $default_delay = (int)get_option('desk_moloni_auto_sync_delay', self::DEFAULT_SYNC_DELAY); // No delay for critical actions if ($action === 'delete') { return 0; } // Reduced delay for important entities $important_entities = ['invoice', 'payment']; if (in_array($entity_type, $important_entities)) { return min($default_delay, self::CRITICAL_SYNC_DELAY); } return $default_delay; } /** * Check if data changes are significant enough to trigger sync * * @param string $entity_type * @param array $changed_data * @return bool */ protected function has_significant_changes($entity_type, $changed_data) { $significant_fields = $this->get_significant_fields($entity_type); foreach (array_keys($changed_data) as $field) { if (in_array($field, $significant_fields)) { return true; } } return false; } /** * Get significant fields for entity type * * @param string $entity_type * @return array */ protected function get_significant_fields($entity_type) { $field_mappings = [ 'customer' => ['company', 'vat', 'email', 'phonenumber', 'billing_street', 'billing_city', 'billing_zip'], 'product' => ['description', 'rate', 'tax', 'unit'], 'invoice' => ['total', 'subtotal', 'tax', 'status', 'date', 'duedate'], 'estimate' => ['total', 'subtotal', 'tax', 'status', 'date', 'expirydate'] ]; return $field_mappings[$entity_type] ?? []; } /** * Ensure client is synced for invoice * * @param int $invoice_id */ protected function ensure_client_synced_for_invoice($invoice_id) { try { $this->CI->load->model('invoices_model'); $invoice = $this->CI->invoices_model->get($invoice_id); if (!$invoice) { return; } $client_mapping = $this->entity_mapping->get_mapping_by_perfex_id( EntityMappingService::ENTITY_CUSTOMER, $invoice->clientid ); if (!$client_mapping) { // Client not synced, add to queue $this->queue_processor->add_to_queue( EntityMappingService::ENTITY_CUSTOMER, $invoice->clientid, 'create', 'perfex_to_moloni', QueueProcessor::PRIORITY_HIGH, ['trigger' => 'invoice_client_dependency'], 0 ); log_activity("Client #{$invoice->clientid} queued for sync (dependency for invoice #{$invoice_id})"); } } catch (\Exception $e) { $this->error_handler->log_error( ErrorHandler::CATEGORY_SYNC, 'CLIENT_DEPENDENCY_SYNC_FAILED', $e->getMessage(), ['invoice_id' => $invoice_id] ); } } /** * Get invoice update priority based on status and changes * * @param object $invoice * @param array $data * @return int */ protected function get_invoice_update_priority($invoice, $data) { // High priority for sent, paid, or cancelled invoices $high_priority_statuses = [2, 3, 5]; // Sent, Paid, Cancelled if (in_array($invoice->status, $high_priority_statuses)) { return QueueProcessor::PRIORITY_HIGH; } // High priority for financial changes $financial_fields = ['total', 'subtotal', 'tax', 'discount_total']; foreach ($financial_fields as $field) { if (array_key_exists($field, $data)) { return QueueProcessor::PRIORITY_HIGH; } } return QueueProcessor::PRIORITY_NORMAL; } /** * Get webhook priority based on entity and action * * @param string $entity_type * @param string $action * @return int */ protected function get_webhook_priority($entity_type, $action) { // Critical for financial documents $critical_entities = ['invoice', 'receipt', 'credit_note']; if (in_array($entity_type, $critical_entities)) { return QueueProcessor::PRIORITY_CRITICAL; } return QueueProcessor::PRIORITY_HIGH; } /** * Get hook statistics for monitoring * * @return array */ public function get_hook_statistics() { return [ 'total_hooks_triggered' => $this->model->count_hook_triggers(), 'hooks_by_entity' => $this->model->count_hooks_by_entity(), 'hooks_by_action' => $this->model->count_hooks_by_action(), 'recent_hooks' => $this->model->get_recent_hook_triggers(10), 'failed_hooks' => $this->model->get_failed_hook_triggers(10) ]; } }