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:
Emanuel Almeida
2025-09-11 17:38:45 +01:00
parent 5e5102db73
commit c19f6fd9ee
193 changed files with 59298 additions and 638 deletions

View 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;
}
}

File diff suppressed because it is too large Load Diff

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

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

View 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;
}
}

View 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;
}
}

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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';
}
}

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

View 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)
];
}
}

File diff suppressed because it is too large Load Diff

View 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;
}
}

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

View 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;
}
}

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

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

View File