Files
breez-woocommerce/includes/class-breez-api-client.php
2025-05-27 17:51:50 +02:00

582 lines
21 KiB
PHP

<?php
/**
* Breez API Client
*
* @package Breez_WooCommerce
*/
if (!defined('ABSPATH')) {
exit; // Exit if accessed directly
}
/**
* Breez API Client Class
*
* Handles communication with the Breez API.
*/
class Breez_API_Client {
/**
* API URL
*
* @var string
*/
private $api_url;
/**
* API Key
*
* @var string
*/
private $api_key;
/**
* Logger instance
*
* @var Breez_Logger
*/
private $logger;
/**
* Constructor
*
* @param string $api_url API URL
* @param string $api_key API Key
*/
public function __construct($api_url, $api_key) {
$this->api_url = rtrim($api_url, '/');
$this->api_key = $api_key;
$this->logger = new Breez_Logger('yes' === get_option('woocommerce_breez_debug', 'no'));
if (!$this->api_url || !$this->api_key) {
$this->logger->log('API client initialized without credentials', 'error');
throw new Exception('API credentials are required');
}
// Validate API URL format
if (!filter_var($this->api_url, FILTER_VALIDATE_URL)) {
$this->logger->log('Invalid API URL format: ' . $this->api_url, 'error');
throw new Exception('API URL must be a valid URL');
}
$this->logger->log('API client initialized', 'debug', array(
'api_url' => $this->api_url,
'api_key_set' => !empty($this->api_key)
));
}
/**
* Check API connectivity
*
* @return bool True if API is accessible, false otherwise
*/
public function check_health() {
// In test mode, bypass the health check
if ('yes' === get_option('woocommerce_breez_testmode', 'no')) {
$this->logger->log('API health check bypassed in test mode', 'debug');
return true;
}
try {
$this->logger->log('Starting API health check', 'debug');
$response = $this->request('GET', '/health');
$this->logger->log('API health check response: ' . json_encode($response), 'debug');
return isset($response['status']) && $response['status'] === 'ok';
} catch (Exception $e) {
$this->logger->log('API health check failed: ' . $e->getMessage(), 'error');
$this->logger->log('Stack trace: ' . $e->getTraceAsString(), 'debug');
return false;
}
}
/**
* Generate a payment request
*
* @param int $amount Amount in satoshis
* @param string $method Payment method (LIGHTNING, BITCOIN_ADDRESS, LIQUID_ADDRESS)
* @param string $description Payment description
* @return array|false Payment details on success, false on failure
*/
public function receive_payment($amount, $method = 'LIGHTNING', $description = '') {
$data = array(
'amount' => $amount,
'method' => $method,
'description' => $description
);
return $this->request('POST', '/receive_payment', $data);
}
/**
* Check payment status using API endpoint
*
* @param string $invoice_id Invoice ID or payment identifier
* @return array Payment status response
*/
public function check_payment_status($invoice_id) {
try {
$url = $this->api_url . "/check_payment_status/{$invoice_id}?source=woocommerce";
$response = wp_remote_get($url, array(
'headers' => array(
'Content-Type' => 'application/json',
'x-api-key' => $this->api_key
),
'timeout' => 30
));
if (is_wp_error($response)) {
throw new Exception($response->get_error_message());
}
$body = wp_remote_retrieve_body($response);
$data = json_decode($body, true);
if (!$data) {
throw new Exception('Invalid response from API');
}
// Log the raw API response for debugging
$this->logger->log('Payment status check response', 'debug', array(
'invoice_id' => $invoice_id,
'response' => $data
));
// If the payment is not found, return pending with full details
if ($data['status'] === 'UNKNOWN') {
return array(
'status' => 'PENDING',
'destination' => $invoice_id,
'sdk_status' => 'UNKNOWN',
'amount_sat' => 0,
'fees_sat' => 0,
'payment_type' => 'UNKNOWN',
'timestamp' => time(),
'payment_hash' => null,
'tx_id' => null,
'swap_id' => null,
'source' => 'woocommerce',
'error' => null,
'description' => null,
'metadata' => array(),
'exchange_rate' => null,
'fiat_amount' => null,
'fiat_currency' => null
);
}
// Extract payment details from response
$payment_details = $data['payment_details'] ?? array();
// Return comprehensive payment status information
return array(
'status' => $data['status'],
'sdk_status' => $payment_details['sdk_status'] ?? $data['status'],
'destination' => $payment_details['destination'] ?? $invoice_id,
'amount_sat' => $payment_details['amount_sat'] ?? 0,
'fees_sat' => $payment_details['fees_sat'] ?? 0,
'payment_type' => $payment_details['payment_type'] ?? 'UNKNOWN',
'timestamp' => $payment_details['timestamp'] ?? time(),
'payment_hash' => $payment_details['payment_hash'] ?? null,
'tx_id' => $payment_details['tx_id'] ?? null,
'swap_id' => $payment_details['swap_id'] ?? null,
'source' => $payment_details['source'] ?? 'woocommerce',
'error' => $payment_details['error'] ?? null,
'description' => $payment_details['description'] ?? null,
'metadata' => $payment_details['metadata'] ?? array(),
'exchange_rate' => $payment_details['exchange_rate'] ?? null,
'fiat_amount' => $payment_details['fiat_amount'] ?? null,
'fiat_currency' => $payment_details['fiat_currency'] ?? null
);
} catch (Exception $e) {
$this->logger->log('Payment status check error: ' . $e->getMessage(), 'error');
// Return pending status with error information
return array(
'status' => 'PENDING',
'sdk_status' => 'UNKNOWN',
'destination' => $invoice_id,
'amount_sat' => 0,
'fees_sat' => 0,
'payment_type' => 'UNKNOWN',
'timestamp' => time(),
'payment_hash' => null,
'tx_id' => null,
'swap_id' => null,
'source' => 'woocommerce',
'error' => $e->getMessage(),
'description' => null,
'metadata' => array(),
'exchange_rate' => null,
'fiat_amount' => null,
'fiat_currency' => null
);
}
}
/**
* Map SDK payment states to WooCommerce payment states
*
* @param string $sdk_status The status from the SDK
* @return string WooCommerce payment status
*/
private function map_payment_status($sdk_status) {
switch ($sdk_status) {
case 'SUCCEEDED':
case 'WAITING_CONFIRMATION': // Consider payment complete when claim tx is broadcast
return 'completed';
case 'PENDING': // Lockup transaction broadcast
return 'pending';
case 'WAITING_FEE_ACCEPTANCE': // Needs fee approval
return 'pending';
case 'FAILED': // Swap failed (expired or lockup tx failed)
return 'failed';
case 'UNKNOWN': // Payment not found or error
default:
return 'pending';
}
}
/**
* Get human-readable status description
*
* @param string $sdk_status The SDK status
* @return string Human-readable description
*/
private function get_status_description($sdk_status) {
switch ($sdk_status) {
case 'SUCCEEDED':
return __('Payment confirmed and completed.', 'breez-woocommerce');
case 'WAITING_CONFIRMATION':
return __('Payment received and being confirmed.', 'breez-woocommerce');
case 'PENDING':
return __('Payment initiated, waiting for completion.', 'breez-woocommerce');
case 'WAITING_FEE_ACCEPTANCE':
return __('Waiting for fee approval.', 'breez-woocommerce');
case 'FAILED':
return __('Payment failed or expired.', 'breez-woocommerce');
case 'UNKNOWN':
default:
return __('Payment status unknown.', 'breez-woocommerce');
}
}
/**
* Register a webhook URL
*
* @param string $webhook_url Webhook URL
* @return array|false Response on success, false on failure
*/
/**
* Check if webhooks are supported by the API
*
* @return bool True if webhooks are supported
*/
public function check_webhook_support() {
try {
// Try to get API endpoints/capabilities
$response = $this->request('GET', '/capabilities', array(), 1);
// If capabilities endpoint exists, check webhook support
if ($response && isset($response['features'])) {
return in_array('webhooks', $response['features']);
}
// If capabilities endpoint doesn't exist, try webhook endpoint directly
$this->request('GET', '/register_webhook', array(), 1);
return true;
} catch (Exception $e) {
// If we get a 404, webhooks aren't supported
if (strpos($e->getMessage(), '404') !== false) {
$this->logger->log('Webhooks not supported by API', 'debug');
return false;
}
// For other errors, assume webhooks might be supported
$this->logger->log('Webhook support check failed: ' . $e->getMessage(), 'warning');
return true;
}
}
/**
* Register a webhook URL
*
* @param string $webhook_url Webhook URL
* @return bool True if registration successful
* @throws Exception if registration fails
*/
public function register_webhook($webhook_url) {
if (!$webhook_url) {
throw new Exception('Webhook URL is required');
}
$this->logger->log('Registering webhook', 'debug', array(
'url' => $webhook_url
));
try {
$data = array(
'webhook_url' => $webhook_url
);
$response = $this->request('POST', '/register_webhook', $data, 1);
if ($response && isset($response['success']) && $response['success']) {
$this->logger->log('Webhook registration successful', 'info');
return true;
}
$this->logger->log('Webhook registration failed - invalid response', 'error');
return false;
} catch (Exception $e) {
// If we get a 404, webhooks aren't supported
if (strpos($e->getMessage(), '404') !== false) {
$this->logger->log('Webhook registration failed - endpoint not found', 'debug');
return false;
}
// Re-throw other errors
throw $e;
}
}
/**
* Get payment by ID
*
* @param string $payment_hash Payment hash
* @return array|false Payment details on success, false on failure
*/
public function get_payment($payment_hash) {
return $this->request('GET', "/payment/{$payment_hash}");
}
/**
* List all payments
*
* @param array $params Optional query parameters
* @return array|false List of payments on success, false on failure
*/
public function list_payments($params = array()) {
$query_string = '';
if (!empty($params)) {
$query_string = '?' . http_build_query($params);
}
return $this->request('GET', "/list_payments{$query_string}");
}
/**
* Make API request
*
* @param string $method HTTP method
* @param string $endpoint API endpoint
* @param array $data Request data
* @param int $max_retries Maximum number of retries
* @return array|false Response data on success, false on failure
*/
public function request($method, $endpoint, $data = array(), $max_retries = 2) {
// Normalize the endpoint to ensure it starts with a slash
$endpoint = ltrim($endpoint, '/');
// Full API URL
$url = $this->api_url . '/' . $endpoint;
$this->logger->log('Making API request', 'debug', array(
'method' => $method,
'url' => $url
));
$args = array(
'method' => $method,
'timeout' => 30,
'headers' => array(
'Content-Type' => 'application/json',
'x-api-key' => $this->api_key,
'Accept' => 'application/json'
)
);
if (!empty($data) && $method !== 'GET') {
$args['body'] = json_encode($data);
} elseif (!empty($data) && $method === 'GET') {
// For GET requests, append query string
$url = add_query_arg($data, $url);
}
$retries = 0;
$response_data = false;
while ($retries <= $max_retries) {
$response = wp_remote_request($url, $args);
if (is_wp_error($response)) {
$error_message = $response->get_error_message();
$this->logger->log('API request error', 'error', array(
'message' => $error_message,
'attempt' => $retries + 1
));
$retries++;
if ($retries <= $max_retries) {
$this->logger->log('Retrying request', 'debug', array(
'attempt' => $retries
));
// Exponential backoff
sleep(pow(2, $retries - 1));
continue;
}
throw new Exception('API request failed: ' . $error_message);
}
$http_code = wp_remote_retrieve_response_code($response);
$body = wp_remote_retrieve_body($response);
$this->logger->log('API response received', 'debug', array(
'http_code' => $http_code,
'body_length' => strlen($body)
));
if ($http_code >= 200 && $http_code < 300) {
if (!empty($body)) {
$response_data = json_decode($body, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$this->logger->log('JSON decode error', 'error', array(
'error' => json_last_error_msg(),
'body_excerpt' => substr($body, 0, 100) . (strlen($body) > 100 ? '...' : '')
));
// If we can't decode JSON, try to return the raw body
$response_data = array(
'raw_response' => $body
);
}
} else {
// Empty but successful response
$response_data = array(
'success' => true
);
}
// Success - break out of retry loop
break;
} else {
// Handle error
$message = $body;
if (!empty($body)) {
$decoded = json_decode($body, true);
if (is_array($decoded) && isset($decoded['message'])) {
$message = $decoded['message'];
} elseif (is_array($decoded) && isset($decoded['error'])) {
$message = $decoded['error'];
}
}
// 404 might be normal in some cases (checking if endpoint exists)
$error_level = ($http_code == 404) ? 'debug' : 'error';
$this->logger->log('API error response', $error_level, array(
'http_code' => $http_code,
'message' => $message,
'endpoint' => $endpoint,
'attempt' => $retries + 1
));
if ($http_code == 404 || ($http_code >= 500 && $retries < $max_retries)) {
$retries++;
if ($retries <= $max_retries) {
$this->logger->log('Retrying request', 'debug', array(
'attempt' => $retries
));
// Exponential backoff
sleep(pow(2, $retries - 1));
continue;
}
}
throw new Exception("API error ($http_code): $message");
}
}
return $response_data;
}
/**
* Create a new payment
*
* @param array $payment_data Payment data including amount, currency, description, etc.
* @return array Payment details
* @throws Exception if payment creation fails
*/
public function create_payment($payment_data) {
$this->logger->log('Creating payment with data: ' . print_r($payment_data, true), 'debug');
try {
// Prepare the API request data according to ReceivePaymentBody schema
$api_data = array(
'amount' => $payment_data['amount_sat'], // Amount must be in satoshis
'method' => strtoupper($payment_data['payment_method']), // LIGHTNING or BITCOIN_ADDRESS
'description' => $payment_data['description'] ?? '',
);
// Make the API request to create payment
$response = $this->request('POST', '/receive_payment', $api_data);
if (!$response || !isset($response['destination'])) {
throw new Exception('Invalid API response: Missing payment destination');
}
// Format the response to match what the gateway expects
return array(
'id' => $response['destination'], // Use destination as ID
'invoice_id' => $response['destination'],
'payment_url' => $response['destination'], // For QR code generation
'payment_request' => $response['destination'],
'status' => 'PENDING',
'amount' => $payment_data['amount'],
'amount_sat' => $payment_data['amount_sat'],
'currency' => $payment_data['currency'],
'created_at' => time(),
'expires_at' => time() + ($payment_data['expires_in'] ?? 1800),
'fees_sat' => $response['fees_sat'] ?? 0
);
} catch (Exception $e) {
$this->logger->log('Payment creation failed: ' . $e->getMessage(), 'error');
throw new Exception('Failed to create payment: ' . $e->getMessage());
}
}
/**
* Convert fiat amount to satoshis
*
* @param float $amount Amount in fiat
* @param string $currency Currency code
* @return int Amount in satoshis
* @throws Exception if conversion fails
*/
private function convert_to_sats($amount, $currency) {
try {
// Try to get the exchange rate from the API
$response = $this->request('GET', '/exchange_rates/' . strtoupper($currency));
if (!$response || !isset($response['rate'])) {
throw new Exception('Invalid exchange rate response');
}
// Calculate satoshis
$btc_amount = $amount / $response['rate'];
return (int)($btc_amount * 100000000); // Convert BTC to sats
} catch (Exception $e) {
$this->logger->log('Currency conversion failed: ' . $e->getMessage(), 'error');
throw new Exception('Failed to convert currency: ' . $e->getMessage());
}
}
}