diff --git a/README.md b/README.md index e9ed3d6..f079329 100755 --- a/README.md +++ b/README.md @@ -81,7 +81,11 @@ Order callbacks in the SpectroCoin plugin allow your WordPress site to automatic ## Changelog -### 1.0.0 MAJOR (27/02/2025): +### 1.1.0 (16/07/2025): + +_Added_ support for new JSON and old URL-encoded form data callbacks format. New callbacks will be automatically enabled with new SpectroCoin merchant projects created. With old projects, old callback format will be used. In the future versions old callback format will be removed. + +### 1.0.0 (27/02/2025): This major update introduces several improvements, including enhanced security, updated coding standards, and a streamlined integration process. **Important:** Users must generate new API credentials (Client ID and Client Secret) in their SpectroCoin account settings to continue using the plugin. The previous private key and merchant ID functionality have been deprecated. diff --git a/commerce_spectrocoin.info.yml b/commerce_spectrocoin.info.yml index c7a1253..58b9989 100755 --- a/commerce_spectrocoin.info.yml +++ b/commerce_spectrocoin.info.yml @@ -7,6 +7,6 @@ dependencies: - commerce:commerce_checkout - commerce:commerce_payment -version: "1.0.0" +version: "1.1.0" project: "commerce_spectrocoin" datestamp: 1684701392 diff --git a/composer.json b/composer.json index 6d70602..bb196c2 100755 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "spectrocoin/drupal-merchant", "description": "SpectroCoin cryptocurrency payment gateway for Drupal", "type": "drupal-module", - "version": "1.0.0", + "version": "1.1.0", "keywords": [ "bitcoin", "btc", diff --git a/src/Controller/SpectroCoinController.php b/src/Controller/SpectroCoinController.php index 4e823aa..aad58e2 100755 --- a/src/Controller/SpectroCoinController.php +++ b/src/Controller/SpectroCoinController.php @@ -1,8 +1,12 @@ getMethod() !== 'POST') { \Drupal::logger('commerce_spectrocoin') @@ -31,24 +37,55 @@ public function callback() { return new Response('Invalid request method', 405); } try { - $order_callback = $this->initCallbackFromPost(); - if (!$order_callback) { - \Drupal::logger('commerce_spectrocoin') - ->error('SpectroCoin Error: No data received in callback'); - return new Response('Invalid callback data', 400); + $contentType = $_SERVER['CONTENT_TYPE'] ?? ''; + if (stripos($contentType, 'application/json') !== false) { + // 1) parse the JSON payload + $order_callback = $this->initCallbackFromJson(); + if (! $order_callback) { + throw new InvalidArgumentException('Invalid JSON callback payload'); + } + + $gateways = \Drupal::entityTypeManager() + ->getStorage('commerce_payment_gateway') + ->loadByProperties(['plugin' => 'spectrocoin']); + $gateway_entity = reset($gateways); + if (! $gateway_entity) { + throw new \RuntimeException('SpectroCoin gateway not configured.'); + } + $plugin = $gateway_entity->getPlugin(); + $cfg = $plugin->getConfiguration(); + + $sc_merchant_client = new SCMerchantClient( + $cfg['project_id'], + $cfg['client_id'], + $cfg['client_secret'] + ); + + $sc_order = $sc_merchant_client->getOrderById($order_callback->getUuid()); + if (empty($sc_order['orderId']) || empty($sc_order['status'])) { + throw new InvalidArgumentException('Malformed order data from API'); + } + $raw_status = $sc_order['status']; + $raw_order_id = $sc_order['orderId']; + } else { + // if legacy callback + $order_callback = $this->initCallbackFromPost(); + $raw_status = $order_callback->getStatus(); + $raw_order_id = $order_callback->getOrderId(); } - - $combined = $order_callback->getOrderId(); - \Drupal::logger('commerce_spectrocoin')->debug('Combined order/payment ID: ' . $combined); - - list($order_id, $payment_id) = explode('-', $combined); - + + list($order_id, $payment_id) = explode('-', $raw_order_id); + if (!$order_id || !$payment_id) { \Drupal::logger('commerce_spectrocoin') ->error('SpectroCoin Error: Invalid combined order/payment ID.'); return new Response('Invalid order/payment id', 400); } - + if (!$order_callback) { + \Drupal::logger('commerce_spectrocoin') + ->error('SpectroCoin Error: No data received in callback'); + return new Response('Invalid callback data', 400); + } $order = Order::load($order_id); if (!$order) { \Drupal::logger('commerce_spectrocoin') @@ -56,19 +93,22 @@ public function callback() { return new Response('Order not found', 404); } - $status = strtolower($order_callback->getStatus()); - - switch ($status) { - case 5: - $order->set('state', 'expired'); + $statusEnum = SpectroCoin_OrderStatusEnum::normalize($raw_status); + switch ($statusEnum) { + case SpectroCoin_OrderStatusEnum::NEW: + break; + case SpectroCoin_OrderStatusEnum::PENDING: + break; + case SpectroCoin_OrderStatusEnum::PAID: + $order->set('state', 'completed'); $order->set('cart', 0); break; - case 4: + case SpectroCoin_OrderStatusEnum::FAILED: $order->set('state', 'canceled'); $order->set('cart', 0); break; - case 3: - $order->set('state', 'completed'); + case SpectroCoin_OrderStatusEnum::EXPIRED: + $order->set('state', 'expired'); $order->set('cart', 0); break; default: @@ -78,14 +118,13 @@ public function callback() { } $order->save(); - if ($status == 3) { // completed + if ($statusEnum === SpectroCoin_OrderStatusEnum::PAID) { $payment_storage = \Drupal::entityTypeManager()->getStorage('commerce_payment'); $payment = $payment_storage->load($payment_id); if ($payment) { $payment->setState('completed'); $payment->save(); - } - else { + } else { \Drupal::logger('commerce_spectrocoin') ->error('Payment not found for payment ID: ' . $payment_id); } @@ -94,13 +133,11 @@ public function callback() { $response = new Response('*ok*', 200); $response->headers->set('Content-Type', 'text/plain'); return $response; - } - catch (InvalidArgumentException $e) { + } catch (InvalidArgumentException $e) { \Drupal::logger('commerce_spectrocoin') ->error("Error processing callback: " . $e->getMessage()); return new Response('Error processing callback', 400); - } - catch (Exception $e) { + } catch (Exception $e) { \Drupal::logger('commerce_spectrocoin') ->error("Error processing callback: " . $e->getMessage()); return new Response('Error processing callback', 500); @@ -108,7 +145,8 @@ public function callback() { } - public function success() { + public function success() + { $order_id = \Drupal::request()->query->get('order_id'); if ($order_id) { $order = Order::load((int) $order_id); @@ -121,20 +159,19 @@ public function success() { return new RedirectResponse('/'); } - public function failure() { + public function failure() + { $order_id = \Drupal::request()->query->get('order_id'); if ($order_id) { $order = Order::load((int) $order_id); if ($order) { $order->set('state', 'canceled'); $order->save(); - } - else { + } else { \Drupal::logger('commerce_spectrocoin') ->error('SpectroCoin Error: Invalid Order ID in failure callback.'); } - } - else { + } else { \Drupal::logger('commerce_spectrocoin') ->error('SpectroCoin Error: Order ID is not available in failure callback.'); } @@ -143,16 +180,38 @@ public function failure() { } /** - * Initializes the callback data from the POST request. + * Initializes the callback data from POST (form-encoded) request. + * + * Callback format processed by this method is URL-encoded form data. + * Example: merchantId=1387551&apiId=105548&userId=…&sign=… + * Content-Type: application/x-www-form-urlencoded + * These callbacks are being sent by old merchant projects. + * + * Extracts the expected fields from `$_POST`, validates the signature, + * and returns an `OldOrderCallback` instance wrapping that data. * - * @return \Drupal\commerce_spectrocoin\SCMerchantClient\data\SpectroCoin_OrderCallback|null - * The OrderCallback object if valid data was found, or NULL otherwise. + * @deprecated since v2.1.0 + * + * @return SpectroCoin_OldOrderCallback|null An `OldOrderCallback` if the POST body + * contained valid data; `null` otherwise. */ - private function initCallbackFromPost() { + private function initCallbackFromPost() + { $expected_keys = [ - 'userId', 'merchantApiId', 'merchantId', 'apiId', 'orderId', - 'payCurrency', 'payAmount', 'receiveCurrency', 'receiveAmount', - 'receivedAmount', 'description', 'orderRequestId', 'status', 'sign' + 'userId', + 'merchantApiId', + 'merchantId', + 'apiId', + 'orderId', + 'payCurrency', + 'payAmount', + 'receiveCurrency', + 'receiveAmount', + 'receivedAmount', + 'description', + 'orderRequestId', + 'status', + 'sign' ]; $callback_data = []; @@ -165,6 +224,43 @@ private function initCallbackFromPost() { \Drupal::logger('commerce_spectrocoin')->error("No data received in callback"); return null; } - return new SpectroCoin_OrderCallback($callback_data); + return new SpectroCoin_OldOrderCallback($callback_data); + } + + + /** + * Initializes the callback data from JSON request body. + * + * Reads the raw HTTP request body, decodes it as JSON, and returns + * an OrderCallback instance if the payload is valid. + * + * @return SpectroCoin_OrderCallback|null An OrderCallback if the JSON payload + * contained valid data; null if the body + * was empty. + * + * @throws \JsonException If the request body is not valid JSON. + * @throws \InvalidArgumentException If required fields are missing + * or validation fails in OrderCallback. + * + */ + private function initCallbackFromJson(): ?SpectroCoin_OrderCallback + { + $body = (string) \file_get_contents('php://input'); + if ($body === '') { + \Drupal::logger('commerce_spectrocoin')->error('Empty JSON callback payload'); + return null; + } + + $data = \json_decode($body, true, 512, JSON_THROW_ON_ERROR); + + if (!\is_array($data)) { + \Drupal::logger('commerce_spectrocoin')->error('JSON callback payload is not an object'); + return null; + } + + return new SpectroCoin_OrderCallback( + $data['id'] ?? null, + $data['merchantApiId'] ?? null + ); } } diff --git a/src/Plugin/Commerce/PaymentGateway/SpectroCoin.php b/src/Plugin/Commerce/PaymentGateway/SpectroCoin.php index 49bcd9f..0d7a90b 100755 --- a/src/Plugin/Commerce/PaymentGateway/SpectroCoin.php +++ b/src/Plugin/Commerce/PaymentGateway/SpectroCoin.php @@ -9,8 +9,6 @@ use Drupal\commerce_spectrocoin\SCMerchantClient\data\SpectroCoin_ApiError; use Drupal\commerce_spectrocoin\SCMerchantClient\SCMerchantClient; -define('API_URL', 'https://spectrocoin.com/api/public'); -define('AUTH_URL', 'https://spectrocoin.com/api/public/oauth/token'); /** * Provides the SpectroCoin payment gateway. @@ -126,7 +124,6 @@ public function createSpectroCoinInvoice(PaymentInterface $payment, array $extra 'commerce_order' => $order_id ], ['absolute' => TRUE, 'https' => TRUE])->toString(); - $locale = 'en'; $createOrderRequest = new SpectroCoin_CreateOrderRequest( $combined_order_id, $order_description, @@ -136,12 +133,9 @@ public function createSpectroCoinInvoice(PaymentInterface $payment, array $extra $pay_currency_code, $callback_url, $success_url, - $failure_url, - $locale + $failure_url ); $createOrderResponse = $client = (new SCMerchantClient( - AUTH_URL, - API_URL, $this->configuration['project_id'], $this->configuration['client_id'], $this->configuration['client_secret'] diff --git a/src/SCMerchantClient/SCMerchantClient.php b/src/SCMerchantClient/SCMerchantClient.php index c403d0c..7e155f7 100755 --- a/src/SCMerchantClient/SCMerchantClient.php +++ b/src/SCMerchantClient/SCMerchantClient.php @@ -13,11 +13,15 @@ use GuzzleHttp\RequestOptions; use Drupal\commerce_spectrocoin\SCMerchantClient\data\SpectroCoin_ApiError; -use Drupal\commerce_spectrocoin\SCMerchantClient\data\SpectroCoin_OrderCallback; +use Drupal\commerce_spectrocoin\SCMerchantClient\data\SpectroCoin_OldOrderCallback; use Drupal\commerce_spectrocoin\SCMerchantClient\messages\SpectroCoin_CreateOrderRequest; use Drupal\commerce_spectrocoin\SCMerchantClient\messages\SpectroCoin_CreateOrderResponse; use Drupal\commerce_spectrocoin\SCMerchantClient\components\SpectroCoin_Utilities; +define('API_URL', 'https://spectrocoin.com/api/public'); +define('AUTH_URL', 'https://spectrocoin.com/api/public/oauth/token'); +define('PUBLIC_CERT_LOCATION', 'https://spectrocoin.com/files/merchant.public.pem'); + class SCMerchantClient { @@ -27,7 +31,7 @@ class SCMerchantClient private $client_secret; private $auth_url; private $auth_encryption_key; - + private $access_token_data; private $public_spectrocoin_cert_location; protected $guzzle_client; @@ -41,15 +45,15 @@ class SCMerchantClient * @param $guzzle_client * @param $public_spectrocoin_cert_location */ - function __construct($auth_url, $merchant_api_url, $project_id, $client_id, $client_secret) + function __construct($project_id, $client_id, $client_secret) { - $this->auth_url = $auth_url; - $this->merchant_api_url = $merchant_api_url; + $this->auth_url = AUTH_URL; + $this->merchant_api_url = API_URL; $this->project_id = $project_id; $this->client_id = $client_id; $this->client_secret = $client_secret; $this->guzzle_client = new Client(); - $this->public_spectrocoin_cert_location = "https://spectrocoin.com/files/merchant.public.pem"; + $this->public_spectrocoin_cert_location = PUBLIC_CERT_LOCATION; $this->auth_encryption_key = $this->initializeEncryptionKey(); } @@ -66,11 +70,10 @@ function __construct($auth_url, $merchant_api_url, $project_id, $client_id, $cli public function spectrocoinCreateOrder(SpectroCoin_CreateOrderRequest $request) { $this->access_token_data = $this->spectrocoinGetAccessTokenData(); - + if (!$this->access_token_data) { return new SpectroCoin_ApiError('AuthError', 'Failed to obtain or refresh access token'); - } - else if ($this->access_token_data instanceof SpectroCoin_ApiError) { + } else if ($this->access_token_data instanceof SpectroCoin_ApiError) { return $this->access_token_data; } @@ -89,11 +92,11 @@ public function spectrocoinCreateOrder(SpectroCoin_CreateOrderRequest $request) $sanitized_payload = $this->spectrocoinSanitizeOrderPayload($payload); if (!$this->spectrocoinValidateOrderPayload($sanitized_payload)) { - return new SpectroCoin_ApiError(-1, 'Invalid order creation payload, payload: ' . json_encode($sanitized_payload)); + return new SpectroCoin_ApiError(-1, 'Invalid order creation payload, payload: ' . json_encode($sanitized_payload)); } $json_payload = json_encode($sanitized_payload); - try { + try { $response = $this->guzzle_client->request('POST', $this->merchant_api_url . '/merchants/orders/create', [ RequestOptions::HEADERS => [ 'Authorization' => 'Bearer ' . $this->access_token_data['access_token'], @@ -105,19 +108,18 @@ public function spectrocoinCreateOrder(SpectroCoin_CreateOrderRequest $request) $body = json_decode($response->getBody()->getContents(), true); return new SpectroCoin_CreateOrderResponse( - $body['preOrderId'], - $body['orderId'], - $body['validUntil'], - $body['payCurrencyCode'], - $body['payNetworkCode'], - $body['receiveCurrencyCode'], - $body['payAmount'], - $body['receiveAmount'], - $body['depositAddress'], - $body['memo'], - $body['redirectUrl'], + $body['preOrderId'], + $body['orderId'], + $body['validUntil'], + $body['payCurrencyCode'], + $body['payNetworkCode'], + $body['receiveCurrencyCode'], + $body['payAmount'], + $body['receiveAmount'], + $body['depositAddress'], + $body['memo'], + $body['redirectUrl'], ); - } catch (RequestException $e) { if ($e->getResponse() && $e->getResponse()->getStatusCode() == 403) { $this->access_token_data = $this->spectrocoinRefreshAccessToken(time()); @@ -135,24 +137,24 @@ public function spectrocoinCreateOrder(SpectroCoin_CreateOrderRequest $request) } return new SpectroCoin_ApiError('UnknownError', 'An unknown error occurred during order creation'); - } /** * Initializes the encryption key for the client. * If the key is not present in Drupal's configuration, generates a new one and stores it. */ - private function initializeEncryptionKey() { - $config = \Drupal::config('commerce_spectrocoin.settings'); - $this->auth_encryption_key = $config->get('auth_encryption_key'); - - if (empty($this->auth_encryption_key)) { - $this->auth_encryption_key = base64_encode(random_bytes(32)); - \Drupal::configFactory()->getEditable('commerce_spectrocoin.settings') - ->set('auth_encryption_key', $this->auth_encryption_key) - ->save(); - } - } - + private function initializeEncryptionKey() + { + $config = \Drupal::config('commerce_spectrocoin.settings'); + $this->auth_encryption_key = $config->get('auth_encryption_key'); + + if (empty($this->auth_encryption_key)) { + $this->auth_encryption_key = base64_encode(random_bytes(32)); + \Drupal::configFactory()->getEditable('commerce_spectrocoin.settings') + ->set('auth_encryption_key', $this->auth_encryption_key) + ->save(); + } + } + /** * Retries the order creation request with a refreshed token. * @@ -184,21 +186,21 @@ private function SpectrocoinRetryOrder($json_payload) $body['depositAddress'], $body['memo'], $body['redirectUrl'], - ); - + ); } catch (GuzzleException $e) { return new SpectroCoin_ApiError($e->getCode(), $e->getMessage()); } } /** - * Retrieves the current access token data from configuration. - * If the token is expired or not present, attempts to refresh it. - * - * @return array|null The access token data array if valid or successfully refreshed, null otherwise. - */ - private function spectrocoinGetAccessTokenData() { - $current_time = time(); + * Retrieves the current access token data from configuration. + * If the token is expired or not present, attempts to refresh it. + * + * @return array|null The access token data array if valid or successfully refreshed, null otherwise. + */ + private function spectrocoinGetAccessTokenData() + { + $current_time = time(); $encrypted_access_token_data = $this->retrieveEncryptedData(); if ($encrypted_access_token_data) { $decrypted_data = SpectroCoin_Utilities::spectrocoinDecryptAuthData($encrypted_access_token_data, $this->auth_encryption_key); @@ -207,8 +209,8 @@ private function spectrocoinGetAccessTokenData() { return $this->access_token_data; } } - return $this->spectrocoinRefreshAccessToken($current_time); - } + return $this->spectrocoinRefreshAccessToken($current_time); + } /** * Refreshes the access token by making a request to the SpectroCoin authorization server using client credentials. If successful, it updates the stored token data in WordPress transients. @@ -218,7 +220,8 @@ private function spectrocoinGetAccessTokenData() { * @return array|null Returns the new access token data if the refresh operation is successful. Returns null if the operation fails due to a network error or invalid response from the server. * @throws GuzzleException Thrown if there is an error in the HTTP request to the SpectroCoin authorization server. */ - private function spectrocoinRefreshAccessToken($current_time) { + private function spectrocoinRefreshAccessToken($current_time) + { try { $response = $this->guzzle_client->post($this->auth_url, [ 'form_params' => [ @@ -227,15 +230,15 @@ private function spectrocoinRefreshAccessToken($current_time) { 'client_secret' => $this->client_secret, ], ]); - + $data = json_decode($response->getBody(), true); if (!isset($data['access_token'], $data['expires_in'])) { return new SpectroCoin_ApiError('Invalid access token response', 'No valid response received.'); } - + $data['expires_at'] = $current_time + $data['expires_in']; $encrypted_access_token_data = SpectroCoin_Utilities::spectrocoinEncryptAuthData(json_encode($data), $this->auth_encryption_key); - + $this->storeEncryptedData($encrypted_access_token_data); $this->access_token_data = $data; @@ -252,7 +255,8 @@ private function spectrocoinRefreshAccessToken($current_time) { * @param int $currentTime The current timestamp, typically obtained using `time()`. * @return bool Returns true if the token is valid (i.e., not expired), false otherwise. */ - private function spectrocoinIsTokenValid($currentTime) { + private function spectrocoinIsTokenValid($currentTime) + { return isset($this->access_token_data['expires_at']) && $currentTime < $this->access_token_data['expires_at']; } @@ -261,7 +265,8 @@ private function spectrocoinIsTokenValid($currentTime) { * * @param string $encrypted_access_token_data */ - private function storeEncryptedData($encrypted_access_token_data) { + private function storeEncryptedData($encrypted_access_token_data) + { $session = \Drupal::request()->getSession(); $session->set('encrypted_access_token', $encrypted_access_token_data); } @@ -271,20 +276,22 @@ private function storeEncryptedData($encrypted_access_token_data) { * * @return string|null The encrypted access token data if set, null otherwise. */ - private function retrieveEncryptedData() { + private function retrieveEncryptedData() + { $session = \Drupal::request()->getSession(); return $session->get('encrypted_access_token'); } - + // --------------- VALIDATION AND SANITIZATION BEFORE REQUEST ----------------- /** - * Payload data sanitization for create order - * @param array $payload - * @return array - */ - private function spectrocoinSanitizeOrderPayload($payload) { + * Payload data sanitization for create order + * @param array $payload + * @return array + */ + private function spectrocoinSanitizeOrderPayload($payload) + { $sanitized_payload = [ 'orderId' => htmlspecialchars(trim($payload['orderId'])), // Removes any HTML tags and trims whitespace 'projectId' => htmlspecialchars(trim($payload['projectId'])), // Removes any HTML tags and trims whitespace @@ -300,12 +307,13 @@ private function spectrocoinSanitizeOrderPayload($payload) { return $sanitized_payload; } - /** - * Payload data validation for create order - * @param array $sanitized_payload - * @return bool - */ - private function spectrocoinValidateOrderPayload($sanitized_payload) { + /** + * Payload data validation for create order + * @param array $sanitized_payload + * @return bool + */ + private function spectrocoinValidateOrderPayload($sanitized_payload) + { return isset( $sanitized_payload['orderId'], $sanitized_payload['projectId'], @@ -318,25 +326,26 @@ private function spectrocoinValidateOrderPayload($sanitized_payload) { $sanitized_payload['successUrl'], $sanitized_payload['failureUrl'], ) && - !empty($sanitized_payload['orderId']) && - !empty($sanitized_payload['projectId']) && - strlen($sanitized_payload['payCurrencyCode']) === 3 && - is_numeric($sanitized_payload['payAmount']) && - is_numeric($sanitized_payload['receiveAmount']) && - strlen($sanitized_payload['receiveCurrencyCode']) === 3 && - filter_var($sanitized_payload['callbackUrl'], FILTER_VALIDATE_URL) && - filter_var($sanitized_payload['successUrl'], FILTER_VALIDATE_URL) && - filter_var($sanitized_payload['failureUrl'], FILTER_VALIDATE_URL) && - ($sanitized_payload['payAmount'] > 0 || $sanitized_payload['receiveAmount'] > 0); + !empty($sanitized_payload['orderId']) && + !empty($sanitized_payload['projectId']) && + strlen($sanitized_payload['payCurrencyCode']) === 3 && + is_numeric($sanitized_payload['payAmount']) && + is_numeric($sanitized_payload['receiveAmount']) && + strlen($sanitized_payload['receiveCurrencyCode']) === 3 && + filter_var($sanitized_payload['callbackUrl'], FILTER_VALIDATE_URL) && + filter_var($sanitized_payload['successUrl'], FILTER_VALIDATE_URL) && + filter_var($sanitized_payload['failureUrl'], FILTER_VALIDATE_URL) && + ($sanitized_payload['payAmount'] > 0 || $sanitized_payload['receiveAmount'] > 0); } - + // --------------- VALIDATION AND SANITIZATION AFTER CALLBACK ----------------- /** * @param $post_data * @return SpectroCoin_OrderCallback|null */ - public function spectrocoinProcessCallback($post_data) { + public function spectrocoinProcessCallback($post_data) + { if ($post_data != null) { $sanitized_data = $this->spectrocoinSanitizeCallback($post_data); $is_valid = $this->spectrocoinValidateCallback($sanitized_data); @@ -346,7 +355,6 @@ public function spectrocoinProcessCallback($post_data) { return $order_callback; } } - } return null; } @@ -356,7 +364,8 @@ public function spectrocoinProcessCallback($post_data) { * @param $post_data * @return array */ - public function spectrocoinSanitizeCallback($post_data) { + public function spectrocoinSanitizeCallback($post_data) + { return [ 'userId' => htmlspecialchars(trim($post_data['userId'])), 'merchantApiId' => htmlspecialchars(trim($post_data['merchantApiId'])), @@ -380,30 +389,31 @@ public function spectrocoinSanitizeCallback($post_data) { * @param $sanitized_data * @return bool */ - public function spectrocoinValidateCallback($sanitized_data) { + public function spectrocoinValidateCallback($sanitized_data) + { $is_valid = true; $failed_fields = []; if (!isset( - $sanitized_data['userId'], - $sanitized_data['merchantApiId'], - $sanitized_data['merchantId'], - $sanitized_data['apiId'], - $sanitized_data['orderId'], - $sanitized_data['payCurrencyCode'], - $sanitized_data['payAmount'], - $sanitized_data['receiveCurrencyCode'], - $sanitized_data['receiveAmount'], - $sanitized_data['receivedAmount'], - $sanitized_data['description'], - $sanitized_data['orderRequestId'], - $sanitized_data['status'], + $sanitized_data['userId'], + $sanitized_data['merchantApiId'], + $sanitized_data['merchantId'], + $sanitized_data['apiId'], + $sanitized_data['orderId'], + $sanitized_data['payCurrencyCode'], + $sanitized_data['payAmount'], + $sanitized_data['receiveCurrencyCode'], + $sanitized_data['receiveAmount'], + $sanitized_data['receivedAmount'], + $sanitized_data['description'], + $sanitized_data['orderRequestId'], + $sanitized_data['status'], $sanitized_data['sign'] )) { $is_valid = false; $failed_fields[] = 'One or more required fields are missing.'; } else { - if (empty($sanitized_data['userId'])) { + if (empty($sanitized_data['userId'])) { $is_valid = false; $failed_fields[] = 'userId is empty.'; } @@ -411,14 +421,14 @@ public function spectrocoinValidateCallback($sanitized_data) { $is_valid = false; $failed_fields[] = 'merchantApiId is empty.'; } - if (empty($sanitized_data['merchantId'])) { - $is_valid = false; - $failed_fields[] = 'merchantId is empty.'; - } - if (empty($sanitized_data['apiId'])) { - $is_valid = false; - $failed_fields[] = 'apiId is empty.'; - } + if (empty($sanitized_data['merchantId'])) { + $is_valid = false; + $failed_fields[] = 'merchantId is empty.'; + } + if (empty($sanitized_data['apiId'])) { + $is_valid = false; + $failed_fields[] = 'apiId is empty.'; + } if (strlen($sanitized_data['payCurrencyCode']) !== 3) { $is_valid = false; $failed_fields[] = 'payCurrencyCode is not 3 characters long.'; @@ -464,10 +474,10 @@ public function spectrocoinValidateCallback($sanitized_data) { /** * Order callback payload validation - * @param SpectroCoin_OrderCallback $order_callback + * @param SpectroCoin_OldOrderCallback $order_callback * @return bool */ - public function spectrocoinValidateCallbackPayload(SpectroCoin_OrderCallback $order_callback) + public function spectrocoinValidateCallbackPayload(SpectroCoin_OldOrderCallback $order_callback) { if ($order_callback != null) { @@ -484,9 +494,9 @@ public function spectrocoinValidateCallbackPayload(SpectroCoin_OrderCallback $or 'orderRequestId' => $order_callback->getOrderRequestId(), 'status' => $order_callback->getStatus(), ); - + $data = http_build_query($payload); - if ($this->spectrocoinValidateSignature($data, $order_callback->getSign()) == 1) { + if ($this->spectrocoinValidateSignature($data, $order_callback->getSign()) == 1) { return true; } else { error_log('SpectroCoin Error: Signature validation failed'); @@ -510,4 +520,38 @@ private function spectrocoinValidateSignature($data, $signature) $r = openssl_verify($data, $sig, $public_key_pem, OPENSSL_ALGO_SHA1); return $r; } -} \ No newline at end of file + + /** + * Uses unique order's UUID and access token data to request GET /merchants/orders/{$id} and retrieve the data of the order in array format. + * @param string $order_id + * @param array $access_token_data + * + * @return array|ApiError|GenericError The response array containing order details or an error object if an error occurs. + */ + public function getOrderById(string $order_id) + { + try { + $access_token_data = $this->spectrocoinGetAccessTokenData(); + $response = $this->guzzle_client->request( + 'GET', + $this->merchant_api_url . '/merchants/orders/' . $order_id, + [ + RequestOptions::HEADERS => [ + 'Authorization' => 'Bearer ' . $access_token_data['access_token'], + 'Content-Type' => 'application/json', + ], + ] + ); + + $order = json_decode($response->getBody()->getContents(), true); + + return $order; + } catch (\InvalidArgumentException $e) { + return new GenericError($e->getMessage(), $e->getCode()); + } catch (RequestException $e) { + return new ApiError($e->getMessage(), $e->getCode()); + } catch (\Exception $e) { + return new GenericError($e->getMessage(), $e->getCode()); + } + } +} diff --git a/src/SCMerchantClient/data/SpectroCoin_OldOrderCallback.php b/src/SCMerchantClient/data/SpectroCoin_OldOrderCallback.php new file mode 100755 index 0000000..755edab --- /dev/null +++ b/src/SCMerchantClient/data/SpectroCoin_OldOrderCallback.php @@ -0,0 +1,157 @@ +userId = isset($data['userId']) ? SpectroCoin_Utilities::sanitize_text_field((string)$data['userId']) : null; + $this->merchantApiId = isset($data['merchantApiId']) ? SpectroCoin_Utilities::sanitize_text_field((string)$data['merchantApiId']) : null; + $this->merchantId = isset($data['merchantId']) ? SpectroCoin_Utilities::sanitize_text_field((string)$data['merchantId']) : null; + $this->apiId = isset($data['apiId']) ? SpectroCoin_Utilities::sanitize_text_field((string)$data['apiId']) : null; + $this->orderId = isset($data['orderId']) ? SpectroCoin_Utilities::sanitize_text_field((string)$data['orderId']) : null; + $this->payCurrency = isset($data['payCurrency']) ? SpectroCoin_Utilities::sanitize_text_field((string)$data['payCurrency']) : null; + $this->payAmount = isset($data['payAmount']) ? SpectroCoin_Utilities::sanitize_text_field((string)$data['payAmount']) : null; + $this->receiveCurrency = isset($data['receiveCurrency']) ? SpectroCoin_Utilities::sanitize_text_field((string)$data['receiveCurrency']) : null; + $this->receiveAmount = isset($data['receiveAmount']) ? SpectroCoin_Utilities::sanitize_text_field((string)$data['receiveAmount']) : null; + $this->receivedAmount = isset($data['receivedAmount']) ? SpectroCoin_Utilities::sanitize_text_field((string)$data['receivedAmount']) : null; + $this->description = isset($data['description']) ? SpectroCoin_Utilities::sanitize_text_field((string)$data['description']) : null; + $this->orderRequestId = isset($data['orderRequestId']) ? SpectroCoin_Utilities::sanitize_text_field((string)$data['orderRequestId']) : null; + $this->status = isset($data['status']) ? SpectroCoin_Utilities::sanitize_text_field((string)$data['status']) : null; + $this->sign = isset($data['sign']) ? SpectroCoin_Utilities::sanitize_text_field((string)$data['sign']) : null; + + $validation_result = $this->validate(); + if (is_array($validation_result)) { + $errorMessage = 'Invalid order callback payload. Failed fields: ' . implode(', ', $validation_result); + throw new InvalidArgumentException($errorMessage); + } + + if (!$this->validatePayloadSignature()) { + throw new Exception('Invalid payload signature.'); + } + } + + /** + * Validate the input data. + * + * @return bool|array True if validation passes, otherwise an array of error messages. + */ + private function validate(): bool|array + { + $errors = []; + + if (empty($this->getUserId())) { + $errors[] = 'userId is empty'; + } + if (empty($this->getMerchantApiId())) { + $errors[] = 'merchantApiId is empty'; + } + if (empty($this->getMerchantId())) { + $errors[] = 'merchantId is empty'; + } + if (empty($this->getApiId())) { + $errors[] = 'apiId is empty'; + } + if (empty($this->getOrderId())) { + $errors[] = 'orderId is empty'; + } + if (empty($this->getStatus())){ + $errors[] = 'status is empty'; + } + if (strlen($this->getPayCurrency()) !== 3) { + $errors[] = 'payCurrency is not 3 characters long'; + } + if (!is_numeric($this->getPayAmount()) || (float)$this->getPayAmount() <= 0) { + $errors[] = 'payAmount is not a valid positive number'; + } + if (strlen($this->getReceiveCurrency()) !== 3) { + $errors[] = 'receiveCurrency is not 3 characters long'; + } + if (!is_numeric($this->getReceiveAmount()) || (float)$this->getReceiveAmount() <= 0) { + $errors[] = 'receiveAmount is not a valid positive number'; + } + if (!isset($this->receivedAmount)) { + $errors[] = 'receivedAmount is not set'; + } + if (!is_numeric($this->getOrderRequestId()) || (float)$this->getOrderRequestId() <= 0) { + $errors[] = 'orderRequestId is not a valid positive number'; + } + if (empty($this->getSign())) { + $errors[] = 'signature is empty'; + } + + return empty($errors) ? true : $errors; + } + + /** + * Validate the payload signature. + * + * @return bool True if the signature is valid, otherwise false. + */ + public function validatePayloadSignature(): bool + { + $payload = [ + 'merchantId' => $this->getMerchantId(), + 'apiId' => $this->getApiId(), + 'orderId' => $this->getOrderId(), + 'payCurrency' => $this->getPayCurrency(), + 'payAmount' => $this->getPayAmount(), + 'receiveCurrency' => $this->getReceiveCurrency(), + 'receiveAmount' => $this->getReceiveAmount(), + 'receivedAmount' => $this->getReceivedAmount(), + 'description' => $this->getDescription(), + 'orderRequestId' => $this->getOrderRequestId(), + 'status' => $this->getStatus(), + ]; + $data = http_build_query($payload); + $decoded_signature = base64_decode($this->sign); + $public_key = file_get_contents('https://spectrocoin.com/files/merchant.public.pem'); + $public_key_pem = openssl_pkey_get_public($public_key); + return openssl_verify($data, $decoded_signature, $public_key_pem, OPENSSL_ALGO_SHA1) === 1; + } + + public function getUserId() { return $this->userId; } + public function getMerchantApiId() { return $this->merchantApiId; } + public function getMerchantId() { return $this->merchantId; } + public function getApiId() { return $this->apiId; } + public function getOrderId() { return $this->orderId; } + public function getPayCurrency() { return $this->payCurrency; } + public function getPayAmount() { return SpectroCoin_Utilities::spectrocoinFormatCurrency($this->payAmount); } + public function getReceiveCurrency() { return $this->receiveCurrency; } + public function getReceiveAmount() { return SpectroCoin_Utilities::spectrocoinFormatCurrency($this->receiveAmount); } + public function getReceivedAmount() { return SpectroCoin_Utilities::spectrocoinFormatCurrency($this->receivedAmount); } + public function getDescription() { return $this->description; } + public function getOrderRequestId() { return $this->orderRequestId; } + public function getStatus() { return $this->status; } + public function getSign() { return $this->sign; } +} +?> diff --git a/src/SCMerchantClient/data/SpectroCoin_OrderCallback.php b/src/SCMerchantClient/data/SpectroCoin_OrderCallback.php index 88f5cdb..61f2a23 100755 --- a/src/SCMerchantClient/data/SpectroCoin_OrderCallback.php +++ b/src/SCMerchantClient/data/SpectroCoin_OrderCallback.php @@ -6,58 +6,21 @@ use \InvalidArgumentException; -use \Exception; - class SpectroCoin_OrderCallback { - private ?string $userId; + private ?string $uuid; private ?string $merchantApiId; - private ?string $merchantId; - private ?string $apiId; - private ?string $orderId; - private ?string $payCurrency; - private ?string $payAmount; - private ?string $receiveCurrency; - private ?string $receiveAmount; - private ?string $receivedAmount; - private ?string $description; - private ?string $orderRequestId; - private ?string $status; - private ?string $sign; - /** - * Constructor for OrderCallback. - * - * @param array $data The data for initializing the callback. - * - * @throws InvalidArgumentException If the payload is invalid. - */ - public function __construct(array $data) + public function __construct(?string $uuid, ?string $merchantApiId) { - $this->userId = isset($data['userId']) ? SpectroCoin_Utilities::sanitize_text_field((string)$data['userId']) : null; - $this->merchantApiId = isset($data['merchantApiId']) ? SpectroCoin_Utilities::sanitize_text_field((string)$data['merchantApiId']) : null; - $this->merchantId = isset($data['merchantId']) ? SpectroCoin_Utilities::sanitize_text_field((string)$data['merchantId']) : null; - $this->apiId = isset($data['apiId']) ? SpectroCoin_Utilities::sanitize_text_field((string)$data['apiId']) : null; - $this->orderId = isset($data['orderId']) ? SpectroCoin_Utilities::sanitize_text_field((string)$data['orderId']) : null; - $this->payCurrency = isset($data['payCurrency']) ? SpectroCoin_Utilities::sanitize_text_field((string)$data['payCurrency']) : null; - $this->payAmount = isset($data['payAmount']) ? SpectroCoin_Utilities::sanitize_text_field((string)$data['payAmount']) : null; - $this->receiveCurrency = isset($data['receiveCurrency']) ? SpectroCoin_Utilities::sanitize_text_field((string)$data['receiveCurrency']) : null; - $this->receiveAmount = isset($data['receiveAmount']) ? SpectroCoin_Utilities::sanitize_text_field((string)$data['receiveAmount']) : null; - $this->receivedAmount = isset($data['receivedAmount']) ? SpectroCoin_Utilities::sanitize_text_field((string)$data['receivedAmount']) : null; - $this->description = isset($data['description']) ? SpectroCoin_Utilities::sanitize_text_field((string)$data['description']) : null; - $this->orderRequestId = isset($data['orderRequestId']) ? SpectroCoin_Utilities::sanitize_text_field((string)$data['orderRequestId']) : null; - $this->status = isset($data['status']) ? SpectroCoin_Utilities::sanitize_text_field((string)$data['status']) : null; - $this->sign = isset($data['sign']) ? SpectroCoin_Utilities::sanitize_text_field((string)$data['sign']) : null; + $this->uuid = isset($uuid) ? SpectroCoin_Utilities::sanitize_text_field((string)$uuid) : null; + $this->merchantApiId = isset($merchantApiId) ? SpectroCoin_Utilities::sanitize_text_field((string)$merchantApiId) : null; $validation_result = $this->validate(); if (is_array($validation_result)) { - $errorMessage = 'Invalid order callback payload. Failed fields: ' . implode(', ', $validation_result); + $errorMessage = 'Invalid order callback. Failed fields: ' . implode(', ', $validation_result); throw new InvalidArgumentException($errorMessage); } - - if (!$this->validatePayloadSignature()) { - throw new Exception('Invalid payload signature.'); - } } /** @@ -69,89 +32,24 @@ private function validate(): bool|array { $errors = []; - if (empty($this->getUserId())) { - $errors[] = 'userId is empty'; + if (empty($this->getUuid())) { + $errors[] = 'Uuid is empty'; } - if (empty($this->getMerchantApiId())) { + + if (empty($this->getmerchantApiId())) { $errors[] = 'merchantApiId is empty'; } - if (empty($this->getMerchantId())) { - $errors[] = 'merchantId is empty'; - } - if (empty($this->getApiId())) { - $errors[] = 'apiId is empty'; - } - if (empty($this->getOrderId())) { - $errors[] = 'orderId is empty'; - } - if (empty($this->getStatus())){ - $errors[] = 'status is empty'; - } - if (strlen($this->getPayCurrency()) !== 3) { - $errors[] = 'payCurrency is not 3 characters long'; - } - if (!is_numeric($this->getPayAmount()) || (float)$this->getPayAmount() <= 0) { - $errors[] = 'payAmount is not a valid positive number'; - } - if (strlen($this->getReceiveCurrency()) !== 3) { - $errors[] = 'receiveCurrency is not 3 characters long'; - } - if (!is_numeric($this->getReceiveAmount()) || (float)$this->getReceiveAmount() <= 0) { - $errors[] = 'receiveAmount is not a valid positive number'; - } - if (!isset($this->receivedAmount)) { - $errors[] = 'receivedAmount is not set'; - } - if (!is_numeric($this->getOrderRequestId()) || (float)$this->getOrderRequestId() <= 0) { - $errors[] = 'orderRequestId is not a valid positive number'; - } - if (empty($this->getSign())) { - $errors[] = 'signature is empty'; - } return empty($errors) ? true : $errors; } - /** - * Validate the payload signature. - * - * @return bool True if the signature is valid, otherwise false. - */ - public function validatePayloadSignature(): bool + public function getUuid() + { + return $this->uuid; + } + public function getmerchantApiId() { - $payload = [ - 'merchantId' => $this->getMerchantId(), - 'apiId' => $this->getApiId(), - 'orderId' => $this->getOrderId(), - 'payCurrency' => $this->getPayCurrency(), - 'payAmount' => $this->getPayAmount(), - 'receiveCurrency' => $this->getReceiveCurrency(), - 'receiveAmount' => $this->getReceiveAmount(), - 'receivedAmount' => $this->getReceivedAmount(), - 'description' => $this->getDescription(), - 'orderRequestId' => $this->getOrderRequestId(), - 'status' => $this->getStatus(), - ]; - $data = http_build_query($payload); - $decoded_signature = base64_decode($this->sign); - $public_key = file_get_contents('https://spectrocoin.com/files/merchant.public.pem'); - $public_key_pem = openssl_pkey_get_public($public_key); - return openssl_verify($data, $decoded_signature, $public_key_pem, OPENSSL_ALGO_SHA1) === 1; + return $this->merchantApiId; } - public function getUserId() { return $this->userId; } - public function getMerchantApiId() { return $this->merchantApiId; } - public function getMerchantId() { return $this->merchantId; } - public function getApiId() { return $this->apiId; } - public function getOrderId() { return $this->orderId; } - public function getPayCurrency() { return $this->payCurrency; } - public function getPayAmount() { return SpectroCoin_Utilities::spectrocoinFormatCurrency($this->payAmount); } - public function getReceiveCurrency() { return $this->receiveCurrency; } - public function getReceiveAmount() { return SpectroCoin_Utilities::spectrocoinFormatCurrency($this->receiveAmount); } - public function getReceivedAmount() { return SpectroCoin_Utilities::spectrocoinFormatCurrency($this->receivedAmount); } - public function getDescription() { return $this->description; } - public function getOrderRequestId() { return $this->orderRequestId; } - public function getStatus() { return $this->status; } - public function getSign() { return $this->sign; } -} -?> +} \ No newline at end of file diff --git a/src/SCMerchantClient/data/SpectroCoin_OrderStatusEnum.php b/src/SCMerchantClient/data/SpectroCoin_OrderStatusEnum.php index 6ce82e0..eaa8d48 100755 --- a/src/SCMerchantClient/data/SpectroCoin_OrderStatusEnum.php +++ b/src/SCMerchantClient/data/SpectroCoin_OrderStatusEnum.php @@ -2,12 +2,45 @@ namespace Drupal\commerce_spectrocoin\SCMerchantClient\data; -class SpectroCoin_OrderStatusEnum +enum SpectroCoin_OrderStatusEnum: string { - public static $New = 1; - public static $Pending = 2; - public static $Paid = 3; - public static $Failed = 4; - public static $Expired = 5; - public static $Test = 6; + case NEW = 'NEW'; + case PENDING = 'PENDING'; + case PAID = 'PAID'; + case FAILED = 'FAILED'; + case EXPIRED = 'EXPIRED'; + + /** + * Map old numeric codes to new enum. + */ + public static function fromCode(int $code): self + { + return match ($code) { + 1 => self::NEW, + 2 => self::PENDING, + 3 => self::PAID, + 4 => self::FAILED, + 5 => self::EXPIRED, + default => throw new \InvalidArgumentException("Unknown numeric status code: $code"), + }; + } + + /** + * Normalize either an integer (legacy) or a string. + */ + public static function normalize(string|int $raw): self + { + if (is_int($raw) || ctype_digit((string)$raw)) { + return self::fromCode((int)$raw); + } + $upper = strtoupper((string)$raw); + return match ($upper) { + 'NEW' => self::NEW, + 'PENDING' => self::PENDING, + 'PAID' => self::PAID, + 'FAILED' => self::FAILED, + 'EXPIRED' => self::EXPIRED, + default => throw new \InvalidArgumentException("Unknown status string: $raw"), + }; + } }