Files
desk-moloni/deploy_temp/desk_moloni/libraries/MoloniApiClient.php
Emanuel Almeida 9510ea61d1 🛡️ CRITICAL SECURITY FIX: XSS Vulnerabilities Eliminated - Score 100/100
CONTEXT:
- Score upgraded from 89/100 to 100/100
- XSS vulnerabilities eliminated: 82/100 → 100/100
- Deploy APPROVED for production

SECURITY FIXES:
 Added h() escaping function in bootstrap.php
 Fixed 26 XSS vulnerabilities across 6 view files
 Secured all dynamic output with proper escaping
 Maintained compatibility with safe functions (_l, admin_url, etc.)

FILES SECURED:
- config.php: 5 vulnerabilities fixed
- logs.php: 4 vulnerabilities fixed
- mapping_management.php: 5 vulnerabilities fixed
- queue_management.php: 6 vulnerabilities fixed
- csrf_token.php: 4 vulnerabilities fixed
- client_portal/index.php: 2 vulnerabilities fixed

VALIDATION:
📊 Files analyzed: 10
 Secure files: 10
 Vulnerable files: 0
🎯 Security Score: 100/100

🚀 Deploy approved for production
🏆 Descomplicar® Gold 100/100 security standard achieved

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 23:59:16 +01:00

1573 lines
53 KiB
PHP

/**
* Descomplicar® Crescimento Digital
* https://descomplicar.pt
*/
<?php
defined('BASEPATH') or exit('No direct script access allowed');
/**
* Enhanced Moloni API Client Library
*
* Handles all API communication with Moloni ERP system
* Implements rate limiting, retry logic, and comprehensive error handling
*
* @package DeskMoloni
* @author Descomplicar®
* @copyright 2025 Descomplicar
* @version 3.0.0
*/
class MoloniApiClient
{
private $CI;
// API configuration
private $api_base_url = 'https://api.moloni.pt/v1/';
private $api_timeout = 30;
private $connect_timeout = 10;
private $max_retries = 3;
private $retry_delay = 1; // seconds
// Rate limiting configuration
private $requests_per_minute = 60;
private $requests_per_hour = 1000;
private $request_count_minute = 0;
private $request_count_hour = 0;
private $minute_window_start = 0;
private $hour_window_start = 0;
// OAuth handler
private $oauth;
// Request logging
private $log_requests = true;
private $log_responses = true;
private $log_errors = true;
// Circuit breaker pattern
private $circuit_breaker_threshold = 5;
private $circuit_breaker_timeout = 300; // 5 minutes
private $circuit_breaker_failures = 0;
private $circuit_breaker_last_failure = 0;
/**
* Moloni API Client Constructor
*
* Initializes the Moloni API client with OAuth handler, configuration loading,
* rate limiting setup, and circuit breaker pattern initialization.
*
* @since 3.0.0
* @author Descomplicar®
* @throws Exception If OAuth library cannot be loaded or configuration fails
*/
public function __construct()
{
$this->CI = &get_instance();
$this->CI->load->library('desk_moloni/moloni_oauth');
$this->oauth = $this->CI->moloni_oauth;
// Load configuration
$this->load_configuration();
}
/**
* Load API client configuration
*/
private function load_configuration(): void
{
$this->api_timeout = (int)get_option('desk_moloni_api_timeout', 30);
$this->connect_timeout = (int)get_option('desk_moloni_connect_timeout', 10);
$this->max_retries = (int)get_option('desk_moloni_max_retries', 3);
$this->requests_per_minute = (int)get_option('desk_moloni_requests_per_minute', 60);
$this->requests_per_hour = (int)get_option('desk_moloni_requests_per_hour', 1000);
$this->log_requests = (bool)get_option('desk_moloni_log_requests', true);
$this->circuit_breaker_threshold = (int)get_option('desk_moloni_circuit_breaker_threshold', 5);
}
/**
* Configure API client settings and operational parameters
*
* Updates API client configuration including timeouts, retry policies,
* rate limiting parameters, and logging preferences with persistent storage.
*
* @param array $config Configuration options array with keys:
* - timeout: API request timeout in seconds
* - max_retries: Maximum retry attempts for failed requests
* - rate_limit_per_minute: Requests per minute limit
* - rate_limit_per_hour: Requests per hour limit
* - log_requests: Enable/disable request logging
* @return void Configuration is applied and persisted
* @throws Exception When configuration validation fails or storage errors
* @since 3.0.0
* @author Descomplicar®
*/
public function configure(array $config = []): void
{
if (isset($config['timeout'])) {
$this->api_timeout = (int)$config['timeout'];
update_option('desk_moloni_api_timeout', $this->api_timeout);
}
if (isset($config['max_retries'])) {
$this->max_retries = (int)$config['max_retries'];
update_option('desk_moloni_max_retries', $this->max_retries);
}
if (isset($config['rate_limit_per_minute'])) {
$this->requests_per_minute = (int)$config['rate_limit_per_minute'];
update_option('desk_moloni_requests_per_minute', $this->requests_per_minute);
}
if (isset($config['rate_limit_per_hour'])) {
$this->requests_per_hour = (int)$config['rate_limit_per_hour'];
update_option('desk_moloni_requests_per_hour', $this->requests_per_hour);
}
if (isset($config['log_requests'])) {
$this->log_requests = (bool)$config['log_requests'];
update_option('desk_moloni_log_requests', $this->log_requests);
}
log_activity('Desk-Moloni: API client configuration updated');
return true;
}
// =====================================================
// OAuth 2.0 Token Exchange
// =====================================================
/**
* Exchange OAuth authorization code for access tokens
*
* Handles OAuth 2.0 authorization code exchange process to obtain
* access and refresh tokens for authenticated API requests.
*
* @param string $code Authorization code received from OAuth callback
* @param string $redirect_uri Redirect URI used in authorization request
* @return array|bool Token response array with access_token, refresh_token, expires_in, or false on failure
* @throws Exception When token exchange fails or OAuth handler errors
* @since 3.0.0
* @author Descomplicar®
*/
public function exchange_token(string $code, string $redirect_uri): array|bool
{
return $this->oauth->handle_callback($code);
}
// =====================================================
// Customer Management
// =====================================================
/**
* Retrieve paginated list of customers from Moloni
*
* Fetches customers with pagination support and optional filtering parameters.
* Includes comprehensive error handling and rate limiting compliance.
*
* @param int $company_id Moloni company identifier
* @param array $options Query options array with keys:
* - qty: Number of records to retrieve (default: 50, max: 100)
* - offset: Starting record offset for pagination (default: 0)
* - search: Search term for customer filtering (optional)
* @return array|bool Customer list array with customer data, or false on failure
* @throws Exception When API request fails, company_id is invalid, or rate limits exceeded
* @since 3.0.0
* @author Descomplicar®
*/
public function list_customers(int $company_id, array $options = []): array|bool
{
$params = array_merge([
'company_id' => $company_id,
'qty' => $options['qty'] ?? 50,
'offset' => $options['offset'] ?? 0
], $options);
return $this->make_request('customers/getAll', $params);
}
/**
* Get customer by ID
*
* @param int $customer_id Customer ID
* @param int $company_id Company ID
* @return array Customer data
*/
public function get_customer(int $customer_id, int $company_id): array|bool
{
return $this->make_request('customers/getOne', [
'customer_id' => $customer_id,
'company_id' => $company_id
]);
}
/**
* Create new customer in Moloni with comprehensive validation
*
* Creates a new customer record in Moloni with required field validation,
* automatic default value assignment, and error handling.
*
* @param array $customer_data Customer data array with required keys:
* - company_id: Moloni company identifier (required)
* - name: Customer full name (required)
* - vat: Customer VAT number (required)
* - country_id: Country identifier (default: 1 for Portugal)
* - email: Customer email address (optional)
* - address: Customer address (optional)
* - city: Customer city (optional)
* - zip_code: Customer postal code (optional)
* @return array|bool Created customer data with assigned customer_id, or false on failure
* @throws InvalidArgumentException When required fields are missing or invalid
* @throws Exception When API request fails or validation errors occur
* @since 3.0.0
* @author Descomplicar®
*/
public function create_customer(array $customer_data): array|bool
{
$required_fields = ['company_id', 'name', 'vat'];
$this->validate_required_fields($customer_data, $required_fields);
// Set defaults
$customer_data = array_merge([
'country_id' => 1 // Portugal default
], $customer_data);
return $this->make_request('customers/insert', $customer_data);
}
/**
* Update existing customer
*
* @param int $customer_id Customer ID
* @param array $customer_data Updated customer data
* @return array Updated customer
*/
public function update_customer(int $customer_id, array $customer_data): array|bool
{
$required_fields = ['customer_id', 'company_id'];
$update_data = array_merge($customer_data, [
'customer_id' => $customer_id
]);
$this->validate_required_fields($update_data, $required_fields);
return $this->make_request('customers/update', $update_data);
}
/**
* Search customers
*
* @param int $company_id Company ID
* @param string $search Search term
* @param array $options Additional options
* @return array Customer search results
*/
public function search_customers(int $company_id, string $search, array $options = []): array|bool
{
$params = array_merge([
'company_id' => $company_id,
'search' => $search,
'qty' => $options['qty'] ?? 50,
'offset' => $options['offset'] ?? 0
], $options);
return $this->make_request('customers/getAll', $params);
}
// =====================================================
// Product Management
// =====================================================
/**
* List products
*
* @param int $company_id Company ID
* @param array $options Query options
* @return array Product list
*/
public function list_products($company_id, $options = [])
{
$params = array_merge([
'company_id' => $company_id,
'qty' => $options['qty'] ?? 100,
'offset' => $options['offset'] ?? 0
], $options);
return $this->make_request('products/getAll', $params);
}
/**
* Get product by ID
*
* @param int $product_id Product ID
* @param int $company_id Company ID
* @return array Product data
*/
public function get_product($product_id, $company_id)
{
return $this->make_request('products/getOne', [
'product_id' => $product_id,
'company_id' => $company_id
]);
}
/**
* Create new product
*
* @param array $product_data Product data
* @return array Created product
*/
public function create_product($product_data)
{
$required_fields = ['company_id', 'name', 'price'];
$this->validate_required_fields($product_data, $required_fields);
// Set defaults
$product_data = array_merge([
'unit_id' => 1,
'has_stock' => 0
], $product_data);
return $this->make_request('products/insert', $product_data);
}
/**
* Update existing product
*
* @param int $product_id Product ID
* @param array $product_data Updated product data
* @return array Updated product
*/
public function update_product($product_id, $product_data)
{
$required_fields = ['product_id', 'company_id'];
$update_data = array_merge($product_data, [
'product_id' => $product_id
]);
$this->validate_required_fields($update_data, $required_fields);
return $this->make_request('products/update', $update_data);
}
/**
* Search products
*
* @param int $company_id Company ID
* @param string $search Search term
* @param array $options Additional options
* @return array Product search results
*/
public function search_products($company_id, $search, $options = [])
{
$params = array_merge([
'company_id' => $company_id,
'search' => $search,
'qty' => $options['qty'] ?? 100,
'offset' => $options['offset'] ?? 0
], $options);
return $this->make_request('products/getAll', $params);
}
// =====================================================
// Invoice Management
// =====================================================
/**
* Create new invoice in Moloni with comprehensive validation
*
* Creates a complete invoice in Moloni with customer, products, and financial data.
* Includes validation for all required fields and product line items.
*
* @param array $invoice_data Invoice data array with required keys:
* - company_id: Moloni company identifier (required)
* - customer_id: Customer identifier in Moloni (required)
* - date: Invoice date in Y-m-d format (required)
* - products: Array of product line items (required)
* Each product must have:
* - product_id: Product identifier
* - name: Product/service name
* - qty: Quantity (decimal)
* - price: Unit price (decimal)
* - document_set_id: Document series ID (optional)
* - payment_method_id: Payment method ID (optional)
* @return array|bool Created invoice data with document_id and PDF link, or false on failure
* @throws InvalidArgumentException When required fields are missing or products array is invalid
* @throws Exception When API request fails or validation errors occur
* @since 3.0.0
* @author Descomplicar®
*/
public function create_invoice($invoice_data)
{
$required_fields = ['company_id', 'customer_id', 'date', 'products'];
$this->validate_required_fields($invoice_data, $required_fields);
// Validate products
if (empty($invoice_data['products']) || !is_array($invoice_data['products'])) {
throw new InvalidArgumentException('Invoice must contain at least one product');
}
foreach ($invoice_data['products'] as $product) {
$this->validate_required_fields($product, ['product_id', 'name', 'qty', 'price']);
}
return $this->make_request('invoices/insert', $invoice_data);
}
/**
* List invoices with pagination
*
* @param int $company_id Company ID
* @param array $options Query options (qty, offset, search)
* @return array Invoice list
*/
public function list_invoices($company_id, $options = [])
{
$params = array_merge([
'company_id' => $company_id,
'qty' => $options['qty'] ?? 50,
'offset' => $options['offset'] ?? 0
], $options);
return $this->make_request('invoices/getAll', $params);
}
/**
* Get invoice by ID
*
* @param int $invoice_id Invoice ID
* @param int $company_id Company ID
* @return array Invoice data
*/
public function get_invoice($invoice_id, $company_id)
{
return $this->make_request('invoices/getOne', [
'document_id' => $invoice_id,
'company_id' => $company_id
]);
}
/**
* Update existing invoice
*
* @param int $invoice_id Invoice ID
* @param array $invoice_data Updated invoice data
* @return array Updated invoice
*/
public function update_invoice($invoice_id, $invoice_data)
{
$required_fields = ['document_id', 'company_id'];
$update_data = array_merge($invoice_data, [
'document_id' => $invoice_id
]);
$this->validate_required_fields($update_data, $required_fields);
return $this->make_request('invoices/update', $update_data);
}
/**
* Get invoice PDF
*
* @param int $invoice_id Invoice ID
* @param int $company_id Company ID
* @return string PDF binary data or PDF URL
*/
public function get_invoice_pdf($invoice_id, $company_id)
{
$response = $this->make_request('invoices/getPDFLink', [
'document_id' => $invoice_id,
'company_id' => $company_id
]);
// If response contains PDF URL, download the PDF
if (isset($response['url'])) {
return $this->download_pdf($response['url']);
}
return $response;
}
// =====================================================
// Estimate Management
// =====================================================
/**
* List estimates
*
* @param int $company_id Company ID
* @param array $options Query options
* @return array Estimate list
*/
public function list_estimates($company_id, $options = [])
{
$params = array_merge([
'company_id' => $company_id,
'qty' => $options['qty'] ?? 50,
'offset' => $options['offset'] ?? 0
], $options);
return $this->make_request('estimates/getAll', $params);
}
/**
* Get estimate by ID
*
* @param int $estimate_id Estimate ID
* @param int $company_id Company ID
* @return array Estimate data
*/
public function get_estimate($estimate_id, $company_id)
{
return $this->make_request('estimates/getOne', [
'document_id' => $estimate_id,
'company_id' => $company_id
]);
}
/**
* Create estimate
*
* @param array $estimate_data Estimate data
* @return array Created estimate
*/
public function create_estimate($estimate_data)
{
$required_fields = ['company_id', 'customer_id', 'date', 'products'];
$this->validate_required_fields($estimate_data, $required_fields);
// Validate products
if (empty($estimate_data['products']) || !is_array($estimate_data['products'])) {
throw new InvalidArgumentException('Estimate must contain at least one product');
}
foreach ($estimate_data['products'] as $product) {
$this->validate_required_fields($product, ['product_id', 'name', 'qty', 'price']);
}
return $this->make_request('estimates/insert', $estimate_data);
}
// =====================================================
// Company Management
// =====================================================
/**
* Get all companies for authenticated user
*
* @return array Company list
*/
public function list_companies()
{
return $this->make_request('companies/getAll');
}
/**
* Get company by ID
*
* @param int $company_id Company ID
* @return array Company data
*/
public function get_company($company_id)
{
return $this->make_request('companies/getOne', [
'company_id' => $company_id
]);
}
// =====================================================
// Tax Management
// =====================================================
/**
* List taxes for company
*
* @param int $company_id Company ID
* @return array Tax list
*/
public function list_taxes($company_id)
{
return $this->make_request('taxes/getAll', [
'company_id' => $company_id
]);
}
/**
* Get tax by ID
*
* @param int $tax_id Tax ID
* @param int $company_id Company ID
* @return array Tax data
*/
public function get_tax($tax_id, $company_id)
{
return $this->make_request('taxes/getOne', [
'tax_id' => $tax_id,
'company_id' => $company_id
]);
}
// =====================================================
// Document Sets Management
// =====================================================
/**
* List document sets
*
* @param int $company_id Company ID
* @return array Document sets list
*/
public function list_document_sets($company_id)
{
return $this->make_request('documentSets/getAll', [
'company_id' => $company_id
]);
}
// =====================================================
// Payment Methods Management
// =====================================================
/**
* List payment methods
*
* @param int $company_id Company ID
* @return array Payment methods list
*/
public function list_payment_methods($company_id)
{
return $this->make_request('paymentMethods/getAll', [
'company_id' => $company_id
]);
}
// =====================================================
// Product Categories Management
// =====================================================
/**
* List product categories
*
* @param int $company_id Company ID
* @return array Categories list
*/
public function list_product_categories($company_id)
{
return $this->make_request('productCategories/getAll', [
'company_id' => $company_id
]);
}
/**
* Create product category
*
* @param array $category_data Category data
* @return array Created category
*/
public function create_product_category($category_data)
{
$required_fields = ['company_id', 'name'];
$this->validate_required_fields($category_data, $required_fields);
return $this->make_request('productCategories/insert', $category_data);
}
// =====================================================
// Units Management
// =====================================================
/**
* List measurement units
*
* @param int $company_id Company ID
* @return array Units list
*/
public function list_units($company_id)
{
return $this->make_request('measurementUnits/getAll', [
'company_id' => $company_id
]);
}
// =====================================================
// Countries and Geographical Data
// =====================================================
/**
* List countries
*
* @return array Countries list
*/
public function list_countries()
{
return $this->make_request('countries/getAll');
}
/**
* List delivery methods
*
* @param int $company_id Company ID
* @return array Delivery methods list
*/
public function list_delivery_methods($company_id)
{
return $this->make_request('deliveryMethods/getAll', [
'company_id' => $company_id
]);
}
// =====================================================
// Core Request Handling
// =====================================================
/**
* Execute HTTP request to Moloni API with comprehensive error handling
*
* Core API request method with circuit breaker pattern, rate limiting,
* OAuth token management, retry logic with exponential backoff, and detailed logging.
*
* @param string $endpoint API endpoint path (without base URL)
* @param array $params Request parameters for POST body or GET query string
* @param string $method HTTP method - POST, GET, PUT, DELETE (default: POST)
* @return array|bool API response data array, or false on failure
* @throws Exception When circuit breaker is open, rate limits exceeded, OAuth not connected,
* request fails after all retry attempts, or API returns errors
* @since 3.0.0
* @author Descomplicar®
*/
public function make_request(string $endpoint, array $params = [], string $method = 'POST'): array|bool
{
// Check circuit breaker
if ($this->is_circuit_open()) {
throw new Exception('Circuit breaker is open - too many recent failures');
}
// Apply rate limiting
$this->enforce_rate_limits();
// Ensure OAuth connection
if (!$this->oauth->is_connected()) {
throw new Exception('OAuth not connected');
}
$url = $this->api_base_url . $endpoint;
$access_token = $this->oauth->get_access_token();
$last_exception = null;
for ($attempt = 1; $attempt <= $this->max_retries; $attempt++) {
try {
$response = $this->execute_request($url, $params, $access_token, $method);
// Reset circuit breaker on success
$this->circuit_breaker_failures = 0;
// Log successful request
if ($this->log_requests) {
$this->log_api_call($endpoint, $params, $response, null, $attempt);
}
return $response;
} catch (Exception $e) {
$last_exception = $e;
// Handle authentication errors
if ($this->is_auth_error($e) && $attempt === 1) {
if ($this->oauth->refresh_access_token()) {
$access_token = $this->oauth->get_access_token();
continue; // Retry with new token
}
}
// Check if it's a rate limit error
if ($this->is_rate_limit_error($e)) {
$wait_time = $this->calculate_rate_limit_wait($e);
if ($wait_time > 0 && $wait_time <= 60) {
sleep($wait_time);
continue; // Retry after waiting
}
}
// Don't retry on client errors (4xx except 401, 429)
if ($this->is_client_error($e) && !$this->is_auth_error($e) && !$this->is_rate_limit_error($e)) {
break;
}
// Wait before retry (exponential backoff)
if ($attempt < $this->max_retries) {
$wait_time = $this->retry_delay * pow(2, $attempt - 1);
sleep($wait_time);
}
}
}
// Update circuit breaker
$this->circuit_breaker_failures++;
$this->circuit_breaker_last_failure = time();
// Log failure
if ($this->log_errors && $last_exception) {
$this->log_api_call($endpoint, $params, null, $last_exception->getMessage(), $this->max_retries);
}
throw new Exception("API request failed after {$this->max_retries} attempts: " . $last_exception->getMessage());
}
/**
* Execute HTTP request
*
* @param string $url Request URL
* @param array $params Request parameters
* @param string $access_token OAuth access token
* @param string $method HTTP method
* @return array Response data
* @throws Exception On request failure
*/
private function execute_request($url, $params, $access_token, $method = 'POST')
{
$ch = curl_init();
$headers = [
'Authorization: Bearer ' . $access_token,
'Accept: application/json',
'User-Agent: Desk-Moloni/3.0',
'Cache-Control: no-cache'
];
if ($method === 'POST') {
$headers[] = 'Content-Type: application/json';
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($params),
]);
} else {
if (!empty($params)) {
$url .= '?' . http_build_query($params);
}
curl_setopt($ch, CURLOPT_URL, $url);
}
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $this->api_timeout,
CURLOPT_CONNECTTIMEOUT => $this->connect_timeout,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_MAXREDIRS => 0,
CURLOPT_ENCODING => '', // Enable compression
CURLOPT_USERAGENT => 'Desk-Moloni/3.0 (Perfex CRM Integration)'
]);
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curl_error = curl_error($ch);
$info = curl_getinfo($ch);
curl_close($ch);
if ($curl_error) {
throw new Exception("CURL Error: {$curl_error}");
}
$decoded = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new Exception('Invalid JSON response from API: ' . json_last_error_msg());
}
// Handle HTTP errors
if ($http_code >= 400) {
$error_msg = $this->extract_error_message($decoded, $http_code);
throw new Exception("HTTP {$http_code}: {$error_msg}");
}
// Check for API-level errors
if (isset($decoded['error'])) {
$error_msg = $decoded['error']['message'] ?? $decoded['error'];
throw new Exception("Moloni API Error: {$error_msg}");
}
return $decoded;
}
// =====================================================
// Helper Methods
// =====================================================
/**
* Validate required fields in data array
*
* @param array $data Data to validate
* @param array $required_fields Required field names
* @throws InvalidArgumentException If required fields are missing
*/
private function validate_required_fields($data, $required_fields)
{
$missing_fields = [];
foreach ($required_fields as $field) {
if (!isset($data[$field]) || $data[$field] === '' || $data[$field] === null) {
$missing_fields[] = $field;
}
}
if (!empty($missing_fields)) {
throw new InvalidArgumentException('Missing required fields: ' . implode(', ', $missing_fields));
}
}
/**
* Check if error is authentication related
*
* @param Exception $exception Exception to check
* @return bool True if auth error
*/
private function is_auth_error($exception)
{
$message = strtolower($exception->getMessage());
return strpos($message, 'unauthorized') !== false ||
strpos($message, 'invalid_token') !== false ||
strpos($message, 'token_expired') !== false ||
strpos($message, 'http 401') !== false;
}
/**
* Check if error is rate limit related
*
* @param Exception $exception Exception to check
* @return bool True if rate limit error
*/
private function is_rate_limit_error($exception)
{
$message = strtolower($exception->getMessage());
return strpos($message, 'rate limit') !== false ||
strpos($message, 'too many requests') !== false ||
strpos($message, 'http 429') !== false;
}
/**
* Check if error is client error (4xx)
*
* @param Exception $exception Exception to check
* @return bool True if client error
*/
private function is_client_error($exception)
{
$message = $exception->getMessage();
return preg_match('/HTTP 4\d{2}/', $message);
}
/**
* Calculate wait time for rate limit error
*
* @param Exception $exception Rate limit exception
* @return int Wait time in seconds
*/
private function calculate_rate_limit_wait($exception)
{
// Default wait time
$default_wait = 60;
// Try to extract wait time from error message
$message = $exception->getMessage();
if (preg_match('/retry after (\d+)/i', $message, $matches)) {
return min((int)$matches[1], 300); // Max 5 minutes
}
return $default_wait;
}
/**
* Extract error message from API response
*
* @param array|null $response Decoded response
* @param int $http_code HTTP status code
* @return string Error message
*/
private function extract_error_message($response, $http_code)
{
if (is_array($response)) {
if (isset($response['error']['message'])) {
return $response['error']['message'];
}
if (isset($response['error'])) {
return is_string($response['error']) ? $response['error'] : 'API Error';
}
if (isset($response['message'])) {
return $response['message'];
}
}
// Default HTTP error messages
$http_messages = [
400 => 'Bad Request',
401 => 'Unauthorized',
403 => 'Forbidden',
404 => 'Not Found',
429 => 'Too Many Requests',
500 => 'Internal Server Error',
502 => 'Bad Gateway',
503 => 'Service Unavailable',
504 => 'Gateway Timeout'
];
return $http_messages[$http_code] ?? "HTTP Error {$http_code}";
}
/**
* Enforce API rate limits
*
* @throws Exception If rate limit exceeded
*/
private function enforce_rate_limits()
{
$current_time = time();
// Check minute window
if ($current_time - $this->minute_window_start >= 60) {
$this->minute_window_start = $current_time;
$this->request_count_minute = 0;
}
// Check hour window
if ($current_time - $this->hour_window_start >= 3600) {
$this->hour_window_start = $current_time;
$this->request_count_hour = 0;
}
// Enforce limits
if ($this->request_count_minute >= $this->requests_per_minute) {
$wait_time = 60 - ($current_time - $this->minute_window_start);
throw new Exception("Rate limit exceeded: {$this->requests_per_minute} requests per minute. Wait {$wait_time} seconds.");
}
if ($this->request_count_hour >= $this->requests_per_hour) {
$wait_time = 3600 - ($current_time - $this->hour_window_start);
throw new Exception("Rate limit exceeded: {$this->requests_per_hour} requests per hour. Wait {$wait_time} seconds.");
}
// Increment counters
$this->request_count_minute++;
$this->request_count_hour++;
}
/**
* Check if circuit breaker is open
*
* @return bool True if circuit is open
*/
private function is_circuit_open()
{
if ($this->circuit_breaker_failures < $this->circuit_breaker_threshold) {
return false;
}
// Check if timeout has passed
if (time() - $this->circuit_breaker_last_failure >= $this->circuit_breaker_timeout) {
// Reset circuit breaker
$this->circuit_breaker_failures = 0;
return false;
}
return true;
}
/**
* Download PDF from URL
*
* @param string $url PDF URL
* @return string PDF binary data
*/
private function download_pdf($url)
{
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_USERAGENT => 'Desk-Moloni/3.0 PDF Downloader'
]);
$pdf_data = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($error || $http_code >= 400) {
throw new Exception("PDF download failed: {$error} (HTTP {$http_code})");
}
return $pdf_data;
}
/**
* Log API call for debugging and monitoring
*
* @param string $endpoint API endpoint
* @param array $params Request parameters
* @param array|null $response Response data
* @param string|null $error Error message
* @param int $attempt Attempt number
*/
private function log_api_call($endpoint, $params, $response, $error, $attempt)
{
if (!$this->log_requests && !$error) {
return;
}
$log_data = [
'endpoint' => $endpoint,
'params' => $this->log_requests ? json_encode($params) : null,
'response' => ($response && $this->log_responses) ? json_encode($response) : null,
'error' => $error,
'attempt' => $attempt,
'timestamp' => date('Y-m-d H:i:s'),
'user_id' => $this->CI->session->userdata('staff_user_id') ?? 0
];
// Save to database if model is available
if (method_exists($this->CI, 'load')) {
try {
// Optional: if a dedicated API log model exists
if (file_exists(APPPATH . 'modules/desk_moloni/models/Desk_moloni_api_log_model.php')) {
$this->CI->load->model('desk_moloni/desk_moloni_api_log_model');
$this->CI->desk_moloni_api_log_model->insert($log_data);
} else {
// Fallback to activity log
$message = "API Call: {$endpoint}";
if ($error) {
$message .= " - Error: {$error}";
}
log_activity($message);
}
} catch (Exception $e) {
// Fallback to activity log
$message = "API Call: {$endpoint}";
if ($error) {
$message .= " - Error: {$error}";
}
log_activity($message);
}
}
}
/**
* Retrieve comprehensive API client status and operational statistics
*
* Provides detailed information about API client health, configuration,
* rate limiting status, circuit breaker state, and OAuth connectivity.
*
* @return array Status information array with keys:
* - oauth_connected: Boolean OAuth connection status
* - rate_limits: Current rate limiting statistics and configuration
* - circuit_breaker: Circuit breaker status and failure counts
* - configuration: Current client configuration settings
* @since 3.0.0
* @author Descomplicar®
*/
public function get_status()
{
return [
'oauth_connected' => $this->oauth->is_connected(),
'rate_limits' => [
'per_minute' => $this->requests_per_minute,
'per_hour' => $this->requests_per_hour,
'current_minute' => $this->request_count_minute,
'current_hour' => $this->request_count_hour
],
'circuit_breaker' => [
'threshold' => $this->circuit_breaker_threshold,
'failures' => $this->circuit_breaker_failures,
'is_open' => $this->is_circuit_open(),
'last_failure' => $this->circuit_breaker_last_failure
],
'configuration' => [
'timeout' => $this->api_timeout,
'max_retries' => $this->max_retries,
'base_url' => $this->api_base_url
]
];
}
// =====================================================
// Webhook and Event Handling
// =====================================================
/**
* Process webhook payload from Moloni
*
* @param array $payload Webhook payload
* @param string $signature Webhook signature for verification
* @return bool Processing success
*/
public function process_webhook($payload, $signature = null)
{
try {
// Verify webhook signature if provided
if ($signature) {
$this->verify_webhook_signature($payload, $signature);
}
// Validate payload structure
if (!isset($payload['event']) || !isset($payload['data'])) {
throw new Exception('Invalid webhook payload structure');
}
$event = $payload['event'];
$data = $payload['data'];
// Log webhook received
if ($this->log_requests) {
$this->log_api_call("webhook:{$event}", $payload, null, null, 1);
}
// Process based on event type
switch ($event) {
case 'customer.created':
case 'customer.updated':
case 'customer.deleted':
return $this->handle_customer_webhook($event, $data);
case 'product.created':
case 'product.updated':
case 'product.deleted':
return $this->handle_product_webhook($event, $data);
case 'invoice.created':
case 'invoice.updated':
case 'invoice.paid':
return $this->handle_invoice_webhook($event, $data);
default:
// Log unknown event type
log_activity("Desk-Moloni: Unknown webhook event type: {$event}");
return true; // Don't fail for unknown events
}
} catch (Exception $e) {
log_activity('Desk-Moloni: Webhook processing failed - ' . $e->getMessage());
return false;
}
}
/**
* Verify webhook signature
*
* @param array $payload Webhook payload
* @param string $signature Provided signature
* @throws Exception If signature is invalid
*/
private function verify_webhook_signature($payload, $signature)
{
$webhook_secret = get_option('desk_moloni_webhook_secret');
if (empty($webhook_secret)) {
throw new Exception('Webhook secret not configured');
}
$expected_signature = hash_hmac('sha256', json_encode($payload), $webhook_secret);
if (!hash_equals($expected_signature, $signature)) {
throw new Exception('Invalid webhook signature');
}
}
/**
* Handle customer webhook events
*
* @param string $event Event type
* @param array $data Event data
* @return bool Processing success
*/
private function handle_customer_webhook($event, $data)
{
// Load client sync service to handle the webhook
if (method_exists($this->CI, 'load')) {
$this->CI->load->library('desk_moloni/client_sync_service');
switch ($event) {
case 'customer.created':
case 'customer.updated':
return $this->CI->client_sync_service->sync_from_moloni($data['customer_id'], $data['company_id']);
case 'customer.deleted':
return $this->CI->client_sync_service->handle_moloni_deletion($data['customer_id'], $data['company_id']);
default:
return true;
}
}
return true;
}
/**
* Handle product webhook events
*
* @param string $event Event type
* @param array $data Event data
* @return bool Processing success
*/
private function handle_product_webhook($event, $data)
{
// Load product sync service to handle the webhook
if (method_exists($this->CI, 'load')) {
$this->CI->load->library('desk_moloni/product_sync_service');
switch ($event) {
case 'product.created':
case 'product.updated':
return $this->CI->product_sync_service->sync_from_moloni($data['product_id'], $data['company_id']);
case 'product.deleted':
return $this->CI->product_sync_service->handle_moloni_deletion($data['product_id'], $data['company_id']);
default:
return true;
}
}
return true;
}
/**
* Handle invoice webhook events
*
* @param string $event Event type
* @param array $data Event data
* @return bool Processing success
*/
private function handle_invoice_webhook($event, $data)
{
// Load invoice sync service to handle the webhook
if (method_exists($this->CI, 'load')) {
$this->CI->load->library('desk_moloni/invoice_sync_service');
switch ($event) {
case 'invoice.created':
case 'invoice.updated':
case 'invoice.paid':
return $this->CI->invoice_sync_service->sync_from_moloni($data['document_id'], $data['company_id']);
default:
return true;
}
}
return true;
}
// =====================================================
// Bulk Operations
// =====================================================
/**
* Bulk create customers
*
* @param array $customers Array of customer data
* @return array Results with success/failure for each customer
*/
public function bulk_create_customers($customers)
{
$results = [];
foreach ($customers as $index => $customer_data) {
try {
$result = $this->create_customer($customer_data);
$results[$index] = [
'success' => true,
'data' => $result,
'error' => null
];
} catch (Exception $e) {
$results[$index] = [
'success' => false,
'data' => null,
'error' => $e->getMessage()
];
}
}
return $results;
}
/**
* Bulk create products
*
* @param array $products Array of product data
* @return array Results with success/failure for each product
*/
public function bulk_create_products($products)
{
$results = [];
foreach ($products as $index => $product_data) {
try {
$result = $this->create_product($product_data);
$results[$index] = [
'success' => true,
'data' => $result,
'error' => null
];
} catch (Exception $e) {
$results[$index] = [
'success' => false,
'data' => null,
'error' => $e->getMessage()
];
}
}
return $results;
}
/**
* Bulk update entities with error handling
*
* @param string $entity_type Entity type (customers, products, invoices)
* @param array $updates Array of update data with IDs
* @return array Results with success/failure for each update
*/
public function bulk_update($entity_type, $updates)
{
$results = [];
foreach ($updates as $index => $update_data) {
try {
switch ($entity_type) {
case 'customers':
$result = $this->update_customer($update_data['customer_id'], $update_data);
break;
case 'products':
$result = $this->update_product($update_data['product_id'], $update_data);
break;
case 'invoices':
$result = $this->update_invoice($update_data['document_id'], $update_data);
break;
default:
throw new Exception("Unsupported entity type: {$entity_type}");
}
$results[$index] = [
'success' => true,
'data' => $result,
'error' => null
];
} catch (Exception $e) {
$results[$index] = [
'success' => false,
'data' => null,
'error' => $e->getMessage()
];
}
}
return $results;
}
/**
* Perform comprehensive API client and service health check
*
* Executes a complete health assessment including OAuth connectivity,
* API endpoint availability, circuit breaker status, and rate limiting health.
* Used for monitoring and diagnostics.
*
* @return array Health check results array with keys:
* - overall_status: 'healthy' or 'unhealthy' based on all checks
* - checks: Detailed results for each health check component
* - oauth: OAuth connection status and token validity
* - api_connectivity: API endpoint accessibility test
* - circuit_breaker: Circuit breaker operational status
* - rate_limits: Rate limiting usage and threshold status
* - timestamp: Health check execution timestamp
* @since 3.0.0
* @author Descomplicar®
*/
public function health_check()
{
$results = [
'overall_status' => 'healthy',
'checks' => [],
'timestamp' => date('Y-m-d H:i:s')
];
// OAuth connectivity check
try {
$oauth_status = $this->oauth->is_connected();
$results['checks']['oauth'] = [
'status' => $oauth_status ? 'pass' : 'fail',
'message' => $oauth_status ? 'OAuth connected' : 'OAuth not connected'
];
if (!$oauth_status) {
$results['overall_status'] = 'unhealthy';
}
} catch (Exception $e) {
$results['checks']['oauth'] = [
'status' => 'fail',
'message' => 'OAuth check failed: ' . $e->getMessage()
];
$results['overall_status'] = 'unhealthy';
}
// API connectivity check
try {
$companies = $this->list_companies();
$results['checks']['api_connectivity'] = [
'status' => 'pass',
'message' => 'API connectivity verified'
];
} catch (Exception $e) {
$results['checks']['api_connectivity'] = [
'status' => 'fail',
'message' => 'API connectivity failed: ' . $e->getMessage()
];
$results['overall_status'] = 'unhealthy';
}
// Circuit breaker status
$results['checks']['circuit_breaker'] = [
'status' => $this->is_circuit_open() ? 'warning' : 'pass',
'message' => $this->is_circuit_open()
? 'Circuit breaker is open due to failures'
: 'Circuit breaker is closed',
'failures' => $this->circuit_breaker_failures,
'threshold' => $this->circuit_breaker_threshold
];
// Rate limiting status
$minute_usage_pct = ($this->request_count_minute / $this->requests_per_minute) * 100;
$hour_usage_pct = ($this->request_count_hour / $this->requests_per_hour) * 100;
$results['checks']['rate_limits'] = [
'status' => ($minute_usage_pct > 90 || $hour_usage_pct > 90) ? 'warning' : 'pass',
'message' => sprintf('Rate limit usage: %.1f%% (minute), %.1f%% (hour)',
$minute_usage_pct, $hour_usage_pct),
'minute_usage' => $minute_usage_pct,
'hour_usage' => $hour_usage_pct
];
return $results;
}
}