| Current Path : /var/www/html/components/com_osmembership/plugins/ |
| 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;
}
}