Files
WooBC/woo-business-central/includes/class-wbc-api-client.php
Malin b64397dcd3 feat: WooCommerce Business Central integration plugin
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>
2026-02-17 09:59:53 +01:00

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