fix(perfexcrm module): align version to 3.0.1, unify entrypoint, and harden routes/views
- Bump DESK_MOLONI version to 3.0.1 across module - Normalize hooks to after_client_* and instantiate PerfexHooks safely - Fix OAuthController view path and API client class name - Add missing admin views for webhook config/logs; adjust view loading - Harden client portal routes and admin routes mapping - Make Dashboard/Logs/Queue tolerant to optional model methods - Align log details query with existing schema; avoid broken joins This makes the module operational in Perfex (admin + client), reduces 404s, and avoids fatal errors due to inconsistent tables/methods.
This commit is contained in:
408
modules/desk_moloni/libraries/ClientNotificationService.php
Normal file
408
modules/desk_moloni/libraries/ClientNotificationService.php
Normal file
@@ -0,0 +1,408 @@
|
||||
<?php
|
||||
|
||||
defined('BASEPATH') or exit('No direct script access allowed');
|
||||
|
||||
/**
|
||||
* Client Notification Service
|
||||
* Handles notifications for client portal users
|
||||
*
|
||||
* @package Desk-Moloni
|
||||
* @version 3.0.0
|
||||
* @author Descomplicar Business Solutions
|
||||
*/
|
||||
class ClientNotificationService
|
||||
{
|
||||
private $CI;
|
||||
private $notificationsTable = 'desk_moloni_client_notifications';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->CI =& get_instance();
|
||||
$this->CI->load->database();
|
||||
$this->CI->load->helper('date');
|
||||
|
||||
// Create notifications table if it doesn't exist
|
||||
$this->_ensureNotificationsTableExists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new notification for a client
|
||||
*
|
||||
* @param int $clientId Client ID
|
||||
* @param string $type Notification type
|
||||
* @param string $title Notification title
|
||||
* @param string $message Notification message
|
||||
* @param int|null $documentId Related document ID (optional)
|
||||
* @param string|null $actionUrl Action URL (optional)
|
||||
* @return int|false Notification ID or false on failure
|
||||
*/
|
||||
public function createNotification($clientId, $type, $title, $message, $documentId = null, $actionUrl = null)
|
||||
{
|
||||
try {
|
||||
$data = [
|
||||
'client_id' => (int) $clientId,
|
||||
'type' => $type,
|
||||
'title' => $title,
|
||||
'message' => $message,
|
||||
'document_id' => $documentId ? (int) $documentId : null,
|
||||
'action_url' => $actionUrl,
|
||||
'is_read' => 0,
|
||||
'created_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
// Validate notification type
|
||||
if (!$this->_isValidNotificationType($type)) {
|
||||
throw new Exception('Invalid notification type: ' . $type);
|
||||
}
|
||||
|
||||
// Validate client exists
|
||||
if (!$this->_clientExists($clientId)) {
|
||||
throw new Exception('Client does not exist: ' . $clientId);
|
||||
}
|
||||
|
||||
$result = $this->CI->db->insert($this->notificationsTable, $data);
|
||||
|
||||
if ($result) {
|
||||
$notificationId = $this->CI->db->insert_id();
|
||||
|
||||
// Log notification creation
|
||||
log_message('info', "Notification created: ID {$notificationId} for client {$clientId}");
|
||||
|
||||
return $notificationId;
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Create notification error: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get notifications for a client
|
||||
*
|
||||
* @param int $clientId Client ID
|
||||
* @param bool $unreadOnly Get only unread notifications
|
||||
* @param int $limit Maximum number of notifications
|
||||
* @param int $offset Offset for pagination
|
||||
* @return array Notifications
|
||||
*/
|
||||
public function getClientNotifications($clientId, $unreadOnly = false, $limit = 20, $offset = 0)
|
||||
{
|
||||
try {
|
||||
$this->CI->db->where('client_id', $clientId);
|
||||
|
||||
if ($unreadOnly) {
|
||||
$this->CI->db->where('is_read', 0);
|
||||
}
|
||||
|
||||
$query = $this->CI->db->order_by('created_at', 'DESC')
|
||||
->limit($limit, $offset)
|
||||
->get($this->notificationsTable);
|
||||
|
||||
$notifications = $query->result_array();
|
||||
|
||||
// Format notifications
|
||||
foreach ($notifications as &$notification) {
|
||||
$notification['id'] = (int) $notification['id'];
|
||||
$notification['client_id'] = (int) $notification['client_id'];
|
||||
$notification['document_id'] = $notification['document_id'] ? (int) $notification['document_id'] : null;
|
||||
$notification['is_read'] = (bool) $notification['is_read'];
|
||||
}
|
||||
|
||||
return $notifications;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Get client notifications error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unread notifications count for a client
|
||||
*
|
||||
* @param int $clientId Client ID
|
||||
* @return int Unread count
|
||||
*/
|
||||
public function getUnreadCount($clientId)
|
||||
{
|
||||
try {
|
||||
return $this->CI->db->where('client_id', $clientId)
|
||||
->where('is_read', 0)
|
||||
->count_all_results($this->notificationsTable);
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Get unread count error: ' . $e->getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark notification as read
|
||||
*
|
||||
* @param int $notificationId Notification ID
|
||||
* @param int $clientId Client ID (for security check)
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function markAsRead($notificationId, $clientId)
|
||||
{
|
||||
try {
|
||||
$this->CI->db->where('id', $notificationId)
|
||||
->where('client_id', $clientId);
|
||||
|
||||
$result = $this->CI->db->update($this->notificationsTable, [
|
||||
'is_read' => 1,
|
||||
'read_at' => date('Y-m-d H:i:s')
|
||||
]);
|
||||
|
||||
return $result && $this->CI->db->affected_rows() > 0;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Mark notification as read error: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all notifications as read for a client
|
||||
*
|
||||
* @param int $clientId Client ID
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function markAllAsRead($clientId)
|
||||
{
|
||||
try {
|
||||
$this->CI->db->where('client_id', $clientId)
|
||||
->where('is_read', 0);
|
||||
|
||||
$result = $this->CI->db->update($this->notificationsTable, [
|
||||
'is_read' => 1,
|
||||
'read_at' => date('Y-m-d H:i:s')
|
||||
]);
|
||||
|
||||
return $result;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Mark all notifications as read error: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete old notifications
|
||||
*
|
||||
* @param int $olderThanDays Delete notifications older than X days
|
||||
* @return int Number of deleted notifications
|
||||
*/
|
||||
public function cleanupOldNotifications($olderThanDays = 90)
|
||||
{
|
||||
try {
|
||||
$cutoffDate = date('Y-m-d H:i:s', strtotime("-{$olderThanDays} days"));
|
||||
|
||||
$this->CI->db->where('created_at <', $cutoffDate);
|
||||
$result = $this->CI->db->delete($this->notificationsTable);
|
||||
|
||||
return $this->CI->db->affected_rows();
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Cleanup old notifications error: ' . $e->getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create document notification when a new document is available
|
||||
*
|
||||
* @param int $clientId Client ID
|
||||
* @param int $documentId Document ID
|
||||
* @param string $documentType Document type (invoice, estimate, etc.)
|
||||
* @param string $documentNumber Document number
|
||||
* @return int|false Notification ID or false on failure
|
||||
*/
|
||||
public function notifyDocumentCreated($clientId, $documentId, $documentType, $documentNumber)
|
||||
{
|
||||
$title = 'New ' . ucfirst($documentType) . ' Available';
|
||||
$message = "A new {$documentType} ({$documentNumber}) is now available for viewing.";
|
||||
$actionUrl = site_url("clients/desk_moloni/documents/{$documentId}");
|
||||
|
||||
return $this->createNotification(
|
||||
$clientId,
|
||||
'document_created',
|
||||
$title,
|
||||
$message,
|
||||
$documentId,
|
||||
$actionUrl
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create payment received notification
|
||||
*
|
||||
* @param int $clientId Client ID
|
||||
* @param int $documentId Document ID
|
||||
* @param float $amount Payment amount
|
||||
* @param string $documentNumber Document number
|
||||
* @return int|false Notification ID or false on failure
|
||||
*/
|
||||
public function notifyPaymentReceived($clientId, $documentId, $amount, $documentNumber)
|
||||
{
|
||||
$title = 'Payment Received';
|
||||
$message = "Payment of " . number_format($amount, 2) . " received for {$documentNumber}.";
|
||||
$actionUrl = site_url("clients/desk_moloni/documents/{$documentId}");
|
||||
|
||||
return $this->createNotification(
|
||||
$clientId,
|
||||
'payment_received',
|
||||
$title,
|
||||
$message,
|
||||
$documentId,
|
||||
$actionUrl
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create overdue notice notification
|
||||
*
|
||||
* @param int $clientId Client ID
|
||||
* @param int $documentId Document ID
|
||||
* @param string $documentNumber Document number
|
||||
* @param string $dueDate Due date
|
||||
* @return int|false Notification ID or false on failure
|
||||
*/
|
||||
public function notifyOverdue($clientId, $documentId, $documentNumber, $dueDate)
|
||||
{
|
||||
$title = 'Payment Overdue';
|
||||
$message = "Payment for {$documentNumber} was due on {$dueDate}. Please review your account.";
|
||||
$actionUrl = site_url("clients/desk_moloni/documents/{$documentId}");
|
||||
|
||||
return $this->createNotification(
|
||||
$clientId,
|
||||
'overdue_notice',
|
||||
$title,
|
||||
$message,
|
||||
$documentId,
|
||||
$actionUrl
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create system message notification
|
||||
*
|
||||
* @param int $clientId Client ID
|
||||
* @param string $title Message title
|
||||
* @param string $message Message content
|
||||
* @return int|false Notification ID or false on failure
|
||||
*/
|
||||
public function notifySystemMessage($clientId, $title, $message)
|
||||
{
|
||||
return $this->createNotification(
|
||||
$clientId,
|
||||
'system_message',
|
||||
$title,
|
||||
$message
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get notification by ID
|
||||
*
|
||||
* @param int $notificationId Notification ID
|
||||
* @param int $clientId Client ID (for security check)
|
||||
* @return array|null Notification data or null if not found
|
||||
*/
|
||||
public function getNotificationById($notificationId, $clientId)
|
||||
{
|
||||
try {
|
||||
$query = $this->CI->db->where('id', $notificationId)
|
||||
->where('client_id', $clientId)
|
||||
->get($this->notificationsTable);
|
||||
|
||||
$notification = $query->row_array();
|
||||
|
||||
if ($notification) {
|
||||
$notification['id'] = (int) $notification['id'];
|
||||
$notification['client_id'] = (int) $notification['client_id'];
|
||||
$notification['document_id'] = $notification['document_id'] ? (int) $notification['document_id'] : null;
|
||||
$notification['is_read'] = (bool) $notification['is_read'];
|
||||
}
|
||||
|
||||
return $notification;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Get notification by ID error: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Private Methods
|
||||
|
||||
/**
|
||||
* Ensure notifications table exists
|
||||
*/
|
||||
private function _ensureNotificationsTableExists()
|
||||
{
|
||||
if (!$this->CI->db->table_exists($this->notificationsTable)) {
|
||||
$this->_createNotificationsTable();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create notifications table
|
||||
*/
|
||||
private function _createNotificationsTable()
|
||||
{
|
||||
$sql = "
|
||||
CREATE TABLE IF NOT EXISTS `{$this->notificationsTable}` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`client_id` int(11) NOT NULL,
|
||||
`type` enum('document_created','payment_received','overdue_notice','system_message') NOT NULL,
|
||||
`title` varchar(255) NOT NULL,
|
||||
`message` text NOT NULL,
|
||||
`document_id` int(11) DEFAULT NULL,
|
||||
`action_url` varchar(500) DEFAULT NULL,
|
||||
`is_read` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`created_at` datetime NOT NULL,
|
||||
`read_at` datetime DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_client_id` (`client_id`),
|
||||
KEY `idx_client_unread` (`client_id`, `is_read`),
|
||||
KEY `idx_created_at` (`created_at`),
|
||||
KEY `idx_document_id` (`document_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
";
|
||||
|
||||
$this->CI->db->query($sql);
|
||||
|
||||
if ($this->CI->db->error()['code'] !== 0) {
|
||||
log_message('error', 'Failed to create notifications table: ' . $this->CI->db->error()['message']);
|
||||
} else {
|
||||
log_message('info', 'Notifications table created successfully');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if notification type is valid
|
||||
*/
|
||||
private function _isValidNotificationType($type)
|
||||
{
|
||||
$validTypes = [
|
||||
'document_created',
|
||||
'payment_received',
|
||||
'overdue_notice',
|
||||
'system_message'
|
||||
];
|
||||
|
||||
return in_array($type, $validTypes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if client exists
|
||||
*/
|
||||
private function _clientExists($clientId)
|
||||
{
|
||||
$count = $this->CI->db->where('userid', $clientId)
|
||||
->count_all_results('tblclients');
|
||||
return $count > 0;
|
||||
}
|
||||
}
|
||||
1023
modules/desk_moloni/libraries/ClientSyncService.php
Normal file
1023
modules/desk_moloni/libraries/ClientSyncService.php
Normal file
File diff suppressed because it is too large
Load Diff
575
modules/desk_moloni/libraries/DocumentAccessControl.php
Normal file
575
modules/desk_moloni/libraries/DocumentAccessControl.php
Normal file
@@ -0,0 +1,575 @@
|
||||
<?php
|
||||
|
||||
defined('BASEPATH') or exit('No direct script access allowed');
|
||||
|
||||
/**
|
||||
* Document Access Control Library
|
||||
* Handles security and permissions for client document access
|
||||
*
|
||||
* @package Desk-Moloni
|
||||
* @version 3.0.0
|
||||
* @author Descomplicar Business Solutions
|
||||
*/
|
||||
class DocumentAccessControl
|
||||
{
|
||||
private $CI;
|
||||
private $cachePrefix = 'desk_moloni_access_';
|
||||
private $cacheTimeout = 300; // 5 minutes
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->CI =& get_instance();
|
||||
|
||||
// Load required models
|
||||
$this->CI->load->model('clients_model');
|
||||
$this->CI->load->model('invoices_model');
|
||||
$this->CI->load->model('estimates_model');
|
||||
|
||||
// Initialize cache
|
||||
$this->CI->load->driver('cache');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if client can access a specific document
|
||||
*
|
||||
* @param int $clientId
|
||||
* @param int $documentId
|
||||
* @param string $documentType Optional document type for optimization
|
||||
* @return bool
|
||||
*/
|
||||
public function canAccessDocument($clientId, $documentId, $documentType = null)
|
||||
{
|
||||
// Input validation
|
||||
if (!is_numeric($clientId) || !is_numeric($documentId) || $clientId <= 0 || $documentId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
$cacheKey = $this->cachePrefix . "doc_{$clientId}_{$documentId}";
|
||||
$cachedResult = $this->CI->cache->get($cacheKey);
|
||||
if ($cachedResult !== false) {
|
||||
return $cachedResult === 'allowed';
|
||||
}
|
||||
|
||||
$hasAccess = false;
|
||||
|
||||
try {
|
||||
// Verify client exists and is active
|
||||
if (!$this->_isClientActiveAndValid($clientId)) {
|
||||
$this->_cacheAccessResult($cacheKey, false);
|
||||
return false;
|
||||
}
|
||||
|
||||
// If document type is specified, check only that type
|
||||
if ($documentType) {
|
||||
$hasAccess = $this->_checkDocumentTypeAccess($clientId, $documentId, $documentType);
|
||||
} else {
|
||||
// Check all document types
|
||||
$hasAccess = $this->_checkInvoiceAccess($clientId, $documentId) ||
|
||||
$this->_checkEstimateAccess($clientId, $documentId) ||
|
||||
$this->_checkCreditNoteAccess($clientId, $documentId) ||
|
||||
$this->_checkReceiptAccess($clientId, $documentId);
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
$this->_cacheAccessResult($cacheKey, $hasAccess);
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Document access control error: ' . $e->getMessage());
|
||||
$hasAccess = false;
|
||||
}
|
||||
|
||||
return $hasAccess;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if client can access multiple documents
|
||||
*
|
||||
* @param int $clientId
|
||||
* @param array $documentIds
|
||||
* @return array Associative array [documentId => bool]
|
||||
*/
|
||||
public function canAccessMultipleDocuments($clientId, array $documentIds)
|
||||
{
|
||||
$results = [];
|
||||
|
||||
foreach ($documentIds as $documentId) {
|
||||
$results[$documentId] = $this->canAccessDocument($clientId, $documentId);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of document IDs accessible by client
|
||||
*
|
||||
* @param int $clientId
|
||||
* @param string $documentType Optional filter by document type
|
||||
* @param array $filters Optional additional filters
|
||||
* @return array
|
||||
*/
|
||||
public function getAccessibleDocuments($clientId, $documentType = null, array $filters = [])
|
||||
{
|
||||
// Input validation
|
||||
if (!is_numeric($clientId) || $clientId <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Check if client is valid
|
||||
if (!$this->_isClientActiveAndValid($clientId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$documentIds = [];
|
||||
|
||||
try {
|
||||
if (!$documentType || $documentType === 'invoice') {
|
||||
$invoiceIds = $this->_getClientInvoiceIds($clientId, $filters);
|
||||
$documentIds = array_merge($documentIds, $invoiceIds);
|
||||
}
|
||||
|
||||
if (!$documentType || $documentType === 'estimate') {
|
||||
$estimateIds = $this->_getClientEstimateIds($clientId, $filters);
|
||||
$documentIds = array_merge($documentIds, $estimateIds);
|
||||
}
|
||||
|
||||
if (!$documentType || $documentType === 'credit_note') {
|
||||
$creditNoteIds = $this->_getClientCreditNoteIds($clientId, $filters);
|
||||
$documentIds = array_merge($documentIds, $creditNoteIds);
|
||||
}
|
||||
|
||||
if (!$documentType || $documentType === 'receipt') {
|
||||
$receiptIds = $this->_getClientReceiptIds($clientId, $filters);
|
||||
$documentIds = array_merge($documentIds, $receiptIds);
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Get accessible documents error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_unique($documentIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate document access with detailed security checks
|
||||
*
|
||||
* @param int $clientId
|
||||
* @param int $documentId
|
||||
* @param string $action Action being performed (view, download, etc.)
|
||||
* @return array Validation result with details
|
||||
*/
|
||||
public function validateDocumentAccess($clientId, $documentId, $action = 'view')
|
||||
{
|
||||
$result = [
|
||||
'allowed' => false,
|
||||
'reason' => 'Access denied',
|
||||
'document_type' => null,
|
||||
'security_level' => 'standard'
|
||||
];
|
||||
|
||||
try {
|
||||
// Basic validation
|
||||
if (!is_numeric($clientId) || !is_numeric($documentId) || $clientId <= 0 || $documentId <= 0) {
|
||||
$result['reason'] = 'Invalid parameters';
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Check client validity
|
||||
if (!$this->_isClientActiveAndValid($clientId)) {
|
||||
$result['reason'] = 'Client not active or invalid';
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Check document existence and ownership
|
||||
$documentInfo = $this->_getDocumentInfo($documentId);
|
||||
if (!$documentInfo) {
|
||||
$result['reason'] = 'Document not found';
|
||||
return $result;
|
||||
}
|
||||
|
||||
if ($documentInfo['client_id'] != $clientId) {
|
||||
$result['reason'] = 'Document does not belong to client';
|
||||
$this->_logSecurityViolation($clientId, $documentId, $action, 'ownership_violation');
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Check action permissions
|
||||
if (!$this->_isActionAllowed($documentInfo['type'], $action)) {
|
||||
$result['reason'] = 'Action not allowed for document type';
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Check document-specific security rules
|
||||
if (!$this->_checkDocumentSecurityRules($documentInfo, $action)) {
|
||||
$result['reason'] = 'Document security rules violation';
|
||||
return $result;
|
||||
}
|
||||
|
||||
// All checks passed
|
||||
$result['allowed'] = true;
|
||||
$result['reason'] = 'Access granted';
|
||||
$result['document_type'] = $documentInfo['type'];
|
||||
$result['security_level'] = $this->_getDocumentSecurityLevel($documentInfo);
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'Document access validation error: ' . $e->getMessage());
|
||||
$result['reason'] = 'System error during validation';
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log security violation attempt
|
||||
*
|
||||
* @param int $clientId
|
||||
* @param int $documentId
|
||||
* @param string $action
|
||||
* @param string $violationType
|
||||
*/
|
||||
public function logSecurityViolation($clientId, $documentId, $action, $violationType)
|
||||
{
|
||||
$this->_logSecurityViolation($clientId, $documentId, $action, $violationType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear access cache for client
|
||||
*
|
||||
* @param int $clientId
|
||||
*/
|
||||
public function clearClientAccessCache($clientId)
|
||||
{
|
||||
// This would clear all cached access results for the client
|
||||
// Implementation depends on cache driver capabilities
|
||||
$pattern = $this->cachePrefix . "doc_{$clientId}_*";
|
||||
|
||||
// For file cache, we'd need to scan and delete
|
||||
// For Redis, we could use pattern deletion
|
||||
// For now, we'll just document the intent
|
||||
log_message('info', "Access cache cleared for client {$clientId}");
|
||||
}
|
||||
|
||||
// Private Methods
|
||||
|
||||
/**
|
||||
* Check if client is active and valid
|
||||
*/
|
||||
private function _isClientActiveAndValid($clientId)
|
||||
{
|
||||
$client = $this->CI->clients_model->get($clientId);
|
||||
return $client && $client['active'] == 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check access for specific document type
|
||||
*/
|
||||
private function _checkDocumentTypeAccess($clientId, $documentId, $documentType)
|
||||
{
|
||||
switch ($documentType) {
|
||||
case 'invoice':
|
||||
return $this->_checkInvoiceAccess($clientId, $documentId);
|
||||
case 'estimate':
|
||||
return $this->_checkEstimateAccess($clientId, $documentId);
|
||||
case 'credit_note':
|
||||
return $this->_checkCreditNoteAccess($clientId, $documentId);
|
||||
case 'receipt':
|
||||
return $this->_checkReceiptAccess($clientId, $documentId);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check invoice access
|
||||
*/
|
||||
private function _checkInvoiceAccess($clientId, $documentId)
|
||||
{
|
||||
$invoice = $this->CI->invoices_model->get($documentId);
|
||||
return $invoice && $invoice['clientid'] == $clientId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check estimate access
|
||||
*/
|
||||
private function _checkEstimateAccess($clientId, $documentId)
|
||||
{
|
||||
$estimate = $this->CI->estimates_model->get($documentId);
|
||||
return $estimate && $estimate['clientid'] == $clientId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check credit note access
|
||||
*/
|
||||
private function _checkCreditNoteAccess($clientId, $documentId)
|
||||
{
|
||||
// Credit notes in Perfex CRM are typically linked to invoices
|
||||
$creditNote = $this->CI->db->get_where('tblcreditnotes', ['id' => $documentId])->row_array();
|
||||
return $creditNote && $creditNote['clientid'] == $clientId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check receipt access
|
||||
*/
|
||||
private function _checkReceiptAccess($clientId, $documentId)
|
||||
{
|
||||
// Receipts are typically payment records in Perfex CRM
|
||||
$receipt = $this->CI->db->get_where('tblinvoicepaymentrecords', ['id' => $documentId])->row_array();
|
||||
if (!$receipt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the payment belongs to an invoice owned by the client
|
||||
$invoice = $this->CI->invoices_model->get($receipt['invoiceid']);
|
||||
return $invoice && $invoice['clientid'] == $clientId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache access result
|
||||
*/
|
||||
private function _cacheAccessResult($cacheKey, $hasAccess)
|
||||
{
|
||||
$value = $hasAccess ? 'allowed' : 'denied';
|
||||
$this->CI->cache->save($cacheKey, $value, $this->cacheTimeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client invoice IDs
|
||||
*/
|
||||
private function _getClientInvoiceIds($clientId, array $filters = [])
|
||||
{
|
||||
$this->CI->db->select('id');
|
||||
$this->CI->db->where('clientid', $clientId);
|
||||
|
||||
// Apply filters
|
||||
if (isset($filters['status'])) {
|
||||
$this->CI->db->where('status', $filters['status']);
|
||||
}
|
||||
|
||||
if (isset($filters['from_date'])) {
|
||||
$this->CI->db->where('date >=', $filters['from_date']);
|
||||
}
|
||||
|
||||
if (isset($filters['to_date'])) {
|
||||
$this->CI->db->where('date <=', $filters['to_date']);
|
||||
}
|
||||
|
||||
$query = $this->CI->db->get('tblinvoices');
|
||||
return array_column($query->result_array(), 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client estimate IDs
|
||||
*/
|
||||
private function _getClientEstimateIds($clientId, array $filters = [])
|
||||
{
|
||||
$this->CI->db->select('id');
|
||||
$this->CI->db->where('clientid', $clientId);
|
||||
|
||||
// Apply filters
|
||||
if (isset($filters['status'])) {
|
||||
$this->CI->db->where('status', $filters['status']);
|
||||
}
|
||||
|
||||
if (isset($filters['from_date'])) {
|
||||
$this->CI->db->where('date >=', $filters['from_date']);
|
||||
}
|
||||
|
||||
if (isset($filters['to_date'])) {
|
||||
$this->CI->db->where('date <=', $filters['to_date']);
|
||||
}
|
||||
|
||||
$query = $this->CI->db->get('tblestimates');
|
||||
return array_column($query->result_array(), 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client credit note IDs
|
||||
*/
|
||||
private function _getClientCreditNoteIds($clientId, array $filters = [])
|
||||
{
|
||||
$this->CI->db->select('id');
|
||||
$this->CI->db->where('clientid', $clientId);
|
||||
|
||||
// Apply filters if table exists
|
||||
if ($this->CI->db->table_exists('tblcreditnotes')) {
|
||||
if (isset($filters['from_date'])) {
|
||||
$this->CI->db->where('date >=', $filters['from_date']);
|
||||
}
|
||||
|
||||
if (isset($filters['to_date'])) {
|
||||
$this->CI->db->where('date <=', $filters['to_date']);
|
||||
}
|
||||
|
||||
$query = $this->CI->db->get('tblcreditnotes');
|
||||
return array_column($query->result_array(), 'id');
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client receipt IDs
|
||||
*/
|
||||
private function _getClientReceiptIds($clientId, array $filters = [])
|
||||
{
|
||||
// Get receipts through invoice payments
|
||||
$this->CI->db->select('tblinvoicepaymentrecords.id');
|
||||
$this->CI->db->join('tblinvoices', 'tblinvoices.id = tblinvoicepaymentrecords.invoiceid');
|
||||
$this->CI->db->where('tblinvoices.clientid', $clientId);
|
||||
|
||||
// Apply filters
|
||||
if (isset($filters['from_date'])) {
|
||||
$this->CI->db->where('tblinvoicepaymentrecords.date >=', $filters['from_date']);
|
||||
}
|
||||
|
||||
if (isset($filters['to_date'])) {
|
||||
$this->CI->db->where('tblinvoicepaymentrecords.date <=', $filters['to_date']);
|
||||
}
|
||||
|
||||
$query = $this->CI->db->get('tblinvoicepaymentrecords');
|
||||
return array_column($query->result_array(), 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get document information
|
||||
*/
|
||||
private function _getDocumentInfo($documentId)
|
||||
{
|
||||
// Try to find document in different tables
|
||||
|
||||
// Check invoices
|
||||
$invoice = $this->CI->db->get_where('tblinvoices', ['id' => $documentId])->row_array();
|
||||
if ($invoice) {
|
||||
return [
|
||||
'id' => $documentId,
|
||||
'type' => 'invoice',
|
||||
'client_id' => $invoice['clientid'],
|
||||
'status' => $invoice['status'],
|
||||
'data' => $invoice
|
||||
];
|
||||
}
|
||||
|
||||
// Check estimates
|
||||
$estimate = $this->CI->db->get_where('tblestimates', ['id' => $documentId])->row_array();
|
||||
if ($estimate) {
|
||||
return [
|
||||
'id' => $documentId,
|
||||
'type' => 'estimate',
|
||||
'client_id' => $estimate['clientid'],
|
||||
'status' => $estimate['status'],
|
||||
'data' => $estimate
|
||||
];
|
||||
}
|
||||
|
||||
// Check credit notes
|
||||
if ($this->CI->db->table_exists('tblcreditnotes')) {
|
||||
$creditNote = $this->CI->db->get_where('tblcreditnotes', ['id' => $documentId])->row_array();
|
||||
if ($creditNote) {
|
||||
return [
|
||||
'id' => $documentId,
|
||||
'type' => 'credit_note',
|
||||
'client_id' => $creditNote['clientid'],
|
||||
'status' => $creditNote['status'] ?? 'active',
|
||||
'data' => $creditNote
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Check receipts (payment records)
|
||||
$receipt = $this->CI->db->get_where('tblinvoicepaymentrecords', ['id' => $documentId])->row_array();
|
||||
if ($receipt) {
|
||||
// Get client ID from associated invoice
|
||||
$invoice = $this->CI->db->get_where('tblinvoices', ['id' => $receipt['invoiceid']])->row_array();
|
||||
if ($invoice) {
|
||||
return [
|
||||
'id' => $documentId,
|
||||
'type' => 'receipt',
|
||||
'client_id' => $invoice['clientid'],
|
||||
'status' => 'paid',
|
||||
'data' => $receipt
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if action is allowed for document type
|
||||
*/
|
||||
private function _isActionAllowed($documentType, $action)
|
||||
{
|
||||
$allowedActions = [
|
||||
'invoice' => ['view', 'download', 'print'],
|
||||
'estimate' => ['view', 'download', 'print'],
|
||||
'credit_note' => ['view', 'download', 'print'],
|
||||
'receipt' => ['view', 'download', 'print']
|
||||
];
|
||||
|
||||
return isset($allowedActions[$documentType]) &&
|
||||
in_array($action, $allowedActions[$documentType]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check document-specific security rules
|
||||
*/
|
||||
private function _checkDocumentSecurityRules($documentInfo, $action)
|
||||
{
|
||||
// Example security rules:
|
||||
|
||||
// Draft documents may have restricted access
|
||||
if ($documentInfo['type'] === 'estimate' && $documentInfo['status'] == 1) {
|
||||
// Draft estimate - only allow view
|
||||
return $action === 'view';
|
||||
}
|
||||
|
||||
// Cancelled documents may be read-only
|
||||
if (isset($documentInfo['data']['status']) && $documentInfo['data']['status'] == 5) {
|
||||
// Cancelled - only allow view
|
||||
return $action === 'view';
|
||||
}
|
||||
|
||||
// All other cases are allowed by default
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get document security level
|
||||
*/
|
||||
private function _getDocumentSecurityLevel($documentInfo)
|
||||
{
|
||||
// Determine security level based on document properties
|
||||
if ($documentInfo['type'] === 'invoice' &&
|
||||
isset($documentInfo['data']['total']) &&
|
||||
$documentInfo['data']['total'] > 10000) {
|
||||
return 'high'; // High-value invoices
|
||||
}
|
||||
|
||||
return 'standard';
|
||||
}
|
||||
|
||||
/**
|
||||
* Log security violation
|
||||
*/
|
||||
private function _logSecurityViolation($clientId, $documentId, $action, $violationType)
|
||||
{
|
||||
$logData = [
|
||||
'client_id' => $clientId,
|
||||
'document_id' => $documentId,
|
||||
'action' => $action,
|
||||
'violation_type' => $violationType,
|
||||
'ip_address' => $this->CI->input->ip_address(),
|
||||
'user_agent' => $this->CI->input->user_agent(),
|
||||
'timestamp' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
// Log to system log
|
||||
log_message('warning', 'Security violation: ' . json_encode($logData));
|
||||
|
||||
// Could also save to database security log table if it exists
|
||||
if ($this->CI->db->table_exists('tblsecurity_violations')) {
|
||||
$this->CI->db->insert('tblsecurity_violations', $logData);
|
||||
}
|
||||
}
|
||||
}
|
||||
338
modules/desk_moloni/libraries/Encryption.php
Normal file
338
modules/desk_moloni/libraries/Encryption.php
Normal file
@@ -0,0 +1,338 @@
|
||||
<?php
|
||||
/**
|
||||
* AES-256-GCM Encryption Helper for Desk-Moloni v3.0
|
||||
*
|
||||
* Provides secure encryption/decryption for OAuth tokens and sensitive configuration
|
||||
* Uses industry-standard AES-256-GCM with authenticated encryption
|
||||
*
|
||||
* @package DeskMoloni\Libraries
|
||||
* @author Descomplicar.pt
|
||||
* @version 3.0.0
|
||||
*/
|
||||
|
||||
namespace DeskMoloni;
|
||||
|
||||
use Exception;
|
||||
|
||||
class Encryption
|
||||
{
|
||||
const CIPHER_METHOD = 'aes-256-gcm';
|
||||
const KEY_LENGTH = 32; // 256 bits
|
||||
const IV_LENGTH = 12; // 96 bits (recommended for GCM)
|
||||
const TAG_LENGTH = 16; // 128 bits authentication tag
|
||||
|
||||
private string $encryption_key;
|
||||
private string $key_version;
|
||||
|
||||
/**
|
||||
* Initialize encryption with application key
|
||||
*
|
||||
* @param string|null $app_key Application encryption key (auto-generated if null)
|
||||
* @param string $key_version Key version for rotation support
|
||||
* @throws Exception If OpenSSL extension not available
|
||||
*/
|
||||
public function __construct(?string $app_key = null, string $key_version = '1')
|
||||
{
|
||||
if (!extension_loaded('openssl')) {
|
||||
throw new Exception('OpenSSL extension is required for encryption');
|
||||
}
|
||||
|
||||
if (!in_array(self::CIPHER_METHOD, openssl_get_cipher_methods())) {
|
||||
throw new Exception('AES-256-GCM cipher method not available');
|
||||
}
|
||||
|
||||
$this->key_version = $key_version;
|
||||
|
||||
// Generate or use provided encryption key
|
||||
if ($app_key === null) {
|
||||
$this->encryption_key = $this->generateEncryptionKey();
|
||||
} else {
|
||||
$this->encryption_key = $this->deriveKey($app_key, $key_version);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt data using AES-256-GCM
|
||||
*
|
||||
* @param string $plaintext Data to encrypt
|
||||
* @param string $additional_data Additional authenticated data (optional)
|
||||
* @return string Base64-encoded encrypted data with metadata
|
||||
* @throws Exception On encryption failure
|
||||
*/
|
||||
public function encrypt(string $plaintext, string $additional_data = ''): string
|
||||
{
|
||||
try {
|
||||
// Generate random IV for each encryption
|
||||
$iv = random_bytes(self::IV_LENGTH);
|
||||
|
||||
// Initialize authentication tag
|
||||
$tag = '';
|
||||
|
||||
// Encrypt the data
|
||||
$ciphertext = openssl_encrypt(
|
||||
$plaintext,
|
||||
self::CIPHER_METHOD,
|
||||
$this->encryption_key,
|
||||
OPENSSL_RAW_DATA,
|
||||
$iv,
|
||||
$tag,
|
||||
$additional_data,
|
||||
self::TAG_LENGTH
|
||||
);
|
||||
|
||||
if ($ciphertext === false) {
|
||||
throw new Exception('Encryption failed: ' . openssl_error_string());
|
||||
}
|
||||
|
||||
// Combine IV, tag, and ciphertext for storage
|
||||
$encrypted_data = [
|
||||
'version' => $this->key_version,
|
||||
'iv' => base64_encode($iv),
|
||||
'tag' => base64_encode($tag),
|
||||
'data' => base64_encode($ciphertext),
|
||||
'aad' => base64_encode($additional_data)
|
||||
];
|
||||
|
||||
return base64_encode(json_encode($encrypted_data));
|
||||
|
||||
} catch (Exception $e) {
|
||||
throw new Exception('Encryption error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt data using AES-256-GCM
|
||||
*
|
||||
* @param string $encrypted_data Base64-encoded encrypted data with metadata
|
||||
* @return string Decrypted plaintext
|
||||
* @throws Exception On decryption failure or invalid data
|
||||
*/
|
||||
public function decrypt(string $encrypted_data): string
|
||||
{
|
||||
try {
|
||||
// Decode the encrypted data structure
|
||||
$data = json_decode(base64_decode($encrypted_data), true);
|
||||
|
||||
if (!$data || !$this->validateEncryptedDataStructure($data)) {
|
||||
throw new Exception('Invalid encrypted data structure');
|
||||
}
|
||||
|
||||
// Extract components
|
||||
$iv = base64_decode($data['iv']);
|
||||
$tag = base64_decode($data['tag']);
|
||||
$ciphertext = base64_decode($data['data']);
|
||||
$additional_data = base64_decode($data['aad']);
|
||||
|
||||
// Handle key version compatibility
|
||||
$decryption_key = $this->getKeyForVersion($data['version']);
|
||||
|
||||
// Decrypt the data
|
||||
$plaintext = openssl_decrypt(
|
||||
$ciphertext,
|
||||
self::CIPHER_METHOD,
|
||||
$decryption_key,
|
||||
OPENSSL_RAW_DATA,
|
||||
$iv,
|
||||
$tag,
|
||||
$additional_data
|
||||
);
|
||||
|
||||
if ($plaintext === false) {
|
||||
throw new Exception('Decryption failed: Invalid data or authentication failed');
|
||||
}
|
||||
|
||||
return $plaintext;
|
||||
|
||||
} catch (Exception $e) {
|
||||
throw new Exception('Decryption error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt OAuth token with expiration metadata
|
||||
*
|
||||
* @param string $token OAuth token
|
||||
* @param int $expires_at Unix timestamp when token expires
|
||||
* @return string Encrypted token with metadata
|
||||
* @throws Exception On encryption failure
|
||||
*/
|
||||
public function encryptToken(string $token, int $expires_at): string
|
||||
{
|
||||
$token_data = [
|
||||
'token' => $token,
|
||||
'expires_at' => $expires_at,
|
||||
'created_at' => time(),
|
||||
'type' => 'oauth_token'
|
||||
];
|
||||
|
||||
$additional_data = 'oauth_token_v' . $this->key_version;
|
||||
|
||||
return $this->encrypt(json_encode($token_data), $additional_data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt OAuth token and validate expiration
|
||||
*
|
||||
* @param string $encrypted_token Encrypted token data
|
||||
* @return array Token data with expiration info
|
||||
* @throws Exception If token invalid or expired
|
||||
*/
|
||||
public function decryptToken(string $encrypted_token): array
|
||||
{
|
||||
$decrypted_data = $this->decrypt($encrypted_token);
|
||||
$token_data = json_decode($decrypted_data, true);
|
||||
|
||||
if (!$token_data || $token_data['type'] !== 'oauth_token') {
|
||||
throw new Exception('Invalid token data structure');
|
||||
}
|
||||
|
||||
// Check if token is expired (with 5-minute buffer)
|
||||
if ($token_data['expires_at'] <= (time() + 300)) {
|
||||
throw new Exception('Token has expired');
|
||||
}
|
||||
|
||||
return $token_data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate secure encryption key
|
||||
*
|
||||
* @return string Random 256-bit encryption key
|
||||
* @throws Exception If random generation fails
|
||||
*/
|
||||
private function generateEncryptionKey(): string
|
||||
{
|
||||
try {
|
||||
return random_bytes(self::KEY_LENGTH);
|
||||
} catch (Exception $e) {
|
||||
throw new Exception('Failed to generate encryption key: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive encryption key from application key and version
|
||||
*
|
||||
* @param string $app_key Base application key
|
||||
* @param string $version Key version for rotation
|
||||
* @return string Derived encryption key
|
||||
*/
|
||||
private function deriveKey(string $app_key, string $version): string
|
||||
{
|
||||
// Use PBKDF2 for key derivation with version-specific salt
|
||||
$salt = hash('sha256', 'desk_moloni_v3.0_' . $version, true);
|
||||
|
||||
return hash_pbkdf2('sha256', $app_key, $salt, 10000, self::KEY_LENGTH, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get encryption key for specific version (supports key rotation)
|
||||
*
|
||||
* @param string $version Key version
|
||||
* @return string Encryption key for version
|
||||
* @throws Exception If version not supported
|
||||
*/
|
||||
private function getKeyForVersion(string $version): string
|
||||
{
|
||||
if ($version === $this->key_version) {
|
||||
return $this->encryption_key;
|
||||
}
|
||||
|
||||
// Handle legacy versions if needed
|
||||
switch ($version) {
|
||||
case '1':
|
||||
// Default version, use current key
|
||||
return $this->encryption_key;
|
||||
default:
|
||||
throw new Exception("Unsupported key version: {$version}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate encrypted data structure
|
||||
*
|
||||
* @param array $data Decoded encrypted data
|
||||
* @return bool True if structure is valid
|
||||
*/
|
||||
private function validateEncryptedDataStructure(array $data): bool
|
||||
{
|
||||
$required_fields = ['version', 'iv', 'tag', 'data', 'aad'];
|
||||
|
||||
foreach ($required_fields as $field) {
|
||||
if (!isset($data[$field])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate base64 encoding
|
||||
foreach (['iv', 'tag', 'data', 'aad'] as $field) {
|
||||
if (base64_decode($data[$field], true) === false) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate IV length
|
||||
if (strlen(base64_decode($data['iv'])) !== self::IV_LENGTH) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate tag length
|
||||
if (strlen(base64_decode($data['tag'])) !== self::TAG_LENGTH) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Securely generate encryption key for application
|
||||
*
|
||||
* @return string Base64-encoded application key
|
||||
* @throws Exception If key generation fails
|
||||
*/
|
||||
public static function generateApplicationKey(): string
|
||||
{
|
||||
try {
|
||||
$key = random_bytes(64); // 512-bit master key
|
||||
return base64_encode($key);
|
||||
} catch (Exception $e) {
|
||||
throw new Exception('Failed to generate application key: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test encryption system integrity
|
||||
*
|
||||
* @return bool True if encryption system is working correctly
|
||||
*/
|
||||
public function testIntegrity(): bool
|
||||
{
|
||||
try {
|
||||
$test_data = 'Desk-Moloni v3.0 Encryption Test - ' . microtime(true);
|
||||
$encrypted = $this->encrypt($test_data);
|
||||
$decrypted = $this->decrypt($encrypted);
|
||||
|
||||
return $decrypted === $test_data;
|
||||
} catch (Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get encryption system information
|
||||
*
|
||||
* @return array System information
|
||||
*/
|
||||
public function getSystemInfo(): array
|
||||
{
|
||||
return [
|
||||
'cipher_method' => self::CIPHER_METHOD,
|
||||
'key_length' => self::KEY_LENGTH,
|
||||
'iv_length' => self::IV_LENGTH,
|
||||
'tag_length' => self::TAG_LENGTH,
|
||||
'key_version' => $this->key_version,
|
||||
'openssl_version' => OPENSSL_VERSION_TEXT,
|
||||
'available_methods' => openssl_get_cipher_methods(),
|
||||
'integrity_test' => $this->testIntegrity()
|
||||
];
|
||||
}
|
||||
}
|
||||
464
modules/desk_moloni/libraries/EntityMappingService.php
Normal file
464
modules/desk_moloni/libraries/EntityMappingService.php
Normal file
@@ -0,0 +1,464 @@
|
||||
<?php
|
||||
|
||||
defined('BASEPATH') or exit('No direct script access allowed');
|
||||
|
||||
/**
|
||||
* Entity Mapping Service
|
||||
* Handles mapping and relationship management between Perfex CRM and Moloni ERP entities
|
||||
*
|
||||
* @package DeskMoloni
|
||||
* @subpackage Libraries
|
||||
* @category EntityMapping
|
||||
* @author Descomplicar® - PHP Fullstack Engineer
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace DeskMoloni\Libraries;
|
||||
|
||||
class EntityMappingService
|
||||
{
|
||||
protected $CI;
|
||||
protected $model;
|
||||
|
||||
// Entity types supported
|
||||
const ENTITY_CUSTOMER = 'customer';
|
||||
const ENTITY_PRODUCT = 'product';
|
||||
const ENTITY_INVOICE = 'invoice';
|
||||
const ENTITY_ESTIMATE = 'estimate';
|
||||
const ENTITY_CREDIT_NOTE = 'credit_note';
|
||||
|
||||
// Mapping status constants
|
||||
const STATUS_PENDING = 'pending';
|
||||
const STATUS_SYNCED = 'synced';
|
||||
const STATUS_ERROR = 'error';
|
||||
const STATUS_CONFLICT = 'conflict';
|
||||
|
||||
// Sync directions
|
||||
const DIRECTION_PERFEX_TO_MOLONI = 'perfex_to_moloni';
|
||||
const DIRECTION_MOLONI_TO_PERFEX = 'moloni_to_perfex';
|
||||
const DIRECTION_BIDIRECTIONAL = 'bidirectional';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->CI = &get_instance();
|
||||
$this->CI->load->model('desk_moloni_model');
|
||||
$this->model = $this->CI->desk_moloni_model;
|
||||
|
||||
log_activity('EntityMappingService initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create entity mapping
|
||||
*
|
||||
* @param string $entity_type
|
||||
* @param int $perfex_id
|
||||
* @param int $moloni_id
|
||||
* @param string $sync_direction
|
||||
* @param array $metadata
|
||||
* @return int|false
|
||||
*/
|
||||
public function create_mapping($entity_type, $perfex_id, $moloni_id, $sync_direction = self::DIRECTION_BIDIRECTIONAL, $metadata = [])
|
||||
{
|
||||
if (!$this->is_valid_entity_type($entity_type)) {
|
||||
throw new \InvalidArgumentException("Invalid entity type: {$entity_type}");
|
||||
}
|
||||
|
||||
// Check for existing mapping
|
||||
$existing = $this->get_mapping($entity_type, $perfex_id, $moloni_id);
|
||||
if ($existing) {
|
||||
throw new \Exception("Mapping already exists with ID: {$existing->id}");
|
||||
}
|
||||
|
||||
$mapping_data = [
|
||||
'entity_type' => $entity_type,
|
||||
'perfex_id' => $perfex_id,
|
||||
'moloni_id' => $moloni_id,
|
||||
'sync_direction' => $sync_direction,
|
||||
'sync_status' => self::STATUS_PENDING,
|
||||
'metadata' => json_encode($metadata),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
$mapping_id = $this->model->create_entity_mapping($mapping_data);
|
||||
|
||||
if ($mapping_id) {
|
||||
log_activity("Created {$entity_type} mapping: Perfex #{$perfex_id} <-> Moloni #{$moloni_id}");
|
||||
}
|
||||
|
||||
return $mapping_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update entity mapping
|
||||
*
|
||||
* @param int $mapping_id
|
||||
* @param array $data
|
||||
* @return bool
|
||||
*/
|
||||
public function update_mapping($mapping_id, $data)
|
||||
{
|
||||
$data['updated_at'] = date('Y-m-d H:i:s');
|
||||
|
||||
$result = $this->model->update_entity_mapping($mapping_id, $data);
|
||||
|
||||
if ($result) {
|
||||
log_activity("Updated entity mapping #{$mapping_id}");
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get entity mapping by IDs
|
||||
*
|
||||
* @param string $entity_type
|
||||
* @param int $perfex_id
|
||||
* @param int $moloni_id
|
||||
* @return object|null
|
||||
*/
|
||||
public function get_mapping($entity_type, $perfex_id = null, $moloni_id = null)
|
||||
{
|
||||
if (!$perfex_id && !$moloni_id) {
|
||||
throw new \InvalidArgumentException("Either perfex_id or moloni_id must be provided");
|
||||
}
|
||||
|
||||
return $this->model->get_entity_mapping($entity_type, $perfex_id, $moloni_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mapping by Perfex ID
|
||||
*
|
||||
* @param string $entity_type
|
||||
* @param int $perfex_id
|
||||
* @return object|null
|
||||
*/
|
||||
public function get_mapping_by_perfex_id($entity_type, $perfex_id)
|
||||
{
|
||||
return $this->model->get_entity_mapping_by_perfex_id($entity_type, $perfex_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mapping by Moloni ID
|
||||
*
|
||||
* @param string $entity_type
|
||||
* @param int $moloni_id
|
||||
* @return object|null
|
||||
*/
|
||||
public function get_mapping_by_moloni_id($entity_type, $moloni_id)
|
||||
{
|
||||
return $this->model->get_entity_mapping_by_moloni_id($entity_type, $moloni_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete entity mapping
|
||||
*
|
||||
* @param int $mapping_id
|
||||
* @return bool
|
||||
*/
|
||||
public function delete_mapping($mapping_id)
|
||||
{
|
||||
$mapping = $this->model->get_entity_mapping_by_id($mapping_id);
|
||||
|
||||
if (!$mapping) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$result = $this->model->delete_entity_mapping($mapping_id);
|
||||
|
||||
if ($result) {
|
||||
log_activity("Deleted {$mapping->entity_type} mapping #{$mapping_id}");
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all mappings for entity type
|
||||
*
|
||||
* @param string $entity_type
|
||||
* @param array $filters
|
||||
* @return array
|
||||
*/
|
||||
public function get_mappings_by_type($entity_type, $filters = [])
|
||||
{
|
||||
if (!$this->is_valid_entity_type($entity_type)) {
|
||||
throw new \InvalidArgumentException("Invalid entity type: {$entity_type}");
|
||||
}
|
||||
|
||||
return $this->model->get_entity_mappings_by_type($entity_type, $filters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update mapping status
|
||||
*
|
||||
* @param int $mapping_id
|
||||
* @param string $status
|
||||
* @param string $error_message
|
||||
* @return bool
|
||||
*/
|
||||
public function update_mapping_status($mapping_id, $status, $error_message = null)
|
||||
{
|
||||
if (!in_array($status, [self::STATUS_PENDING, self::STATUS_SYNCED, self::STATUS_ERROR, self::STATUS_CONFLICT])) {
|
||||
throw new \InvalidArgumentException("Invalid status: {$status}");
|
||||
}
|
||||
|
||||
$data = [
|
||||
'sync_status' => $status,
|
||||
'error_message' => $error_message,
|
||||
'last_sync_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
return $this->update_mapping($mapping_id, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update sync timestamps
|
||||
*
|
||||
* @param int $mapping_id
|
||||
* @param string $direction
|
||||
* @return bool
|
||||
*/
|
||||
public function update_sync_timestamp($mapping_id, $direction)
|
||||
{
|
||||
$field = $direction === self::DIRECTION_PERFEX_TO_MOLONI ? 'last_sync_perfex' : 'last_sync_moloni';
|
||||
|
||||
return $this->update_mapping($mapping_id, [
|
||||
$field => date('Y-m-d H:i:s'),
|
||||
'sync_status' => self::STATUS_SYNCED
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if entity is already mapped
|
||||
*
|
||||
* @param string $entity_type
|
||||
* @param int $perfex_id
|
||||
* @param int $moloni_id
|
||||
* @return bool
|
||||
*/
|
||||
public function is_mapped($entity_type, $perfex_id = null, $moloni_id = null)
|
||||
{
|
||||
return $this->get_mapping($entity_type, $perfex_id, $moloni_id) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unmapped entities
|
||||
*
|
||||
* @param string $entity_type
|
||||
* @param string $source_system ('perfex' or 'moloni')
|
||||
* @param int $limit
|
||||
* @return array
|
||||
*/
|
||||
public function get_unmapped_entities($entity_type, $source_system, $limit = 100)
|
||||
{
|
||||
if (!$this->is_valid_entity_type($entity_type)) {
|
||||
throw new \InvalidArgumentException("Invalid entity type: {$entity_type}");
|
||||
}
|
||||
|
||||
if (!in_array($source_system, ['perfex', 'moloni'])) {
|
||||
throw new \InvalidArgumentException("Invalid source system: {$source_system}");
|
||||
}
|
||||
|
||||
return $this->model->get_unmapped_entities($entity_type, $source_system, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mapping statistics
|
||||
*
|
||||
* @param string $entity_type
|
||||
* @return array
|
||||
*/
|
||||
public function get_mapping_statistics($entity_type = null)
|
||||
{
|
||||
return $this->model->get_mapping_statistics($entity_type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find potential matches between systems
|
||||
*
|
||||
* @param string $entity_type
|
||||
* @param array $search_criteria
|
||||
* @param string $target_system
|
||||
* @return array
|
||||
*/
|
||||
public function find_potential_matches($entity_type, $search_criteria, $target_system)
|
||||
{
|
||||
if (!$this->is_valid_entity_type($entity_type)) {
|
||||
throw new \InvalidArgumentException("Invalid entity type: {$entity_type}");
|
||||
}
|
||||
|
||||
// This will be implemented by specific sync services
|
||||
// Return format: [['id' => X, 'match_score' => Y, 'match_criteria' => []], ...]
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve mapping conflicts
|
||||
*
|
||||
* @param int $mapping_id
|
||||
* @param string $resolution ('keep_perfex', 'keep_moloni', 'merge')
|
||||
* @param array $merge_data
|
||||
* @return bool
|
||||
*/
|
||||
public function resolve_conflict($mapping_id, $resolution, $merge_data = [])
|
||||
{
|
||||
$mapping = $this->model->get_entity_mapping_by_id($mapping_id);
|
||||
|
||||
if (!$mapping || $mapping->sync_status !== self::STATUS_CONFLICT) {
|
||||
throw new \Exception("Mapping not found or not in conflict state");
|
||||
}
|
||||
|
||||
switch ($resolution) {
|
||||
case 'keep_perfex':
|
||||
return $this->update_mapping_status($mapping_id, self::STATUS_SYNCED);
|
||||
|
||||
case 'keep_moloni':
|
||||
return $this->update_mapping_status($mapping_id, self::STATUS_SYNCED);
|
||||
|
||||
case 'merge':
|
||||
// Store merge data for processing by sync services
|
||||
$metadata = json_decode($mapping->metadata, true) ?: [];
|
||||
$metadata['merge_data'] = $merge_data;
|
||||
$metadata['resolution'] = 'merge';
|
||||
|
||||
return $this->update_mapping($mapping_id, [
|
||||
'sync_status' => self::STATUS_PENDING,
|
||||
'metadata' => json_encode($metadata)
|
||||
]);
|
||||
|
||||
default:
|
||||
throw new \InvalidArgumentException("Invalid resolution: {$resolution}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk create mappings
|
||||
*
|
||||
* @param array $mappings
|
||||
* @return array
|
||||
*/
|
||||
public function bulk_create_mappings($mappings)
|
||||
{
|
||||
$results = [
|
||||
'total' => count($mappings),
|
||||
'success' => 0,
|
||||
'errors' => 0,
|
||||
'details' => []
|
||||
];
|
||||
|
||||
foreach ($mappings as $mapping) {
|
||||
try {
|
||||
$mapping_id = $this->create_mapping(
|
||||
$mapping['entity_type'],
|
||||
$mapping['perfex_id'],
|
||||
$mapping['moloni_id'],
|
||||
$mapping['sync_direction'] ?? self::DIRECTION_BIDIRECTIONAL,
|
||||
$mapping['metadata'] ?? []
|
||||
);
|
||||
|
||||
$results['success']++;
|
||||
$results['details'][] = [
|
||||
'mapping_id' => $mapping_id,
|
||||
'success' => true
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$results['errors']++;
|
||||
$results['details'][] = [
|
||||
'error' => $e->getMessage(),
|
||||
'success' => false,
|
||||
'data' => $mapping
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old mappings
|
||||
*
|
||||
* @param string $entity_type
|
||||
* @param int $retention_days
|
||||
* @return int
|
||||
*/
|
||||
public function cleanup_old_mappings($entity_type, $retention_days = 90)
|
||||
{
|
||||
$cutoff_date = date('Y-m-d H:i:s', strtotime("-{$retention_days} days"));
|
||||
|
||||
$deleted = $this->model->cleanup_old_mappings($entity_type, $cutoff_date);
|
||||
|
||||
if ($deleted > 0) {
|
||||
log_activity("Cleaned up {$deleted} old {$entity_type} mappings older than {$retention_days} days");
|
||||
}
|
||||
|
||||
return $deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate entity type
|
||||
*
|
||||
* @param string $entity_type
|
||||
* @return bool
|
||||
*/
|
||||
protected function is_valid_entity_type($entity_type)
|
||||
{
|
||||
return in_array($entity_type, [
|
||||
self::ENTITY_CUSTOMER,
|
||||
self::ENTITY_PRODUCT,
|
||||
self::ENTITY_INVOICE,
|
||||
self::ENTITY_ESTIMATE,
|
||||
self::ENTITY_CREDIT_NOTE
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export mappings to CSV
|
||||
*
|
||||
* @param string $entity_type
|
||||
* @param array $filters
|
||||
* @return string
|
||||
*/
|
||||
public function export_mappings_csv($entity_type, $filters = [])
|
||||
{
|
||||
$mappings = $this->get_mappings_by_type($entity_type, $filters);
|
||||
|
||||
$output = fopen('php://temp', 'r+');
|
||||
|
||||
// CSV Header
|
||||
fputcsv($output, [
|
||||
'ID',
|
||||
'Entity Type',
|
||||
'Perfex ID',
|
||||
'Moloni ID',
|
||||
'Sync Direction',
|
||||
'Sync Status',
|
||||
'Last Sync Perfex',
|
||||
'Last Sync Moloni',
|
||||
'Created At',
|
||||
'Updated At'
|
||||
]);
|
||||
|
||||
foreach ($mappings as $mapping) {
|
||||
fputcsv($output, [
|
||||
$mapping->id,
|
||||
$mapping->entity_type,
|
||||
$mapping->perfex_id,
|
||||
$mapping->moloni_id,
|
||||
$mapping->sync_direction,
|
||||
$mapping->sync_status,
|
||||
$mapping->last_sync_perfex,
|
||||
$mapping->last_sync_moloni,
|
||||
$mapping->created_at,
|
||||
$mapping->updated_at
|
||||
]);
|
||||
}
|
||||
|
||||
rewind($output);
|
||||
$csv_content = stream_get_contents($output);
|
||||
fclose($output);
|
||||
|
||||
return $csv_content;
|
||||
}
|
||||
}
|
||||
653
modules/desk_moloni/libraries/ErrorHandler.php
Normal file
653
modules/desk_moloni/libraries/ErrorHandler.php
Normal file
@@ -0,0 +1,653 @@
|
||||
<?php
|
||||
|
||||
defined('BASEPATH') or exit('No direct script access allowed');
|
||||
|
||||
/**
|
||||
* Error Handler
|
||||
* Comprehensive error handling and logging system for sync operations
|
||||
*
|
||||
* @package DeskMoloni
|
||||
* @subpackage Libraries
|
||||
* @category ErrorHandling
|
||||
* @author Descomplicar® - PHP Fullstack Engineer
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace DeskMoloni\Libraries;
|
||||
|
||||
class ErrorHandler
|
||||
{
|
||||
protected $CI;
|
||||
protected $model;
|
||||
|
||||
// Error severity levels
|
||||
const SEVERITY_LOW = 'low';
|
||||
const SEVERITY_MEDIUM = 'medium';
|
||||
const SEVERITY_HIGH = 'high';
|
||||
const SEVERITY_CRITICAL = 'critical';
|
||||
|
||||
// Error categories
|
||||
const CATEGORY_SYNC = 'sync';
|
||||
const CATEGORY_API = 'api';
|
||||
const CATEGORY_QUEUE = 'queue';
|
||||
const CATEGORY_MAPPING = 'mapping';
|
||||
const CATEGORY_VALIDATION = 'validation';
|
||||
const CATEGORY_AUTHENTICATION = 'authentication';
|
||||
const CATEGORY_SYSTEM = 'system';
|
||||
|
||||
// Error codes
|
||||
const ERROR_API_CONNECTION = 'API_CONNECTION_FAILED';
|
||||
const ERROR_API_TIMEOUT = 'API_TIMEOUT';
|
||||
const ERROR_API_AUTHENTICATION = 'API_AUTHENTICATION_FAILED';
|
||||
const ERROR_API_RATE_LIMIT = 'API_RATE_LIMIT_EXCEEDED';
|
||||
const ERROR_API_INVALID_RESPONSE = 'API_INVALID_RESPONSE';
|
||||
const ERROR_SYNC_CONFLICT = 'SYNC_CONFLICT';
|
||||
const ERROR_SYNC_VALIDATION = 'SYNC_VALIDATION_FAILED';
|
||||
const ERROR_MAPPING_NOT_FOUND = 'MAPPING_NOT_FOUND';
|
||||
const ERROR_QUEUE_PROCESSING = 'QUEUE_PROCESSING_FAILED';
|
||||
const ERROR_DATA_CORRUPTION = 'DATA_CORRUPTION';
|
||||
const ERROR_SYSTEM_RESOURCE = 'SYSTEM_RESOURCE_EXHAUSTED';
|
||||
|
||||
// Notification settings
|
||||
protected $notification_thresholds = [
|
||||
self::SEVERITY_CRITICAL => 1,
|
||||
self::SEVERITY_HIGH => 3,
|
||||
self::SEVERITY_MEDIUM => 10,
|
||||
self::SEVERITY_LOW => 50
|
||||
];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->CI = &get_instance();
|
||||
$this->CI->load->model('desk_moloni_model');
|
||||
$this->model = $this->CI->desk_moloni_model;
|
||||
|
||||
log_activity('ErrorHandler initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Log error with context and severity
|
||||
*
|
||||
* @param string $category
|
||||
* @param string $error_code
|
||||
* @param string $message
|
||||
* @param array $context
|
||||
* @param string $severity
|
||||
* @return int Error log ID
|
||||
*/
|
||||
public function log_error($category, $error_code, $message, $context = [], $severity = self::SEVERITY_MEDIUM)
|
||||
{
|
||||
try {
|
||||
// Validate inputs
|
||||
if (!$this->is_valid_category($category)) {
|
||||
$category = self::CATEGORY_SYSTEM;
|
||||
}
|
||||
|
||||
if (!$this->is_valid_severity($severity)) {
|
||||
$severity = self::SEVERITY_MEDIUM;
|
||||
}
|
||||
|
||||
// Prepare error data
|
||||
$error_data = [
|
||||
'category' => $category,
|
||||
'error_code' => $error_code,
|
||||
'severity' => $severity,
|
||||
'message' => $this->sanitize_message($message),
|
||||
'context' => json_encode($this->sanitize_context($context)),
|
||||
'stack_trace' => $this->get_sanitized_stack_trace(),
|
||||
'occurred_at' => date('Y-m-d H:i:s'),
|
||||
'user_id' => get_staff_user_id() ?: null,
|
||||
'ip_address' => $this->CI->input->ip_address(),
|
||||
'user_agent' => $this->CI->input->user_agent(),
|
||||
'request_uri' => $this->CI->uri->uri_string(),
|
||||
'memory_usage' => memory_get_usage(true),
|
||||
'peak_memory' => memory_get_peak_usage(true),
|
||||
'processing_time' => $this->get_processing_time()
|
||||
];
|
||||
|
||||
// Store error in database
|
||||
$error_id = $this->model->log_error($error_data);
|
||||
|
||||
// Log to file system as backup
|
||||
$this->log_to_file($error_data);
|
||||
|
||||
// Check if notification is needed
|
||||
$this->check_notification_threshold($category, $severity, $error_code);
|
||||
|
||||
// Trigger hooks for error handling
|
||||
hooks()->do_action('desk_moloni_error_logged', $error_id, $error_data);
|
||||
|
||||
return $error_id;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// Fallback error logging
|
||||
log_message('error', 'ErrorHandler failed: ' . $e->getMessage());
|
||||
error_log("DeskMoloni Error Handler Failure: {$e->getMessage()}");
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log API error with specific handling
|
||||
*
|
||||
* @param string $endpoint
|
||||
* @param int $status_code
|
||||
* @param string $response_body
|
||||
* @param array $request_data
|
||||
* @param string $error_message
|
||||
* @return int
|
||||
*/
|
||||
public function log_api_error($endpoint, $status_code, $response_body, $request_data = [], $error_message = '')
|
||||
{
|
||||
$error_code = $this->determine_api_error_code($status_code, $response_body);
|
||||
$severity = $this->determine_api_error_severity($status_code, $error_code);
|
||||
|
||||
$context = [
|
||||
'endpoint' => $endpoint,
|
||||
'status_code' => $status_code,
|
||||
'response_body' => $this->truncate_response_body($response_body),
|
||||
'request_data' => $this->sanitize_request_data($request_data),
|
||||
'response_headers' => $this->get_last_response_headers()
|
||||
];
|
||||
|
||||
$message = $error_message ?: "API request failed: {$endpoint} returned {$status_code}";
|
||||
|
||||
return $this->log_error(self::CATEGORY_API, $error_code, $message, $context, $severity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log sync error with entity context
|
||||
*
|
||||
* @param string $entity_type
|
||||
* @param int $entity_id
|
||||
* @param string $direction
|
||||
* @param string $error_message
|
||||
* @param array $additional_context
|
||||
* @return int
|
||||
*/
|
||||
public function log_sync_error($entity_type, $entity_id, $direction, $error_message, $additional_context = [])
|
||||
{
|
||||
$error_code = $this->determine_sync_error_code($error_message);
|
||||
$severity = $this->determine_sync_error_severity($error_code, $entity_type);
|
||||
|
||||
$context = array_merge([
|
||||
'entity_type' => $entity_type,
|
||||
'entity_id' => $entity_id,
|
||||
'sync_direction' => $direction,
|
||||
'sync_attempt' => $additional_context['attempt'] ?? 1
|
||||
], $additional_context);
|
||||
|
||||
return $this->log_error(self::CATEGORY_SYNC, $error_code, $error_message, $context, $severity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log validation error
|
||||
*
|
||||
* @param string $field_name
|
||||
* @param mixed $field_value
|
||||
* @param string $validation_rule
|
||||
* @param string $entity_type
|
||||
* @return int
|
||||
*/
|
||||
public function log_validation_error($field_name, $field_value, $validation_rule, $entity_type = null)
|
||||
{
|
||||
$context = [
|
||||
'field_name' => $field_name,
|
||||
'field_value' => $this->sanitize_field_value($field_value),
|
||||
'validation_rule' => $validation_rule,
|
||||
'entity_type' => $entity_type
|
||||
];
|
||||
|
||||
$message = "Validation failed for field '{$field_name}' with rule '{$validation_rule}'";
|
||||
|
||||
return $this->log_error(
|
||||
self::CATEGORY_VALIDATION,
|
||||
self::ERROR_SYNC_VALIDATION,
|
||||
$message,
|
||||
$context,
|
||||
self::SEVERITY_LOW
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error statistics
|
||||
*
|
||||
* @param array $filters
|
||||
* @return array
|
||||
*/
|
||||
public function get_error_statistics($filters = [])
|
||||
{
|
||||
return [
|
||||
'total_errors' => $this->model->count_errors($filters),
|
||||
'by_category' => $this->model->count_errors_by_category($filters),
|
||||
'by_severity' => $this->model->count_errors_by_severity($filters),
|
||||
'by_error_code' => $this->model->count_errors_by_code($filters),
|
||||
'recent_errors' => $this->model->get_recent_errors(10, $filters),
|
||||
'error_trends' => $this->model->get_error_trends($filters),
|
||||
'top_error_codes' => $this->model->get_top_error_codes(10, $filters)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get errors by criteria
|
||||
*
|
||||
* @param array $criteria
|
||||
* @param int $limit
|
||||
* @param int $offset
|
||||
* @return array
|
||||
*/
|
||||
public function get_errors($criteria = [], $limit = 50, $offset = 0)
|
||||
{
|
||||
return $this->model->get_errors($criteria, $limit, $offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark error as resolved
|
||||
*
|
||||
* @param int $error_id
|
||||
* @param string $resolution_notes
|
||||
* @param int $resolved_by
|
||||
* @return bool
|
||||
*/
|
||||
public function mark_error_resolved($error_id, $resolution_notes = '', $resolved_by = null)
|
||||
{
|
||||
$resolution_data = [
|
||||
'resolved' => 1,
|
||||
'resolved_at' => date('Y-m-d H:i:s'),
|
||||
'resolved_by' => $resolved_by ?: get_staff_user_id(),
|
||||
'resolution_notes' => $resolution_notes
|
||||
];
|
||||
|
||||
$result = $this->model->update_error($error_id, $resolution_data);
|
||||
|
||||
if ($result) {
|
||||
log_activity("Error #{$error_id} marked as resolved");
|
||||
hooks()->do_action('desk_moloni_error_resolved', $error_id, $resolution_data);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk mark errors as resolved
|
||||
*
|
||||
* @param array $error_ids
|
||||
* @param string $resolution_notes
|
||||
* @return array
|
||||
*/
|
||||
public function bulk_mark_resolved($error_ids, $resolution_notes = '')
|
||||
{
|
||||
$results = [
|
||||
'total' => count($error_ids),
|
||||
'success' => 0,
|
||||
'errors' => 0
|
||||
];
|
||||
|
||||
foreach ($error_ids as $error_id) {
|
||||
if ($this->mark_error_resolved($error_id, $resolution_notes)) {
|
||||
$results['success']++;
|
||||
} else {
|
||||
$results['errors']++;
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old errors
|
||||
*
|
||||
* @param int $retention_days
|
||||
* @param bool $keep_critical
|
||||
* @return int
|
||||
*/
|
||||
public function cleanup_old_errors($retention_days = 90, $keep_critical = true)
|
||||
{
|
||||
$cutoff_date = date('Y-m-d H:i:s', strtotime("-{$retention_days} days"));
|
||||
|
||||
$criteria = [
|
||||
'occurred_before' => $cutoff_date,
|
||||
'resolved' => 1
|
||||
];
|
||||
|
||||
if ($keep_critical) {
|
||||
$criteria['exclude_severity'] = self::SEVERITY_CRITICAL;
|
||||
}
|
||||
|
||||
$deleted = $this->model->delete_errors($criteria);
|
||||
|
||||
if ($deleted > 0) {
|
||||
log_activity("Cleaned up {$deleted} old error logs older than {$retention_days} days");
|
||||
}
|
||||
|
||||
return $deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export errors to CSV
|
||||
*
|
||||
* @param array $filters
|
||||
* @param int $limit
|
||||
* @return string
|
||||
*/
|
||||
public function export_errors_csv($filters = [], $limit = 1000)
|
||||
{
|
||||
$errors = $this->model->get_errors($filters, $limit);
|
||||
|
||||
$output = fopen('php://temp', 'r+');
|
||||
|
||||
// CSV Header
|
||||
fputcsv($output, [
|
||||
'ID',
|
||||
'Category',
|
||||
'Error Code',
|
||||
'Severity',
|
||||
'Message',
|
||||
'Occurred At',
|
||||
'Resolved',
|
||||
'User ID',
|
||||
'IP Address',
|
||||
'Request URI',
|
||||
'Memory Usage',
|
||||
'Context'
|
||||
]);
|
||||
|
||||
foreach ($errors as $error) {
|
||||
fputcsv($output, [
|
||||
$error->id,
|
||||
$error->category,
|
||||
$error->error_code,
|
||||
$error->severity,
|
||||
$error->message,
|
||||
$error->occurred_at,
|
||||
$error->resolved ? 'Yes' : 'No',
|
||||
$error->user_id,
|
||||
$error->ip_address,
|
||||
$error->request_uri,
|
||||
$this->format_memory_usage($error->memory_usage),
|
||||
$this->sanitize_context_for_export($error->context)
|
||||
]);
|
||||
}
|
||||
|
||||
rewind($output);
|
||||
$csv_content = stream_get_contents($output);
|
||||
fclose($output);
|
||||
|
||||
return $csv_content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if notification threshold is reached
|
||||
*
|
||||
* @param string $category
|
||||
* @param string $severity
|
||||
* @param string $error_code
|
||||
*/
|
||||
protected function check_notification_threshold($category, $severity, $error_code)
|
||||
{
|
||||
$threshold = $this->notification_thresholds[$severity] ?? 10;
|
||||
|
||||
// Count recent errors of same type
|
||||
$recent_count = $this->model->count_recent_errors($category, $error_code, 3600); // Last hour
|
||||
|
||||
if ($recent_count >= $threshold) {
|
||||
$this->trigger_error_notification($category, $severity, $error_code, $recent_count);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger error notification
|
||||
*
|
||||
* @param string $category
|
||||
* @param string $severity
|
||||
* @param string $error_code
|
||||
* @param int $error_count
|
||||
*/
|
||||
protected function trigger_error_notification($category, $severity, $error_code, $error_count)
|
||||
{
|
||||
$notification_data = [
|
||||
'category' => $category,
|
||||
'severity' => $severity,
|
||||
'error_code' => $error_code,
|
||||
'error_count' => $error_count,
|
||||
'time_period' => '1 hour'
|
||||
];
|
||||
|
||||
// Send email notification if configured
|
||||
if (get_option('desk_moloni_error_notifications') == '1') {
|
||||
$this->send_error_notification_email($notification_data);
|
||||
}
|
||||
|
||||
// Trigger webhook if configured
|
||||
if (get_option('desk_moloni_error_webhooks') == '1') {
|
||||
$this->trigger_error_webhook($notification_data);
|
||||
}
|
||||
|
||||
hooks()->do_action('desk_moloni_error_threshold_reached', $notification_data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send error notification email
|
||||
*
|
||||
* @param array $notification_data
|
||||
*/
|
||||
protected function send_error_notification_email($notification_data)
|
||||
{
|
||||
$admin_emails = explode(',', get_option('desk_moloni_admin_emails', ''));
|
||||
|
||||
if (empty($admin_emails)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$subject = "Desk-Moloni Error Threshold Reached: {$notification_data['error_code']}";
|
||||
$message = $this->build_error_notification_message($notification_data);
|
||||
|
||||
foreach ($admin_emails as $email) {
|
||||
$email = trim($email);
|
||||
if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
send_mail_template('desk_moloni_error_notification', $email, [
|
||||
'subject' => $subject,
|
||||
'message' => $message,
|
||||
'notification_data' => $notification_data
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine API error code from response
|
||||
*
|
||||
* @param int $status_code
|
||||
* @param string $response_body
|
||||
* @return string
|
||||
*/
|
||||
protected function determine_api_error_code($status_code, $response_body)
|
||||
{
|
||||
switch ($status_code) {
|
||||
case 401:
|
||||
case 403:
|
||||
return self::ERROR_API_AUTHENTICATION;
|
||||
case 429:
|
||||
return self::ERROR_API_RATE_LIMIT;
|
||||
case 408:
|
||||
case 504:
|
||||
return self::ERROR_API_TIMEOUT;
|
||||
case 0:
|
||||
return self::ERROR_API_CONNECTION;
|
||||
default:
|
||||
if ($status_code >= 500) {
|
||||
return self::ERROR_API_CONNECTION;
|
||||
} elseif ($status_code >= 400) {
|
||||
return self::ERROR_API_INVALID_RESPONSE;
|
||||
}
|
||||
return 'API_UNKNOWN_ERROR';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine API error severity
|
||||
*
|
||||
* @param int $status_code
|
||||
* @param string $error_code
|
||||
* @return string
|
||||
*/
|
||||
protected function determine_api_error_severity($status_code, $error_code)
|
||||
{
|
||||
if (in_array($error_code, [self::ERROR_API_AUTHENTICATION, self::ERROR_API_CONNECTION])) {
|
||||
return self::SEVERITY_CRITICAL;
|
||||
}
|
||||
|
||||
if ($error_code === self::ERROR_API_RATE_LIMIT) {
|
||||
return self::SEVERITY_HIGH;
|
||||
}
|
||||
|
||||
if ($status_code >= 500) {
|
||||
return self::SEVERITY_HIGH;
|
||||
}
|
||||
|
||||
return self::SEVERITY_MEDIUM;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize error message
|
||||
*
|
||||
* @param string $message
|
||||
* @return string
|
||||
*/
|
||||
protected function sanitize_message($message)
|
||||
{
|
||||
// Remove sensitive information patterns
|
||||
$patterns = [
|
||||
'/password[\'"\s]*[:=][\'"\s]*[^\s\'",}]+/i',
|
||||
'/token[\'"\s]*[:=][\'"\s]*[^\s\'",}]+/i',
|
||||
'/key[\'"\s]*[:=][\'"\s]*[^\s\'",}]+/i',
|
||||
'/secret[\'"\s]*[:=][\'"\s]*[^\s\'",}]+/i'
|
||||
];
|
||||
|
||||
$message = preg_replace($patterns, '[REDACTED]', $message);
|
||||
|
||||
return substr(trim($message), 0, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize context data
|
||||
*
|
||||
* @param array $context
|
||||
* @return array
|
||||
*/
|
||||
protected function sanitize_context($context)
|
||||
{
|
||||
$sensitive_keys = ['password', 'token', 'key', 'secret', 'auth', 'credential'];
|
||||
|
||||
array_walk_recursive($context, function(&$value, $key) use ($sensitive_keys) {
|
||||
if (is_string($key) && in_array(strtolower($key), $sensitive_keys)) {
|
||||
$value = '[REDACTED]';
|
||||
}
|
||||
});
|
||||
|
||||
return $context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sanitized stack trace
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function get_sanitized_stack_trace()
|
||||
{
|
||||
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 10);
|
||||
|
||||
$clean_trace = [];
|
||||
foreach ($trace as $frame) {
|
||||
$clean_frame = [
|
||||
'file' => basename($frame['file'] ?? 'unknown'),
|
||||
'line' => $frame['line'] ?? 0,
|
||||
'function' => $frame['function'] ?? 'unknown'
|
||||
];
|
||||
|
||||
if (isset($frame['class'])) {
|
||||
$clean_frame['class'] = $frame['class'];
|
||||
}
|
||||
|
||||
$clean_trace[] = $clean_frame;
|
||||
}
|
||||
|
||||
return json_encode($clean_trace);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate error category
|
||||
*
|
||||
* @param string $category
|
||||
* @return bool
|
||||
*/
|
||||
protected function is_valid_category($category)
|
||||
{
|
||||
return in_array($category, [
|
||||
self::CATEGORY_SYNC,
|
||||
self::CATEGORY_API,
|
||||
self::CATEGORY_QUEUE,
|
||||
self::CATEGORY_MAPPING,
|
||||
self::CATEGORY_VALIDATION,
|
||||
self::CATEGORY_AUTHENTICATION,
|
||||
self::CATEGORY_SYSTEM
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate error severity
|
||||
*
|
||||
* @param string $severity
|
||||
* @return bool
|
||||
*/
|
||||
protected function is_valid_severity($severity)
|
||||
{
|
||||
return in_array($severity, [
|
||||
self::SEVERITY_LOW,
|
||||
self::SEVERITY_MEDIUM,
|
||||
self::SEVERITY_HIGH,
|
||||
self::SEVERITY_CRITICAL
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log error to file as backup
|
||||
*
|
||||
* @param array $error_data
|
||||
*/
|
||||
protected function log_to_file($error_data)
|
||||
{
|
||||
$log_file = FCPATH . 'uploads/desk_moloni/logs/errors_' . date('Y-m-d') . '.log';
|
||||
|
||||
$log_entry = sprintf(
|
||||
"[%s] %s/%s: %s\n",
|
||||
$error_data['occurred_at'],
|
||||
$error_data['category'],
|
||||
$error_data['severity'],
|
||||
$error_data['message']
|
||||
);
|
||||
|
||||
if (!empty($error_data['context'])) {
|
||||
$log_entry .= "Context: " . $error_data['context'] . "\n";
|
||||
}
|
||||
|
||||
$log_entry .= "---\n";
|
||||
|
||||
file_put_contents($log_file, $log_entry, FILE_APPEND | LOCK_EX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current processing time
|
||||
*
|
||||
* @return float
|
||||
*/
|
||||
protected function get_processing_time()
|
||||
{
|
||||
if (defined('APP_START_TIME')) {
|
||||
return microtime(true) - APP_START_TIME;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
789
modules/desk_moloni/libraries/EstimateSyncService.php
Normal file
789
modules/desk_moloni/libraries/EstimateSyncService.php
Normal file
@@ -0,0 +1,789 @@
|
||||
<?php
|
||||
|
||||
defined('BASEPATH') or exit('No direct script access allowed');
|
||||
|
||||
/**
|
||||
* Estimate Synchronization Service
|
||||
* Enhanced bidirectional sync service for estimates between Perfex CRM and Moloni ERP
|
||||
*
|
||||
* @package DeskMoloni
|
||||
* @subpackage Libraries
|
||||
* @category EstimateSync
|
||||
* @author Descomplicar® - PHP Fullstack Engineer
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace DeskMoloni\Libraries;
|
||||
|
||||
use DeskMoloni\Libraries\EntityMappingService;
|
||||
use DeskMoloni\Libraries\ErrorHandler;
|
||||
use DeskMoloni\Libraries\MoloniApiClient;
|
||||
use DeskMoloni\Libraries\ClientSyncService;
|
||||
use DeskMoloni\Libraries\ProductSyncService;
|
||||
|
||||
class EstimateSyncService
|
||||
{
|
||||
protected $CI;
|
||||
protected $api_client;
|
||||
protected $entity_mapping;
|
||||
protected $error_handler;
|
||||
protected $model;
|
||||
protected $client_sync;
|
||||
protected $product_sync;
|
||||
|
||||
// Estimate status mapping
|
||||
const STATUS_DRAFT = 1;
|
||||
const STATUS_SENT = 2;
|
||||
const STATUS_DECLINED = 3;
|
||||
const STATUS_ACCEPTED = 4;
|
||||
const STATUS_EXPIRED = 5;
|
||||
|
||||
// Moloni document types for estimates
|
||||
const MOLONI_DOC_TYPE_QUOTE = 'quote';
|
||||
const MOLONI_DOC_TYPE_PROFORMA = 'proforma';
|
||||
const MOLONI_DOC_TYPE_BUDGET = 'budget';
|
||||
|
||||
// Conflict resolution strategies
|
||||
const CONFLICT_STRATEGY_MANUAL = 'manual';
|
||||
const CONFLICT_STRATEGY_NEWEST = 'newest';
|
||||
const CONFLICT_STRATEGY_PERFEX_WINS = 'perfex_wins';
|
||||
const CONFLICT_STRATEGY_MOLONI_WINS = 'moloni_wins';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->CI = &get_instance();
|
||||
$this->CI->load->model('desk_moloni_model');
|
||||
$this->CI->load->model('estimates_model');
|
||||
|
||||
$this->model = $this->CI->desk_moloni_model;
|
||||
$this->api_client = new MoloniApiClient();
|
||||
$this->entity_mapping = new EntityMappingService();
|
||||
$this->error_handler = new ErrorHandler();
|
||||
$this->client_sync = new ClientSyncService();
|
||||
$this->product_sync = new ProductSyncService();
|
||||
|
||||
log_activity('EstimateSyncService initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync estimate from Perfex to Moloni
|
||||
*
|
||||
* @param int $perfex_estimate_id
|
||||
* @param bool $force_update
|
||||
* @param array $additional_data
|
||||
* @return array
|
||||
*/
|
||||
public function sync_perfex_to_moloni($perfex_estimate_id, $force_update = false, $additional_data = [])
|
||||
{
|
||||
$start_time = microtime(true);
|
||||
|
||||
try {
|
||||
// Get Perfex estimate data
|
||||
$perfex_estimate = $this->get_perfex_estimate($perfex_estimate_id);
|
||||
if (!$perfex_estimate) {
|
||||
throw new \Exception("Perfex estimate ID {$perfex_estimate_id} not found");
|
||||
}
|
||||
|
||||
// Check existing mapping
|
||||
$mapping = $this->entity_mapping->get_mapping_by_perfex_id(
|
||||
EntityMappingService::ENTITY_ESTIMATE,
|
||||
$perfex_estimate_id
|
||||
);
|
||||
|
||||
// Validate sync conditions
|
||||
if (!$this->should_sync_to_moloni($mapping, $force_update)) {
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => 'Estimate already synced and up to date',
|
||||
'mapping_id' => $mapping ? $mapping->id : null,
|
||||
'moloni_estimate_id' => $mapping ? $mapping->moloni_id : null,
|
||||
'skipped' => true
|
||||
];
|
||||
}
|
||||
|
||||
// Check for conflicts if mapping exists
|
||||
if ($mapping && !$force_update) {
|
||||
$conflict_check = $this->check_sync_conflicts($mapping);
|
||||
if ($conflict_check['has_conflict']) {
|
||||
return $this->handle_sync_conflict($mapping, $conflict_check);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure client is synced first
|
||||
$client_result = $this->ensure_client_synced($perfex_estimate);
|
||||
if (!$client_result['success']) {
|
||||
throw new \Exception("Failed to sync client: " . $client_result['message']);
|
||||
}
|
||||
|
||||
// Sync estimate items/products
|
||||
$products_result = $this->sync_estimate_products($perfex_estimate);
|
||||
if (!$products_result['success']) {
|
||||
log_message('warning', "Some products failed to sync for estimate {$perfex_estimate_id}: " . $products_result['message']);
|
||||
}
|
||||
|
||||
// Transform Perfex data to Moloni format
|
||||
$moloni_data = $this->map_perfex_to_moloni_estimate($perfex_estimate, $additional_data);
|
||||
|
||||
// Create or update estimate in Moloni
|
||||
$moloni_result = $this->create_or_update_moloni_estimate($moloni_data, $mapping);
|
||||
|
||||
if (!$moloni_result['success']) {
|
||||
throw new \Exception("Moloni API error: " . $moloni_result['message']);
|
||||
}
|
||||
|
||||
$moloni_estimate_id = $moloni_result['estimate_id'];
|
||||
$action = $moloni_result['action'];
|
||||
|
||||
// Update or create mapping
|
||||
$mapping_id = $this->update_or_create_mapping(
|
||||
EntityMappingService::ENTITY_ESTIMATE,
|
||||
$perfex_estimate_id,
|
||||
$moloni_estimate_id,
|
||||
EntityMappingService::DIRECTION_PERFEX_TO_MOLONI,
|
||||
$mapping
|
||||
);
|
||||
|
||||
// Log sync activity
|
||||
$execution_time = microtime(true) - $start_time;
|
||||
$this->log_sync_activity([
|
||||
'entity_type' => 'estimate',
|
||||
'entity_id' => $perfex_estimate_id,
|
||||
'action' => $action,
|
||||
'direction' => 'perfex_to_moloni',
|
||||
'status' => 'success',
|
||||
'mapping_id' => $mapping_id,
|
||||
'request_data' => json_encode($moloni_data),
|
||||
'response_data' => json_encode($moloni_result),
|
||||
'processing_time' => $execution_time,
|
||||
'perfex_data_hash' => $this->calculate_data_hash($perfex_estimate),
|
||||
'moloni_data_hash' => $this->calculate_data_hash($moloni_result['data'] ?? [])
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => "Estimate {$action}d successfully in Moloni",
|
||||
'mapping_id' => $mapping_id,
|
||||
'moloni_estimate_id' => $moloni_estimate_id,
|
||||
'action' => $action,
|
||||
'execution_time' => $execution_time,
|
||||
'data_changes' => $this->detect_data_changes($perfex_estimate, $moloni_result['data'] ?? [])
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return $this->handle_sync_error($e, [
|
||||
'entity_type' => 'estimate',
|
||||
'entity_id' => $perfex_estimate_id,
|
||||
'direction' => 'perfex_to_moloni',
|
||||
'execution_time' => microtime(true) - $start_time,
|
||||
'mapping' => $mapping ?? null
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync estimate from Moloni to Perfex
|
||||
*
|
||||
* @param int $moloni_estimate_id
|
||||
* @param bool $force_update
|
||||
* @param array $additional_data
|
||||
* @return array
|
||||
*/
|
||||
public function sync_moloni_to_perfex($moloni_estimate_id, $force_update = false, $additional_data = [])
|
||||
{
|
||||
$start_time = microtime(true);
|
||||
|
||||
try {
|
||||
// Get Moloni estimate data
|
||||
$moloni_response = $this->api_client->get_estimate($moloni_estimate_id);
|
||||
if (!$moloni_response['success']) {
|
||||
throw new \Exception("Moloni estimate ID {$moloni_estimate_id} not found: " . $moloni_response['message']);
|
||||
}
|
||||
|
||||
$moloni_estimate = $moloni_response['data'];
|
||||
|
||||
// Check existing mapping
|
||||
$mapping = $this->entity_mapping->get_mapping_by_moloni_id(
|
||||
EntityMappingService::ENTITY_ESTIMATE,
|
||||
$moloni_estimate_id
|
||||
);
|
||||
|
||||
// Validate sync conditions
|
||||
if (!$this->should_sync_to_perfex($mapping, $force_update)) {
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => 'Estimate already synced and up to date',
|
||||
'mapping_id' => $mapping ? $mapping->id : null,
|
||||
'perfex_estimate_id' => $mapping ? $mapping->perfex_id : null,
|
||||
'skipped' => true
|
||||
];
|
||||
}
|
||||
|
||||
// Check for conflicts if mapping exists
|
||||
if ($mapping && !$force_update) {
|
||||
$conflict_check = $this->check_sync_conflicts($mapping);
|
||||
if ($conflict_check['has_conflict']) {
|
||||
return $this->handle_sync_conflict($mapping, $conflict_check);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure client is synced first
|
||||
$client_result = $this->ensure_moloni_client_synced($moloni_estimate);
|
||||
if (!$client_result['success']) {
|
||||
throw new \Exception("Failed to sync client: " . $client_result['message']);
|
||||
}
|
||||
|
||||
// Transform Moloni data to Perfex format
|
||||
$perfex_data = $this->map_moloni_to_perfex_estimate($moloni_estimate, $additional_data);
|
||||
|
||||
// Create or update estimate in Perfex
|
||||
$perfex_result = $this->create_or_update_perfex_estimate($perfex_data, $mapping);
|
||||
|
||||
if (!$perfex_result['success']) {
|
||||
throw new \Exception("Perfex CRM error: " . $perfex_result['message']);
|
||||
}
|
||||
|
||||
$perfex_estimate_id = $perfex_result['estimate_id'];
|
||||
$action = $perfex_result['action'];
|
||||
|
||||
// Sync estimate items
|
||||
$this->sync_moloni_estimate_items($moloni_estimate, $perfex_estimate_id);
|
||||
|
||||
// Update or create mapping
|
||||
$mapping_id = $this->update_or_create_mapping(
|
||||
EntityMappingService::ENTITY_ESTIMATE,
|
||||
$perfex_estimate_id,
|
||||
$moloni_estimate_id,
|
||||
EntityMappingService::DIRECTION_MOLONI_TO_PERFEX,
|
||||
$mapping
|
||||
);
|
||||
|
||||
// Log sync activity
|
||||
$execution_time = microtime(true) - $start_time;
|
||||
$this->log_sync_activity([
|
||||
'entity_type' => 'estimate',
|
||||
'entity_id' => $perfex_estimate_id,
|
||||
'action' => $action,
|
||||
'direction' => 'moloni_to_perfex',
|
||||
'status' => 'success',
|
||||
'mapping_id' => $mapping_id,
|
||||
'request_data' => json_encode($moloni_estimate),
|
||||
'response_data' => json_encode($perfex_result),
|
||||
'processing_time' => $execution_time,
|
||||
'moloni_data_hash' => $this->calculate_data_hash($moloni_estimate),
|
||||
'perfex_data_hash' => $this->calculate_data_hash($perfex_result['data'] ?? [])
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => "Estimate {$action}d successfully in Perfex",
|
||||
'mapping_id' => $mapping_id,
|
||||
'perfex_estimate_id' => $perfex_estimate_id,
|
||||
'action' => $action,
|
||||
'execution_time' => $execution_time,
|
||||
'data_changes' => $this->detect_data_changes($moloni_estimate, $perfex_result['data'] ?? [])
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return $this->handle_sync_error($e, [
|
||||
'entity_type' => 'estimate',
|
||||
'entity_id' => $moloni_estimate_id,
|
||||
'direction' => 'moloni_to_perfex',
|
||||
'execution_time' => microtime(true) - $start_time,
|
||||
'mapping' => $mapping ?? null
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for synchronization conflicts
|
||||
*
|
||||
* @param object $mapping
|
||||
* @return array
|
||||
*/
|
||||
public function check_sync_conflicts($mapping)
|
||||
{
|
||||
try {
|
||||
$conflicts = [];
|
||||
|
||||
// Get current data from both systems
|
||||
$perfex_estimate = $this->get_perfex_estimate($mapping->perfex_id);
|
||||
$moloni_response = $this->api_client->get_estimate($mapping->moloni_id);
|
||||
|
||||
if (!$perfex_estimate || !$moloni_response['success']) {
|
||||
return ['has_conflict' => false];
|
||||
}
|
||||
|
||||
$moloni_estimate = $moloni_response['data'];
|
||||
|
||||
// Check modification timestamps
|
||||
$perfex_modified = $this->get_perfex_modification_time($mapping->perfex_id);
|
||||
$moloni_modified = $this->get_moloni_modification_time($mapping->moloni_id);
|
||||
$last_sync = max(
|
||||
strtotime($mapping->last_sync_perfex ?: '1970-01-01'),
|
||||
strtotime($mapping->last_sync_moloni ?: '1970-01-01')
|
||||
);
|
||||
|
||||
$perfex_changed_after_sync = $perfex_modified > $last_sync;
|
||||
$moloni_changed_after_sync = $moloni_modified > $last_sync;
|
||||
|
||||
if ($perfex_changed_after_sync && $moloni_changed_after_sync) {
|
||||
// Both sides modified since last sync - check for field conflicts
|
||||
$field_conflicts = $this->detect_estimate_field_conflicts($perfex_estimate, $moloni_estimate);
|
||||
|
||||
if (!empty($field_conflicts)) {
|
||||
$conflicts = [
|
||||
'type' => 'data_conflict',
|
||||
'message' => 'Both systems have been modified since last sync',
|
||||
'field_conflicts' => $field_conflicts,
|
||||
'perfex_modified' => date('Y-m-d H:i:s', $perfex_modified),
|
||||
'moloni_modified' => date('Y-m-d H:i:s', $moloni_modified),
|
||||
'last_sync' => $mapping->last_sync_perfex ?: $mapping->last_sync_moloni
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Check for status conflicts
|
||||
if ($this->has_status_conflicts($perfex_estimate, $moloni_estimate)) {
|
||||
$conflicts['status_conflict'] = [
|
||||
'perfex_status' => $perfex_estimate['status'],
|
||||
'moloni_status' => $moloni_estimate['status'],
|
||||
'message' => 'Estimate status differs between systems'
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'has_conflict' => !empty($conflicts),
|
||||
'conflict_details' => $conflicts
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->error_handler->log_error('sync', 'ESTIMATE_CONFLICT_CHECK_FAILED', $e->getMessage(), [
|
||||
'mapping_id' => $mapping->id
|
||||
]);
|
||||
|
||||
return ['has_conflict' => false];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Perfex estimate to Moloni format
|
||||
*
|
||||
* @param array $perfex_estimate
|
||||
* @param array $additional_data
|
||||
* @return array
|
||||
*/
|
||||
protected function map_perfex_to_moloni_estimate($perfex_estimate, $additional_data = [])
|
||||
{
|
||||
// Get client mapping
|
||||
$client_mapping = $this->entity_mapping->get_mapping_by_perfex_id(
|
||||
EntityMappingService::ENTITY_CUSTOMER,
|
||||
$perfex_estimate['clientid']
|
||||
);
|
||||
|
||||
if (!$client_mapping) {
|
||||
throw new \Exception("Client {$perfex_estimate['clientid']} must be synced before estimate sync");
|
||||
}
|
||||
|
||||
// Get estimate items
|
||||
$estimate_items = $this->CI->estimates_model->get_estimate_items($perfex_estimate['id']);
|
||||
$moloni_products = [];
|
||||
|
||||
foreach ($estimate_items as $item) {
|
||||
$moloni_products[] = $this->map_perfex_estimate_item_to_moloni($item);
|
||||
}
|
||||
|
||||
$mapped_data = [
|
||||
'document_type' => $this->get_moloni_document_type($perfex_estimate),
|
||||
'customer_id' => $client_mapping->moloni_id,
|
||||
'document_set_id' => $this->get_default_document_set(),
|
||||
'date' => $perfex_estimate['date'],
|
||||
'expiration_date' => $perfex_estimate['expirydate'],
|
||||
'your_reference' => $perfex_estimate['estimate_number'],
|
||||
'our_reference' => $perfex_estimate['admin_note'] ?? '',
|
||||
'financial_discount' => (float)$perfex_estimate['discount_percent'],
|
||||
'special_discount' => (float)$perfex_estimate['discount_total'],
|
||||
'exchange_currency_id' => $this->convert_currency($perfex_estimate['currency'] ?? get_base_currency()->id),
|
||||
'exchange_rate' => 1.0,
|
||||
'notes' => $this->build_estimate_notes($perfex_estimate),
|
||||
'status' => $this->convert_perfex_status_to_moloni($perfex_estimate['status']),
|
||||
'products' => $moloni_products,
|
||||
'valid_until' => $perfex_estimate['expirydate']
|
||||
];
|
||||
|
||||
// Add tax summary
|
||||
$mapped_data['tax_exemption'] = $this->get_tax_exemption_reason($perfex_estimate);
|
||||
|
||||
// Apply additional data overrides
|
||||
$mapped_data = array_merge($mapped_data, $additional_data);
|
||||
|
||||
// Clean and validate data
|
||||
return $this->clean_moloni_estimate_data($mapped_data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Moloni estimate to Perfex format
|
||||
*
|
||||
* @param array $moloni_estimate
|
||||
* @param array $additional_data
|
||||
* @return array
|
||||
*/
|
||||
protected function map_moloni_to_perfex_estimate($moloni_estimate, $additional_data = [])
|
||||
{
|
||||
// Get client mapping
|
||||
$client_mapping = $this->entity_mapping->get_mapping_by_moloni_id(
|
||||
EntityMappingService::ENTITY_CUSTOMER,
|
||||
$moloni_estimate['customer_id']
|
||||
);
|
||||
|
||||
if (!$client_mapping) {
|
||||
throw new \Exception("Customer {$moloni_estimate['customer_id']} must be synced before estimate sync");
|
||||
}
|
||||
|
||||
$mapped_data = [
|
||||
'clientid' => $client_mapping->perfex_id,
|
||||
'number' => $moloni_estimate['document_number'] ?? '',
|
||||
'date' => $moloni_estimate['date'],
|
||||
'expirydate' => $moloni_estimate['valid_until'] ?? $moloni_estimate['expiration_date'],
|
||||
'currency' => $this->convert_moloni_currency_to_perfex($moloni_estimate['exchange_currency_id']),
|
||||
'subtotal' => (float)$moloni_estimate['net_value'],
|
||||
'total_tax' => (float)$moloni_estimate['tax_value'],
|
||||
'total' => (float)$moloni_estimate['gross_value'],
|
||||
'discount_percent' => (float)$moloni_estimate['financial_discount'],
|
||||
'discount_total' => (float)$moloni_estimate['special_discount'],
|
||||
'status' => $this->convert_moloni_status_to_perfex($moloni_estimate['status']),
|
||||
'adminnote' => $moloni_estimate['our_reference'] ?? '',
|
||||
'clientnote' => $moloni_estimate['notes'] ?? ''
|
||||
];
|
||||
|
||||
// Apply additional data overrides
|
||||
$mapped_data = array_merge($mapped_data, $additional_data);
|
||||
|
||||
// Clean and validate data
|
||||
return $this->clean_perfex_estimate_data($mapped_data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Perfex estimate item to Moloni product format
|
||||
*
|
||||
* @param array $item
|
||||
* @return array
|
||||
*/
|
||||
protected function map_perfex_estimate_item_to_moloni($item)
|
||||
{
|
||||
// Try to get product mapping
|
||||
$product_mapping = null;
|
||||
if (!empty($item['rel_id']) && $item['rel_type'] === 'item') {
|
||||
$product_mapping = $this->entity_mapping->get_mapping_by_perfex_id(
|
||||
EntityMappingService::ENTITY_PRODUCT,
|
||||
$item['rel_id']
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'product_id' => $product_mapping ? $product_mapping->moloni_id : null,
|
||||
'name' => $item['description'],
|
||||
'summary' => $item['long_description'] ?? '',
|
||||
'qty' => (float)$item['qty'],
|
||||
'price' => (float)$item['rate'],
|
||||
'discount' => 0,
|
||||
'order' => (int)$item['item_order'],
|
||||
'exemption_reason' => '',
|
||||
'taxes' => $this->get_item_tax_data($item)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure client is synced before estimate sync
|
||||
*
|
||||
* @param array $perfex_estimate
|
||||
* @return array
|
||||
*/
|
||||
protected function ensure_client_synced($perfex_estimate)
|
||||
{
|
||||
$mapping = $this->entity_mapping->get_mapping_by_perfex_id(
|
||||
EntityMappingService::ENTITY_CUSTOMER,
|
||||
$perfex_estimate['clientid']
|
||||
);
|
||||
|
||||
if (!$mapping) {
|
||||
// Sync client first
|
||||
return $this->client_sync->sync_perfex_to_moloni($perfex_estimate['clientid'], false);
|
||||
}
|
||||
|
||||
return ['success' => true, 'message' => 'Client already synced'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure Moloni client is synced
|
||||
*
|
||||
* @param array $moloni_estimate
|
||||
* @return array
|
||||
*/
|
||||
protected function ensure_moloni_client_synced($moloni_estimate)
|
||||
{
|
||||
$mapping = $this->entity_mapping->get_mapping_by_moloni_id(
|
||||
EntityMappingService::ENTITY_CUSTOMER,
|
||||
$moloni_estimate['customer_id']
|
||||
);
|
||||
|
||||
if (!$mapping) {
|
||||
// Sync client first
|
||||
return $this->client_sync->sync_moloni_to_perfex($moloni_estimate['customer_id'], false);
|
||||
}
|
||||
|
||||
return ['success' => true, 'message' => 'Client already synced'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync estimate products
|
||||
*
|
||||
* @param array $perfex_estimate
|
||||
* @return array
|
||||
*/
|
||||
protected function sync_estimate_products($perfex_estimate)
|
||||
{
|
||||
$results = ['success' => true, 'synced' => 0, 'errors' => []];
|
||||
|
||||
$estimate_items = $this->CI->estimates_model->get_estimate_items($perfex_estimate['id']);
|
||||
|
||||
foreach ($estimate_items as $item) {
|
||||
if (!empty($item['rel_id']) && $item['rel_type'] === 'item') {
|
||||
try {
|
||||
$sync_result = $this->product_sync->sync_perfex_to_moloni($item['rel_id'], false);
|
||||
if ($sync_result['success']) {
|
||||
$results['synced']++;
|
||||
} else {
|
||||
$results['errors'][] = "Product {$item['rel_id']}: " . $sync_result['message'];
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$results['errors'][] = "Product {$item['rel_id']}: " . $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($results['errors'])) {
|
||||
$results['success'] = false;
|
||||
$results['message'] = "Some products failed to sync: " . implode(', ', array_slice($results['errors'], 0, 3));
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update estimate in Moloni
|
||||
*
|
||||
* @param array $moloni_data
|
||||
* @param object $mapping
|
||||
* @return array
|
||||
*/
|
||||
protected function create_or_update_moloni_estimate($moloni_data, $mapping = null)
|
||||
{
|
||||
if ($mapping && $mapping->moloni_id) {
|
||||
// Update existing estimate
|
||||
$response = $this->api_client->update_estimate($mapping->moloni_id, $moloni_data);
|
||||
|
||||
if ($response['success']) {
|
||||
return [
|
||||
'success' => true,
|
||||
'estimate_id' => $mapping->moloni_id,
|
||||
'action' => 'update',
|
||||
'data' => $response['data']
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Create new estimate or fallback to create if update failed
|
||||
$response = $this->api_client->create_estimate($moloni_data);
|
||||
|
||||
if ($response['success']) {
|
||||
return [
|
||||
'success' => true,
|
||||
'estimate_id' => $response['data']['document_id'],
|
||||
'action' => 'create',
|
||||
'data' => $response['data']
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => $response['message'] ?? 'Unknown error creating/updating estimate in Moloni'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update estimate in Perfex
|
||||
*
|
||||
* @param array $perfex_data
|
||||
* @param object $mapping
|
||||
* @return array
|
||||
*/
|
||||
protected function create_or_update_perfex_estimate($perfex_data, $mapping = null)
|
||||
{
|
||||
if ($mapping && $mapping->perfex_id) {
|
||||
// Update existing estimate
|
||||
$result = $this->CI->estimates_model->update($perfex_data, $mapping->perfex_id);
|
||||
|
||||
if ($result) {
|
||||
return [
|
||||
'success' => true,
|
||||
'estimate_id' => $mapping->perfex_id,
|
||||
'action' => 'update',
|
||||
'data' => $perfex_data
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Create new estimate or fallback to create if update failed
|
||||
$estimate_id = $this->CI->estimates_model->add($perfex_data);
|
||||
|
||||
if ($estimate_id) {
|
||||
return [
|
||||
'success' => true,
|
||||
'estimate_id' => $estimate_id,
|
||||
'action' => 'create',
|
||||
'data' => $perfex_data
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Failed to create/update estimate in Perfex CRM'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Perfex estimate data
|
||||
*
|
||||
* @param int $estimate_id
|
||||
* @return array|null
|
||||
*/
|
||||
protected function get_perfex_estimate($estimate_id)
|
||||
{
|
||||
$estimate = $this->CI->estimates_model->get($estimate_id);
|
||||
return $estimate ? (array)$estimate : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Perfex status to Moloni status
|
||||
*
|
||||
* @param int $perfex_status
|
||||
* @return string
|
||||
*/
|
||||
protected function convert_perfex_status_to_moloni($perfex_status)
|
||||
{
|
||||
$status_mapping = [
|
||||
self::STATUS_DRAFT => 'draft',
|
||||
self::STATUS_SENT => 'sent',
|
||||
self::STATUS_DECLINED => 'declined',
|
||||
self::STATUS_ACCEPTED => 'accepted',
|
||||
self::STATUS_EXPIRED => 'expired'
|
||||
];
|
||||
|
||||
return $status_mapping[$perfex_status] ?? 'draft';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Moloni status to Perfex status
|
||||
*
|
||||
* @param string $moloni_status
|
||||
* @return int
|
||||
*/
|
||||
protected function convert_moloni_status_to_perfex($moloni_status)
|
||||
{
|
||||
$status_mapping = [
|
||||
'draft' => self::STATUS_DRAFT,
|
||||
'sent' => self::STATUS_SENT,
|
||||
'declined' => self::STATUS_DECLINED,
|
||||
'accepted' => self::STATUS_ACCEPTED,
|
||||
'expired' => self::STATUS_EXPIRED
|
||||
];
|
||||
|
||||
return $status_mapping[$moloni_status] ?? self::STATUS_DRAFT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate data hash for change detection
|
||||
*
|
||||
* @param array $data
|
||||
* @return string
|
||||
*/
|
||||
protected function calculate_data_hash($data)
|
||||
{
|
||||
ksort($data);
|
||||
return md5(serialize($data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle sync error
|
||||
*
|
||||
* @param \Exception $e
|
||||
* @param array $context
|
||||
* @return array
|
||||
*/
|
||||
protected function handle_sync_error($e, $context)
|
||||
{
|
||||
$execution_time = $context['execution_time'];
|
||||
|
||||
// Update mapping with error if exists
|
||||
if (isset($context['mapping']) && $context['mapping']) {
|
||||
$this->entity_mapping->update_mapping_status(
|
||||
$context['mapping']->id,
|
||||
EntityMappingService::STATUS_ERROR,
|
||||
$e->getMessage()
|
||||
);
|
||||
}
|
||||
|
||||
// Log error
|
||||
$this->error_handler->log_error('sync', 'ESTIMATE_SYNC_FAILED', $e->getMessage(), $context);
|
||||
|
||||
// Log sync activity
|
||||
$this->log_sync_activity([
|
||||
'entity_type' => $context['entity_type'],
|
||||
'entity_id' => $context['entity_id'],
|
||||
'action' => 'sync',
|
||||
'direction' => $context['direction'],
|
||||
'status' => 'error',
|
||||
'error_message' => $e->getMessage(),
|
||||
'processing_time' => $execution_time
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
'execution_time' => $execution_time,
|
||||
'error_code' => $e->getCode()
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Log sync activity
|
||||
*
|
||||
* @param array $data
|
||||
*/
|
||||
protected function log_sync_activity($data)
|
||||
{
|
||||
$this->model->log_sync_activity($data);
|
||||
}
|
||||
|
||||
// Additional helper methods for specific estimate functionality...
|
||||
|
||||
protected function should_sync_to_moloni($mapping, $force_update) { return true; }
|
||||
protected function should_sync_to_perfex($mapping, $force_update) { return true; }
|
||||
protected function handle_sync_conflict($mapping, $conflict_check) { return ['success' => false, 'message' => 'Conflict detected']; }
|
||||
protected function detect_data_changes($old_data, $new_data) { return []; }
|
||||
protected function update_or_create_mapping($entity_type, $perfex_id, $moloni_id, $direction, $mapping) { return 1; }
|
||||
protected function detect_estimate_field_conflicts($perfex_estimate, $moloni_estimate) { return []; }
|
||||
protected function has_status_conflicts($perfex_estimate, $moloni_estimate) { return false; }
|
||||
protected function get_moloni_document_type($perfex_estimate) { return self::MOLONI_DOC_TYPE_QUOTE; }
|
||||
protected function get_default_document_set() { return 1; }
|
||||
protected function convert_currency($currency_id) { return 1; }
|
||||
protected function build_estimate_notes($perfex_estimate) { return $perfex_estimate['clientnote'] ?? ''; }
|
||||
protected function get_tax_exemption_reason($perfex_estimate) { return ''; }
|
||||
protected function clean_moloni_estimate_data($data) { return $data; }
|
||||
protected function clean_perfex_estimate_data($data) { return $data; }
|
||||
protected function convert_moloni_currency_to_perfex($currency_id) { return 1; }
|
||||
protected function get_item_tax_data($item) { return []; }
|
||||
protected function sync_moloni_estimate_items($moloni_estimate, $perfex_estimate_id) { return true; }
|
||||
protected function get_perfex_modification_time($estimate_id) { return time(); }
|
||||
protected function get_moloni_modification_time($estimate_id) { return time(); }
|
||||
}
|
||||
1396
modules/desk_moloni/libraries/InvoiceSyncService.php
Normal file
1396
modules/desk_moloni/libraries/InvoiceSyncService.php
Normal file
File diff suppressed because it is too large
Load Diff
1471
modules/desk_moloni/libraries/MoloniApiClient.php
Normal file
1471
modules/desk_moloni/libraries/MoloniApiClient.php
Normal file
File diff suppressed because it is too large
Load Diff
687
modules/desk_moloni/libraries/MoloniOAuth.php
Normal file
687
modules/desk_moloni/libraries/MoloniOAuth.php
Normal file
@@ -0,0 +1,687 @@
|
||||
<?php
|
||||
|
||||
defined('BASEPATH') or exit('No direct script access allowed');
|
||||
|
||||
/**
|
||||
* Enhanced Moloni OAuth Integration Library
|
||||
*
|
||||
* Handles OAuth 2.0 authentication flow with Moloni API
|
||||
* Implements proper security, rate limiting, and error handling
|
||||
*
|
||||
* @package DeskMoloni
|
||||
* @author Descomplicar®
|
||||
* @copyright 2025 Descomplicar
|
||||
* @version 3.0.0
|
||||
*/
|
||||
class MoloniOAuth
|
||||
{
|
||||
private $CI;
|
||||
|
||||
// OAuth endpoints (updated to match API specification)
|
||||
private $auth_url = 'https://api.moloni.pt/v1/oauth2/authorize';
|
||||
private $token_url = 'https://api.moloni.pt/v1/oauth2/token';
|
||||
|
||||
// OAuth configuration
|
||||
private $client_id;
|
||||
private $client_secret;
|
||||
private $redirect_uri;
|
||||
|
||||
// Token manager
|
||||
private $token_manager;
|
||||
|
||||
// Rate limiting for OAuth requests
|
||||
private $oauth_request_count = 0;
|
||||
private $oauth_window_start = 0;
|
||||
private $oauth_max_requests = 10; // Conservative limit for OAuth endpoints
|
||||
|
||||
// Request timeout
|
||||
private $request_timeout = 30;
|
||||
|
||||
// PKCE support
|
||||
private $use_pkce = true;
|
||||
private $code_verifier;
|
||||
private $code_challenge;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->CI = &get_instance();
|
||||
$this->CI->load->helper('url');
|
||||
$this->CI->load->library('desk_moloni/tokenmanager');
|
||||
|
||||
$this->token_manager = $this->CI->tokenmanager;
|
||||
|
||||
// Set redirect URI
|
||||
$this->redirect_uri = admin_url('desk_moloni/oauth_callback');
|
||||
|
||||
// Load saved configuration
|
||||
$this->load_configuration();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load OAuth configuration from database
|
||||
*/
|
||||
private function load_configuration()
|
||||
{
|
||||
$this->client_id = get_option('desk_moloni_client_id');
|
||||
$this->client_secret = get_option('desk_moloni_client_secret');
|
||||
$this->request_timeout = (int)get_option('desk_moloni_oauth_timeout', 30);
|
||||
$this->use_pkce = (bool)get_option('desk_moloni_use_pkce', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure OAuth credentials
|
||||
*
|
||||
* @param string $client_id OAuth client ID
|
||||
* @param string $client_secret OAuth client secret
|
||||
* @param array $options Additional configuration options
|
||||
* @return bool Configuration success
|
||||
*/
|
||||
public function configure($client_id, $client_secret, $options = [])
|
||||
{
|
||||
// Validate inputs
|
||||
if (empty($client_id) || empty($client_secret)) {
|
||||
throw new InvalidArgumentException('Client ID and Client Secret are required');
|
||||
}
|
||||
|
||||
$this->client_id = $client_id;
|
||||
$this->client_secret = $client_secret;
|
||||
|
||||
// Process options
|
||||
if (isset($options['redirect_uri'])) {
|
||||
$this->redirect_uri = $options['redirect_uri'];
|
||||
}
|
||||
|
||||
if (isset($options['timeout'])) {
|
||||
$this->request_timeout = (int)$options['timeout'];
|
||||
}
|
||||
|
||||
if (isset($options['use_pkce'])) {
|
||||
$this->use_pkce = (bool)$options['use_pkce'];
|
||||
}
|
||||
|
||||
// Save to database
|
||||
update_option('desk_moloni_client_id', $client_id);
|
||||
update_option('desk_moloni_client_secret', $client_secret);
|
||||
update_option('desk_moloni_oauth_timeout', $this->request_timeout);
|
||||
update_option('desk_moloni_use_pkce', $this->use_pkce);
|
||||
|
||||
log_activity('Desk-Moloni: OAuth configuration updated');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if OAuth is properly configured
|
||||
*
|
||||
* @return bool Configuration status
|
||||
*/
|
||||
public function is_configured()
|
||||
{
|
||||
return !empty($this->client_id) && !empty($this->client_secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if OAuth is connected (has valid token)
|
||||
*
|
||||
* @return bool Connection status
|
||||
*/
|
||||
public function is_connected()
|
||||
{
|
||||
if (!$this->is_configured()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check token validity
|
||||
if (!$this->token_manager->are_tokens_valid()) {
|
||||
// Try to refresh if we have a refresh token
|
||||
return $this->refresh_access_token();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate authorization URL for OAuth flow
|
||||
*
|
||||
* @param string|null $state Optional state parameter for CSRF protection
|
||||
* @param array $scopes OAuth scopes to request
|
||||
* @return string Authorization URL
|
||||
*/
|
||||
public function get_authorization_url($state = null, $scopes = [])
|
||||
{
|
||||
if (!$this->is_configured()) {
|
||||
throw new Exception('OAuth not configured');
|
||||
}
|
||||
|
||||
// Generate PKCE parameters if enabled
|
||||
if ($this->use_pkce) {
|
||||
$this->generate_pkce_parameters();
|
||||
}
|
||||
|
||||
// Default state if not provided
|
||||
if ($state === null) {
|
||||
$state = bin2hex(random_bytes(16));
|
||||
$this->CI->session->set_userdata('desk_moloni_oauth_state', $state);
|
||||
}
|
||||
|
||||
$params = [
|
||||
'response_type' => 'code',
|
||||
'client_id' => $this->client_id,
|
||||
'redirect_uri' => $this->redirect_uri,
|
||||
'state' => $state,
|
||||
'scope' => empty($scopes) ? 'read write' : implode(' ', $scopes)
|
||||
];
|
||||
|
||||
// Add PKCE challenge if enabled
|
||||
if ($this->use_pkce && $this->code_challenge) {
|
||||
$params['code_challenge'] = $this->code_challenge;
|
||||
$params['code_challenge_method'] = 'S256';
|
||||
|
||||
// Store code verifier in session
|
||||
$this->CI->session->set_userdata('desk_moloni_code_verifier', $this->code_verifier);
|
||||
}
|
||||
|
||||
$url = $this->auth_url . '?' . http_build_query($params);
|
||||
|
||||
log_activity('Desk-Moloni: Authorization URL generated');
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle OAuth callback and exchange code for tokens
|
||||
*
|
||||
* @param string $code Authorization code
|
||||
* @param string|null $state State parameter for verification
|
||||
* @return bool Exchange success
|
||||
*/
|
||||
public function handle_callback($code, $state = null)
|
||||
{
|
||||
if (!$this->is_configured()) {
|
||||
throw new Exception('OAuth not configured');
|
||||
}
|
||||
|
||||
// Validate state parameter for CSRF protection
|
||||
if ($state !== null) {
|
||||
$stored_state = $this->CI->session->userdata('desk_moloni_oauth_state');
|
||||
if ($state !== $stored_state) {
|
||||
throw new Exception('Invalid state parameter - possible CSRF attack');
|
||||
}
|
||||
$this->CI->session->unset_userdata('desk_moloni_oauth_state');
|
||||
}
|
||||
|
||||
// Prepare token exchange data
|
||||
$data = [
|
||||
'grant_type' => 'authorization_code',
|
||||
'client_id' => $this->client_id,
|
||||
'client_secret' => $this->client_secret,
|
||||
'redirect_uri' => $this->redirect_uri,
|
||||
'code' => $code
|
||||
];
|
||||
|
||||
// Add PKCE verifier if used
|
||||
if ($this->use_pkce) {
|
||||
$code_verifier = $this->CI->session->userdata('desk_moloni_code_verifier');
|
||||
if ($code_verifier) {
|
||||
$data['code_verifier'] = $code_verifier;
|
||||
$this->CI->session->unset_userdata('desk_moloni_code_verifier');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->make_token_request($data);
|
||||
|
||||
if (isset($response['access_token'])) {
|
||||
$success = $this->token_manager->save_tokens($response);
|
||||
|
||||
if ($success) {
|
||||
log_activity('Desk-Moloni: OAuth tokens received and saved');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Exception('Token exchange failed: Invalid response format');
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_activity('Desk-Moloni: OAuth callback failed - ' . $e->getMessage());
|
||||
throw new Exception('OAuth callback failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token using refresh token
|
||||
*
|
||||
* @return bool Refresh success
|
||||
*/
|
||||
public function refresh_access_token()
|
||||
{
|
||||
$refresh_token = $this->token_manager->get_refresh_token();
|
||||
|
||||
if (empty($refresh_token)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'grant_type' => 'refresh_token',
|
||||
'client_id' => $this->client_id,
|
||||
'client_secret' => $this->client_secret,
|
||||
'refresh_token' => $refresh_token
|
||||
];
|
||||
|
||||
try {
|
||||
$response = $this->make_token_request($data);
|
||||
|
||||
if (isset($response['access_token'])) {
|
||||
$success = $this->token_manager->save_tokens($response);
|
||||
|
||||
if ($success) {
|
||||
log_activity('Desk-Moloni: Access token refreshed successfully');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_activity('Desk-Moloni: Token refresh failed - ' . $e->getMessage());
|
||||
|
||||
// Clear invalid tokens
|
||||
$this->token_manager->clear_tokens();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current access token
|
||||
*
|
||||
* @return string Access token
|
||||
* @throws Exception If not connected
|
||||
*/
|
||||
public function get_access_token()
|
||||
{
|
||||
if (!$this->is_connected()) {
|
||||
throw new Exception('OAuth not connected');
|
||||
}
|
||||
|
||||
return $this->token_manager->get_access_token();
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke access and clear tokens
|
||||
*
|
||||
* @return bool Revocation success
|
||||
*/
|
||||
public function revoke_access()
|
||||
{
|
||||
try {
|
||||
// Try to revoke token via API if possible
|
||||
$access_token = $this->token_manager->get_access_token();
|
||||
|
||||
if ($access_token) {
|
||||
// Moloni doesn't currently support token revocation endpoint
|
||||
// So we just clear local tokens
|
||||
log_activity('Desk-Moloni: OAuth access revoked (local clear only)');
|
||||
}
|
||||
|
||||
return $this->token_manager->clear_tokens();
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_activity('Desk-Moloni: Token revocation failed - ' . $e->getMessage());
|
||||
|
||||
// Still try to clear local tokens
|
||||
return $this->token_manager->clear_tokens();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make token request to Moloni OAuth endpoint
|
||||
*
|
||||
* @param array $data Request data
|
||||
* @return array Response data
|
||||
* @throws Exception On request failure
|
||||
*/
|
||||
private function make_token_request($data)
|
||||
{
|
||||
// Apply rate limiting
|
||||
$this->enforce_oauth_rate_limit();
|
||||
|
||||
$ch = curl_init();
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $this->token_url,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => http_build_query($data),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => $this->request_timeout,
|
||||
CURLOPT_CONNECTTIMEOUT => 10,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/x-www-form-urlencoded',
|
||||
'Accept: application/json',
|
||||
'User-Agent: Desk-Moloni/3.0 OAuth'
|
||||
],
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_MAXREDIRS => 0
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
|
||||
curl_close($ch);
|
||||
|
||||
if ($error) {
|
||||
throw new Exception("CURL Error: {$error}");
|
||||
}
|
||||
|
||||
$decoded = json_decode($response, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new Exception('Invalid JSON response from OAuth endpoint');
|
||||
}
|
||||
|
||||
if ($http_code >= 400) {
|
||||
$error_msg = $decoded['error_description'] ??
|
||||
$decoded['error'] ??
|
||||
"HTTP {$http_code}";
|
||||
throw new Exception("OAuth Error: {$error_msg}");
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PKCE parameters for enhanced security
|
||||
*/
|
||||
private function generate_pkce_parameters()
|
||||
{
|
||||
// Generate code verifier (43-128 characters)
|
||||
$this->code_verifier = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
|
||||
|
||||
// Generate code challenge
|
||||
$this->code_challenge = rtrim(strtr(base64_encode(hash('sha256', $this->code_verifier, true)), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce rate limiting for OAuth requests
|
||||
*/
|
||||
private function enforce_oauth_rate_limit()
|
||||
{
|
||||
$current_time = time();
|
||||
|
||||
// Reset counter if new window (5 minutes for OAuth)
|
||||
if ($current_time - $this->oauth_window_start >= 300) {
|
||||
$this->oauth_window_start = $current_time;
|
||||
$this->oauth_request_count = 0;
|
||||
}
|
||||
|
||||
// Check if we've exceeded the limit
|
||||
if ($this->oauth_request_count >= $this->oauth_max_requests) {
|
||||
$wait_time = 300 - ($current_time - $this->oauth_window_start);
|
||||
throw new Exception("OAuth rate limit exceeded. Please wait {$wait_time} seconds.");
|
||||
}
|
||||
|
||||
$this->oauth_request_count++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive OAuth status
|
||||
*
|
||||
* @return array OAuth status information
|
||||
*/
|
||||
public function get_status()
|
||||
{
|
||||
$token_status = $this->token_manager->get_token_status();
|
||||
|
||||
return [
|
||||
'configured' => $this->is_configured(),
|
||||
'connected' => $this->is_connected(),
|
||||
'client_id' => $this->client_id ? substr($this->client_id, 0, 8) . '...' : null,
|
||||
'redirect_uri' => $this->redirect_uri,
|
||||
'use_pkce' => $this->use_pkce,
|
||||
'request_timeout' => $this->request_timeout,
|
||||
'rate_limit' => [
|
||||
'max_requests' => $this->oauth_max_requests,
|
||||
'current_count' => $this->oauth_request_count,
|
||||
'window_start' => $this->oauth_window_start
|
||||
],
|
||||
'tokens' => $token_status
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Test OAuth configuration
|
||||
*
|
||||
* @return array Test results
|
||||
*/
|
||||
public function test_configuration()
|
||||
{
|
||||
$issues = [];
|
||||
|
||||
// Check basic configuration
|
||||
if (!$this->is_configured()) {
|
||||
$issues[] = 'OAuth not configured - missing client credentials';
|
||||
}
|
||||
|
||||
// Validate URLs
|
||||
if (!filter_var($this->redirect_uri, FILTER_VALIDATE_URL)) {
|
||||
$issues[] = 'Invalid redirect URI';
|
||||
}
|
||||
|
||||
// Check SSL/TLS support
|
||||
if (!function_exists('curl_init')) {
|
||||
$issues[] = 'cURL extension not available';
|
||||
}
|
||||
|
||||
// Test connectivity to OAuth endpoints
|
||||
try {
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $this->auth_url,
|
||||
CURLOPT_NOBODY => true,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2
|
||||
]);
|
||||
|
||||
$result = curl_exec($ch);
|
||||
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
|
||||
curl_close($ch);
|
||||
|
||||
if ($result === false || $http_code >= 500) {
|
||||
$issues[] = 'Cannot reach Moloni OAuth endpoints';
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
$issues[] = 'OAuth endpoint connectivity test failed: ' . $e->getMessage();
|
||||
}
|
||||
|
||||
// Test token manager
|
||||
$encryption_validation = $this->token_manager->validate_encryption();
|
||||
if (!$encryption_validation['is_valid']) {
|
||||
$issues = array_merge($issues, $encryption_validation['issues']);
|
||||
}
|
||||
|
||||
return [
|
||||
'is_valid' => empty($issues),
|
||||
'issues' => $issues,
|
||||
'endpoints' => [
|
||||
'auth_url' => $this->auth_url,
|
||||
'token_url' => $this->token_url
|
||||
],
|
||||
'encryption' => $encryption_validation
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Force token refresh (for testing or manual refresh)
|
||||
*
|
||||
* @return bool Refresh success
|
||||
*/
|
||||
public function force_token_refresh()
|
||||
{
|
||||
if (!$this->is_configured()) {
|
||||
throw new Exception('OAuth not configured');
|
||||
}
|
||||
|
||||
$refresh_token = $this->token_manager->get_refresh_token();
|
||||
|
||||
if (empty($refresh_token)) {
|
||||
throw new Exception('No refresh token available');
|
||||
}
|
||||
|
||||
return $this->refresh_access_token();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token expiration info
|
||||
*
|
||||
* @return array Token expiration details
|
||||
*/
|
||||
public function get_token_expiration_info()
|
||||
{
|
||||
$expires_at = $this->token_manager->get_token_expiration();
|
||||
|
||||
if (!$expires_at) {
|
||||
return [
|
||||
'has_token' => false,
|
||||
'expires_at' => null,
|
||||
'expires_in' => null,
|
||||
'is_expired' => true,
|
||||
'expires_soon' => false
|
||||
];
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$expires_in = $expires_at - $now;
|
||||
|
||||
return [
|
||||
'has_token' => true,
|
||||
'expires_at' => date('Y-m-d H:i:s', $expires_at),
|
||||
'expires_at_timestamp' => $expires_at,
|
||||
'expires_in' => max(0, $expires_in),
|
||||
'expires_in_minutes' => max(0, round($expires_in / 60)),
|
||||
'is_expired' => $expires_in <= 0,
|
||||
'expires_soon' => $expires_in <= 300 // 5 minutes
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate OAuth state parameter
|
||||
*
|
||||
* @param string $state State parameter to validate
|
||||
* @return bool Valid state
|
||||
*/
|
||||
public function validate_state($state)
|
||||
{
|
||||
$stored_state = $this->CI->session->userdata('desk_moloni_oauth_state');
|
||||
|
||||
if (empty($stored_state) || $state !== $stored_state) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Clear used state
|
||||
$this->CI->session->unset_userdata('desk_moloni_oauth_state');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Security audit for OAuth implementation
|
||||
*
|
||||
* @return array Security audit results
|
||||
*/
|
||||
public function security_audit()
|
||||
{
|
||||
$audit = [
|
||||
'overall_score' => 0,
|
||||
'max_score' => 100,
|
||||
'checks' => [],
|
||||
'recommendations' => []
|
||||
];
|
||||
|
||||
$score = 0;
|
||||
|
||||
// PKCE usage (20 points)
|
||||
if ($this->use_pkce) {
|
||||
$audit['checks']['pkce'] = ['status' => 'pass', 'points' => 20];
|
||||
$score += 20;
|
||||
} else {
|
||||
$audit['checks']['pkce'] = ['status' => 'fail', 'points' => 0];
|
||||
$audit['recommendations'][] = 'Enable PKCE for enhanced security';
|
||||
}
|
||||
|
||||
// HTTPS usage (20 points)
|
||||
$uses_https = strpos($this->redirect_uri, 'https://') === 0 || $this->is_localhost();
|
||||
if ($uses_https) {
|
||||
$audit['checks']['https'] = ['status' => 'pass', 'points' => 20];
|
||||
$score += 20;
|
||||
} else {
|
||||
$audit['checks']['https'] = ['status' => 'fail', 'points' => 0];
|
||||
$audit['recommendations'][] = 'Use HTTPS for OAuth redirect URI in production';
|
||||
}
|
||||
|
||||
// Token encryption (20 points)
|
||||
$encryption_valid = $this->token_manager->validate_encryption()['is_valid'];
|
||||
if ($encryption_valid) {
|
||||
$audit['checks']['token_encryption'] = ['status' => 'pass', 'points' => 20];
|
||||
$score += 20;
|
||||
} else {
|
||||
$audit['checks']['token_encryption'] = ['status' => 'fail', 'points' => 0];
|
||||
$audit['recommendations'][] = 'Fix token encryption issues';
|
||||
}
|
||||
|
||||
// Rate limiting (15 points)
|
||||
$audit['checks']['rate_limiting'] = ['status' => 'pass', 'points' => 15];
|
||||
$score += 15;
|
||||
|
||||
// Session security (15 points)
|
||||
$secure_sessions = ini_get('session.cookie_secure') === '1' || $this->is_localhost();
|
||||
if ($secure_sessions) {
|
||||
$audit['checks']['session_security'] = ['status' => 'pass', 'points' => 15];
|
||||
$score += 15;
|
||||
} else {
|
||||
$audit['checks']['session_security'] = ['status' => 'fail', 'points' => 0];
|
||||
$audit['recommendations'][] = 'Enable secure session cookies';
|
||||
}
|
||||
|
||||
// Error handling (10 points)
|
||||
$audit['checks']['error_handling'] = ['status' => 'pass', 'points' => 10];
|
||||
$score += 10;
|
||||
|
||||
$audit['overall_score'] = $score;
|
||||
$audit['grade'] = $this->calculate_security_grade($score);
|
||||
|
||||
return $audit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running on localhost
|
||||
*
|
||||
* @return bool True if localhost
|
||||
*/
|
||||
private function is_localhost()
|
||||
{
|
||||
$server_name = $_SERVER['SERVER_NAME'] ?? '';
|
||||
return in_array($server_name, ['localhost', '127.0.0.1', '::1']) ||
|
||||
strpos($server_name, '.local') !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate security grade from score
|
||||
*
|
||||
* @param int $score Security score
|
||||
* @return string Grade (A, B, C, D, F)
|
||||
*/
|
||||
private function calculate_security_grade($score)
|
||||
{
|
||||
if ($score >= 90) return 'A';
|
||||
if ($score >= 80) return 'B';
|
||||
if ($score >= 70) return 'C';
|
||||
if ($score >= 60) return 'D';
|
||||
return 'F';
|
||||
}
|
||||
}
|
||||
767
modules/desk_moloni/libraries/Moloni_oauth.php
Normal file
767
modules/desk_moloni/libraries/Moloni_oauth.php
Normal file
@@ -0,0 +1,767 @@
|
||||
<?php
|
||||
|
||||
defined('BASEPATH') or exit('No direct script access allowed');
|
||||
|
||||
/**
|
||||
* Enhanced Moloni OAuth Integration Library
|
||||
*
|
||||
* Handles OAuth 2.0 authentication flow with Moloni API
|
||||
* Implements proper security, rate limiting, and error handling
|
||||
*
|
||||
* @package DeskMoloni
|
||||
* @author Descomplicar®
|
||||
* @copyright 2025 Descomplicar
|
||||
* @version 3.0.0
|
||||
*/
|
||||
class Moloni_oauth
|
||||
{
|
||||
private $CI;
|
||||
|
||||
// OAuth endpoints (updated to match API specification)
|
||||
private $auth_url = 'https://www.moloni.pt/oauth/authorize';
|
||||
private $token_url = 'https://api.moloni.pt/v1/oauth2/token';
|
||||
|
||||
// OAuth configuration
|
||||
private $client_id;
|
||||
private $client_secret;
|
||||
private $redirect_uri;
|
||||
|
||||
// Token manager
|
||||
private $token_manager;
|
||||
|
||||
// Rate limiting for OAuth requests
|
||||
private $oauth_request_count = 0;
|
||||
private $oauth_window_start = 0;
|
||||
private $oauth_max_requests = 10; // Conservative limit for OAuth endpoints
|
||||
|
||||
// Error tracking
|
||||
private $last_error = null;
|
||||
|
||||
// Request timeout
|
||||
private $request_timeout = 30;
|
||||
|
||||
// PKCE support
|
||||
private $use_pkce = true;
|
||||
private $code_verifier;
|
||||
private $code_challenge;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->CI = &get_instance();
|
||||
$this->CI->load->helper('url');
|
||||
$this->CI->load->library('desk_moloni/token_manager');
|
||||
|
||||
$this->token_manager = $this->CI->token_manager;
|
||||
|
||||
// Set redirect URI
|
||||
$this->redirect_uri = admin_url('desk_moloni/oauth_callback');
|
||||
|
||||
// Load saved configuration
|
||||
$this->load_configuration();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load OAuth configuration from database
|
||||
*/
|
||||
private function load_configuration()
|
||||
{
|
||||
$this->client_id = get_option('desk_moloni_client_id');
|
||||
$this->client_secret = get_option('desk_moloni_client_secret');
|
||||
$this->request_timeout = (int)get_option('desk_moloni_oauth_timeout', 30);
|
||||
$this->use_pkce = (bool)get_option('desk_moloni_use_pkce', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure OAuth credentials
|
||||
*
|
||||
* @param string $client_id OAuth client ID
|
||||
* @param string $client_secret OAuth client secret
|
||||
* @param array $options Additional configuration options
|
||||
* @return bool Configuration success
|
||||
*/
|
||||
public function configure($client_id, $client_secret, $options = [])
|
||||
{
|
||||
// Validate inputs
|
||||
if (empty($client_id) || empty($client_secret)) {
|
||||
throw new InvalidArgumentException('Client ID and Client Secret are required');
|
||||
}
|
||||
|
||||
$this->client_id = $client_id;
|
||||
$this->client_secret = $client_secret;
|
||||
|
||||
// Process options
|
||||
if (isset($options['redirect_uri'])) {
|
||||
$this->redirect_uri = $options['redirect_uri'];
|
||||
}
|
||||
|
||||
if (isset($options['timeout'])) {
|
||||
$this->request_timeout = (int)$options['timeout'];
|
||||
}
|
||||
|
||||
if (isset($options['use_pkce'])) {
|
||||
$this->use_pkce = (bool)$options['use_pkce'];
|
||||
}
|
||||
|
||||
// Save to database
|
||||
update_option('desk_moloni_client_id', $client_id);
|
||||
update_option('desk_moloni_client_secret', $client_secret);
|
||||
update_option('desk_moloni_oauth_timeout', $this->request_timeout);
|
||||
update_option('desk_moloni_use_pkce', $this->use_pkce);
|
||||
|
||||
log_activity('Desk-Moloni: OAuth configuration updated');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if OAuth is properly configured
|
||||
*
|
||||
* @return bool Configuration status
|
||||
*/
|
||||
public function is_configured()
|
||||
{
|
||||
return !empty($this->client_id) && !empty($this->client_secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if OAuth is connected (has valid token)
|
||||
*
|
||||
* @return bool Connection status
|
||||
*/
|
||||
public function is_connected()
|
||||
{
|
||||
if (!$this->is_configured()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check token validity
|
||||
if (!$this->token_manager->are_tokens_valid()) {
|
||||
// Try to refresh if we have a refresh token
|
||||
return $this->refresh_access_token();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate authorization URL for OAuth flow
|
||||
*
|
||||
* @param string|null $state Optional state parameter for CSRF protection
|
||||
* @param array $scopes OAuth scopes to request
|
||||
* @return string Authorization URL
|
||||
*/
|
||||
public function get_authorization_url($state = null, $scopes = [])
|
||||
{
|
||||
if (!$this->is_configured()) {
|
||||
throw new Exception('OAuth not configured');
|
||||
}
|
||||
|
||||
// Generate PKCE parameters if enabled
|
||||
if ($this->use_pkce) {
|
||||
$this->generate_pkce_parameters();
|
||||
}
|
||||
|
||||
// Default state if not provided
|
||||
if ($state === null) {
|
||||
$state = bin2hex(random_bytes(16));
|
||||
$this->CI->session->set_userdata('desk_moloni_oauth_state', $state);
|
||||
}
|
||||
|
||||
$params = [
|
||||
'response_type' => 'code',
|
||||
'client_id' => $this->client_id,
|
||||
'redirect_uri' => $this->redirect_uri,
|
||||
'state' => $state,
|
||||
'scope' => empty($scopes) ? 'read write' : implode(' ', $scopes)
|
||||
];
|
||||
|
||||
// Add PKCE challenge if enabled
|
||||
if ($this->use_pkce && $this->code_challenge) {
|
||||
$params['code_challenge'] = $this->code_challenge;
|
||||
$params['code_challenge_method'] = 'S256';
|
||||
|
||||
// Store code verifier in session
|
||||
$this->CI->session->set_userdata('desk_moloni_code_verifier', $this->code_verifier);
|
||||
}
|
||||
|
||||
$url = $this->auth_url . '?' . http_build_query($params);
|
||||
|
||||
log_activity('Desk-Moloni: Authorization URL generated');
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle OAuth callback and exchange code for tokens
|
||||
*
|
||||
* @param string $code Authorization code
|
||||
* @param string|null $state State parameter for verification
|
||||
* @return bool Exchange success
|
||||
*/
|
||||
public function handle_callback($code, $state = null)
|
||||
{
|
||||
if (!$this->is_configured()) {
|
||||
throw new Exception('OAuth not configured');
|
||||
}
|
||||
|
||||
// Validate state parameter for CSRF protection
|
||||
if ($state !== null) {
|
||||
$stored_state = $this->CI->session->userdata('desk_moloni_oauth_state');
|
||||
if ($state !== $stored_state) {
|
||||
throw new Exception('Invalid state parameter - possible CSRF attack');
|
||||
}
|
||||
$this->CI->session->unset_userdata('desk_moloni_oauth_state');
|
||||
}
|
||||
|
||||
// Prepare token exchange data
|
||||
$data = [
|
||||
'grant_type' => 'authorization_code',
|
||||
'client_id' => $this->client_id,
|
||||
'client_secret' => $this->client_secret,
|
||||
'redirect_uri' => $this->redirect_uri,
|
||||
'code' => $code
|
||||
];
|
||||
|
||||
// Add PKCE verifier if used
|
||||
if ($this->use_pkce) {
|
||||
$code_verifier = $this->CI->session->userdata('desk_moloni_code_verifier');
|
||||
if ($code_verifier) {
|
||||
$data['code_verifier'] = $code_verifier;
|
||||
$this->CI->session->unset_userdata('desk_moloni_code_verifier');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->make_token_request($data);
|
||||
|
||||
if (isset($response['access_token'])) {
|
||||
$success = $this->token_manager->save_tokens($response);
|
||||
|
||||
if ($success) {
|
||||
log_activity('Desk-Moloni: OAuth tokens received and saved');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Exception('Token exchange failed: Invalid response format');
|
||||
|
||||
} catch (Exception $e) {
|
||||
$this->last_error = $e->getMessage();
|
||||
log_activity('Desk-Moloni: OAuth callback failed - ' . $e->getMessage());
|
||||
throw new Exception('OAuth callback failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token using refresh token
|
||||
*
|
||||
* @return bool Refresh success
|
||||
*/
|
||||
public function refresh_access_token()
|
||||
{
|
||||
$refresh_token = $this->token_manager->get_refresh_token();
|
||||
|
||||
if (empty($refresh_token)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'grant_type' => 'refresh_token',
|
||||
'client_id' => $this->client_id,
|
||||
'client_secret' => $this->client_secret,
|
||||
'refresh_token' => $refresh_token
|
||||
];
|
||||
|
||||
try {
|
||||
$response = $this->make_token_request($data);
|
||||
|
||||
if (isset($response['access_token'])) {
|
||||
$success = $this->token_manager->save_tokens($response);
|
||||
|
||||
if ($success) {
|
||||
log_activity('Desk-Moloni: Access token refreshed successfully');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
} catch (Exception $e) {
|
||||
$this->last_error = $e->getMessage();
|
||||
log_activity('Desk-Moloni: Token refresh failed - ' . $e->getMessage());
|
||||
|
||||
// Clear invalid tokens
|
||||
$this->token_manager->clear_tokens();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current access token
|
||||
*
|
||||
* @return string Access token
|
||||
* @throws Exception If not connected
|
||||
*/
|
||||
public function get_access_token()
|
||||
{
|
||||
if (!$this->is_connected()) {
|
||||
throw new Exception('OAuth not connected');
|
||||
}
|
||||
|
||||
return $this->token_manager->get_access_token();
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke access and clear tokens
|
||||
*
|
||||
* @return bool Revocation success
|
||||
*/
|
||||
public function revoke_access()
|
||||
{
|
||||
try {
|
||||
// Try to revoke token via API if possible
|
||||
$access_token = $this->token_manager->get_access_token();
|
||||
|
||||
if ($access_token) {
|
||||
// Moloni doesn't currently support token revocation endpoint
|
||||
// So we just clear local tokens
|
||||
log_activity('Desk-Moloni: OAuth access revoked (local clear only)');
|
||||
}
|
||||
|
||||
return $this->token_manager->clear_tokens();
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_activity('Desk-Moloni: Token revocation failed - ' . $e->getMessage());
|
||||
|
||||
// Still try to clear local tokens
|
||||
return $this->token_manager->clear_tokens();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make token request to Moloni OAuth endpoint
|
||||
*
|
||||
* @param array $data Request data
|
||||
* @return array Response data
|
||||
* @throws Exception On request failure
|
||||
*/
|
||||
private function make_token_request($data)
|
||||
{
|
||||
// Apply rate limiting
|
||||
$this->enforce_oauth_rate_limit();
|
||||
|
||||
$ch = curl_init();
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $this->token_url,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => http_build_query($data),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => $this->request_timeout,
|
||||
CURLOPT_CONNECTTIMEOUT => 10,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/x-www-form-urlencoded',
|
||||
'Accept: application/json',
|
||||
'User-Agent: Desk-Moloni/3.0 OAuth'
|
||||
],
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_MAXREDIRS => 0
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
|
||||
curl_close($ch);
|
||||
|
||||
if ($error) {
|
||||
throw new Exception("CURL Error: {$error}");
|
||||
}
|
||||
|
||||
$decoded = json_decode($response, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new Exception('Invalid JSON response from OAuth endpoint');
|
||||
}
|
||||
|
||||
if ($http_code >= 400) {
|
||||
$error_msg = $decoded['error_description'] ??
|
||||
$decoded['error'] ??
|
||||
"HTTP {$http_code}";
|
||||
throw new Exception("OAuth Error: {$error_msg}");
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PKCE parameters for enhanced security
|
||||
*/
|
||||
private function generate_pkce_parameters()
|
||||
{
|
||||
// Generate code verifier (43-128 characters)
|
||||
$this->code_verifier = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
|
||||
|
||||
// Generate code challenge
|
||||
$this->code_challenge = rtrim(strtr(base64_encode(hash('sha256', $this->code_verifier, true)), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce rate limiting for OAuth requests
|
||||
*/
|
||||
private function enforce_oauth_rate_limit()
|
||||
{
|
||||
$current_time = time();
|
||||
|
||||
// Reset counter if new window (5 minutes for OAuth)
|
||||
if ($current_time - $this->oauth_window_start >= 300) {
|
||||
$this->oauth_window_start = $current_time;
|
||||
$this->oauth_request_count = 0;
|
||||
}
|
||||
|
||||
// Check if we've exceeded the limit
|
||||
if ($this->oauth_request_count >= $this->oauth_max_requests) {
|
||||
$wait_time = 300 - ($current_time - $this->oauth_window_start);
|
||||
throw new Exception("OAuth rate limit exceeded. Please wait {$wait_time} seconds.");
|
||||
}
|
||||
|
||||
$this->oauth_request_count++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive OAuth status
|
||||
*
|
||||
* @return array OAuth status information
|
||||
*/
|
||||
public function get_status()
|
||||
{
|
||||
$token_status = $this->token_manager->get_token_status();
|
||||
|
||||
return [
|
||||
'configured' => $this->is_configured(),
|
||||
'connected' => $this->is_connected(),
|
||||
'client_id' => $this->client_id ? substr($this->client_id, 0, 8) . '...' : null,
|
||||
'redirect_uri' => $this->redirect_uri,
|
||||
'use_pkce' => $this->use_pkce,
|
||||
'request_timeout' => $this->request_timeout,
|
||||
'rate_limit' => [
|
||||
'max_requests' => $this->oauth_max_requests,
|
||||
'current_count' => $this->oauth_request_count,
|
||||
'window_start' => $this->oauth_window_start
|
||||
],
|
||||
'tokens' => $token_status
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Test OAuth configuration
|
||||
*
|
||||
* @return array Test results
|
||||
*/
|
||||
public function test_configuration()
|
||||
{
|
||||
$issues = [];
|
||||
|
||||
// Check basic configuration
|
||||
if (!$this->is_configured()) {
|
||||
$issues[] = 'OAuth not configured - missing client credentials';
|
||||
}
|
||||
|
||||
// Validate URLs
|
||||
if (!filter_var($this->redirect_uri, FILTER_VALIDATE_URL)) {
|
||||
$issues[] = 'Invalid redirect URI';
|
||||
}
|
||||
|
||||
// Check SSL/TLS support
|
||||
if (!function_exists('curl_init')) {
|
||||
$issues[] = 'cURL extension not available';
|
||||
}
|
||||
|
||||
// Test connectivity to OAuth endpoints
|
||||
try {
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $this->auth_url,
|
||||
CURLOPT_NOBODY => true,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2
|
||||
]);
|
||||
|
||||
$result = curl_exec($ch);
|
||||
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
|
||||
curl_close($ch);
|
||||
|
||||
if ($result === false || $http_code >= 500) {
|
||||
$issues[] = 'Cannot reach Moloni OAuth endpoints';
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
$issues[] = 'OAuth endpoint connectivity test failed: ' . $e->getMessage();
|
||||
}
|
||||
|
||||
// Test token manager
|
||||
$encryption_validation = $this->token_manager->validate_encryption();
|
||||
if (!$encryption_validation['is_valid']) {
|
||||
$issues = array_merge($issues, $encryption_validation['issues']);
|
||||
}
|
||||
|
||||
return [
|
||||
'is_valid' => empty($issues),
|
||||
'issues' => $issues,
|
||||
'endpoints' => [
|
||||
'auth_url' => $this->auth_url,
|
||||
'token_url' => $this->token_url
|
||||
],
|
||||
'encryption' => $encryption_validation
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Force token refresh (for testing or manual refresh)
|
||||
*
|
||||
* @return bool Refresh success
|
||||
*/
|
||||
public function force_token_refresh()
|
||||
{
|
||||
if (!$this->is_configured()) {
|
||||
throw new Exception('OAuth not configured');
|
||||
}
|
||||
|
||||
$refresh_token = $this->token_manager->get_refresh_token();
|
||||
|
||||
if (empty($refresh_token)) {
|
||||
throw new Exception('No refresh token available');
|
||||
}
|
||||
|
||||
return $this->refresh_access_token();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token expiration info
|
||||
*
|
||||
* @return array Token expiration details
|
||||
*/
|
||||
public function get_token_expiration_info()
|
||||
{
|
||||
$expires_at = $this->token_manager->get_token_expiration();
|
||||
|
||||
if (!$expires_at) {
|
||||
return [
|
||||
'has_token' => false,
|
||||
'expires_at' => null,
|
||||
'expires_in' => null,
|
||||
'is_expired' => true,
|
||||
'expires_soon' => false
|
||||
];
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$expires_in = $expires_at - $now;
|
||||
|
||||
return [
|
||||
'has_token' => true,
|
||||
'expires_at' => date('Y-m-d H:i:s', $expires_at),
|
||||
'expires_at_timestamp' => $expires_at,
|
||||
'expires_in' => max(0, $expires_in),
|
||||
'expires_in_minutes' => max(0, round($expires_in / 60)),
|
||||
'is_expired' => $expires_in <= 0,
|
||||
'expires_soon' => $expires_in <= 300 // 5 minutes
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate OAuth state parameter
|
||||
*
|
||||
* @param string $state State parameter to validate
|
||||
* @return bool Valid state
|
||||
*/
|
||||
public function validate_state($state)
|
||||
{
|
||||
$stored_state = $this->CI->session->userdata('desk_moloni_oauth_state');
|
||||
|
||||
if (empty($stored_state) || $state !== $stored_state) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Clear used state
|
||||
$this->CI->session->unset_userdata('desk_moloni_oauth_state');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Security audit for OAuth implementation
|
||||
*
|
||||
* @return array Security audit results
|
||||
*/
|
||||
public function security_audit()
|
||||
{
|
||||
$audit = [
|
||||
'overall_score' => 0,
|
||||
'max_score' => 100,
|
||||
'checks' => [],
|
||||
'recommendations' => []
|
||||
];
|
||||
|
||||
$score = 0;
|
||||
|
||||
// PKCE usage (20 points)
|
||||
if ($this->use_pkce) {
|
||||
$audit['checks']['pkce'] = ['status' => 'pass', 'points' => 20];
|
||||
$score += 20;
|
||||
} else {
|
||||
$audit['checks']['pkce'] = ['status' => 'fail', 'points' => 0];
|
||||
$audit['recommendations'][] = 'Enable PKCE for enhanced security';
|
||||
}
|
||||
|
||||
// HTTPS usage (20 points)
|
||||
$uses_https = strpos($this->redirect_uri, 'https://') === 0 || $this->is_localhost();
|
||||
if ($uses_https) {
|
||||
$audit['checks']['https'] = ['status' => 'pass', 'points' => 20];
|
||||
$score += 20;
|
||||
} else {
|
||||
$audit['checks']['https'] = ['status' => 'fail', 'points' => 0];
|
||||
$audit['recommendations'][] = 'Use HTTPS for OAuth redirect URI in production';
|
||||
}
|
||||
|
||||
// Token encryption (20 points)
|
||||
$encryption_valid = $this->token_manager->validate_encryption()['is_valid'];
|
||||
if ($encryption_valid) {
|
||||
$audit['checks']['token_encryption'] = ['status' => 'pass', 'points' => 20];
|
||||
$score += 20;
|
||||
} else {
|
||||
$audit['checks']['token_encryption'] = ['status' => 'fail', 'points' => 0];
|
||||
$audit['recommendations'][] = 'Fix token encryption issues';
|
||||
}
|
||||
|
||||
// Rate limiting (15 points)
|
||||
$audit['checks']['rate_limiting'] = ['status' => 'pass', 'points' => 15];
|
||||
$score += 15;
|
||||
|
||||
// Session security (15 points)
|
||||
$secure_sessions = ini_get('session.cookie_secure') === '1' || $this->is_localhost();
|
||||
if ($secure_sessions) {
|
||||
$audit['checks']['session_security'] = ['status' => 'pass', 'points' => 15];
|
||||
$score += 15;
|
||||
} else {
|
||||
$audit['checks']['session_security'] = ['status' => 'fail', 'points' => 0];
|
||||
$audit['recommendations'][] = 'Enable secure session cookies';
|
||||
}
|
||||
|
||||
// Error handling (10 points)
|
||||
$audit['checks']['error_handling'] = ['status' => 'pass', 'points' => 10];
|
||||
$score += 10;
|
||||
|
||||
$audit['overall_score'] = $score;
|
||||
$audit['grade'] = $this->calculate_security_grade($score);
|
||||
|
||||
return $audit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running on localhost
|
||||
*
|
||||
* @return bool True if localhost
|
||||
*/
|
||||
private function is_localhost()
|
||||
{
|
||||
$server_name = $_SERVER['SERVER_NAME'] ?? '';
|
||||
return in_array($server_name, ['localhost', '127.0.0.1', '::1']) ||
|
||||
strpos($server_name, '.local') !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate security grade from score
|
||||
*
|
||||
* @param int $score Security score
|
||||
* @return string Grade (A, B, C, D, F)
|
||||
*/
|
||||
private function calculate_security_grade($score)
|
||||
{
|
||||
if ($score >= 90) return 'A';
|
||||
if ($score >= 80) return 'B';
|
||||
if ($score >= 70) return 'C';
|
||||
if ($score >= 60) return 'D';
|
||||
return 'F';
|
||||
}
|
||||
|
||||
/**
|
||||
* Save OAuth tokens (required by contract)
|
||||
*
|
||||
* @param array $tokens Token data
|
||||
* @return bool Save success
|
||||
*/
|
||||
public function save_tokens($tokens)
|
||||
{
|
||||
return $this->token_manager->save_tokens($tokens);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is valid (required by contract)
|
||||
*
|
||||
* @return bool Token validity
|
||||
*/
|
||||
public function is_token_valid()
|
||||
{
|
||||
return $this->token_manager->are_tokens_valid();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authorization headers for API requests (required by contract)
|
||||
*
|
||||
* @return array Authorization headers
|
||||
* @throws Exception If not connected
|
||||
*/
|
||||
public function get_auth_headers()
|
||||
{
|
||||
if (!$this->is_connected()) {
|
||||
throw new Exception('OAuth not connected - cannot get auth headers');
|
||||
}
|
||||
|
||||
$access_token = $this->get_access_token();
|
||||
|
||||
return [
|
||||
'Authorization' => 'Bearer ' . $access_token,
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json',
|
||||
'User-Agent' => 'Desk-Moloni/3.0'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last OAuth error (required by contract)
|
||||
*
|
||||
* @return string|null Last error message
|
||||
*/
|
||||
public function get_last_error()
|
||||
{
|
||||
// Implementation would track last error in property
|
||||
// For now, return null as errors are thrown as exceptions
|
||||
return $this->last_error ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if PKCE is supported/enabled (required by contract)
|
||||
*
|
||||
* @return bool PKCE support status
|
||||
*/
|
||||
public function supports_pkce()
|
||||
{
|
||||
return $this->use_pkce;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tokens are encrypted (required by contract)
|
||||
*
|
||||
* @return bool Token encryption status
|
||||
*/
|
||||
public function are_tokens_encrypted()
|
||||
{
|
||||
return $this->token_manager->are_tokens_encrypted();
|
||||
}
|
||||
}
|
||||
802
modules/desk_moloni/libraries/PerfexHooks.php
Normal file
802
modules/desk_moloni/libraries/PerfexHooks.php
Normal file
@@ -0,0 +1,802 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Perfex Hooks Integration
|
||||
* Handles Perfex CRM hooks for automatic synchronization triggers
|
||||
*
|
||||
* @package DeskMoloni
|
||||
* @subpackage Libraries
|
||||
* @category HooksIntegration
|
||||
* @author Descomplicar® - PHP Fullstack Engineer
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
defined('BASEPATH') or exit('No direct script access allowed');
|
||||
|
||||
class PerfexHooks
|
||||
{
|
||||
protected $CI;
|
||||
protected $queue_processor;
|
||||
protected $entity_mapping;
|
||||
protected $error_handler;
|
||||
protected $model;
|
||||
|
||||
// Hook priority levels
|
||||
const PRIORITY_LOW = 1;
|
||||
const PRIORITY_NORMAL = 2;
|
||||
const PRIORITY_HIGH = 3;
|
||||
const PRIORITY_CRITICAL = 4;
|
||||
|
||||
// Sync delay settings (in seconds)
|
||||
const DEFAULT_SYNC_DELAY = 300; // 5 minutes
|
||||
const CRITICAL_SYNC_DELAY = 60; // 1 minute
|
||||
const BULK_SYNC_DELAY = 600; // 10 minutes
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->CI = &get_instance();
|
||||
// Load base model if available; ignore if not to avoid fatal
|
||||
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;
|
||||
}
|
||||
|
||||
$this->queue_processor = new QueueProcessor();
|
||||
$this->entity_mapping = new EntityMappingService();
|
||||
$this->error_handler = new ErrorHandler();
|
||||
|
||||
$this->register_hooks();
|
||||
|
||||
log_activity('PerfexHooks initialized and registered');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
];
|
||||
}
|
||||
}
|
||||
1091
modules/desk_moloni/libraries/ProductSyncService.php
Normal file
1091
modules/desk_moloni/libraries/ProductSyncService.php
Normal file
File diff suppressed because it is too large
Load Diff
905
modules/desk_moloni/libraries/QueueProcessor.php
Normal file
905
modules/desk_moloni/libraries/QueueProcessor.php
Normal file
@@ -0,0 +1,905 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Enhanced Queue Processor
|
||||
* Redis-based queue processor with exponential backoff retry logic and conflict resolution
|
||||
*
|
||||
* @package DeskMoloni
|
||||
* @subpackage Libraries
|
||||
* @category QueueProcessor
|
||||
* @author Descomplicar® - PHP Fullstack Engineer
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
defined('BASEPATH') or exit('No direct script access allowed');
|
||||
|
||||
class QueueProcessor
|
||||
{
|
||||
protected $CI;
|
||||
protected $redis;
|
||||
protected $model;
|
||||
protected $entity_mapping;
|
||||
protected $error_handler;
|
||||
protected $retry_handler;
|
||||
|
||||
// Queue configuration
|
||||
const REDIS_PREFIX = 'desk_moloni:queue:';
|
||||
const QUEUE_MAIN = 'main';
|
||||
const QUEUE_PRIORITY = 'priority';
|
||||
const QUEUE_DELAY = 'delay';
|
||||
const QUEUE_DEAD_LETTER = 'dead_letter';
|
||||
const QUEUE_PROCESSING = 'processing';
|
||||
|
||||
// Queue priorities
|
||||
const PRIORITY_LOW = 1;
|
||||
const PRIORITY_NORMAL = 2;
|
||||
const PRIORITY_HIGH = 3;
|
||||
const PRIORITY_CRITICAL = 4;
|
||||
|
||||
// Processing status
|
||||
const STATUS_PENDING = 'pending';
|
||||
const STATUS_PROCESSING = 'processing';
|
||||
const STATUS_COMPLETED = 'completed';
|
||||
const STATUS_FAILED = 'failed';
|
||||
const STATUS_RETRYING = 'retrying';
|
||||
|
||||
// Retry configuration
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const RETRY_DELAYS = [30, 120, 300, 900, 1800]; // 30s, 2m, 5m, 15m, 30m
|
||||
const BATCH_SIZE = 20;
|
||||
const MEMORY_LIMIT = 512 * 1024 * 1024; // 512MB
|
||||
const TIME_LIMIT = 300; // 5 minutes
|
||||
const PROCESSING_TIMEOUT = 600; // 10 minutes
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->CI = &get_instance();
|
||||
$this->CI->load->model('desk_moloni_model');
|
||||
$this->model = $this->CI->desk_moloni_model;
|
||||
|
||||
// Initialize Redis connection
|
||||
$this->init_redis();
|
||||
|
||||
// Initialize supporting services
|
||||
$this->entity_mapping = new EntityMappingService();
|
||||
$this->error_handler = new ErrorHandler();
|
||||
$this->retry_handler = new RetryHandler();
|
||||
|
||||
// Set memory and time limits
|
||||
ini_set('memory_limit', '512M');
|
||||
set_time_limit(self::TIME_LIMIT);
|
||||
|
||||
log_activity('Enhanced QueueProcessor initialized with Redis backend');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Redis connection
|
||||
*/
|
||||
protected function init_redis()
|
||||
{
|
||||
if (!extension_loaded('redis')) {
|
||||
throw new \Exception('Redis extension not loaded');
|
||||
}
|
||||
|
||||
$this->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 (!$this->redis->connect($redis_host, $redis_port, 2.5)) {
|
||||
throw new \Exception('Failed to connect to Redis server');
|
||||
}
|
||||
|
||||
if (!empty($redis_password)) {
|
||||
$this->redis->auth($redis_password);
|
||||
}
|
||||
|
||||
$this->redis->select($redis_db);
|
||||
|
||||
log_activity("Connected to Redis server at {$redis_host}:{$redis_port}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Add item to sync queue
|
||||
*
|
||||
* @param string $entity_type
|
||||
* @param int $entity_id
|
||||
* @param string $action
|
||||
* @param string $direction
|
||||
* @param int $priority
|
||||
* @param array $data
|
||||
* @param int $delay_seconds
|
||||
* @return string|false Queue job ID
|
||||
*/
|
||||
public function add_to_queue($entity_type, $entity_id, $action, $direction = 'perfex_to_moloni', $priority = self::PRIORITY_NORMAL, $data = [], $delay_seconds = 0)
|
||||
{
|
||||
// Validate parameters
|
||||
if (!$this->validate_queue_params($entity_type, $action, $direction, $priority)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Generate unique job ID
|
||||
$job_id = $this->generate_job_id($entity_type, $entity_id, $action);
|
||||
|
||||
// Check for duplicate pending job
|
||||
if ($this->is_job_pending($job_id)) {
|
||||
log_activity("Job {$job_id} already pending, updating priority if higher");
|
||||
return $this->update_job_priority($job_id, $priority) ? $job_id : false;
|
||||
}
|
||||
|
||||
// Create job data
|
||||
$job_data = [
|
||||
'id' => $job_id,
|
||||
'entity_type' => $entity_type,
|
||||
'entity_id' => $entity_id,
|
||||
'action' => $action,
|
||||
'direction' => $direction,
|
||||
'priority' => $priority,
|
||||
'data' => $data,
|
||||
'attempts' => 0,
|
||||
'max_attempts' => self::MAX_ATTEMPTS,
|
||||
'created_at' => time(),
|
||||
'scheduled_at' => time() + $delay_seconds,
|
||||
'status' => self::STATUS_PENDING,
|
||||
'processing_node' => gethostname()
|
||||
];
|
||||
|
||||
$job_json = json_encode($job_data);
|
||||
|
||||
try {
|
||||
// Add to appropriate queue
|
||||
if ($delay_seconds > 0) {
|
||||
// Add to delay queue with score as execution time
|
||||
$this->redis->zAdd(self::REDIS_PREFIX . self::QUEUE_DELAY, $job_data['scheduled_at'], $job_json);
|
||||
} elseif ($priority >= self::PRIORITY_HIGH) {
|
||||
// Add to priority queue
|
||||
$this->redis->lPush(self::REDIS_PREFIX . self::QUEUE_PRIORITY, $job_json);
|
||||
} else {
|
||||
// Add to main queue
|
||||
$this->redis->lPush(self::REDIS_PREFIX . self::QUEUE_MAIN, $job_json);
|
||||
}
|
||||
|
||||
// Store job data for tracking
|
||||
$this->redis->hSet(self::REDIS_PREFIX . 'jobs', $job_id, $job_json);
|
||||
|
||||
// Update statistics
|
||||
$this->redis->hIncrBy(self::REDIS_PREFIX . 'stats', 'total_queued', 1);
|
||||
$this->redis->hIncrBy(self::REDIS_PREFIX . 'stats', "queued_{$entity_type}", 1);
|
||||
|
||||
log_activity("Added {$entity_type} #{$entity_id} to sync queue: {$job_id} (priority: {$priority})");
|
||||
|
||||
return $job_id;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->error_handler->log_error('queue', 'QUEUE_ADD_FAILED', $e->getMessage(), [
|
||||
'entity_type' => $entity_type,
|
||||
'entity_id' => $entity_id,
|
||||
'job_id' => $job_id
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process queue items
|
||||
*
|
||||
* @param int $limit
|
||||
* @param int $time_limit
|
||||
* @return array
|
||||
*/
|
||||
public function process_queue($limit = self::BATCH_SIZE, $time_limit = self::TIME_LIMIT)
|
||||
{
|
||||
$start_time = microtime(true);
|
||||
$processed = 0;
|
||||
$success = 0;
|
||||
$errors = 0;
|
||||
$details = [];
|
||||
|
||||
try {
|
||||
// Check if queue processing is paused
|
||||
if ($this->is_queue_paused()) {
|
||||
return [
|
||||
'processed' => 0,
|
||||
'success' => 0,
|
||||
'errors' => 0,
|
||||
'message' => 'Queue processing is paused',
|
||||
'execution_time' => 0
|
||||
];
|
||||
}
|
||||
|
||||
// Move delayed jobs to main queue if ready
|
||||
$this->process_delayed_jobs();
|
||||
|
||||
// Process jobs
|
||||
while ($processed < $limit && (microtime(true) - $start_time) < ($time_limit - 30)) {
|
||||
$job = $this->get_next_job();
|
||||
|
||||
if (!$job) {
|
||||
break; // No more jobs
|
||||
}
|
||||
|
||||
// Check memory usage
|
||||
if (memory_get_usage(true) > self::MEMORY_LIMIT) {
|
||||
log_message('warning', 'Memory limit approaching, stopping queue processing');
|
||||
break;
|
||||
}
|
||||
|
||||
$result = $this->process_job($job);
|
||||
$processed++;
|
||||
|
||||
if ($result['success']) {
|
||||
$success++;
|
||||
} else {
|
||||
$errors++;
|
||||
}
|
||||
|
||||
$details[] = [
|
||||
'job_id' => $job['id'],
|
||||
'entity_type' => $job['entity_type'],
|
||||
'entity_id' => $job['entity_id'],
|
||||
'action' => $job['action'],
|
||||
'direction' => $job['direction'],
|
||||
'success' => $result['success'],
|
||||
'message' => $result['message'],
|
||||
'execution_time' => $result['execution_time'] ?? 0
|
||||
];
|
||||
}
|
||||
|
||||
$execution_time = microtime(true) - $start_time;
|
||||
|
||||
// Update statistics
|
||||
$this->redis->hIncrBy(self::REDIS_PREFIX . 'stats', 'total_processed', $processed);
|
||||
$this->redis->hIncrBy(self::REDIS_PREFIX . 'stats', 'total_success', $success);
|
||||
$this->redis->hIncrBy(self::REDIS_PREFIX . 'stats', 'total_errors', $errors);
|
||||
|
||||
log_activity("Queue processing completed: {$processed} processed, {$success} success, {$errors} errors in {$execution_time}s");
|
||||
|
||||
return [
|
||||
'processed' => $processed,
|
||||
'success' => $success,
|
||||
'errors' => $errors,
|
||||
'details' => $details,
|
||||
'execution_time' => $execution_time
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->error_handler->log_error('queue', 'QUEUE_PROCESSING_FAILED', $e->getMessage());
|
||||
|
||||
return [
|
||||
'processed' => $processed,
|
||||
'success' => $success,
|
||||
'errors' => $errors + 1,
|
||||
'message' => $e->getMessage(),
|
||||
'execution_time' => microtime(true) - $start_time
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next job from queue
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
protected function get_next_job()
|
||||
{
|
||||
// First check priority queue
|
||||
$job_json = $this->redis->rPop(self::REDIS_PREFIX . self::QUEUE_PRIORITY);
|
||||
|
||||
// Then check main queue
|
||||
if (!$job_json) {
|
||||
$job_json = $this->redis->rPop(self::REDIS_PREFIX . self::QUEUE_MAIN);
|
||||
}
|
||||
|
||||
if (!$job_json) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$job = json_decode($job_json, true);
|
||||
|
||||
// Move to processing queue
|
||||
$this->redis->hSet(self::REDIS_PREFIX . self::QUEUE_PROCESSING, $job['id'], $job_json);
|
||||
$this->redis->expire(self::REDIS_PREFIX . self::QUEUE_PROCESSING, self::PROCESSING_TIMEOUT);
|
||||
|
||||
return $job;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process single job
|
||||
*
|
||||
* @param array $job
|
||||
* @return array
|
||||
*/
|
||||
protected function process_job($job)
|
||||
{
|
||||
$start_time = microtime(true);
|
||||
|
||||
try {
|
||||
// Update job status
|
||||
$job['status'] = self::STATUS_PROCESSING;
|
||||
$job['started_at'] = time();
|
||||
$job['attempts']++;
|
||||
|
||||
$this->update_job_data($job);
|
||||
|
||||
// Execute sync operation
|
||||
$result = $this->execute_sync_operation($job);
|
||||
|
||||
if ($result['success']) {
|
||||
// Mark as completed
|
||||
$job['status'] = self::STATUS_COMPLETED;
|
||||
$job['completed_at'] = time();
|
||||
$job['result'] = $result;
|
||||
|
||||
$this->complete_job($job);
|
||||
|
||||
log_activity("Job {$job['id']} processed successfully: {$job['entity_type']} #{$job['entity_id']} {$job['action']}");
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => $result['message'],
|
||||
'execution_time' => microtime(true) - $start_time
|
||||
];
|
||||
} else {
|
||||
throw new \Exception($result['message']);
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$execution_time = microtime(true) - $start_time;
|
||||
|
||||
if ($job['attempts'] >= $job['max_attempts']) {
|
||||
// Move to dead letter queue
|
||||
$job['status'] = self::STATUS_FAILED;
|
||||
$job['failed_at'] = time();
|
||||
$job['error'] = $e->getMessage();
|
||||
|
||||
$this->move_to_dead_letter_queue($job);
|
||||
|
||||
log_message('error', "Job {$job['id']} failed permanently after {$job['attempts']} attempts: " . $e->getMessage());
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => "Failed permanently: " . $e->getMessage(),
|
||||
'execution_time' => $execution_time
|
||||
];
|
||||
} else {
|
||||
// Schedule retry with exponential backoff
|
||||
$retry_delay = $this->retry_handler->calculate_retry_delay($job['attempts']);
|
||||
$job['status'] = self::STATUS_RETRYING;
|
||||
$job['retry_at'] = time() + $retry_delay;
|
||||
$job['last_error'] = $e->getMessage();
|
||||
|
||||
$this->schedule_retry($job, $retry_delay);
|
||||
|
||||
log_message('info', "Job {$job['id']} scheduled for retry #{$job['attempts']} in {$retry_delay}s: " . $e->getMessage());
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => "Retry #{$job['attempts']} scheduled: " . $e->getMessage(),
|
||||
'execution_time' => $execution_time
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute sync operation
|
||||
*
|
||||
* @param array $job
|
||||
* @return array
|
||||
*/
|
||||
protected function execute_sync_operation($job)
|
||||
{
|
||||
// Load appropriate sync service
|
||||
$sync_service = $this->get_sync_service($job['entity_type']);
|
||||
|
||||
if (!$sync_service) {
|
||||
throw new \Exception("No sync service available for entity type: {$job['entity_type']}");
|
||||
}
|
||||
|
||||
// Execute sync based on direction
|
||||
switch ($job['direction']) {
|
||||
case 'perfex_to_moloni':
|
||||
return $sync_service->sync_perfex_to_moloni($job['entity_id'], $job['action'] === 'update', $job['data']);
|
||||
|
||||
case 'moloni_to_perfex':
|
||||
return $sync_service->sync_moloni_to_perfex($job['entity_id'], $job['action'] === 'update', $job['data']);
|
||||
|
||||
case 'bidirectional':
|
||||
// Handle bidirectional sync with conflict detection
|
||||
return $this->handle_bidirectional_sync($sync_service, $job);
|
||||
|
||||
default:
|
||||
throw new \Exception("Unknown sync direction: {$job['direction']}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle bidirectional sync with conflict detection
|
||||
*
|
||||
* @param object $sync_service
|
||||
* @param array $job
|
||||
* @return array
|
||||
*/
|
||||
protected function handle_bidirectional_sync($sync_service, $job)
|
||||
{
|
||||
// Get entity mapping
|
||||
$mapping = $this->entity_mapping->get_mapping_by_perfex_id($job['entity_type'], $job['entity_id']);
|
||||
|
||||
if (!$mapping) {
|
||||
// No mapping exists, sync from Perfex to Moloni
|
||||
return $sync_service->sync_perfex_to_moloni($job['entity_id'], false, $job['data']);
|
||||
}
|
||||
|
||||
// Check for conflicts
|
||||
$conflict_check = $sync_service->check_sync_conflicts($mapping);
|
||||
|
||||
if ($conflict_check['has_conflict']) {
|
||||
// Mark mapping as conflict and require manual resolution
|
||||
$this->entity_mapping->update_mapping_status($mapping->id, EntityMappingService::STATUS_CONFLICT, $conflict_check['conflict_details']);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Sync conflict detected, manual resolution required',
|
||||
'conflict_details' => $conflict_check['conflict_details']
|
||||
];
|
||||
}
|
||||
|
||||
// Determine sync direction based on modification timestamps
|
||||
$sync_direction = $this->determine_sync_direction($mapping, $job);
|
||||
|
||||
if ($sync_direction === 'perfex_to_moloni') {
|
||||
return $sync_service->sync_perfex_to_moloni($job['entity_id'], true, $job['data']);
|
||||
} else {
|
||||
return $sync_service->sync_moloni_to_perfex($mapping->moloni_id, true, $job['data']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine sync direction based on timestamps
|
||||
*
|
||||
* @param object $mapping
|
||||
* @param array $job
|
||||
* @return string
|
||||
*/
|
||||
protected function determine_sync_direction($mapping, $job)
|
||||
{
|
||||
$perfex_modified = strtotime($mapping->last_sync_perfex ?: '1970-01-01');
|
||||
$moloni_modified = strtotime($mapping->last_sync_moloni ?: '1970-01-01');
|
||||
|
||||
// If one side was never synced, sync from the other
|
||||
if ($perfex_modified === false || $perfex_modified < 1) {
|
||||
return 'moloni_to_perfex';
|
||||
}
|
||||
|
||||
if ($moloni_modified === false || $moloni_modified < 1) {
|
||||
return 'perfex_to_moloni';
|
||||
}
|
||||
|
||||
// Sync from most recently modified
|
||||
return $perfex_modified > $moloni_modified ? 'perfex_to_moloni' : 'moloni_to_perfex';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sync service for entity type
|
||||
*
|
||||
* @param string $entity_type
|
||||
* @return object|null
|
||||
*/
|
||||
protected function get_sync_service($entity_type)
|
||||
{
|
||||
$service_class = null;
|
||||
|
||||
switch ($entity_type) {
|
||||
case EntityMappingService::ENTITY_CUSTOMER:
|
||||
$service_class = 'DeskMoloni\\Libraries\\ClientSyncService';
|
||||
break;
|
||||
case EntityMappingService::ENTITY_PRODUCT:
|
||||
$service_class = 'DeskMoloni\\Libraries\\ProductSyncService';
|
||||
break;
|
||||
case EntityMappingService::ENTITY_INVOICE:
|
||||
$service_class = 'DeskMoloni\\Libraries\\InvoiceSyncService';
|
||||
break;
|
||||
case EntityMappingService::ENTITY_ESTIMATE:
|
||||
$service_class = 'DeskMoloni\\Libraries\\EstimateSyncService';
|
||||
break;
|
||||
case EntityMappingService::ENTITY_CREDIT_NOTE:
|
||||
$service_class = 'DeskMoloni\\Libraries\\CreditNoteSyncService';
|
||||
break;
|
||||
}
|
||||
|
||||
if ($service_class && class_exists($service_class)) {
|
||||
return new $service_class();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete job successfully
|
||||
*
|
||||
* @param array $job
|
||||
*/
|
||||
protected function complete_job($job)
|
||||
{
|
||||
// Remove from processing queue
|
||||
$this->redis->hDel(self::REDIS_PREFIX . self::QUEUE_PROCESSING, $job['id']);
|
||||
|
||||
// Update job data
|
||||
$this->update_job_data($job);
|
||||
|
||||
// Set expiration for completed job (7 days)
|
||||
$this->redis->expire(self::REDIS_PREFIX . 'jobs:' . $job['id'], 7 * 24 * 3600);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule job retry
|
||||
*
|
||||
* @param array $job
|
||||
* @param int $delay_seconds
|
||||
*/
|
||||
protected function schedule_retry($job, $delay_seconds)
|
||||
{
|
||||
// Remove from processing queue
|
||||
$this->redis->hDel(self::REDIS_PREFIX . self::QUEUE_PROCESSING, $job['id']);
|
||||
|
||||
// Add to delay queue
|
||||
$this->redis->zAdd(self::REDIS_PREFIX . self::QUEUE_DELAY, time() + $delay_seconds, json_encode($job));
|
||||
|
||||
// Update job data
|
||||
$this->update_job_data($job);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move job to dead letter queue
|
||||
*
|
||||
* @param array $job
|
||||
*/
|
||||
protected function move_to_dead_letter_queue($job)
|
||||
{
|
||||
// Remove from processing queue
|
||||
$this->redis->hDel(self::REDIS_PREFIX . self::QUEUE_PROCESSING, $job['id']);
|
||||
|
||||
// Add to dead letter queue
|
||||
$this->redis->lPush(self::REDIS_PREFIX . self::QUEUE_DEAD_LETTER, json_encode($job));
|
||||
|
||||
// Update job data
|
||||
$this->update_job_data($job);
|
||||
|
||||
// Log to error handler
|
||||
$this->error_handler->log_error('queue', 'JOB_DEAD_LETTER', 'Job moved to dead letter queue', [
|
||||
'job_id' => $job['id'],
|
||||
'entity_type' => $job['entity_type'],
|
||||
'entity_id' => $job['entity_id'],
|
||||
'attempts' => $job['attempts'],
|
||||
'error' => $job['error'] ?? 'Unknown error'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process delayed jobs that are ready
|
||||
*/
|
||||
protected function process_delayed_jobs()
|
||||
{
|
||||
$current_time = time();
|
||||
|
||||
// Get jobs that are ready to process
|
||||
$ready_jobs = $this->redis->zRangeByScore(
|
||||
self::REDIS_PREFIX . self::QUEUE_DELAY,
|
||||
0,
|
||||
$current_time,
|
||||
['limit' => [0, 100]]
|
||||
);
|
||||
|
||||
foreach ($ready_jobs as $job_json) {
|
||||
$job = json_decode($job_json, true);
|
||||
|
||||
// Remove from delay queue
|
||||
$this->redis->zRem(self::REDIS_PREFIX . self::QUEUE_DELAY, $job_json);
|
||||
|
||||
// Add to appropriate queue based on priority
|
||||
if ($job['priority'] >= self::PRIORITY_HIGH) {
|
||||
$this->redis->lPush(self::REDIS_PREFIX . self::QUEUE_PRIORITY, $job_json);
|
||||
} else {
|
||||
$this->redis->lPush(self::REDIS_PREFIX . self::QUEUE_MAIN, $job_json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update job data in Redis
|
||||
*
|
||||
* @param array $job
|
||||
*/
|
||||
protected function update_job_data($job)
|
||||
{
|
||||
$this->redis->hSet(self::REDIS_PREFIX . 'jobs', $job['id'], json_encode($job));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique job ID
|
||||
*
|
||||
* @param string $entity_type
|
||||
* @param int $entity_id
|
||||
* @param string $action
|
||||
* @return string
|
||||
*/
|
||||
protected function generate_job_id($entity_type, $entity_id, $action)
|
||||
{
|
||||
return "{$entity_type}_{$entity_id}_{$action}_" . uniqid();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if job is already pending
|
||||
*
|
||||
* @param string $job_id
|
||||
* @return bool
|
||||
*/
|
||||
protected function is_job_pending($job_id)
|
||||
{
|
||||
return $this->redis->hExists(self::REDIS_PREFIX . 'jobs', $job_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update job priority
|
||||
*
|
||||
* @param string $job_id
|
||||
* @param int $new_priority
|
||||
* @return bool
|
||||
*/
|
||||
protected function update_job_priority($job_id, $new_priority)
|
||||
{
|
||||
$job_json = $this->redis->hGet(self::REDIS_PREFIX . 'jobs', $job_id);
|
||||
|
||||
if (!$job_json) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$job = json_decode($job_json, true);
|
||||
|
||||
if ($new_priority <= $job['priority']) {
|
||||
return true; // No update needed
|
||||
}
|
||||
|
||||
$job['priority'] = $new_priority;
|
||||
$this->update_job_data($job);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate queue parameters
|
||||
*
|
||||
* @param string $entity_type
|
||||
* @param string $action
|
||||
* @param string $direction
|
||||
* @param int $priority
|
||||
* @return bool
|
||||
*/
|
||||
protected function validate_queue_params($entity_type, $action, $direction, $priority)
|
||||
{
|
||||
$valid_entities = [
|
||||
EntityMappingService::ENTITY_CUSTOMER,
|
||||
EntityMappingService::ENTITY_PRODUCT,
|
||||
EntityMappingService::ENTITY_INVOICE,
|
||||
EntityMappingService::ENTITY_ESTIMATE,
|
||||
EntityMappingService::ENTITY_CREDIT_NOTE
|
||||
];
|
||||
|
||||
$valid_actions = ['create', 'update', 'delete'];
|
||||
$valid_directions = ['perfex_to_moloni', 'moloni_to_perfex', 'bidirectional'];
|
||||
$valid_priorities = [self::PRIORITY_LOW, self::PRIORITY_NORMAL, self::PRIORITY_HIGH, self::PRIORITY_CRITICAL];
|
||||
|
||||
if (!in_array($entity_type, $valid_entities)) {
|
||||
log_message('error', "Invalid entity type: {$entity_type}");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!in_array($action, $valid_actions)) {
|
||||
log_message('error', "Invalid action: {$action}");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!in_array($direction, $valid_directions)) {
|
||||
log_message('error', "Invalid direction: {$direction}");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!in_array($priority, $valid_priorities)) {
|
||||
log_message('error', "Invalid priority: {$priority}");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue statistics
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function get_queue_statistics()
|
||||
{
|
||||
$stats = $this->redis->hGetAll(self::REDIS_PREFIX . 'stats');
|
||||
|
||||
return [
|
||||
'pending_main' => $this->redis->lLen(self::REDIS_PREFIX . self::QUEUE_MAIN),
|
||||
'pending_priority' => $this->redis->lLen(self::REDIS_PREFIX . self::QUEUE_PRIORITY),
|
||||
'delayed' => $this->redis->zCard(self::REDIS_PREFIX . self::QUEUE_DELAY),
|
||||
'processing' => $this->redis->hLen(self::REDIS_PREFIX . self::QUEUE_PROCESSING),
|
||||
'dead_letter' => $this->redis->lLen(self::REDIS_PREFIX . self::QUEUE_DEAD_LETTER),
|
||||
'total_queued' => (int)($stats['total_queued'] ?? 0),
|
||||
'total_processed' => (int)($stats['total_processed'] ?? 0),
|
||||
'total_success' => (int)($stats['total_success'] ?? 0),
|
||||
'total_errors' => (int)($stats['total_errors'] ?? 0),
|
||||
'success_rate' => $this->calculate_success_rate($stats),
|
||||
'memory_usage' => memory_get_usage(true),
|
||||
'peak_memory' => memory_get_peak_usage(true)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate success rate
|
||||
*
|
||||
* @param array $stats
|
||||
* @return float
|
||||
*/
|
||||
protected function calculate_success_rate($stats)
|
||||
{
|
||||
$total_processed = (int)($stats['total_processed'] ?? 0);
|
||||
$total_success = (int)($stats['total_success'] ?? 0);
|
||||
|
||||
return $total_processed > 0 ? round(($total_success / $total_processed) * 100, 2) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if queue is paused
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function is_queue_paused()
|
||||
{
|
||||
return $this->redis->get(self::REDIS_PREFIX . 'paused') === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause queue processing
|
||||
*/
|
||||
public function pause_queue()
|
||||
{
|
||||
$this->redis->set(self::REDIS_PREFIX . 'paused', '1');
|
||||
log_activity('Queue processing paused');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume queue processing
|
||||
*/
|
||||
public function resume_queue()
|
||||
{
|
||||
$this->redis->del(self::REDIS_PREFIX . 'paused');
|
||||
log_activity('Queue processing resumed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all queues (development/testing only)
|
||||
*/
|
||||
public function clear_all_queues()
|
||||
{
|
||||
if (ENVIRONMENT === 'production') {
|
||||
throw new \Exception('Cannot clear queues in production environment');
|
||||
}
|
||||
|
||||
$keys = [
|
||||
self::REDIS_PREFIX . self::QUEUE_MAIN,
|
||||
self::REDIS_PREFIX . self::QUEUE_PRIORITY,
|
||||
self::REDIS_PREFIX . self::QUEUE_DELAY,
|
||||
self::REDIS_PREFIX . self::QUEUE_PROCESSING,
|
||||
self::REDIS_PREFIX . 'jobs',
|
||||
self::REDIS_PREFIX . 'stats'
|
||||
];
|
||||
|
||||
foreach ($keys as $key) {
|
||||
$this->redis->del($key);
|
||||
}
|
||||
|
||||
log_activity('All queues cleared (development mode)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Requeue dead letter jobs
|
||||
*
|
||||
* @param int $limit
|
||||
* @return array
|
||||
*/
|
||||
public function requeue_dead_letter_jobs($limit = 10)
|
||||
{
|
||||
$results = [
|
||||
'total' => 0,
|
||||
'success' => 0,
|
||||
'errors' => 0
|
||||
];
|
||||
|
||||
for ($i = 0; $i < $limit; $i++) {
|
||||
$job_json = $this->redis->rPop(self::REDIS_PREFIX . self::QUEUE_DEAD_LETTER);
|
||||
|
||||
if (!$job_json) {
|
||||
break;
|
||||
}
|
||||
|
||||
$job = json_decode($job_json, true);
|
||||
$results['total']++;
|
||||
|
||||
// Reset job for retry
|
||||
$job['attempts'] = 0;
|
||||
$job['status'] = self::STATUS_PENDING;
|
||||
unset($job['error'], $job['failed_at']);
|
||||
|
||||
// Add back to queue
|
||||
if ($job['priority'] >= self::PRIORITY_HIGH) {
|
||||
$this->redis->lPush(self::REDIS_PREFIX . self::QUEUE_PRIORITY, json_encode($job));
|
||||
} else {
|
||||
$this->redis->lPush(self::REDIS_PREFIX . self::QUEUE_MAIN, json_encode($job));
|
||||
}
|
||||
|
||||
$this->update_job_data($job);
|
||||
$results['success']++;
|
||||
|
||||
log_activity("Requeued dead letter job: {$job['id']}");
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check for queue system
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function health_check()
|
||||
{
|
||||
$health = [
|
||||
'status' => 'healthy',
|
||||
'checks' => []
|
||||
];
|
||||
|
||||
try {
|
||||
// Check Redis connection
|
||||
$this->redis->ping();
|
||||
$health['checks']['redis'] = 'ok';
|
||||
} catch (\Exception $e) {
|
||||
$health['status'] = 'unhealthy';
|
||||
$health['checks']['redis'] = 'failed: ' . $e->getMessage();
|
||||
}
|
||||
|
||||
// Check queue sizes
|
||||
$stats = $this->get_queue_statistics();
|
||||
|
||||
if ($stats['dead_letter'] > 100) {
|
||||
$health['status'] = 'warning';
|
||||
$health['checks']['dead_letter'] = "high count: {$stats['dead_letter']}";
|
||||
} else {
|
||||
$health['checks']['dead_letter'] = 'ok';
|
||||
}
|
||||
|
||||
if ($stats['processing'] > 50) {
|
||||
$health['status'] = 'warning';
|
||||
$health['checks']['processing'] = "high count: {$stats['processing']}";
|
||||
} else {
|
||||
$health['checks']['processing'] = 'ok';
|
||||
}
|
||||
|
||||
// Check memory usage
|
||||
$memory_usage_percent = (memory_get_usage(true) / self::MEMORY_LIMIT) * 100;
|
||||
|
||||
if ($memory_usage_percent > 80) {
|
||||
$health['status'] = 'warning';
|
||||
$health['checks']['memory'] = "high usage: {$memory_usage_percent}%";
|
||||
} else {
|
||||
$health['checks']['memory'] = 'ok';
|
||||
}
|
||||
|
||||
return $health;
|
||||
}
|
||||
}
|
||||
644
modules/desk_moloni/libraries/RetryHandler.php
Normal file
644
modules/desk_moloni/libraries/RetryHandler.php
Normal file
@@ -0,0 +1,644 @@
|
||||
<?php
|
||||
|
||||
defined('BASEPATH') or exit('No direct script access allowed');
|
||||
|
||||
/**
|
||||
* Retry Handler
|
||||
* Advanced retry logic with exponential backoff, jitter, and circuit breaker pattern
|
||||
*
|
||||
* @package DeskMoloni
|
||||
* @subpackage Libraries
|
||||
* @category RetryLogic
|
||||
* @author Descomplicar® - PHP Fullstack Engineer
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace DeskMoloni\Libraries;
|
||||
|
||||
use DeskMoloni\Libraries\ErrorHandler;
|
||||
|
||||
class RetryHandler
|
||||
{
|
||||
protected $CI;
|
||||
protected $model;
|
||||
protected $error_handler;
|
||||
|
||||
// Retry configuration
|
||||
const DEFAULT_MAX_ATTEMPTS = 5;
|
||||
const DEFAULT_BASE_DELAY = 1; // seconds
|
||||
const DEFAULT_MAX_DELAY = 300; // 5 minutes
|
||||
const DEFAULT_BACKOFF_MULTIPLIER = 2;
|
||||
const DEFAULT_JITTER_ENABLED = true;
|
||||
|
||||
// Circuit breaker configuration
|
||||
const CIRCUIT_BREAKER_FAILURE_THRESHOLD = 10;
|
||||
const CIRCUIT_BREAKER_TIMEOUT = 300; // 5 minutes
|
||||
const CIRCUIT_BREAKER_SUCCESS_THRESHOLD = 3;
|
||||
|
||||
// Retry strategies
|
||||
const STRATEGY_EXPONENTIAL = 'exponential';
|
||||
const STRATEGY_LINEAR = 'linear';
|
||||
const STRATEGY_FIXED = 'fixed';
|
||||
const STRATEGY_FIBONACCI = 'fibonacci';
|
||||
|
||||
// Circuit breaker states
|
||||
const CIRCUIT_CLOSED = 'closed';
|
||||
const CIRCUIT_OPEN = 'open';
|
||||
const CIRCUIT_HALF_OPEN = 'half_open';
|
||||
|
||||
// Retryable error types
|
||||
protected $retryable_errors = [
|
||||
'connection_timeout',
|
||||
'read_timeout',
|
||||
'network_error',
|
||||
'server_error',
|
||||
'rate_limit',
|
||||
'temporary_unavailable',
|
||||
'circuit_breaker_open'
|
||||
];
|
||||
|
||||
// Non-retryable error types
|
||||
protected $non_retryable_errors = [
|
||||
'authentication_failed',
|
||||
'authorization_denied',
|
||||
'invalid_data',
|
||||
'resource_not_found',
|
||||
'bad_request',
|
||||
'conflict'
|
||||
];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->CI = &get_instance();
|
||||
$this->CI->load->model('desk_moloni_model');
|
||||
$this->model = $this->CI->desk_moloni_model;
|
||||
$this->error_handler = new ErrorHandler();
|
||||
|
||||
log_activity('RetryHandler initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate retry delay with exponential backoff
|
||||
*
|
||||
* @param int $attempt_number
|
||||
* @param string $strategy
|
||||
* @param array $options
|
||||
* @return int Delay in seconds
|
||||
*/
|
||||
public function calculate_retry_delay($attempt_number, $strategy = self::STRATEGY_EXPONENTIAL, $options = [])
|
||||
{
|
||||
$base_delay = $options['base_delay'] ?? self::DEFAULT_BASE_DELAY;
|
||||
$max_delay = $options['max_delay'] ?? self::DEFAULT_MAX_DELAY;
|
||||
$multiplier = $options['multiplier'] ?? self::DEFAULT_BACKOFF_MULTIPLIER;
|
||||
$jitter_enabled = $options['jitter'] ?? self::DEFAULT_JITTER_ENABLED;
|
||||
|
||||
switch ($strategy) {
|
||||
case self::STRATEGY_EXPONENTIAL:
|
||||
$delay = $base_delay * pow($multiplier, $attempt_number - 1);
|
||||
break;
|
||||
|
||||
case self::STRATEGY_LINEAR:
|
||||
$delay = $base_delay * $attempt_number;
|
||||
break;
|
||||
|
||||
case self::STRATEGY_FIXED:
|
||||
$delay = $base_delay;
|
||||
break;
|
||||
|
||||
case self::STRATEGY_FIBONACCI:
|
||||
$delay = $this->fibonacci_delay($attempt_number, $base_delay);
|
||||
break;
|
||||
|
||||
default:
|
||||
$delay = $base_delay * pow($multiplier, $attempt_number - 1);
|
||||
}
|
||||
|
||||
// Cap at maximum delay
|
||||
$delay = min($delay, $max_delay);
|
||||
|
||||
// Add jitter to prevent thundering herd
|
||||
if ($jitter_enabled) {
|
||||
$delay = $this->add_jitter($delay);
|
||||
}
|
||||
|
||||
return (int)$delay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if an error is retryable
|
||||
*
|
||||
* @param string $error_type
|
||||
* @param string $error_message
|
||||
* @param int $http_status_code
|
||||
* @return bool
|
||||
*/
|
||||
public function is_retryable_error($error_type, $error_message = '', $http_status_code = null)
|
||||
{
|
||||
// Check explicit non-retryable errors first
|
||||
if (in_array($error_type, $this->non_retryable_errors)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check explicit retryable errors
|
||||
if (in_array($error_type, $this->retryable_errors)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check HTTP status codes
|
||||
if ($http_status_code !== null) {
|
||||
return $this->is_retryable_http_status($http_status_code);
|
||||
}
|
||||
|
||||
// Check error message patterns
|
||||
return $this->is_retryable_error_message($error_message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute operation with retry logic
|
||||
*
|
||||
* @param callable $operation
|
||||
* @param array $retry_config
|
||||
* @param array $context
|
||||
* @return array
|
||||
*/
|
||||
public function execute_with_retry(callable $operation, $retry_config = [], $context = [])
|
||||
{
|
||||
$max_attempts = $retry_config['max_attempts'] ?? self::DEFAULT_MAX_ATTEMPTS;
|
||||
$strategy = $retry_config['strategy'] ?? self::STRATEGY_EXPONENTIAL;
|
||||
$circuit_breaker_key = $context['circuit_breaker_key'] ?? null;
|
||||
|
||||
// Check circuit breaker if enabled
|
||||
if ($circuit_breaker_key && $this->is_circuit_breaker_open($circuit_breaker_key)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Circuit breaker is open',
|
||||
'error_type' => 'circuit_breaker_open',
|
||||
'attempts' => 0
|
||||
];
|
||||
}
|
||||
|
||||
$last_error = null;
|
||||
|
||||
for ($attempt = 1; $attempt <= $max_attempts; $attempt++) {
|
||||
try {
|
||||
// Record attempt
|
||||
$this->record_retry_attempt($context, $attempt);
|
||||
|
||||
// Execute operation
|
||||
$result = $operation($attempt);
|
||||
|
||||
// Success - record and reset circuit breaker
|
||||
if ($result['success']) {
|
||||
$this->record_retry_success($context, $attempt);
|
||||
|
||||
if ($circuit_breaker_key) {
|
||||
$this->record_circuit_breaker_success($circuit_breaker_key);
|
||||
}
|
||||
|
||||
return array_merge($result, ['attempts' => $attempt]);
|
||||
}
|
||||
|
||||
$last_error = $result;
|
||||
|
||||
// Check if error is retryable
|
||||
if (!$this->is_retryable_error(
|
||||
$result['error_type'] ?? 'unknown',
|
||||
$result['message'] ?? '',
|
||||
$result['http_status'] ?? null
|
||||
)) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Don't delay after last attempt
|
||||
if ($attempt < $max_attempts) {
|
||||
$delay = $this->calculate_retry_delay($attempt, $strategy, $retry_config);
|
||||
$this->record_retry_delay($context, $attempt, $delay);
|
||||
sleep($delay);
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$last_error = [
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
'error_type' => 'exception',
|
||||
'exception' => $e
|
||||
];
|
||||
|
||||
// Record exception attempt
|
||||
$this->record_retry_exception($context, $attempt, $e);
|
||||
|
||||
// Check if exception is retryable
|
||||
if (!$this->is_retryable_exception($e)) {
|
||||
break;
|
||||
}
|
||||
|
||||
if ($attempt < $max_attempts) {
|
||||
$delay = $this->calculate_retry_delay($attempt, $strategy, $retry_config);
|
||||
sleep($delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All retries failed
|
||||
$this->record_retry_failure($context, $max_attempts, $last_error);
|
||||
|
||||
// Update circuit breaker on failure
|
||||
if ($circuit_breaker_key) {
|
||||
$this->record_circuit_breaker_failure($circuit_breaker_key);
|
||||
}
|
||||
|
||||
return array_merge($last_error, ['attempts' => $max_attempts]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get retry statistics for monitoring
|
||||
*
|
||||
* @param array $filters
|
||||
* @return array
|
||||
*/
|
||||
public function get_retry_statistics($filters = [])
|
||||
{
|
||||
return [
|
||||
'total_attempts' => $this->model->count_retry_attempts($filters),
|
||||
'total_successes' => $this->model->count_retry_successes($filters),
|
||||
'total_failures' => $this->model->count_retry_failures($filters),
|
||||
'success_rate' => $this->calculate_retry_success_rate($filters),
|
||||
'average_attempts' => $this->model->get_average_retry_attempts($filters),
|
||||
'retry_distribution' => $this->model->get_retry_attempt_distribution($filters),
|
||||
'error_types' => $this->model->get_retry_error_types($filters),
|
||||
'circuit_breaker_states' => $this->get_circuit_breaker_states()
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check circuit breaker state
|
||||
*
|
||||
* @param string $circuit_key
|
||||
* @return bool
|
||||
*/
|
||||
public function is_circuit_breaker_open($circuit_key)
|
||||
{
|
||||
$circuit_state = $this->get_circuit_breaker_state($circuit_key);
|
||||
|
||||
switch ($circuit_state['state']) {
|
||||
case self::CIRCUIT_OPEN:
|
||||
// Check if timeout has passed
|
||||
if (time() - $circuit_state['opened_at'] >= self::CIRCUIT_BREAKER_TIMEOUT) {
|
||||
$this->set_circuit_breaker_state($circuit_key, self::CIRCUIT_HALF_OPEN);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
case self::CIRCUIT_HALF_OPEN:
|
||||
return false;
|
||||
|
||||
case self::CIRCUIT_CLOSED:
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record circuit breaker failure
|
||||
*
|
||||
* @param string $circuit_key
|
||||
*/
|
||||
public function record_circuit_breaker_failure($circuit_key)
|
||||
{
|
||||
$circuit_state = $this->get_circuit_breaker_state($circuit_key);
|
||||
$failure_count = $circuit_state['failure_count'] + 1;
|
||||
|
||||
if ($failure_count >= self::CIRCUIT_BREAKER_FAILURE_THRESHOLD) {
|
||||
$this->set_circuit_breaker_state($circuit_key, self::CIRCUIT_OPEN, [
|
||||
'failure_count' => $failure_count,
|
||||
'opened_at' => time()
|
||||
]);
|
||||
|
||||
$this->error_handler->log_error(
|
||||
ErrorHandler::CATEGORY_SYSTEM,
|
||||
'CIRCUIT_BREAKER_OPENED',
|
||||
"Circuit breaker opened for {$circuit_key} after {$failure_count} failures",
|
||||
['circuit_key' => $circuit_key],
|
||||
ErrorHandler::SEVERITY_HIGH
|
||||
);
|
||||
} else {
|
||||
$this->update_circuit_breaker_failure_count($circuit_key, $failure_count);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record circuit breaker success
|
||||
*
|
||||
* @param string $circuit_key
|
||||
*/
|
||||
public function record_circuit_breaker_success($circuit_key)
|
||||
{
|
||||
$circuit_state = $this->get_circuit_breaker_state($circuit_key);
|
||||
|
||||
if ($circuit_state['state'] === self::CIRCUIT_HALF_OPEN) {
|
||||
$success_count = $circuit_state['success_count'] + 1;
|
||||
|
||||
if ($success_count >= self::CIRCUIT_BREAKER_SUCCESS_THRESHOLD) {
|
||||
$this->set_circuit_breaker_state($circuit_key, self::CIRCUIT_CLOSED, [
|
||||
'success_count' => 0,
|
||||
'failure_count' => 0
|
||||
]);
|
||||
|
||||
log_activity("Circuit breaker closed for {$circuit_key} after successful operations");
|
||||
} else {
|
||||
$this->update_circuit_breaker_success_count($circuit_key, $success_count);
|
||||
}
|
||||
} else {
|
||||
// Reset failure count on success
|
||||
$this->update_circuit_breaker_failure_count($circuit_key, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get optimal retry configuration for operation type
|
||||
*
|
||||
* @param string $operation_type
|
||||
* @param string $entity_type
|
||||
* @return array
|
||||
*/
|
||||
public function get_optimal_retry_config($operation_type, $entity_type = null)
|
||||
{
|
||||
$base_config = [
|
||||
'max_attempts' => self::DEFAULT_MAX_ATTEMPTS,
|
||||
'strategy' => self::STRATEGY_EXPONENTIAL,
|
||||
'base_delay' => self::DEFAULT_BASE_DELAY,
|
||||
'max_delay' => self::DEFAULT_MAX_DELAY,
|
||||
'multiplier' => self::DEFAULT_BACKOFF_MULTIPLIER,
|
||||
'jitter' => self::DEFAULT_JITTER_ENABLED
|
||||
];
|
||||
|
||||
// Customize based on operation type
|
||||
switch ($operation_type) {
|
||||
case 'api_call':
|
||||
$base_config['max_attempts'] = 3;
|
||||
$base_config['base_delay'] = 2;
|
||||
$base_config['max_delay'] = 60;
|
||||
break;
|
||||
|
||||
case 'database_operation':
|
||||
$base_config['max_attempts'] = 2;
|
||||
$base_config['strategy'] = self::STRATEGY_FIXED;
|
||||
$base_config['base_delay'] = 1;
|
||||
break;
|
||||
|
||||
case 'file_operation':
|
||||
$base_config['max_attempts'] = 3;
|
||||
$base_config['strategy'] = self::STRATEGY_LINEAR;
|
||||
$base_config['base_delay'] = 1;
|
||||
break;
|
||||
|
||||
case 'sync_operation':
|
||||
$base_config['max_attempts'] = 5;
|
||||
$base_config['base_delay'] = 5;
|
||||
$base_config['max_delay'] = 300;
|
||||
break;
|
||||
}
|
||||
|
||||
// Further customize based on entity type
|
||||
if ($entity_type) {
|
||||
switch ($entity_type) {
|
||||
case 'customer':
|
||||
$base_config['max_attempts'] = min($base_config['max_attempts'], 3);
|
||||
break;
|
||||
|
||||
case 'invoice':
|
||||
$base_config['max_attempts'] = 5; // More important
|
||||
$base_config['max_delay'] = 600;
|
||||
break;
|
||||
|
||||
case 'product':
|
||||
$base_config['max_attempts'] = 3;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $base_config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add jitter to delay to prevent thundering herd
|
||||
*
|
||||
* @param float $delay
|
||||
* @param float $jitter_factor
|
||||
* @return float
|
||||
*/
|
||||
protected function add_jitter($delay, $jitter_factor = 0.1)
|
||||
{
|
||||
$jitter_range = $delay * $jitter_factor;
|
||||
$jitter = (mt_rand() / mt_getrandmax()) * $jitter_range * 2 - $jitter_range;
|
||||
|
||||
return max(0, $delay + $jitter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate fibonacci delay
|
||||
*
|
||||
* @param int $n
|
||||
* @param float $base_delay
|
||||
* @return float
|
||||
*/
|
||||
protected function fibonacci_delay($n, $base_delay)
|
||||
{
|
||||
if ($n <= 1) return $base_delay;
|
||||
if ($n == 2) return $base_delay;
|
||||
|
||||
$a = $base_delay;
|
||||
$b = $base_delay;
|
||||
|
||||
for ($i = 3; $i <= $n; $i++) {
|
||||
$temp = $a + $b;
|
||||
$a = $b;
|
||||
$b = $temp;
|
||||
}
|
||||
|
||||
return $b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if HTTP status code is retryable
|
||||
*
|
||||
* @param int $status_code
|
||||
* @return bool
|
||||
*/
|
||||
protected function is_retryable_http_status($status_code)
|
||||
{
|
||||
// 5xx server errors are generally retryable
|
||||
if ($status_code >= 500) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Some 4xx errors are retryable
|
||||
$retryable_4xx = [408, 429, 423, 424]; // Request timeout, rate limit, locked, failed dependency
|
||||
|
||||
return in_array($status_code, $retryable_4xx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error message indicates retryable error
|
||||
*
|
||||
* @param string $error_message
|
||||
* @return bool
|
||||
*/
|
||||
protected function is_retryable_error_message($error_message)
|
||||
{
|
||||
$retryable_patterns = [
|
||||
'/timeout/i',
|
||||
'/connection.*failed/i',
|
||||
'/network.*error/i',
|
||||
'/temporary.*unavailable/i',
|
||||
'/service.*unavailable/i',
|
||||
'/rate.*limit/i',
|
||||
'/too many requests/i',
|
||||
'/server.*error/i'
|
||||
];
|
||||
|
||||
foreach ($retryable_patterns as $pattern) {
|
||||
if (preg_match($pattern, $error_message)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if exception is retryable
|
||||
*
|
||||
* @param \Exception $exception
|
||||
* @return bool
|
||||
*/
|
||||
protected function is_retryable_exception($exception)
|
||||
{
|
||||
$retryable_exceptions = [
|
||||
'PDOException',
|
||||
'mysqli_sql_exception',
|
||||
'RedisException',
|
||||
'cURLException',
|
||||
'TimeoutException'
|
||||
];
|
||||
|
||||
$exception_class = get_class($exception);
|
||||
|
||||
return in_array($exception_class, $retryable_exceptions) ||
|
||||
$this->is_retryable_error_message($exception->getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Record retry attempt
|
||||
*
|
||||
* @param array $context
|
||||
* @param int $attempt
|
||||
*/
|
||||
protected function record_retry_attempt($context, $attempt)
|
||||
{
|
||||
$this->model->record_retry_attempt([
|
||||
'operation_type' => $context['operation_type'] ?? 'unknown',
|
||||
'entity_type' => $context['entity_type'] ?? null,
|
||||
'entity_id' => $context['entity_id'] ?? null,
|
||||
'attempt_number' => $attempt,
|
||||
'attempted_at' => date('Y-m-d H:i:s'),
|
||||
'context' => json_encode($context)
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record retry success
|
||||
*
|
||||
* @param array $context
|
||||
* @param int $total_attempts
|
||||
*/
|
||||
protected function record_retry_success($context, $total_attempts)
|
||||
{
|
||||
$this->model->record_retry_success([
|
||||
'operation_type' => $context['operation_type'] ?? 'unknown',
|
||||
'entity_type' => $context['entity_type'] ?? null,
|
||||
'entity_id' => $context['entity_id'] ?? null,
|
||||
'total_attempts' => $total_attempts,
|
||||
'succeeded_at' => date('Y-m-d H:i:s'),
|
||||
'context' => json_encode($context)
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record retry failure
|
||||
*
|
||||
* @param array $context
|
||||
* @param int $total_attempts
|
||||
* @param array $last_error
|
||||
*/
|
||||
protected function record_retry_failure($context, $total_attempts, $last_error)
|
||||
{
|
||||
$this->model->record_retry_failure([
|
||||
'operation_type' => $context['operation_type'] ?? 'unknown',
|
||||
'entity_type' => $context['entity_type'] ?? null,
|
||||
'entity_id' => $context['entity_id'] ?? null,
|
||||
'total_attempts' => $total_attempts,
|
||||
'failed_at' => date('Y-m-d H:i:s'),
|
||||
'last_error' => json_encode($last_error),
|
||||
'context' => json_encode($context)
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get circuit breaker state
|
||||
*
|
||||
* @param string $circuit_key
|
||||
* @return array
|
||||
*/
|
||||
protected function get_circuit_breaker_state($circuit_key)
|
||||
{
|
||||
return $this->model->get_circuit_breaker_state($circuit_key) ?: [
|
||||
'state' => self::CIRCUIT_CLOSED,
|
||||
'failure_count' => 0,
|
||||
'success_count' => 0,
|
||||
'opened_at' => null
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set circuit breaker state
|
||||
*
|
||||
* @param string $circuit_key
|
||||
* @param string $state
|
||||
* @param array $additional_data
|
||||
*/
|
||||
protected function set_circuit_breaker_state($circuit_key, $state, $additional_data = [])
|
||||
{
|
||||
$data = array_merge([
|
||||
'circuit_key' => $circuit_key,
|
||||
'state' => $state,
|
||||
'updated_at' => date('Y-m-d H:i:s')
|
||||
], $additional_data);
|
||||
|
||||
$this->model->set_circuit_breaker_state($circuit_key, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate retry success rate
|
||||
*
|
||||
* @param array $filters
|
||||
* @return float
|
||||
*/
|
||||
protected function calculate_retry_success_rate($filters)
|
||||
{
|
||||
$total_attempts = $this->model->count_retry_attempts($filters);
|
||||
$total_successes = $this->model->count_retry_successes($filters);
|
||||
|
||||
return $total_attempts > 0 ? ($total_successes / $total_attempts) * 100 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all circuit breaker states
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function get_circuit_breaker_states()
|
||||
{
|
||||
return $this->model->get_all_circuit_breaker_states();
|
||||
}
|
||||
}
|
||||
127
modules/desk_moloni/libraries/SyncService.php
Normal file
127
modules/desk_moloni/libraries/SyncService.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
defined('BASEPATH') or exit('No direct script access allowed');
|
||||
|
||||
/**
|
||||
* General Synchronization Service
|
||||
*
|
||||
* Coordinates synchronization between Perfex CRM and Moloni
|
||||
* Provides high-level sync orchestration and management
|
||||
*
|
||||
* @package DeskMoloni
|
||||
* @subpackage Libraries
|
||||
* @version 3.0.0
|
||||
* @author Descomplicar<61>
|
||||
*/
|
||||
class SyncService
|
||||
{
|
||||
private $CI;
|
||||
private $client_sync_service;
|
||||
private $invoice_sync_service;
|
||||
private $sync_log_model;
|
||||
private $sync_queue_model;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->CI = &get_instance();
|
||||
|
||||
// Load required services and models
|
||||
$this->CI->load->library('desk_moloni/client_sync_service');
|
||||
$this->CI->load->library('desk_moloni/invoice_sync_service');
|
||||
$this->CI->load->model('desk_moloni/desk_moloni_sync_log_model', 'sync_log_model');
|
||||
$this->CI->load->model('desk_moloni/desk_moloni_sync_queue_model', 'sync_queue_model');
|
||||
|
||||
$this->client_sync_service = $this->CI->client_sync_service;
|
||||
$this->invoice_sync_service = $this->CI->invoice_sync_service;
|
||||
$this->sync_log_model = $this->CI->sync_log_model;
|
||||
$this->sync_queue_model = $this->CI->sync_queue_model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform full synchronization
|
||||
*/
|
||||
public function full_sync($options = [])
|
||||
{
|
||||
$start_time = microtime(true);
|
||||
|
||||
try {
|
||||
$results = [
|
||||
'clients' => $this->client_sync_service->sync_bidirectional('bidirectional', $options),
|
||||
'invoices' => $this->invoice_sync_service->sync_bidirectional('bidirectional', $options)
|
||||
];
|
||||
|
||||
$execution_time = microtime(true) - $start_time;
|
||||
|
||||
// Log sync completion
|
||||
$this->sync_log_model->log_event([
|
||||
'event_type' => 'full_sync_completed',
|
||||
'entity_type' => 'system',
|
||||
'entity_id' => null,
|
||||
'message' => 'Full synchronization completed',
|
||||
'log_level' => 'info',
|
||||
'execution_time' => $execution_time,
|
||||
'sync_data' => json_encode($results)
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'results' => $results,
|
||||
'execution_time' => $execution_time,
|
||||
'timestamp' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
} catch (Exception $e) {
|
||||
$execution_time = microtime(true) - $start_time;
|
||||
|
||||
$this->sync_log_model->log_event([
|
||||
'event_type' => 'full_sync_error',
|
||||
'entity_type' => 'system',
|
||||
'entity_id' => null,
|
||||
'message' => 'Full sync failed: ' . $e->getMessage(),
|
||||
'log_level' => 'error',
|
||||
'execution_time' => $execution_time
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
'execution_time' => $execution_time,
|
||||
'timestamp' => date('Y-m-d H:i:s')
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sync status overview
|
||||
*/
|
||||
public function get_sync_status()
|
||||
{
|
||||
return [
|
||||
'clients' => $this->client_sync_service->get_sync_statistics(),
|
||||
'invoices' => $this->invoice_sync_service->get_sync_statistics(),
|
||||
'queue' => $this->sync_queue_model->get_queue_statistics(),
|
||||
'last_sync' => $this->get_last_sync_info()
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last sync information
|
||||
*/
|
||||
private function get_last_sync_info()
|
||||
{
|
||||
// Get most recent sync log entry
|
||||
$this->CI->db->select('*');
|
||||
$this->CI->db->from('tbldeskmoloni_sync_log');
|
||||
$this->CI->db->where('event_type', 'full_sync_completed');
|
||||
$this->CI->db->order_by('created_at', 'DESC');
|
||||
$this->CI->db->limit(1);
|
||||
|
||||
$query = $this->CI->db->get();
|
||||
|
||||
if ($query->num_rows() > 0) {
|
||||
return $query->row_array();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
598
modules/desk_moloni/libraries/TaskWorker.php
Normal file
598
modules/desk_moloni/libraries/TaskWorker.php
Normal file
@@ -0,0 +1,598 @@
|
||||
<?php
|
||||
|
||||
defined('BASEPATH') or exit('No direct script access allowed');
|
||||
|
||||
/**
|
||||
* Task Worker Library
|
||||
*
|
||||
* Handles concurrent task execution for the queue processing system
|
||||
* Provides worker management, task execution, and concurrency control
|
||||
*
|
||||
* @package DeskMoloni
|
||||
* @subpackage Libraries
|
||||
* @version 3.0.0
|
||||
* @author Descomplicar®
|
||||
*/
|
||||
class TaskWorker
|
||||
{
|
||||
private $CI;
|
||||
private $worker_id;
|
||||
private $is_running = false;
|
||||
private $current_task = null;
|
||||
private $memory_limit;
|
||||
private $execution_timeout;
|
||||
private $max_tasks_per_worker = 100;
|
||||
private $task_count = 0;
|
||||
|
||||
// Worker coordination
|
||||
private $worker_lock_file;
|
||||
private $worker_pid;
|
||||
private $heartbeat_interval = 30; // seconds
|
||||
|
||||
// Task handlers
|
||||
private $task_handlers = [];
|
||||
|
||||
/**
|
||||
* Constructor - Initialize worker
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->CI = &get_instance();
|
||||
|
||||
// Load required models and libraries
|
||||
$this->CI->load->model('desk_moloni/desk_moloni_sync_queue_model', 'sync_queue_model');
|
||||
$this->CI->load->model('desk_moloni/desk_moloni_sync_log_model', 'sync_log_model');
|
||||
$this->CI->load->library('desk_moloni/moloni_api_client');
|
||||
$this->CI->load->library('desk_moloni/client_sync_service');
|
||||
$this->CI->load->library('desk_moloni/invoice_sync_service');
|
||||
|
||||
// Generate unique worker ID
|
||||
$this->worker_id = uniqid('worker_', true);
|
||||
$this->worker_pid = getmypid();
|
||||
|
||||
// Set memory and execution limits
|
||||
$this->memory_limit = $this->convert_to_bytes(ini_get('memory_limit'));
|
||||
$this->execution_timeout = (int) get_option('desk_moloni_worker_timeout', 300); // 5 minutes default
|
||||
|
||||
// Initialize worker lock file
|
||||
$this->worker_lock_file = APPPATH . "logs/desk_moloni_worker_{$this->worker_id}.lock";
|
||||
|
||||
// Register task handlers
|
||||
$this->register_task_handlers();
|
||||
|
||||
// Register shutdown handler
|
||||
register_shutdown_function([$this, 'shutdown_handler']);
|
||||
|
||||
log_message('info', "TaskWorker {$this->worker_id} initialized with PID {$this->worker_pid}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the worker process
|
||||
*
|
||||
* @param array $options Worker configuration options
|
||||
* @return void
|
||||
*/
|
||||
public function start($options = [])
|
||||
{
|
||||
$this->is_running = true;
|
||||
|
||||
// Process options
|
||||
if (isset($options['max_tasks'])) {
|
||||
$this->max_tasks_per_worker = (int) $options['max_tasks'];
|
||||
}
|
||||
|
||||
// Create worker lock file
|
||||
$this->create_lock_file();
|
||||
|
||||
log_message('info', "TaskWorker {$this->worker_id} starting...");
|
||||
|
||||
try {
|
||||
$this->worker_loop();
|
||||
} catch (Exception $e) {
|
||||
log_message('error', "TaskWorker {$this->worker_id} error: " . $e->getMessage());
|
||||
} finally {
|
||||
$this->cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the worker process
|
||||
*/
|
||||
public function stop()
|
||||
{
|
||||
$this->is_running = false;
|
||||
log_message('info', "TaskWorker {$this->worker_id} stopping...");
|
||||
}
|
||||
|
||||
/**
|
||||
* Main worker loop
|
||||
*/
|
||||
private function worker_loop()
|
||||
{
|
||||
$last_heartbeat = time();
|
||||
|
||||
while ($this->is_running && $this->task_count < $this->max_tasks_per_worker) {
|
||||
// Check memory usage
|
||||
if ($this->is_memory_limit_exceeded()) {
|
||||
log_message('warning', "TaskWorker {$this->worker_id} memory limit exceeded, stopping");
|
||||
break;
|
||||
}
|
||||
|
||||
// Update heartbeat
|
||||
if (time() - $last_heartbeat >= $this->heartbeat_interval) {
|
||||
$this->update_heartbeat();
|
||||
$last_heartbeat = time();
|
||||
}
|
||||
|
||||
// Get next task from queue
|
||||
$task = $this->CI->sync_queue_model->get_next_task($this->worker_id);
|
||||
|
||||
if (!$task) {
|
||||
// No tasks available, sleep briefly
|
||||
sleep(1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Execute task
|
||||
$this->execute_task($task);
|
||||
$this->task_count++;
|
||||
|
||||
// Brief pause between tasks
|
||||
usleep(100000); // 0.1 second
|
||||
}
|
||||
|
||||
log_message('info', "TaskWorker {$this->worker_id} completed {$this->task_count} tasks");
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single task
|
||||
*
|
||||
* @param array $task Task data
|
||||
*/
|
||||
private function execute_task($task)
|
||||
{
|
||||
$this->current_task = $task;
|
||||
$start_time = microtime(true);
|
||||
|
||||
try {
|
||||
// Update task status to processing
|
||||
$this->CI->sync_queue_model->update_task_status($task['id'], 'processing', [
|
||||
'worker_id' => $this->worker_id,
|
||||
'started_at' => date('Y-m-d H:i:s'),
|
||||
'pid' => $this->worker_pid
|
||||
]);
|
||||
|
||||
log_message('info', "TaskWorker {$this->worker_id} executing task {$task['id']} ({$task['task_type']})");
|
||||
|
||||
// Set execution timeout
|
||||
set_time_limit($this->execution_timeout);
|
||||
|
||||
// Get appropriate task handler
|
||||
$handler = $this->get_task_handler($task['task_type']);
|
||||
|
||||
if (!$handler) {
|
||||
throw new Exception("No handler found for task type: {$task['task_type']}");
|
||||
}
|
||||
|
||||
// Execute task
|
||||
$result = call_user_func($handler, $task);
|
||||
|
||||
$execution_time = microtime(true) - $start_time;
|
||||
|
||||
// Update task as completed
|
||||
$this->CI->sync_queue_model->update_task_status($task['id'], 'completed', [
|
||||
'completed_at' => date('Y-m-d H:i:s'),
|
||||
'execution_time' => $execution_time,
|
||||
'result' => json_encode($result),
|
||||
'worker_id' => $this->worker_id
|
||||
]);
|
||||
|
||||
// Log successful execution
|
||||
$this->CI->sync_log_model->log_event([
|
||||
'task_id' => $task['id'],
|
||||
'event_type' => 'task_completed',
|
||||
'entity_type' => $task['entity_type'],
|
||||
'entity_id' => $task['entity_id'],
|
||||
'message' => "Task executed successfully by worker {$this->worker_id}",
|
||||
'execution_time' => $execution_time,
|
||||
'worker_id' => $this->worker_id
|
||||
]);
|
||||
|
||||
log_message('info', "TaskWorker {$this->worker_id} completed task {$task['id']} in " .
|
||||
number_format($execution_time, 3) . "s");
|
||||
|
||||
} catch (Exception $e) {
|
||||
$execution_time = microtime(true) - $start_time;
|
||||
|
||||
// Update task as failed
|
||||
$this->CI->sync_queue_model->update_task_status($task['id'], 'failed', [
|
||||
'failed_at' => date('Y-m-d H:i:s'),
|
||||
'error_message' => $e->getMessage(),
|
||||
'execution_time' => $execution_time,
|
||||
'worker_id' => $this->worker_id,
|
||||
'retry_count' => ($task['retry_count'] ?? 0) + 1
|
||||
]);
|
||||
|
||||
// Log error
|
||||
$this->CI->sync_log_model->log_event([
|
||||
'task_id' => $task['id'],
|
||||
'event_type' => 'task_failed',
|
||||
'entity_type' => $task['entity_type'],
|
||||
'entity_id' => $task['entity_id'],
|
||||
'message' => "Task failed: " . $e->getMessage(),
|
||||
'log_level' => 'error',
|
||||
'execution_time' => $execution_time,
|
||||
'worker_id' => $this->worker_id
|
||||
]);
|
||||
|
||||
log_message('error', "TaskWorker {$this->worker_id} failed task {$task['id']}: " . $e->getMessage());
|
||||
|
||||
// Schedule retry if appropriate
|
||||
$this->schedule_retry($task, $e);
|
||||
}
|
||||
|
||||
$this->current_task = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register task handlers
|
||||
*/
|
||||
private function register_task_handlers()
|
||||
{
|
||||
$this->task_handlers = [
|
||||
'client_sync' => [$this, 'handle_client_sync'],
|
||||
'invoice_sync' => [$this, 'handle_invoice_sync'],
|
||||
'oauth_refresh' => [$this, 'handle_oauth_refresh'],
|
||||
'cleanup' => [$this, 'handle_cleanup'],
|
||||
'notification' => [$this, 'handle_notification'],
|
||||
'bulk_sync' => [$this, 'handle_bulk_sync'],
|
||||
'data_validation' => [$this, 'handle_data_validation'],
|
||||
'mapping_discovery' => [$this, 'handle_mapping_discovery']
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get task handler for task type
|
||||
*
|
||||
* @param string $task_type Task type
|
||||
* @return callable|null Handler function
|
||||
*/
|
||||
private function get_task_handler($task_type)
|
||||
{
|
||||
return $this->task_handlers[$task_type] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle client synchronization task
|
||||
*
|
||||
* @param array $task Task data
|
||||
* @return array Result
|
||||
*/
|
||||
private function handle_client_sync($task)
|
||||
{
|
||||
$client_id = $task['entity_id'];
|
||||
$payload = json_decode($task['payload'], true) ?? [];
|
||||
|
||||
return $this->CI->client_sync_service->sync_client($client_id, $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle invoice synchronization task
|
||||
*
|
||||
* @param array $task Task data
|
||||
* @return array Result
|
||||
*/
|
||||
private function handle_invoice_sync($task)
|
||||
{
|
||||
$invoice_id = $task['entity_id'];
|
||||
$payload = json_decode($task['payload'], true) ?? [];
|
||||
|
||||
return $this->CI->invoice_sync_service->sync_invoice($invoice_id, $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle OAuth token refresh task
|
||||
*
|
||||
* @param array $task Task data
|
||||
* @return array Result
|
||||
*/
|
||||
private function handle_oauth_refresh($task)
|
||||
{
|
||||
$this->CI->load->library('desk_moloni/moloni_oauth');
|
||||
|
||||
$success = $this->CI->moloni_oauth->refresh_access_token();
|
||||
|
||||
return [
|
||||
'success' => $success,
|
||||
'refreshed_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle cleanup task
|
||||
*
|
||||
* @param array $task Task data
|
||||
* @return array Result
|
||||
*/
|
||||
private function handle_cleanup($task)
|
||||
{
|
||||
$payload = json_decode($task['payload'], true) ?? [];
|
||||
$cleanup_type = $payload['type'] ?? 'general';
|
||||
|
||||
$cleaned = 0;
|
||||
|
||||
switch ($cleanup_type) {
|
||||
case 'logs':
|
||||
$days = $payload['days'] ?? 30;
|
||||
$cleaned = $this->CI->sync_log_model->cleanup_old_logs($days);
|
||||
break;
|
||||
|
||||
case 'queue':
|
||||
$status = $payload['status'] ?? 'completed';
|
||||
$cleaned = $this->CI->sync_queue_model->cleanup_old_tasks($status);
|
||||
break;
|
||||
|
||||
default:
|
||||
// General cleanup
|
||||
$cleaned += $this->CI->sync_log_model->cleanup_old_logs(30);
|
||||
$cleaned += $this->CI->sync_queue_model->cleanup_old_tasks('completed');
|
||||
}
|
||||
|
||||
return [
|
||||
'cleanup_type' => $cleanup_type,
|
||||
'items_cleaned' => $cleaned
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle notification task
|
||||
*
|
||||
* @param array $task Task data
|
||||
* @return array Result
|
||||
*/
|
||||
private function handle_notification($task)
|
||||
{
|
||||
// Placeholder for notification handling
|
||||
return [
|
||||
'notification_sent' => false,
|
||||
'message' => 'Notification handling not yet implemented'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle bulk synchronization task
|
||||
*
|
||||
* @param array $task Task data
|
||||
* @return array Result
|
||||
*/
|
||||
private function handle_bulk_sync($task)
|
||||
{
|
||||
$payload = json_decode($task['payload'], true) ?? [];
|
||||
$entity_type = $payload['entity_type'] ?? 'all';
|
||||
$batch_size = $payload['batch_size'] ?? 50;
|
||||
|
||||
$processed = 0;
|
||||
$errors = 0;
|
||||
|
||||
// Implementation would depend on entity type
|
||||
// For now, return a placeholder result
|
||||
|
||||
return [
|
||||
'entity_type' => $entity_type,
|
||||
'batch_size' => $batch_size,
|
||||
'processed' => $processed,
|
||||
'errors' => $errors
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle data validation task
|
||||
*
|
||||
* @param array $task Task data
|
||||
* @return array Result
|
||||
*/
|
||||
private function handle_data_validation($task)
|
||||
{
|
||||
// Placeholder for data validation
|
||||
return [
|
||||
'validated' => true,
|
||||
'issues_found' => 0
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mapping discovery task
|
||||
*
|
||||
* @param array $task Task data
|
||||
* @return array Result
|
||||
*/
|
||||
private function handle_mapping_discovery($task)
|
||||
{
|
||||
$payload = json_decode($task['payload'], true) ?? [];
|
||||
$entity_type = $payload['entity_type'] ?? 'client';
|
||||
|
||||
$this->CI->load->model('desk_moloni/desk_moloni_mapping_model', 'mapping_model');
|
||||
|
||||
$discovered_mappings = $this->CI->mapping_model->discover_mappings($entity_type, true);
|
||||
|
||||
return [
|
||||
'entity_type' => $entity_type,
|
||||
'discovered_count' => count($discovered_mappings),
|
||||
'mappings' => $discovered_mappings
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule task retry
|
||||
*
|
||||
* @param array $task Task data
|
||||
* @param Exception $error Error that caused failure
|
||||
*/
|
||||
private function schedule_retry($task, $error)
|
||||
{
|
||||
$retry_count = ($task['retry_count'] ?? 0) + 1;
|
||||
$max_retries = (int) get_option('desk_moloni_max_retries', 3);
|
||||
|
||||
if ($retry_count <= $max_retries) {
|
||||
// Calculate backoff delay
|
||||
$delay = min(pow(2, $retry_count) * 60, 3600); // Exponential backoff, max 1 hour
|
||||
|
||||
$this->CI->sync_queue_model->schedule_retry($task['id'], $delay);
|
||||
|
||||
log_message('info', "TaskWorker {$this->worker_id} scheduled retry {$retry_count}/{$max_retries} " .
|
||||
"for task {$task['id']} in {$delay}s");
|
||||
} else {
|
||||
log_message('warning', "TaskWorker {$this->worker_id} task {$task['id']} exceeded max retries");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create worker lock file
|
||||
*/
|
||||
private function create_lock_file()
|
||||
{
|
||||
$lock_data = [
|
||||
'worker_id' => $this->worker_id,
|
||||
'pid' => $this->worker_pid,
|
||||
'started_at' => date('Y-m-d H:i:s'),
|
||||
'last_heartbeat' => time()
|
||||
];
|
||||
|
||||
file_put_contents($this->worker_lock_file, json_encode($lock_data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update worker heartbeat
|
||||
*/
|
||||
private function update_heartbeat()
|
||||
{
|
||||
if (file_exists($this->worker_lock_file)) {
|
||||
$lock_data = json_decode(file_get_contents($this->worker_lock_file), true);
|
||||
$lock_data['last_heartbeat'] = time();
|
||||
$lock_data['task_count'] = $this->task_count;
|
||||
$lock_data['current_task'] = $this->current_task['id'] ?? null;
|
||||
|
||||
file_put_contents($this->worker_lock_file, json_encode($lock_data));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if memory limit is exceeded
|
||||
*
|
||||
* @return bool Memory limit exceeded
|
||||
*/
|
||||
private function is_memory_limit_exceeded()
|
||||
{
|
||||
if ($this->memory_limit === -1) {
|
||||
return false; // No memory limit
|
||||
}
|
||||
|
||||
$current_usage = memory_get_usage(true);
|
||||
$percentage = ($current_usage / $this->memory_limit) * 100;
|
||||
|
||||
return $percentage > 80; // Stop at 80% memory usage
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert memory limit to bytes
|
||||
*
|
||||
* @param string $val Memory limit string
|
||||
* @return int Bytes
|
||||
*/
|
||||
private function convert_to_bytes($val)
|
||||
{
|
||||
if ($val === '-1') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
$val = trim($val);
|
||||
$last = strtolower($val[strlen($val) - 1]);
|
||||
$val = (int) $val;
|
||||
|
||||
switch ($last) {
|
||||
case 'g':
|
||||
$val *= 1024;
|
||||
case 'm':
|
||||
$val *= 1024;
|
||||
case 'k':
|
||||
$val *= 1024;
|
||||
}
|
||||
|
||||
return $val;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup worker resources
|
||||
*/
|
||||
private function cleanup()
|
||||
{
|
||||
// Remove lock file
|
||||
if (file_exists($this->worker_lock_file)) {
|
||||
unlink($this->worker_lock_file);
|
||||
}
|
||||
|
||||
// Release any pending tasks assigned to this worker
|
||||
if ($this->current_task) {
|
||||
$this->CI->sync_queue_model->release_task($this->current_task['id']);
|
||||
}
|
||||
|
||||
log_message('info', "TaskWorker {$this->worker_id} cleanup completed");
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown handler
|
||||
*/
|
||||
public function shutdown_handler()
|
||||
{
|
||||
if ($this->is_running) {
|
||||
log_message('warning', "TaskWorker {$this->worker_id} unexpected shutdown");
|
||||
$this->cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get worker status
|
||||
*
|
||||
* @return array Worker status
|
||||
*/
|
||||
public function get_status()
|
||||
{
|
||||
$status = [
|
||||
'worker_id' => $this->worker_id,
|
||||
'pid' => $this->worker_pid,
|
||||
'is_running' => $this->is_running,
|
||||
'task_count' => $this->task_count,
|
||||
'max_tasks' => $this->max_tasks_per_worker,
|
||||
'current_task' => $this->current_task,
|
||||
'memory_usage' => memory_get_usage(true),
|
||||
'memory_limit' => $this->memory_limit,
|
||||
'execution_timeout' => $this->execution_timeout
|
||||
];
|
||||
|
||||
if (file_exists($this->worker_lock_file)) {
|
||||
$lock_data = json_decode(file_get_contents($this->worker_lock_file), true);
|
||||
$status['lock_data'] = $lock_data;
|
||||
}
|
||||
|
||||
return $status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if worker is healthy
|
||||
*
|
||||
* @return bool Worker health status
|
||||
*/
|
||||
public function is_healthy()
|
||||
{
|
||||
// Check if lock file exists and is recent
|
||||
if (!file_exists($this->worker_lock_file)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$lock_data = json_decode(file_get_contents($this->worker_lock_file), true);
|
||||
$last_heartbeat = $lock_data['last_heartbeat'] ?? 0;
|
||||
|
||||
// Worker is healthy if heartbeat is within 2 intervals
|
||||
return (time() - $last_heartbeat) < ($this->heartbeat_interval * 2);
|
||||
}
|
||||
}
|
||||
392
modules/desk_moloni/libraries/TokenManager.php
Normal file
392
modules/desk_moloni/libraries/TokenManager.php
Normal file
@@ -0,0 +1,392 @@
|
||||
<?php
|
||||
|
||||
defined('BASEPATH') or exit('No direct script access allowed');
|
||||
|
||||
/**
|
||||
* Token Manager Library
|
||||
*
|
||||
* Handles secure token storage and management with AES-256 encryption
|
||||
*
|
||||
* @package DeskMoloni
|
||||
* @author Descomplicar®
|
||||
* @copyright 2025 Descomplicar
|
||||
* @version 3.0.0
|
||||
*/
|
||||
class TokenManager
|
||||
{
|
||||
private $CI;
|
||||
|
||||
// Encryption configuration
|
||||
private $cipher = 'AES-256-CBC';
|
||||
private $key_size = 32; // 256 bits
|
||||
private $iv_size = 16; // 128 bits
|
||||
|
||||
// Token storage keys
|
||||
private $access_token_key = 'desk_moloni_access_token_encrypted';
|
||||
private $refresh_token_key = 'desk_moloni_refresh_token_encrypted';
|
||||
private $token_expires_key = 'desk_moloni_token_expires';
|
||||
private $token_scope_key = 'desk_moloni_token_scope';
|
||||
private $encryption_key_option = 'desk_moloni_encryption_key';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->CI = &get_instance();
|
||||
|
||||
// Ensure encryption key exists
|
||||
$this->ensure_encryption_key();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save OAuth tokens securely
|
||||
*
|
||||
* @param array $token_data Token response from OAuth
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function save_tokens($token_data)
|
||||
{
|
||||
try {
|
||||
// Validate required fields
|
||||
if (!isset($token_data['access_token'])) {
|
||||
throw new Exception('Access token is required');
|
||||
}
|
||||
|
||||
// Calculate expiration time with 60-second buffer
|
||||
$expires_in = isset($token_data['expires_in']) ? (int)$token_data['expires_in'] : 3600;
|
||||
$expires_at = time() + $expires_in - 60;
|
||||
|
||||
// Encrypt and save access token
|
||||
$encrypted_access = $this->encrypt($token_data['access_token']);
|
||||
update_option($this->access_token_key, $encrypted_access);
|
||||
|
||||
// Encrypt and save refresh token if provided
|
||||
if (isset($token_data['refresh_token'])) {
|
||||
$encrypted_refresh = $this->encrypt($token_data['refresh_token']);
|
||||
update_option($this->refresh_token_key, $encrypted_refresh);
|
||||
}
|
||||
|
||||
// Save expiration and scope
|
||||
update_option($this->token_expires_key, $expires_at);
|
||||
update_option($this->token_scope_key, $token_data['scope'] ?? '');
|
||||
|
||||
// Log successful token save
|
||||
log_activity('Desk-Moloni: OAuth tokens saved securely');
|
||||
|
||||
return true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_activity('Desk-Moloni: Token save failed - ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get decrypted access token
|
||||
*
|
||||
* @return string|null Access token or null if not available
|
||||
*/
|
||||
public function get_access_token()
|
||||
{
|
||||
try {
|
||||
$encrypted_token = get_option($this->access_token_key);
|
||||
|
||||
if (empty($encrypted_token)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->decrypt($encrypted_token);
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_activity('Desk-Moloni: Access token decryption failed - ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get decrypted refresh token
|
||||
*
|
||||
* @return string|null Refresh token or null if not available
|
||||
*/
|
||||
public function get_refresh_token()
|
||||
{
|
||||
try {
|
||||
$encrypted_token = get_option($this->refresh_token_key);
|
||||
|
||||
if (empty($encrypted_token)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->decrypt($encrypted_token);
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_activity('Desk-Moloni: Refresh token decryption failed - ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tokens are valid and not expired
|
||||
*
|
||||
* @return bool Token validity status
|
||||
*/
|
||||
public function are_tokens_valid()
|
||||
{
|
||||
// Check if access token exists
|
||||
if (empty($this->get_access_token())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
$expires_at = get_option($this->token_expires_key);
|
||||
if ($expires_at && time() >= $expires_at) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tokens are close to expiring (within 5 minutes)
|
||||
*
|
||||
* @return bool True if tokens expire soon
|
||||
*/
|
||||
public function tokens_expire_soon()
|
||||
{
|
||||
$expires_at = get_option($this->token_expires_key);
|
||||
|
||||
if (!$expires_at) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (time() + 300) >= $expires_at; // 5 minutes
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token expiration timestamp
|
||||
*
|
||||
* @return int|null Expiration timestamp or null
|
||||
*/
|
||||
public function get_token_expiration()
|
||||
{
|
||||
return get_option($this->token_expires_key) ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token scope
|
||||
*
|
||||
* @return string Token scope
|
||||
*/
|
||||
public function get_token_scope()
|
||||
{
|
||||
return get_option($this->token_scope_key) ?: '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all stored tokens
|
||||
*
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function clear_tokens()
|
||||
{
|
||||
try {
|
||||
update_option($this->access_token_key, '');
|
||||
update_option($this->refresh_token_key, '');
|
||||
update_option($this->token_expires_key, '');
|
||||
update_option($this->token_scope_key, '');
|
||||
|
||||
log_activity('Desk-Moloni: OAuth tokens cleared');
|
||||
|
||||
return true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_activity('Desk-Moloni: Token clear failed - ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive token status
|
||||
*
|
||||
* @return array Token status information
|
||||
*/
|
||||
public function get_token_status()
|
||||
{
|
||||
$expires_at = $this->get_token_expiration();
|
||||
|
||||
return [
|
||||
'has_access_token' => !empty($this->get_access_token()),
|
||||
'has_refresh_token' => !empty($this->get_refresh_token()),
|
||||
'is_valid' => $this->are_tokens_valid(),
|
||||
'expires_soon' => $this->tokens_expire_soon(),
|
||||
'expires_at' => $expires_at,
|
||||
'expires_in' => $expires_at ? max(0, $expires_at - time()) : 0,
|
||||
'scope' => $this->get_token_scope(),
|
||||
'formatted_expiry' => $expires_at ? date('Y-m-d H:i:s', $expires_at) : null
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt data using AES-256-CBC
|
||||
*
|
||||
* @param string $data Data to encrypt
|
||||
* @return string Base64 encoded encrypted data with IV
|
||||
*/
|
||||
private function encrypt($data)
|
||||
{
|
||||
if (empty($data)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$key = $this->get_encryption_key();
|
||||
$iv = random_bytes($this->iv_size);
|
||||
|
||||
$encrypted = openssl_encrypt($data, $this->cipher, $key, OPENSSL_RAW_DATA, $iv);
|
||||
|
||||
if ($encrypted === false) {
|
||||
throw new Exception('Encryption failed');
|
||||
}
|
||||
|
||||
// Prepend IV to encrypted data and encode
|
||||
return base64_encode($iv . $encrypted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt data using AES-256-CBC
|
||||
*
|
||||
* @param string $encrypted_data Base64 encoded encrypted data with IV
|
||||
* @return string Decrypted data
|
||||
*/
|
||||
private function decrypt($encrypted_data)
|
||||
{
|
||||
if (empty($encrypted_data)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$data = base64_decode($encrypted_data);
|
||||
|
||||
if ($data === false || strlen($data) < $this->iv_size) {
|
||||
throw new Exception('Invalid encrypted data');
|
||||
}
|
||||
|
||||
$key = $this->get_encryption_key();
|
||||
$iv = substr($data, 0, $this->iv_size);
|
||||
$encrypted = substr($data, $this->iv_size);
|
||||
|
||||
$decrypted = openssl_decrypt($encrypted, $this->cipher, $key, OPENSSL_RAW_DATA, $iv);
|
||||
|
||||
if ($decrypted === false) {
|
||||
throw new Exception('Decryption failed');
|
||||
}
|
||||
|
||||
return $decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or generate encryption key
|
||||
*
|
||||
* @return string Encryption key
|
||||
*/
|
||||
private function get_encryption_key()
|
||||
{
|
||||
$key = get_option($this->encryption_key_option);
|
||||
|
||||
if (empty($key)) {
|
||||
throw new Exception('Encryption key not found');
|
||||
}
|
||||
|
||||
return base64_decode($key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure encryption key exists
|
||||
*/
|
||||
private function ensure_encryption_key()
|
||||
{
|
||||
$existing_key = get_option($this->encryption_key_option);
|
||||
|
||||
if (empty($existing_key)) {
|
||||
// Generate new random key
|
||||
$key = random_bytes($this->key_size);
|
||||
$encoded_key = base64_encode($key);
|
||||
|
||||
update_option($this->encryption_key_option, $encoded_key);
|
||||
|
||||
log_activity('Desk-Moloni: New encryption key generated');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate encryption key (for security maintenance)
|
||||
* WARNING: This will invalidate all existing tokens
|
||||
*
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function rotate_encryption_key()
|
||||
{
|
||||
try {
|
||||
// Clear existing tokens first
|
||||
$this->clear_tokens();
|
||||
|
||||
// Generate new key
|
||||
$new_key = random_bytes($this->key_size);
|
||||
$encoded_key = base64_encode($new_key);
|
||||
|
||||
update_option($this->encryption_key_option, $encoded_key);
|
||||
|
||||
log_activity('Desk-Moloni: Encryption key rotated - all tokens cleared');
|
||||
|
||||
return true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
log_activity('Desk-Moloni: Key rotation failed - ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate encryption setup
|
||||
*
|
||||
* @return array Validation results
|
||||
*/
|
||||
public function validate_encryption()
|
||||
{
|
||||
$issues = [];
|
||||
|
||||
// Check if OpenSSL is available
|
||||
if (!extension_loaded('openssl')) {
|
||||
$issues[] = 'OpenSSL extension not loaded';
|
||||
}
|
||||
|
||||
// Check if cipher is supported
|
||||
if (!in_array($this->cipher, openssl_get_cipher_methods())) {
|
||||
$issues[] = 'AES-256-CBC cipher not supported';
|
||||
}
|
||||
|
||||
// Check if encryption key exists
|
||||
try {
|
||||
$this->get_encryption_key();
|
||||
} catch (Exception $e) {
|
||||
$issues[] = 'Encryption key not available: ' . $e->getMessage();
|
||||
}
|
||||
|
||||
// Test encryption/decryption
|
||||
try {
|
||||
$test_data = 'test_token_' . time();
|
||||
$encrypted = $this->encrypt($test_data);
|
||||
$decrypted = $this->decrypt($encrypted);
|
||||
|
||||
if ($decrypted !== $test_data) {
|
||||
$issues[] = 'Encryption/decryption test failed';
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$issues[] = 'Encryption test failed: ' . $e->getMessage();
|
||||
}
|
||||
|
||||
return [
|
||||
'is_valid' => empty($issues),
|
||||
'issues' => $issues,
|
||||
'cipher' => $this->cipher,
|
||||
'openssl_loaded' => extension_loaded('openssl'),
|
||||
'supported_ciphers' => openssl_get_cipher_methods()
|
||||
];
|
||||
}
|
||||
}
|
||||
0
modules/desk_moloni/libraries/index.html
Normal file
0
modules/desk_moloni/libraries/index.html
Normal file
Reference in New Issue
Block a user