Native PHP plugin (no Composer) that syncs: - Product stock and pricing from BC to WooCommerce (scheduled cron) - Orders from WooCommerce to BC (on payment received) - Auto-creates customers in BC from WooCommerce billing data Product matching: WooCommerce SKU → BC Item Number, fallback to GTIN (EAN). OAuth2 client credentials auth with encrypted secret storage. Admin settings page with connection test, manual sync, and log viewer. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
382 lines
11 KiB
PHP
382 lines
11 KiB
PHP
<?php
|
|
/**
|
|
* Business Central API Client
|
|
*
|
|
* @package WooBusinessCentral
|
|
*/
|
|
|
|
// Prevent direct access
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Class WBC_API_Client
|
|
*
|
|
* HTTP client for Business Central API using native cURL.
|
|
*/
|
|
class WBC_API_Client {
|
|
|
|
/**
|
|
* Base API URL template
|
|
*/
|
|
const BASE_URL = 'https://api.businesscentral.dynamics.com/v2.0/%s/api/v2.0';
|
|
|
|
/**
|
|
* Maximum retry attempts
|
|
*/
|
|
const MAX_RETRIES = 3;
|
|
|
|
/**
|
|
* Retry delay in seconds
|
|
*/
|
|
const RETRY_DELAY = 2;
|
|
|
|
/**
|
|
* Request timeout in seconds
|
|
*/
|
|
const TIMEOUT = 30;
|
|
|
|
/**
|
|
* Get the base API URL
|
|
*
|
|
* @return string
|
|
*/
|
|
private static function get_base_url() {
|
|
$environment = WBC_OAuth::get_environment();
|
|
return sprintf( self::BASE_URL, $environment );
|
|
}
|
|
|
|
/**
|
|
* Get company ID for URL
|
|
*
|
|
* @return string
|
|
*/
|
|
private static function get_company_id() {
|
|
return WBC_OAuth::get_company_id();
|
|
}
|
|
|
|
/**
|
|
* Build full endpoint URL
|
|
*
|
|
* @param string $endpoint The API endpoint.
|
|
* @param bool $include_company Whether to include company ID in URL.
|
|
* @return string Full URL.
|
|
*/
|
|
private static function build_url( $endpoint, $include_company = true ) {
|
|
$base_url = self::get_base_url();
|
|
|
|
if ( $include_company ) {
|
|
$company_id = self::get_company_id();
|
|
return $base_url . '/companies(' . $company_id . ')' . $endpoint;
|
|
}
|
|
|
|
return $base_url . $endpoint;
|
|
}
|
|
|
|
/**
|
|
* Make an HTTP request to the Business Central API
|
|
*
|
|
* @param string $method HTTP method (GET, POST, PATCH, DELETE).
|
|
* @param string $endpoint API endpoint.
|
|
* @param array $params Query parameters (for GET) or body data (for POST/PATCH).
|
|
* @param bool $include_company Whether to include company ID in URL.
|
|
* @param int $retry_count Current retry attempt.
|
|
* @return array|WP_Error Response data or error.
|
|
*/
|
|
private static function request( $method, $endpoint, $params = array(), $include_company = true, $retry_count = 0 ) {
|
|
// Get access token
|
|
$token = WBC_OAuth::get_access_token();
|
|
|
|
if ( is_wp_error( $token ) ) {
|
|
return $token;
|
|
}
|
|
|
|
// Build URL
|
|
$url = self::build_url( $endpoint, $include_company );
|
|
|
|
// Add query parameters for GET requests
|
|
if ( $method === 'GET' && ! empty( $params ) ) {
|
|
$url .= '?' . http_build_query( $params );
|
|
}
|
|
|
|
WBC_Logger::debug( 'API', "Making $method request", array(
|
|
'url' => $url,
|
|
'method' => $method,
|
|
) );
|
|
|
|
// Initialize cURL
|
|
$ch = curl_init();
|
|
|
|
// Set base options
|
|
$curl_options = array(
|
|
CURLOPT_URL => $url,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_TIMEOUT => self::TIMEOUT,
|
|
CURLOPT_HTTPHEADER => array(
|
|
'Authorization: Bearer ' . $token,
|
|
'Content-Type: application/json',
|
|
'Accept: application/json',
|
|
),
|
|
);
|
|
|
|
// Set method-specific options
|
|
switch ( $method ) {
|
|
case 'POST':
|
|
$curl_options[ CURLOPT_POST ] = true;
|
|
if ( ! empty( $params ) ) {
|
|
$curl_options[ CURLOPT_POSTFIELDS ] = wp_json_encode( $params );
|
|
}
|
|
break;
|
|
|
|
case 'PATCH':
|
|
$curl_options[ CURLOPT_CUSTOMREQUEST ] = 'PATCH';
|
|
if ( ! empty( $params ) ) {
|
|
$curl_options[ CURLOPT_POSTFIELDS ] = wp_json_encode( $params );
|
|
}
|
|
// Add If-Match header for PATCH requests
|
|
$curl_options[ CURLOPT_HTTPHEADER ][] = 'If-Match: *';
|
|
break;
|
|
|
|
case 'DELETE':
|
|
$curl_options[ CURLOPT_CUSTOMREQUEST ] = 'DELETE';
|
|
break;
|
|
|
|
default: // GET
|
|
$curl_options[ CURLOPT_HTTPGET ] = true;
|
|
break;
|
|
}
|
|
|
|
curl_setopt_array( $ch, $curl_options );
|
|
|
|
// Execute request
|
|
$response = curl_exec( $ch );
|
|
$http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE );
|
|
$curl_error = curl_error( $ch );
|
|
|
|
curl_close( $ch );
|
|
|
|
// Handle cURL errors
|
|
if ( $response === false ) {
|
|
WBC_Logger::error( 'API', 'cURL error', array(
|
|
'error' => $curl_error,
|
|
'url' => $url,
|
|
) );
|
|
return new WP_Error( 'wbc_curl_error', $curl_error );
|
|
}
|
|
|
|
// Parse response
|
|
$data = json_decode( $response, true );
|
|
|
|
// Handle 401 (unauthorized) - token might be expired
|
|
if ( $http_code === 401 && $retry_count < self::MAX_RETRIES ) {
|
|
WBC_Logger::warning( 'API', 'Received 401, refreshing token and retrying', array(
|
|
'retry_count' => $retry_count + 1,
|
|
) );
|
|
|
|
// Clear token cache and retry
|
|
WBC_OAuth::clear_token_cache();
|
|
sleep( self::RETRY_DELAY );
|
|
|
|
return self::request( $method, $endpoint, $params, $include_company, $retry_count + 1 );
|
|
}
|
|
|
|
// Handle rate limiting (429)
|
|
if ( $http_code === 429 && $retry_count < self::MAX_RETRIES ) {
|
|
$retry_after = isset( $data['error']['retryAfterSeconds'] ) ? (int) $data['error']['retryAfterSeconds'] : self::RETRY_DELAY * ( $retry_count + 1 );
|
|
|
|
WBC_Logger::warning( 'API', 'Rate limited, retrying after delay', array(
|
|
'retry_after' => $retry_after,
|
|
'retry_count' => $retry_count + 1,
|
|
) );
|
|
|
|
sleep( $retry_after );
|
|
|
|
return self::request( $method, $endpoint, $params, $include_company, $retry_count + 1 );
|
|
}
|
|
|
|
// Handle server errors (5xx)
|
|
if ( $http_code >= 500 && $retry_count < self::MAX_RETRIES ) {
|
|
WBC_Logger::warning( 'API', 'Server error, retrying', array(
|
|
'http_code' => $http_code,
|
|
'retry_count' => $retry_count + 1,
|
|
) );
|
|
|
|
sleep( self::RETRY_DELAY * ( $retry_count + 1 ) );
|
|
|
|
return self::request( $method, $endpoint, $params, $include_company, $retry_count + 1 );
|
|
}
|
|
|
|
// Handle other errors
|
|
if ( $http_code >= 400 ) {
|
|
$error_message = isset( $data['error']['message'] ) ? $data['error']['message'] : __( 'API request failed', 'woo-business-central' );
|
|
|
|
WBC_Logger::error( 'API', 'Request failed', array(
|
|
'http_code' => $http_code,
|
|
'error' => $data['error'] ?? null,
|
|
'url' => $url,
|
|
) );
|
|
|
|
return new WP_Error( 'wbc_api_error', $error_message, array(
|
|
'status' => $http_code,
|
|
'error' => $data['error'] ?? null,
|
|
) );
|
|
}
|
|
|
|
WBC_Logger::debug( 'API', 'Request successful', array(
|
|
'http_code' => $http_code,
|
|
'url' => $url,
|
|
) );
|
|
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* GET request
|
|
*
|
|
* @param string $endpoint API endpoint.
|
|
* @param array $params Query parameters.
|
|
* @param bool $include_company Whether to include company ID.
|
|
* @return array|WP_Error Response data or error.
|
|
*/
|
|
public static function get( $endpoint, $params = array(), $include_company = true ) {
|
|
return self::request( 'GET', $endpoint, $params, $include_company );
|
|
}
|
|
|
|
/**
|
|
* POST request
|
|
*
|
|
* @param string $endpoint API endpoint.
|
|
* @param array $data Request body data.
|
|
* @param bool $include_company Whether to include company ID.
|
|
* @return array|WP_Error Response data or error.
|
|
*/
|
|
public static function post( $endpoint, $data = array(), $include_company = true ) {
|
|
return self::request( 'POST', $endpoint, $data, $include_company );
|
|
}
|
|
|
|
/**
|
|
* PATCH request
|
|
*
|
|
* @param string $endpoint API endpoint.
|
|
* @param array $data Request body data.
|
|
* @param bool $include_company Whether to include company ID.
|
|
* @return array|WP_Error Response data or error.
|
|
*/
|
|
public static function patch( $endpoint, $data = array(), $include_company = true ) {
|
|
return self::request( 'PATCH', $endpoint, $data, $include_company );
|
|
}
|
|
|
|
/**
|
|
* DELETE request
|
|
*
|
|
* @param string $endpoint API endpoint.
|
|
* @param bool $include_company Whether to include company ID.
|
|
* @return array|WP_Error Response data or error.
|
|
*/
|
|
public static function delete( $endpoint, $include_company = true ) {
|
|
return self::request( 'DELETE', $endpoint, array(), $include_company );
|
|
}
|
|
|
|
/**
|
|
* Get all companies
|
|
*
|
|
* @return array|WP_Error Array of companies or error.
|
|
*/
|
|
public static function get_companies() {
|
|
return self::get( '/companies', array(), false );
|
|
}
|
|
|
|
/**
|
|
* Get items with pagination support
|
|
*
|
|
* @param int $top Number of items to fetch.
|
|
* @param int $skip Number of items to skip.
|
|
* @param string $select Fields to select.
|
|
* @return array|WP_Error Response data or error.
|
|
*/
|
|
public static function get_items( $top = 100, $skip = 0, $select = '' ) {
|
|
$params = array(
|
|
'$top' => $top,
|
|
'$skip' => $skip,
|
|
);
|
|
|
|
if ( ! empty( $select ) ) {
|
|
$params['$select'] = $select;
|
|
}
|
|
|
|
return self::get( '/items', $params );
|
|
}
|
|
|
|
/**
|
|
* Get customers with optional filter
|
|
*
|
|
* @param string $filter OData filter expression.
|
|
* @return array|WP_Error Response data or error.
|
|
*/
|
|
public static function get_customers( $filter = '' ) {
|
|
$params = array();
|
|
|
|
if ( ! empty( $filter ) ) {
|
|
$params['$filter'] = $filter;
|
|
}
|
|
|
|
return self::get( '/customers', $params );
|
|
}
|
|
|
|
/**
|
|
* Create a customer
|
|
*
|
|
* @param array $data Customer data.
|
|
* @return array|WP_Error Response data or error.
|
|
*/
|
|
public static function create_customer( $data ) {
|
|
return self::post( '/customers', $data );
|
|
}
|
|
|
|
/**
|
|
* Create a sales order
|
|
*
|
|
* @param array $data Sales order data.
|
|
* @return array|WP_Error Response data or error.
|
|
*/
|
|
public static function create_sales_order( $data ) {
|
|
return self::post( '/salesOrders', $data );
|
|
}
|
|
|
|
/**
|
|
* Create a sales order line
|
|
*
|
|
* @param string $order_id Sales order ID.
|
|
* @param array $data Sales order line data.
|
|
* @return array|WP_Error Response data or error.
|
|
*/
|
|
public static function create_sales_order_line( $order_id, $data ) {
|
|
return self::post( '/salesOrders(' . $order_id . ')/salesOrderLines', $data );
|
|
}
|
|
|
|
/**
|
|
* Escape a string value for use in OData filter expressions
|
|
*
|
|
* @param string $value The value to escape.
|
|
* @return string Escaped value safe for OData filters.
|
|
*/
|
|
public static function escape_odata_string( $value ) {
|
|
return str_replace( "'", "''", $value );
|
|
}
|
|
|
|
/**
|
|
* Get a specific item by number
|
|
*
|
|
* @param string $item_number Item number.
|
|
* @return array|WP_Error Response data or error.
|
|
*/
|
|
public static function get_item_by_number( $item_number ) {
|
|
$params = array(
|
|
'$filter' => "number eq '" . self::escape_odata_string( $item_number ) . "'",
|
|
);
|
|
|
|
return self::get( '/items', $params );
|
|
}
|
|
}
|