Files
desk-moloni/modules/desk_moloni/libraries/MoloniApiClient.php
Emanuel Almeida c19f6fd9ee 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.
2025-09-11 17:38:45 +01:00

1471 lines
47 KiB
PHP

<?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;
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()
{
$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
*
* @param array $config Configuration options
* @return bool Configuration success
*/
public function configure($config = [])
{
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 authorization code for access token (OAuth callback)
*
* @param string $code Authorization code
* @param string $redirect_uri Redirect URI
* @return array Token response
*/
public function exchange_token($code, $redirect_uri)
{
return $this->oauth->handle_callback($code);
}
// =====================================================
// Customer Management
// =====================================================
/**
* List customers with pagination
*
* @param int $company_id Company ID
* @param array $options Query options (qty, offset, search)
* @return array Customer list
*/
public function list_customers($company_id, $options = [])
{
$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($customer_id, $company_id)
{
return $this->make_request('customers/getOne', [
'customer_id' => $customer_id,
'company_id' => $company_id
]);
}
/**
* Create new customer
*
* @param array $customer_data Customer data
* @return array Created customer
*/
public function create_customer($customer_data)
{
$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($customer_id, $customer_data)
{
$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($company_id, $search, $options = [])
{
$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 invoice
*
* @param array $invoice_data Invoice data
* @return array Created invoice
*/
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
// =====================================================
/**
* Make API request with comprehensive error handling
*
* @param string $endpoint API endpoint
* @param array $params Request parameters
* @param string $method HTTP method
* @return array Response data
* @throws Exception On request failure
*/
public function make_request($endpoint, $params = [], $method = 'POST')
{
// 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);
}
}
}
/**
* Get API client status and statistics
*
* @return array Status information
*/
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 health check
*
* @return array Health check results
*/
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;
}
}