3
0
WHMCS/modules/gateways/payanyway.php

750 lines
23 KiB
PHP

<?php
/**
* PayAnyWay - WHMCS Payment Gateway Module
*
* WHMCS Gateway Module Developer Documentation:
*
* @see https://developers.whmcs.com/payment-gateways/
*/
const PAYANYWAY_PAYMENT_GATEWAY_MODULE_VERSION = 'PAYANYWAY_1.1.0';
const PAYANYWAY_API_URL_PROD = 'https://www.payanyway.ru/assistant.htm';
const PAYANYWAY_API_URL_DEMO = 'https://demo.moneta.ru/assistant.htm';
const PAYANYWAY_SUPPORTED_CURRENCIES = ['RUB'];
const PAYANYWAY_DESCRIPTION_MAX_LENGTH = 500;
const PAYANYWAY_INVENTORY_ITEM_NAME_MAX_LENGTH = 128;
const PAYANYWAY_INVENTORY_ITEM_DEFAULT_PAYMENT_METHOD = 'full_payment';
const PAYANYWAY_INVENTORY_ITEM_DEFAULT_PAYMENT_OBJECT = 'service';
const PAYANYWAY_INVENTORY_ITEM_DEFAULT_QUANTITY = 1;
const PAYANYWAY_INVENTORY_ITEM_DEFAULT_MEASURE = 'unit';
const PAYANYWAY_VAT_TAG_NONE = '1105'; // none
const PAYANYWAY_PAYMENT_FEE = 0.0;
if (!defined('WHMCS')) {
die('This file cannot be accessed directly');
}
/**
* Module metadata for WHMCS gateway registration.
*
* @return array
*/
function payanyway_MetaData()
{
return [
'DisplayName' => 'PayAnyWay',
'APIVersion' => '1.1',
'DisableLocalCreditCardInput' => true,
'TokenisedStorage' => false,
];
}
/**
* Define gateway configuration options.
*
* The fields you define here determine the configuration options that are
* presented to administrator users when activating and configuring your
* payment gateway module for use.
*
* @return array
*/
function payanyway_config()
{
return [
'FriendlyName' => [
'Type' => 'System',
'Value' => 'PayAnyWay',
],
'mnt_id' => [
'FriendlyName' => 'Account Number',
'Type' => 'text',
'Size' => '20',
'Default' => '',
'Description' => 'Enter your account number provided by PayAnyWay ',
],
'mnt_dataintegrity_code' => [
'FriendlyName' => 'Code of data integrity verification',
'Type' => 'text',
'Size' => '20',
'Default' => '',
'Description' => 'Enter your integrity code provided by PayAnyWay',
],
'mnt_test_mode' => [
'FriendlyName' => 'Test Mode',
'Type' => 'yesno',
'Description' => 'Tick to enable test mode',
],
];
}
/**
* Generate a PayAnyWay payment link for a WHMCS invoice.
*
* Creates a payment preference via PayAnyWay and returns an
* anchor tag (<a>) pointing to the hosted checkout page. WHMCS will render
* this HTML inside the invoice payment section.
*
* @param array $params Standard WHMCS gateway params array.
* See: https://developers.whmcs.com/payment-gateways/third-party-gateway/
* @return string HTML output to display on the invoice page.
*/
function payanyway_link($params)
{
try {
// Gateway Configuration Parameters
$mntId = payanyway_getPayAnyWayMntIdOrFail($params);
$testMode = !empty($params['mnt_test_mode']) ? '1' : '0';
$integrityCode = payanyway_getPayAnyWayIntegrityCodeOrFail($params);
// Invoice Parameters
$invoiceId = payanyway_getWhmcsInvoiceIdOrFail($params);
$amount = payanyway_getWhmcsInvoiceAmountOrFail($params);
$formatAmount = payanyway_formatAmount($amount);
$currency = payanyway_getWhmcsInvoiceCurrencyOrFail($params);
} catch (\PayAnyWayException $e) {
return '<p style="color:red;"><strong>Error:</strong> '
. htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_HTML5, 'UTF-8') . '</p>';
}
// System Parameters
$langPayNow = payanyway_getOptionalStringWithDefault($params, 'langpaynow', 'Pay Now');
$whmcsVersionNumber = payanyway_getOptionalString($params, 'whmcsVersion');
$whmcsVersion = isset($whmcsVersionNumber) ? 'WHMCS_' . $whmcsVersionNumber : 'WHMCS_unknown_version';
$url = ('1' === $testMode) ? PAYANYWAY_API_URL_DEMO : PAYANYWAY_API_URL_PROD;
$subscriberId = payanyway_getSubscriberId($params);
$signature = md5($mntId . $invoiceId . $formatAmount . $currency . $subscriberId . $testMode . $integrityCode);
$postFields = [
'MNT_ID' => $mntId,
'MNT_TRANSACTION_ID' => $invoiceId,
'MNT_AMOUNT' => $formatAmount,
'MNT_CURRENCY_CODE' => $currency,
'MNT_TEST_MODE' => $testMode,
'MNT_DESCRIPTION' => payanyway_getDescription($invoiceId, $params),
'MNT_SUBSCRIBER_ID' => $subscriberId,
'MNT_SIGNATURE' => $signature,
'MNT_CMS' => $whmcsVersion . '|' . PAYANYWAY_PAYMENT_GATEWAY_MODULE_VERSION,
];
$htmlOutput = '<form method="post" action="' . $url . '">';
foreach ($postFields as $key => $value) {
$value = htmlspecialchars($value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$htmlOutput .= '<input type="hidden" name="' . $key . '" value="' . $value . '" />';
}
$htmlOutput .= '<input type="submit" value="' . $langPayNow . '" />';
$htmlOutput .= '</form>';
return $htmlOutput;
}
/**
* WHMCS PayAnyWay Payment Gateway Helper Functions
*/
/**
* @param array<non-empty-string, mixed> $params
* @return string
* @throws PayAnyWayException
*/
function payanyway_getPayAnyWayMntIdOrFail($params)
{
$mntId = payanyway_getOptionalString($params, 'mnt_id');
if (null === $mntId) {
throw new \PayAnyWayException(
'PayAnyWay module is not configured properly. '
. 'Please set Account Number in WHMCS Admin → Payment Gateways.'
);
}
return $mntId;
}
/**
* @param array<non-empty-string, mixed> $params
* @return string
* @throws PayAnyWayException
*/
function payanyway_getPayAnyWayIntegrityCodeOrFail($params)
{
$integrityCode = payanyway_getOptionalString($params, 'mnt_dataintegrity_code');
if (null === $integrityCode) {
throw new \PayAnyWayException(
'PayAnyWay module is not configured properly. '
. 'Please set Code of Data Integrity Verification in WHMCS Admin → Payment Gateways.'
);
}
return $integrityCode;
}
/**
* @param array<non-empty-string, mixed> $params
* @return int
* @throws PayAnyWayException
*/
function payanyway_getWhmcsInvoiceIdOrFail($params)
{
$invoiceId = payanyway_getOptionalInt($params, 'invoiceid');
if (null === $invoiceId) {
throw new \PayAnyWayException('Missing or invalid invoice ID (integer required)');
}
return $invoiceId;
}
/**
* @param array<non-empty-string, mixed> $params
* @return float
* @throws PayAnyWayException
*/
function payanyway_getWhmcsInvoiceAmountOrFail($params)
{
$amount = payanyway_getOptionalFloat($params, 'amount');
if (null === $amount) {
throw new \PayAnyWayException('Missing or invalid invoice amount (float required).');
}
return $amount;
}
/**
* @param array<non-empty-string, mixed> $params
* @return non-empty-string
* @throws PayAnyWayException
*/
function payanyway_getWhmcsInvoiceCurrencyOrFail($params)
{
$currency = payanyway_getOptionalString($params, 'currency');
if (null === $currency) {
$supported = implode(', ', PAYANYWAY_SUPPORTED_CURRENCIES);
throw new \PayAnyWayException(
'Missing currency code. Module supports only: ' . $supported
);
}
$currency = strtoupper($currency);
if (!in_array($currency, PAYANYWAY_SUPPORTED_CURRENCIES, true)) {
$supported = implode(', ', PAYANYWAY_SUPPORTED_CURRENCIES);
throw new \PayAnyWayException(
"Unsupported currency code: '{$currency}'. Module supports only: {$supported}"
);
}
return $currency;
}
/**
* @param array<non-empty-string, mixed> $params
* @return string
* @throws PayAnyWayException
*/
function payanyway_getWhmcsVariableOrFail($params, $name)
{
$var = payanyway_getOptionalString($params, $name);
if (null === $var) {
throw new \PayAnyWayException(sprintf('Whmcs variable $params[\'%s\'] is not set.', $name));
}
return $var;
}
/**
* @param array<non-empty-string, mixed> $array
* @param string $key
* @return int|null
*/
function payanyway_getOptionalInt($array, $key)
{
if (!isset($array[$key]) || !is_numeric($array[$key])) {
return null;
}
return (int)$array[$key];
}
/**
* @param array<non-empty-string, mixed> $array
* @param string $key
* @return float|null
*/
function payanyway_getOptionalFloat($array, $key)
{
if (!isset($array[$key]) || !is_numeric($array[$key])) {
return null;
}
return (float)$array[$key];
}
/**
* @param array<non-empty-string, mixed> $array
* @param string $key
* @return float
* @throws PayAnyWayException
*/
function payanyway_getRequiredFloat($array, $key)
{
if (!isset($array[$key])) {
throw new \PayAnyWayException('Missing required field ' . $key);
}
$value = $array[$key];
if (!is_numeric($value)) {
throw new \PayAnyWayException(
sprintf('Field "%s" must be a number, %s given', $key, gettype($value))
);
}
return (float)$value;
}
/**
* @param array<non-empty-string, mixed> $array
* @param string $key
* @return string
* @throws PayAnyWayException
*/
function payanyway_getRequiredString($array, $key)
{
if (!isset($array[$key])) {
throw new \PayAnyWayException('Missing required field ' . $key);
}
$value = $array[$key];
if (!is_string($value)) {
throw new \PayAnyWayException(
sprintf('Field "%s" must be a string, %s given', $key, gettype($value))
);
}
if ('' === $value) {
throw new \PayAnyWayException(sprintf('Field "%s" must be a non-empty string', $key));
}
return $value;
}
/**
* @param array<non-empty-string, mixed> $array
* @param string $key
* @return string|null
*/
function payanyway_getOptionalString($array, $key)
{
if (!isset($array[$key])) {
return null;
}
$value = $array[$key];
return (is_string($value) && ('' !== trim($value))) ? $value : null;
}
/**
* @param array<non-empty-string, mixed> $params
* @param non-empty-string $key
* @param string $default
* @return string
*/
function payanyway_getOptionalStringWithDefault($params, $key, $default = '')
{
$value = payanyway_getOptionalString($params, $key);
return (null !== $value) ? $value : $default;
}
/**
* @param float $amount
* @return non-empty-string
*/
function payanyway_formatAmount($amount)
{
return number_format($amount, 2, '.', '');
}
/**
* @param array $params
* @return string
*/
function payanyway_getSubscriberId($params)
{
$email = isset($params['clientdetails']['email'])
? filter_var($params['clientdetails']['email'], FILTER_SANITIZE_EMAIL)
: '';
$phoneNumber = isset($params['clientdetails']['phonenumber'])
? preg_replace('/[^0-9+]/', '', $params['clientdetails']['phonenumber'])
: '';
return $email ?: $phoneNumber;
}
/**
* @param int $invoiceId
* @param array<non-empty-string, mixed> $params
* @return non-empty-string
*/
function payanyway_getDescription($invoiceId, $params)
{
$clientFirstName = isset($params['clientdetails']['firstname']) ? $params['clientdetails']['firstname'] : '';
$clientLastName = isset($params['clientdetails']['lastname']) ? $params['clientdetails']['lastname'] : '';
$clientName = trim($clientFirstName . ' ' . $clientLastName);
$description = "Payment for Invoice #{$invoiceId}";
if ('' !== $clientName) {
$description .= " from {$clientName}";
}
$originalDescription = isset($params['description']) ? trim($params['description']) : '';
if ('' !== $originalDescription) {
$description .= ': ' . $originalDescription;
}
if (mb_strlen($description, 'UTF-8') > PAYANYWAY_DESCRIPTION_MAX_LENGTH) {
$description = mb_substr($description, 0, 497, 'UTF-8') . '...';
}
return $description;
}
/**
* @param array<non-empty-string, mixed> $requestData
* @return array<non-empty-string, mixed>
* @throws PayAnyWayException
*/
function payanyway_getCallbackData($requestData)
{
return [
'MNT_COMMAND' => payanyway_getOptionalStringWithDefault($requestData, 'MNT_COMMAND'),
'MNT_ID' => payanyway_getRequiredString($requestData, 'MNT_ID'),
'MNT_TRANSACTION_ID' => payanyway_getRequiredString($requestData, 'MNT_TRANSACTION_ID'),
'MNT_OPERATION_ID' => payanyway_getOptionalStringWithDefault($requestData, 'MNT_OPERATION_ID'),
'MNT_AMOUNT' => payanyway_getOptionalStringWithDefault($requestData, 'MNT_AMOUNT'),
'MNT_CURRENCY_CODE' => payanyway_getRequiredString($requestData, 'MNT_CURRENCY_CODE'),
'MNT_SUBSCRIBER_ID' => payanyway_getOptionalStringWithDefault($requestData, 'MNT_SUBSCRIBER_ID'),
'MNT_TEST_MODE' => payanyway_getRequiredString($requestData, 'MNT_TEST_MODE'),
'MNT_SIGNATURE' => payanyway_getRequiredString($requestData, 'MNT_SIGNATURE'),
];
}
/**
* @param array<non-empty-string, mixed> $callbackData
* @param non-empty-string $gatewayName
* @param non-empty-string $integrityCode
* @return void
* @throws DOMException
*/
function payanyway_handleCheckCallback($callbackData, $gatewayName, $integrityCode)
{
if (!payanyway_checkSignature($callbackData, $integrityCode, $callbackData['MNT_COMMAND'])) {
payanyway_sendResponse('FAIL', 500);
}
$rawInvoiceId = (int)$callbackData['MNT_TRANSACTION_ID'];
$invoiceId = checkCbInvoiceID($rawInvoiceId, $gatewayName);
$invoiceDetails = payanyway_getInvoiceDetails($invoiceId);
if (null === $invoiceDetails) {
payanyway_sendResponse('FAIL', 500);
}
$invoiceItems = payanyway_getInvoiceItems($invoiceDetails);
$invoiceTotal = payanyway_formatAmount($invoiceDetails['total']);
$invoicePaymentStatus = $invoiceDetails['status'];
$xmlData = [
'MNT_ID' => $callbackData['MNT_ID'],
'MNT_TRANSACTION_ID' => $callbackData['MNT_TRANSACTION_ID'],
'MNT_AMOUNT' => $invoiceTotal,
'MNT_CURRENCY_CODE' => $callbackData['MNT_CURRENCY_CODE'],
'inventory' => payanyway_getInventoryJson($invoiceItems) ?: null,
'client' => $callbackData['MNT_SUBSCRIBER_ID'],
'sno' => null,
'delivery' => $invoiceTotal,
];
switch (true) {
case empty($callbackData['MNT_AMOUNT']):
$xmlData['MNT_RESULT_CODE'] = 100;
$xmlData['MNT_DESCRIPTION'] = 'Invoice created, but amount not set';
break;
case ('Paid' === $invoicePaymentStatus):
$xmlData['MNT_RESULT_CODE'] = 200;
$xmlData['MNT_DESCRIPTION'] = 'Invoice Paid';
break;
default:
$xmlData['MNT_RESULT_CODE'] = 402;
$xmlData['MNT_DESCRIPTION'] = 'Invoice Unpaid';
}
$xmlData['MNT_SIGNATURE'] = md5(
$xmlData['MNT_RESULT_CODE']
. $xmlData['MNT_ID']
. $xmlData['MNT_TRANSACTION_ID']
. $integrityCode
);
payanyway_sendResponse(payanyway_buildXMLResponse($xmlData), 200);
}
/**
* @param array<non-empty-string, mixed> $callbackData
* @param non-empty-string $gatewayName
* @param non-empty-string $integrityCode
* @param non-empty-string $gatewayModuleName
* @return void
* @throws DOMException
*/
function payanyway_handlePayCallback($callbackData, $gatewayName, $integrityCode, $gatewayModuleName)
{
if (!payanyway_checkSignature($callbackData, $integrityCode)) {
payanyway_sendResponse('FAIL', 500);
}
$rawInvoiceId = $callbackData['MNT_TRANSACTION_ID'];
$invoiceId = checkCbInvoiceID($rawInvoiceId, $gatewayName);
$paidAmount = $callbackData['MNT_AMOUNT'];
$invoiceDetails = payanyway_getInvoiceDetailsOrFail($invoiceId);
if (null === $invoiceDetails) {
payanyway_sendResponse('FAIL', 500);
}
$invoiceItems = payanyway_getInvoiceItems($invoiceDetails);
$invoicePaymentStatus = $invoiceDetails['status'];
$resultCode = 200;
$xmlData = [
'MNT_ID' => $callbackData['MNT_ID'],
'MNT_TRANSACTION_ID' => $callbackData['MNT_TRANSACTION_ID'],
'MNT_RESULT_CODE' => $resultCode,
'MNT_SIGNATURE' => md5(
$resultCode . $callbackData['MNT_ID'] . $callbackData['MNT_TRANSACTION_ID'] . $integrityCode
),
'MNT_AMOUNT' => $paidAmount,
'MNT_CURRENCY_CODE' => $callbackData['MNT_CURRENCY_CODE'],
'MNT_DESCRIPTION' => ('Paid' !== $invoicePaymentStatus)
? 'Invoice success paid'
: 'Invoice already paid',
'inventory' => payanyway_getInventoryJson($invoiceItems),
'client' => $callbackData['MNT_SUBSCRIBER_ID'],
'sno' => null,
'delivery' => $paidAmount,
];
if ('Paid' !== $invoicePaymentStatus) {
$transactionId = $callbackData['MNT_OPERATION_ID'];
checkCbTransID($transactionId); // if $transactionId exist, script die()
if (!payanyway_existInvoiceTransaction($invoiceId, $transactionId)) {
addInvoicePayment($invoiceId, $transactionId, $paidAmount, PAYANYWAY_PAYMENT_FEE, $gatewayModuleName);
logTransaction($gatewayName, $callbackData, 'Success');
}
}
payanyway_sendResponse(payanyway_buildXMLResponse($xmlData), 200);
}
/**
* @param array<non-empty-string, mixed> $callbackData
* @param string $integrityCode
* @param non-empty-string|null $command
* @return bool
*/
function payanyway_checkSignature($callbackData, $integrityCode, $command = null)
{
$baseFields = [
'MNT_ID',
'MNT_TRANSACTION_ID',
'MNT_OPERATION_ID',
'MNT_AMOUNT',
'MNT_CURRENCY_CODE',
'MNT_SUBSCRIBER_ID',
'MNT_TEST_MODE',
];
$fields = ('CHECK' === $command) ? array_merge(['MNT_COMMAND'], $baseFields) : $baseFields;
$signatureString = array_reduce($fields, static function ($carry, $field) use ($callbackData) {
return $carry . $callbackData[$field];
}, '') . $integrityCode;
return hash_equals(md5($signatureString), $callbackData['MNT_SIGNATURE']);
}
/**
* @see https://developers.whmcs.com/api-reference/getinvoice/
* @param int $invoiceId
* @return array<non-empty-string, mixed>|null
*/
function payanyway_getInvoiceDetails($invoiceId)
{
$command = 'GetInvoice';
$postData = [
'invoiceid' => $invoiceId,
];
$results = localAPI($command, $postData);
if ($results['result'] !== 'success') {
return null;
}
return $results;
}
/**
* @param array<non-empty-string, mixed> $invoiceDetails
* @return array<non-empty-string, mixed>|null
*/
function payanyway_getInvoiceItems($invoiceDetails)
{
return (isset($invoiceDetails['items']['item']) && is_array($invoiceDetails['items']['item']))
? $invoiceDetails['items']['item']
: null;
}
/**
* @see https://developers.whmcs.com/api-reference/gettransactions/
* @param int $invoiceId
* @param non-empty-string $transactionId
* @return bool
*/
function payanyway_existInvoiceTransaction($invoiceId, $transactionId)
{
$results = localAPI('GetTransactions', [
'invoiceid' => $invoiceId,
'transid' => $transactionId,
]);
if ($results['result'] !== 'success') {
return false;
}
$transactions = isset($results['transactions']['transaction']) ? $results['transactions']['transaction'] : [];
if ([] === $transactions) {
return false;
}
foreach ($transactions as $transaction) {
if (isset($transaction['transid']) && ($transaction['transid'] === $transactionId)) {
return true;
}
}
return false;
}
/**
* @param array<non-empty-string, mixed> $items
* @return false|string
*/
function payanyway_getInventoryJson($items)
{
$inventory = [];
foreach ($items as $item) {
$description = html_entity_decode($item['description'], ENT_QUOTES | ENT_HTML5, 'UTF-8');
$positionName = trim(preg_replace('/[^\p{L}\p{N}\s\-]/u', '', $description));
if (empty($positionName)) {
$positionName = 'Service';
}
$inventory[] = [
'name' => mb_substr($positionName, 0, PAYANYWAY_INVENTORY_ITEM_NAME_MAX_LENGTH, 'UTF-8'),
'price' => (float)payanyway_formatAmount($item['amount']),
'quantity' => PAYANYWAY_INVENTORY_ITEM_DEFAULT_QUANTITY,
'vatTag' => PAYANYWAY_VAT_TAG_NONE,
'pm' => PAYANYWAY_INVENTORY_ITEM_DEFAULT_PAYMENT_METHOD,
'po' => PAYANYWAY_INVENTORY_ITEM_DEFAULT_PAYMENT_OBJECT,
'measure' => PAYANYWAY_INVENTORY_ITEM_DEFAULT_MEASURE,
];
}
return json_encode($inventory, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
/**
* @param array<non-empty-string, mixed> $data
* @return false|string
* @throws DOMException
*/
function payanyway_buildXMLResponse($data)
{
$dom = new DOMDocument('1.0', 'UTF-8');
$dom->formatOutput = false;
$root = $dom->createElement('MNT_RESPONSE');
$dom->appendChild($root);
$requiredFields = [
'MNT_ID',
'MNT_TRANSACTION_ID',
'MNT_RESULT_CODE',
'MNT_DESCRIPTION',
'MNT_AMOUNT',
'MNT_CURRENCY_CODE',
'MNT_SIGNATURE',
];
foreach ($requiredFields as $field) {
if (isset($data[$field])) {
$element = $dom->createElement($field, $data[$field]);
$root->appendChild($element);
}
}
$attributes = $dom->createElement('MNT_ATTRIBUTES');
$root->appendChild($attributes);
$addAttribute = static function ($key, $value) use ($dom, $attributes) {
if (!empty($value)) {
$attrElement = $dom->createElement('ATTRIBUTE');
$keyElement = $dom->createElement('KEY', $key);
$valueElement = $dom->createElement('VALUE', $value);
$attrElement->appendChild($keyElement);
$attrElement->appendChild($valueElement);
$attributes->appendChild($attrElement);
}
};
$addAttribute('INVENTORY', json_encode($data['inventory'], JSON_UNESCAPED_UNICODE));
$addAttribute('CLIENT', $data['client']);
if (!empty($data['sno'])) {
$addAttribute('SNO', $data['sno']);
}
if (!empty($data['delivery'])) {
$addAttribute('DELIVERY', $data['delivery']);
}
return $dom->saveXML();
}
/**
* @param string $response
* @param int $statusCode
* @return void
*/
function payanyway_sendResponse($response, $statusCode)
{
http_response_code($statusCode);
if (strpos($response, '<?xml') === 0 || strpos($response, '<MNT_RESPONSE') !== false) {
header('Content-Type: application/xml');
} else {
header('Content-Type: text/plain; charset=UTF-8');
}
echo $response;
die();
}
class PayAnyWayException extends \Exception
{
public function __construct($message = '', $code = 0, \Exception $previous = null)
{
parent::__construct($message, $code, $previous);
}
}