Skip to content
Merged
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion commerce_spectrocoin.info.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@ dependencies:
- commerce:commerce_checkout
- commerce:commerce_payment

version: "1.0.0"
version: "1.1.0"
project: "commerce_spectrocoin"
datestamp: 1684701392
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
182 changes: 139 additions & 43 deletions src/Controller/SpectroCoinController.php
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
<?php

namespace Drupal\commerce_spectrocoin\Controller;

use Drupal\Core\Controller\ControllerBase;
use Drupal\commerce_spectrocoin\SCMerchantClient\data\SpectroCoin_OldOrderCallback;
use Drupal\commerce_spectrocoin\SCMerchantClient\data\SpectroCoin_OrderCallback;
use Drupal\commerce_spectrocoin\SCMerchantClient\data\SpectroCoin_OrderStatusEnum;
use Drupal\commerce_spectrocoin\SCMerchantClient\SCMerchantClient;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Drupal\commerce_order\Entity\Order;
Expand All @@ -13,7 +17,8 @@
/**
* Controller for handling SpectroCoin callbacks and redirects.
*/
class SpectroCoinController extends ControllerBase {
class SpectroCoinController extends ControllerBase
{

/**
* Processes the payment callback.
Expand All @@ -23,52 +28,87 @@ class SpectroCoinController extends ControllerBase {
* @return \Symfony\Component\HttpFoundation\Response
* A response with status 200 and "*ok*" if successful.
*/
public function callback() {
public function callback()
{
$request = \Drupal::request();
if ($request->getMethod() !== 'POST') {
\Drupal::logger('commerce_spectrocoin')
->error('SpectroCoin Error: Invalid request method, POST is required');
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')
->error('SpectroCoin Error: Order not found - Order ID: ' . $order_id);
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:
Expand All @@ -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);
}
Expand All @@ -94,21 +133,20 @@ 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);
}
}


public function success() {
public function success()
{
$order_id = \Drupal::request()->query->get('order_id');
if ($order_id) {
$order = Order::load((int) $order_id);
Expand All @@ -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.');
}
Expand All @@ -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 = [];
Expand All @@ -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
);
}
}
8 changes: 1 addition & 7 deletions src/Plugin/Commerce/PaymentGateway/SpectroCoin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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']
Expand Down
Loading