Your IP : 216.73.216.224


Current Path : /var/www/html/components/com_osmembership/plugins/
Upload File :
Current File : /var/www/html/components/com_osmembership/plugins/os_authnet.php

<?php
/**
 * @package        Joomla
 * @subpackage     Membership Pro
 * @author         Tuan Pham Ngoc
 * @copyright      Copyright (C) 2012 - 2026 Ossolution Team
 * @license        GNU/GPL, see LICENSE.php
 */

defined('_JEXEC') or die;

use Joomla\CMS\Factory;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Table\Table;
use Joomla\CMS\Version;
use Joomla\Database\DatabaseDriver;
use Joomla\Http\HttpFactory;
use Omnipay\AuthorizeNet\AIMGateway;
use Omnipay\Common\Message\AbstractRequest;
use Omnipay\Common\Message\ResponseInterface;

class os_authnet extends MPFPaymentOmnipay
{
	/**
	 * Authorize.net API URL
	 *
	 * @var string
	 */
	protected $url;

	/**
	 * Omnipay package
	 *
	 * @var string
	 */
	protected $omnipayPackage = 'AuthorizeNet_AIM';

	/**
	 * Result code of the operation
	 *
	 * @var string
	 */
	protected $resultCode;

	/**
	 * Subscription ID
	 *
	 * @var string
	 */
	protected $subscriptionId;

	/**
	 * Result text of the operation
	 *
	 * @var string
	 */
	protected $text;

	/**
	 * Constructor
	 *
	 * @param   Joomla\Registry\Registry  $params
	 * @param   array                     $config
	 */
	public function __construct($params, $config = ['type' => 1])
	{
		if (!$params->get('authnet_mode'))
		{
			foreach (['x_login', 'x_tran_key', 'signature_key'] as $field)
			{
				if ($params->get('sandbox_' . $field))
				{
					$params->set($field, $params->get('sandbox_' . $field));
				}
			}
		}

		$config['params_map'] = [
			'apiLoginId'     => 'x_login',
			'transactionKey' => 'x_tran_key',
			'developerMode'  => 'authnet_mode',
		];

		parent::__construct($params, $config);

		if ($this->params->get('authnet_mode'))
		{
			$this->url = 'https://api.authorize.net/xml/v1/request.api';
		}
		else
		{
			$this->url = 'https://apitest.authorize.net/xml/v1/request.api';
		}
	}

	/**
	 * Override beforeRequestSend to send invoice number to authorize.net
	 *
	 * @param   AbstractRequest  $request
	 * @param   Table            $row
	 * @param   array            $data
	 */
	protected function beforeRequestSend($request, $row, $data)
	{
		parent::beforeRequestSend($request, $row, $data); // TODO: Change the autogenerated stub

		$config        = OSMembershipHelper::getConfig();
		$invoiceNumber = OSMembershipHelper::getInvoiceNumber($row);
		$invoicePrefix = str_replace('[YEAR]', date('Y'), $config->invoice_prefix);
		$invoiceNumber = $invoicePrefix . str_pad(
				$invoiceNumber,
				$config->invoice_number_length ?: 4,
				'0',
				STR_PAD_LEFT
			);
		$request->setTransactionId($invoiceNumber);
	}

	/**
	 * Process recurring payment
	 *
	 * @param   OSMembershipTableSubscriber  $row
	 * @param   array                        $data
	 *
	 * @return void
	 */
	public function processRecurringPayment($row, $data)
	{
		$app    = Factory::getApplication();
		$Itemid = $app->getInput()->getInt('Itemid', 0);

		$rowPlan   = OSMembershipHelperDatabase::getPlan($row->plan_id);
		$frequency = $rowPlan->subscription_length_unit;
		$length    = $rowPlan->subscription_length;

		// Initialize some recurring parameters
		$this->parameters['startDate']        = date('Y-m-d');
		$this->parameters['trialOccurrences'] = 0;
		$this->parameters['trialAmount']      = 0.00;

		// Process first payment if this is not free trial, this is to make sure the provided credit card number is valid
		if (!$row->is_free_trial)
		{
			$transactionId = $this->processFirstPayment($rowPlan, $data);

			if ($transactionId === false)
			{
				$app->redirect($this->getPaymentFailureUrl($row, $Itemid));

				return;
			}

			$row->payment_made = 1;
			$this->onPaymentSuccess($row, $transactionId);
		}
		else
		{
			// Free trial, adjust recurring subscription start date
			$trialDurationUnit   = $data['trial_duration_unit'];
			$trialDurationLength = $data['trial_duration'];

			switch ($trialDurationUnit)
			{
				case 'D':
					$this->parameters['startDate'] = date('Y-m-d', strtotime('+' . $trialDurationLength . ' days'));
					break;
				case 'W':
					$this->parameters['startDate'] = date('Y-m-d', strtotime('+' . $trialDurationLength . ' weeks'));
					break;
				case 'M':
					$this->parameters['startDate'] = date('Y-m-d', strtotime('+' . $trialDurationLength . ' months'));
					break;
				case 'Y':
					$this->parameters['startDate'] = date('Y-m-d', strtotime('+' . $trialDurationLength . ' years'));
					break;
			}
		}

		switch ($frequency)
		{
			case 'D':
				$unit = 'days';
				break;
			case 'W':
				$length *= 7;
				$unit   = 'days';
				break;
			case 'M':
				$unit = 'months';
				break;
			case 'Y':
				$length *= 12;
				$unit   = 'months';
				break;
			default:
				$unit = 'days';
				break;
		}

		$this->setParameter('refID', $row->id . '-' . HTMLHelper::_('date', 'now', 'Y-m-d'));
		$this->setParameter('subscrName', $row->first_name . ' ' . $row->last_name);
		$this->setParameter('interval_length', $length);
		$this->setParameter('interval_unit', $unit);
		$this->setParameter(
			'expirationDate',
			str_pad($data['exp_month'], 2, '0', STR_PAD_LEFT) . '/' . substr($data['exp_year'], 2, 2)
		);
		$this->setParameter('cardNumber', $data['x_card_num']);
		$this->setParameter('firstName', $row->first_name);
		$this->setParameter('lastName', $row->last_name);
		$this->setParameter('email', $row->email);
		$this->setParameter('address', $row->address);
		$this->setParameter('city', $row->city);
		$this->setParameter('state', $row->state);
		$this->setParameter('zip', $row->zip);
		$this->setParameter('amount', round($data['regular_price'], 2));

		if ($rowPlan->number_payments >= 2)
		{
			if (!$row->is_free_trial)
			{
				$totalOccurrences = $rowPlan->number_payments - 1;
			}
			else
			{
				$totalOccurrences = $rowPlan->number_payments;
			}
		}
		else
		{
			$totalOccurrences = 9999;
		}

		$this->setParameter('totalOccurrences', $totalOccurrences);

		// Call authorize.net API for creating recurring subscription
		if ($this->createAccount())
		{
			$row->subscription_id = $this->subscriptionId;
			$this->onPaymentSuccess($row, $row->transaction_id);
			$app->redirect($this->getPaymentCompleteUrl($row, $Itemid));
		}
		else
		{
			$this->setPaymentErrorMessage($this->text);
			$app->redirect($this->getPaymentFailureUrl($row, $Itemid));
		}
	}

	/**
	 * Verify recurring payment
	 */
	public function verifyRecurringPayment()
	{
		if ($this->params->get('signature_key'))
		{
			$this->verifyRecurringPaymentUsingWebhook();

			return;
		}

		if (!$this->validate())
		{
			return;
		}

		$transactionId = $this->notificationData['x_trans_id'];

		if ($transactionId && OSMembershipHelper::isTransactionProcessed($transactionId))
		{
			// Transaction already processed, no need to process it again
			return;
		}

		$subscriptionId = $this->notificationData['x_subscription_id'];

		/* @var DatabaseDriver $db */
		$db    = Factory::getContainer()->get('db');
		$query = $db->getQuery(true);
		$query->select('id')
			->from('#__osmembership_subscribers')
			->where('subscription_id = ' . $db->quote($subscriptionId));
		$db->setQuery($query);
		$id = (int) $db->loadResult();

		if ($id)
		{
			// Valid payment, extend the recurring subscription
			/* @var OSMembershipModelApi $model */
			$model = MPFModel::getTempInstance('Api', 'OSMembershipModel');
			$model->renewRecurringSubscription($id, $subscriptionId, $transactionId);
		}
	}

	/**
	 * Refund payment
	 *
	 * @param   object  $row  Subscription row with transaction_id and gross_amount properties
	 *
	 * @return bool
	 *
	 * @since 1.0
	 */
	public function refund($row)
	{
		$cardNumber = $this->getCardNumberFromTransaction($row->transaction_id);

		if ($cardNumber === null)
		{
			Factory::getApplication()->enqueueMessage(
				'Failed to retrieve transaction details from Authorize.Net for refund.',
				'error'
			);

			return false;
		}

		$payload = [
			'createTransactionRequest' => [
				'merchantAuthentication' => [
					'name'           => $this->params->get('x_login'),
					'transactionKey' => $this->params->get('x_tran_key'),
				],
				'refId'                  => (string) $row->id,
				'transactionRequest'     => [
					'transactionType' => 'refundTransaction',
					'amount'          => number_format((float) $row->gross_amount, 2, '.', ''),
					'payment'         => [
						'creditCard' => [
							'cardNumber'     => substr($cardNumber, -4),
							'expirationDate' => 'XXXX',
						],
					],
					'refTransId'      => $row->transaction_id,
				],
			],
		];

		$http     = $this->getHttp();
		$response = $http->post($this->url, json_encode($payload), ['Content-Type' => 'application/json']);

		if ($response->getStatusCode() !== 200)
		{
			Factory::getApplication()->enqueueMessage(
				'Authorize.Net refund request failed with HTTP status ' . $response->getStatusCode() . '.',
				'error'
			);

			return false;
		}

		$jsonResponse = preg_replace('/^\xEF\xBB\xBF/', '', trim((string) $response->getBody()));
		$this->logNotificationData('Refund Response: ' . $jsonResponse);

		$data = json_decode($jsonResponse, true);

		if (json_last_error() !== JSON_ERROR_NONE)
		{
			Factory::getApplication()->enqueueMessage('Failed to parse Authorize.Net refund response.', 'error');

			return false;
		}

		if (($data['messages']['resultCode'] ?? '') === 'Ok'
			&& ($data['transactionResponse']['responseCode'] ?? '') === '1')
		{
			return true;
		}

		$errorText = $data['transactionResponse']['errors'][0]['errorText']
			?? $data['messages']['message'][0]['text']
			?? 'Unknown error';

		Factory::getApplication()->enqueueMessage($errorText, 'error');

		return false;
	}

	/**
	 * Update credit card for a recurring subscription
	 *
	 * @param   array   $data          Form data containing credit card information
	 * @param   object  $subscription  Subscription object
	 *
	 * @return void
	 * @throws Exception
	 */
	public function updateCard($data, $subscription)
	{
		foreach (['x_card_num', 'exp_month', 'exp_year'] as $field)
		{
			if (empty($data[$field]))
			{
				throw new Exception('Missing required field: ' . $field);
			}
		}

		$cardNumber     = preg_replace('/\D/', '', $data['x_card_num']);
		$expirationDate = $data['exp_year'] . '-' . str_pad($data['exp_month'], 2, '0', STR_PAD_LEFT);

		$payload = [
			'ARBUpdateSubscriptionRequest' => [
				'merchantAuthentication' => [
					'name'           => $this->params->get('x_login'),
					'transactionKey' => $this->params->get('x_tran_key'),
				],
				'subscriptionId'         => $subscription->subscription_id,
				'subscription'           => [
					'payment' => [
						'creditCard' => [
							'cardNumber'     => $cardNumber,
							'expirationDate' => $expirationDate,
						],
					]
				],
			],
		];

		$http     = $this->getHttp();
		$response = $http->post($this->url, json_encode($payload), ['Content-Type' => 'application/json']);

		if ($response->getStatusCode() !== 200)
		{
			throw new Exception(
				'Authorize.Net update card request failed with HTTP status ' . $response->getStatusCode() . '.'
			);
		}

		$jsonResponse = preg_replace('/^\xEF\xBB\xBF/', '', trim((string) $response->getBody()));
		$this->logNotificationData('Update Card Response: ' . $jsonResponse);

		$responseData = json_decode($jsonResponse, true);

		if (json_last_error() !== JSON_ERROR_NONE)
		{
			throw new Exception('Failed to parse Authorize.Net update card response.');
		}

		if (($responseData['messages']['resultCode'] ?? '') !== 'Ok')
		{
			$errorText = $responseData['messages']['message'][0]['text'] ?? 'Unknown error';

			throw new Exception($errorText);
		}
	}

	/**
	 * Cancel recurring subscription
	 *
	 * @param   OSMembershipTableSubscriber  $row
	 *
	 * @return bool
	 * @throws Exception
	 */
	public function cancelSubscription($row)
	{
		$xml =
			'<?xml version="1.0" encoding="utf-8"?>' .
			'<ARBCancelSubscriptionRequest xmlns="AnetApi/xml/v1/schema/AnetApiSchema.xsd">' .
			'<merchantAuthentication>' .
			'<name>' . $this->params->get('x_login') . '</name>' .
			'<transactionKey>' . $this->params->get('x_tran_key') . '</transactionKey>' .
			'</merchantAuthentication>' .
			'<subscriptionId>' . $row->subscription_id . '</subscriptionId>' .
			'</ARBCancelSubscriptionRequest>';

		if ($this->process($xml))
		{
			return true;
		}

		Factory::getApplication()->enqueueMessage($this->text, 'error');

		return false;
	}

	/**
	 * Perform a recurring payment subscription
	 *
	 * @return bool True if subscription is created successfully, false otherwise
	 */
	protected function createAccount()
	{
		$xml = "<?xml version='1.0' encoding='utf-8'?>
          <ARBCreateSubscriptionRequest xmlns='AnetApi/xml/v1/schema/AnetApiSchema.xsd'>
              <merchantAuthentication>
                  <name>" . $this->params->get('x_login') . '</name>
                  <transactionKey>' . $this->params->get('x_tran_key') . '</transactionKey>
              </merchantAuthentication>
              <refId>' . $this->parameters['refID'] . '</refId>
              <subscription>
                  <name>' . $this->parameters['subscrName'] . '</name>
                  <paymentSchedule>
                      <interval>
                          <length>' . $this->parameters['interval_length'] . '</length>
                          <unit>' . $this->parameters['interval_unit'] . '</unit>
                      </interval>
                      <startDate>' . $this->parameters['startDate'] . '</startDate>
                      <totalOccurrences>' . $this->parameters['totalOccurrences'] . '</totalOccurrences>
                      <trialOccurrences>' . $this->parameters['trialOccurrences'] . '</trialOccurrences>
                  </paymentSchedule>
                  <amount>' . $this->parameters['amount'] . '</amount>
                  <trialAmount>' . $this->parameters['trialAmount'] . '</trialAmount>
                  <payment>
                      <creditCard>
                          <cardNumber>' . $this->parameters['cardNumber'] . '</cardNumber>
                          <expirationDate>' . $this->parameters['expirationDate'] . '</expirationDate>
                      </creditCard>
                  </payment>
                  <customer>
                  	  <type>individual</type>
                      <email>' . $this->parameters['email'] . '</email>                      
                  </customer>                 
                  <billTo>
                      <firstName>' . $this->parameters['firstName'] . '</firstName>
                      <lastName>' . $this->parameters['lastName'] . '</lastName>
                      <address>' . $this->parameters['address'] . '</address>
                      <city>' . $this->parameters['city'] . '</city>
                      <state>' . $this->parameters['state'] . '</state>
                      <zip>' . $this->parameters['zip'] . '</zip>
                  </billTo>
              </subscription>
          </ARBCreateSubscriptionRequest>';

		return $this->process($xml);
	}

	/**
	 * Call authorize.net for processing payment
	 *
	 * @param   string  $xml
	 *
	 * @return  bool    True if payment is successful, false otherwise
	 */
	protected function process($xml)
	{
		$ch = curl_init();
		curl_setopt($ch, CURLOPT_URL, $this->url);
		curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
		curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: text/xml']);
		curl_setopt($ch, CURLOPT_HEADER, 1);
		curl_setopt($ch, CURLOPT_SSLVERSION, 6);
		curl_setopt($ch, CURLOPT_POSTFIELDS, $xml);
		curl_setopt($ch, CURLOPT_POST, 1);
		curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0);

		$response = curl_exec($ch);

		curl_close($ch);

		$this->parseResults($response);

		if ($this->resultCode === 'Ok')
		{
			return true;
		}

		return false;
	}

	/**
	 * Validate recurring payment
	 *
	 * @return bool
	 */
	protected function validate()
	{
		$this->notificationData = $_POST;

		$this->logNotificationData();

		if (!empty($this->notificationData['x_subscription_id']) && @$this->notificationData['x_response_code'] == 1)
		{
			return true;
		}

		return false;
	}

	/**
	 * Process first payment for the subscription
	 *
	 * @param   OSMembershipTablePlan  $rowPlan
	 * @param   array                  $data
	 *
	 * @return mixed false on failure, string contain transaction id on success
	 */
	protected function processFirstPayment($rowPlan, $data)
	{
		// Process the first payment
		if ($data['trial_duration'])
		{
			$paymentAmount       = $data['trial_amount'];
			$trialDurationUnit   = $data['trial_duration_unit'];
			$trialDurationLength = $data['trial_duration'];
		}
		else
		{
			$paymentAmount       = $data['regular_price'];
			$trialDurationUnit   = $rowPlan->subscription_length_unit;
			$trialDurationLength = $rowPlan->subscription_length;
		}

		switch ($trialDurationUnit)
		{
			case 'D':
				$this->parameters['startDate'] = date('Y-m-d', strtotime('+' . $trialDurationLength . ' days'));
				break;
			case 'W':
				$this->parameters['startDate'] = date('Y-m-d', strtotime('+' . $trialDurationLength . ' weeks'));
				break;
			case 'M':
				$this->parameters['startDate'] = date('Y-m-d', strtotime('+' . $trialDurationLength . ' months'));
				break;
			case 'Y':
				$this->parameters['startDate'] = date('Y-m-d', strtotime('+' . $trialDurationLength . ' years'));
				break;
		}

		/* @var AIMGateway $gateway */
		$gateway  = $this->getGateway();
		$cardData = $this->getOmnipayCard($data);

		/* @var $request AbstractRequest */
		try
		{
			$request = $gateway->purchase(['card' => $cardData]);

			$request->setAmount($paymentAmount);
			$request->setCurrency($data['currency']);
			$request->setDescription($data['item_name']);

			/* @var $response ResponseInterface */

			$response = $request->send();
		}
		catch (Exception $e)
		{
			$this->setPaymentErrorMessage($e->getMessage());

			return false;
		}

		if ($response->isSuccessful())
		{
			return $response->getTransactionReference();
		}

		//Payment failure, display error message to users
		$this->setPaymentErrorMessage($response->getMessage());

		return false;
	}

	/**
	 * Log the notification data sent from Authorize.net webhook
	 *
	 * @return void
	 */
	protected function verifyRecurringPaymentUsingWebhook()
	{
		$payload = file_get_contents("php://input");

		file_put_contents(
			__DIR__ . '/webhook.log',
			date('Y-m-d H:i:s') . ' - Payload: ' . $payload . "\n",
			FILE_APPEND
		);

		// Get signature header
		$signature = $_SERVER['HTTP_X_ANET_SIGNATURE'] ?? '';

		$this->logNotificationData(date('Y-m-d H:i:s') . ' - Signature: ' . $signature);

		// Your Webhook Signature Key (from Merchant Interface)
		$signatureKey = $this->params->get('signature_key');

		if ($signatureKey && !$this->validateWebhook($payload, $signature, $signatureKey))
		{
			// For now, we log it for information purpose only
			$this->logNotificationData('Invalid webhook signature');

			return;
		}

		$webhookData = json_decode($payload, true);

		$transactionId = $webhookData['payload']['id'] ?? '';
		$eventType     = $webhookData['eventType'] ?? '';

		if ($eventType !== 'net.authorize.payment.authcapture.created')
		{
			// We only process authcapture.created event here
			return;
		}

		if (!$transactionId)
		{
			$this->logNotificationData('Invalid transaction id');

			// No transaction ID found, we cannot process this webhook
			return;
		}

		if (OSMembershipHelper::isTransactionProcessed($transactionId))
		{
			$this->logNotificationData('Transaction ' . $transactionId . ' has already been processed.');

			// Transaction already processed, no need to process it again
			return;
		}

		$subscriptionId = $this->getSubscriptionIDFromTransaction($transactionId);

		// No Subscription ID found, we cannot process this webhook
		if (!$subscriptionId)
		{
			$this->logNotificationData('No subscription ID found for transaction ' . $transactionId);

			return;
		}

		/* @var DatabaseDriver $db */
		$db    = Factory::getContainer()->get('db');
		$query = $db->getQuery(true)
			->select('id')
			->from('#__osmembership_subscribers')
			->where('subscription_id = ' . $db->quote($subscriptionId));
		$db->setQuery($query);
		$id = (int) $db->loadResult();

		if ($id)
		{
			$this->logNotificationData(
				'Processing subscription ID ' . $subscriptionId . ' for transaction ' . $transactionId
			);
			// Valid payment, extend the recurring subscription
			/* @var OSMembershipModelApi $model */
			$model = MPFModel::getTempInstance('Api', 'OSMembershipModel');
			$model->renewRecurringSubscription($id, $subscriptionId, $transactionId);
		}
		else
		{
			$this->logNotificationData('No subscriber found with subscription ID ' . $subscriptionId);
		}
	}

	/**
	 * Parse the xml to get the necessary information of the subscription
	 *
	 * @param   string  $response
	 *
	 * @return void
	 */
	protected function parseResults($response)
	{
		$this->resultCode     = self::substring_between($response, '<resultCode>', '</resultCode>');
		$this->text           = self::substring_between($response, '<text>', '</text>');
		$this->subscriptionId = self::substring_between($response, '<subscriptionId>', '</subscriptionId>');
	}

	/**
	 * Get content between tags
	 *
	 * @param   string  $haystack
	 * @param   string  $start
	 * @param   string  $end
	 *
	 * @return bool|string
	 */
	protected static function substring_between($haystack, $start, $end)
	{
		if (!str_contains($haystack, $start) || !str_contains($haystack, $end))
		{
			return false;
		}
		$start_position = strpos($haystack, $start) + strlen($start);
		$end_position   = strpos($haystack, $end);

		return substr($haystack, $start_position, $end_position - $start_position);
	}

	/**
	 * Validate Authorize.Net webhook signature
	 *
	 * @param   string  $payload          The raw request body
	 * @param   string  $signatureHeader  The X-ANET-Signature header value
	 * @param   string  $signatureKey     Your Webhook Signature Key from Authorize.Net
	 *
	 * @return bool True if valid, False otherwise
	 */
	private function validateWebhook($payload, $signatureHeader, $signatureKey): bool
	{
		// Extract the signature from the header (remove "sha512=" prefix)
		if (strpos($signatureHeader, 'sha512=') === 0)
		{
			$receivedSignature = substr($signatureHeader, 7);
		}
		else
		{
			return false;
		}

		// Create HMAC-SHA512 hash
		$computedHash = hash_hmac('sha512', $payload, $signatureKey);

		// Convert to uppercase (Authorize.Net uses uppercase)
		$computedSignature = strtoupper($computedHash);

		$this->logNotificationData(
			'Computed Signature: ' . $computedSignature . ', Received Signature: ' . $receivedSignature
		);

		// Compare signatures (use hash_equals for timing attack protection)
		return hash_equals($computedSignature, strtoupper($receivedSignature));
	}

	/**
	 * Retrieve the masked card number for a transaction from Authorize.Net.
	 *
	 * @param   string  $transactionId  The Authorize.Net transaction ID
	 *
	 * @return string|null  The masked card number (e.g. XXXX1234) as returned by the API, or null on failure
	 *
	 * @since 1.0
	 */
	private function getCardNumberFromTransaction($transactionId)
	{
		$data = $this->getTransactionDetails($transactionId);

		return $data['transaction']['payment']['creditCard']['cardNumber'] ?? null;
	}

	/**
	 * Get HTTP client with appropriate user agent for Authorize.Net API requests
	 *
	 * @return \Joomla\Http\Http
	 */
	private function getHttp()
	{
		return (new HttpFactory())->getHttp(['userAgent' => (new Version())->getUserAgent('Joomla', true, false)]);
	}

	/**
	 * Retrieve and parse the transaction details from Authorize.Net for the given transaction ID.
	 * Returns the decoded response array on success, or null on failure.
	 *
	 * @param   string  $transactionId  The Authorize.Net transaction ID
	 *
	 * @return array|null  Decoded response data, or null on failure
	 */
	private function getTransactionDetails($transactionId)
	{
		$payload = [
			'getTransactionDetailsRequest' => [
				'merchantAuthentication' => [
					'name'           => $this->params->get('x_login'),
					'transactionKey' => $this->params->get('x_tran_key'),
				],
				'transId'                => $transactionId,
			],
		];

		$http     = $this->getHttp();
		$response = $http->post($this->url, json_encode($payload), ['Content-Type' => 'application/json']);

		if ($response->getStatusCode() !== 200)
		{
			$this->logNotificationData(
				'Failed to retrieve transaction details. HTTP Status Code: ' . $response->getStatusCode()
			);

			return null;
		}

		/**
		 * Remove BOM if exists and trim the response. This is to prevent json_decode failure due to invalid characters
		 * at the beginning of the response. This happens with Authorize.Net environment for some reason.
		 */
		$jsonResponse = preg_replace('/^\xEF\xBB\xBF/', '', trim((string) $response->getBody()));
		$this->logNotificationData('Transaction Response: ' . $jsonResponse);

		$data = json_decode($jsonResponse, true);

		if (json_last_error() !== JSON_ERROR_NONE)
		{
			$this->logNotificationData('Failed to parse JSON response: ' . json_last_error_msg());

			return null;
		}

		if (($data['messages']['resultCode'] ?? '') !== 'Ok')
		{
			$this->logNotificationData(
				'Authorize.Net API returned error: ' . ($data['messages']['message'][0]['text'] ?? 'Unknown error')
			);

			return null;
		}

		return $data;
	}

	/**
	 * Get Subscription ID from Authorize.Net transaction ID
	 *
	 * @param   string  $transactionId
	 *
	 * @return string|null Subscription ID if found, null otherwise
	 */
	private function getSubscriptionIDFromTransaction($transactionId)
	{
		$data = $this->getTransactionDetails($transactionId);

		return $data['transaction']['subscription']['id'] ?? null;
	}
}