- 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.
1471 lines
47 KiB
PHP
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;
|
|
}
|
|
} |